Skip to content

Commit bd9181d

Browse files
vladfranguB4nan
andauthored
feat: respect input schema defaults in Actor.getInput() (#409)
Way too overdue, closes #287 --------- Co-authored-by: Martin Adámek <[email protected]>
1 parent c3c9937 commit bd9181d

File tree

9 files changed

+264
-4
lines changed

9 files changed

+264
-4
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
"test/**/*": [
6262
"eslint --fix"
6363
],
64-
"*": "prettier --write"
64+
"*": "prettier --write --ignore-unknown"
6565
},
6666
"devDependencies": {
6767
"@apify/consts": "^2.29.0",

packages/apify/src/actor.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ import { addTimeoutToPromise } from '@apify/timeout';
4747
import type { ChargeOptions, ChargeResult } from './charging.js';
4848
import { ChargingManager } from './charging.js';
4949
import { Configuration } from './configuration.js';
50+
import {
51+
getDefaultsFromInputSchema,
52+
noActorInputSchemaDefinedMarker,
53+
readInputSchema,
54+
} from './input-schemas.js';
5055
import { KeyValueStore } from './key_value_store.js';
5156
import { PlatformEventManager } from './platform_event_manager.js';
5257
import type { ProxyConfigurationOptions } from './proxy_configuration.js';
@@ -1235,18 +1240,27 @@ export class Actor<Data extends Dictionary = Dictionary> {
12351240
const inputSecretsPrivateKeyPassphrase = this.config.get(
12361241
'inputSecretsPrivateKeyPassphrase',
12371242
);
1238-
const input = await this.getValue<T>(this.config.get('inputKey'));
1243+
const rawInput = await this.getValue<T>(this.config.get('inputKey'));
1244+
1245+
let input = rawInput as T;
1246+
12391247
if (
1240-
ow.isValid(input, ow.object.nonEmpty) &&
1248+
ow.isValid(rawInput, ow.object.nonEmpty) &&
12411249
inputSecretsPrivateKeyFile &&
12421250
inputSecretsPrivateKeyPassphrase
12431251
) {
12441252
const privateKey = createPrivateKey({
12451253
key: Buffer.from(inputSecretsPrivateKeyFile, 'base64'),
12461254
passphrase: inputSecretsPrivateKeyPassphrase,
12471255
});
1248-
return decryptInputSecrets<T>({ input, privateKey });
1256+
1257+
input = decryptInputSecrets({ input: rawInput, privateKey });
1258+
}
1259+
1260+
if (ow.isValid(input, ow.object.nonEmpty) && !Buffer.isBuffer(input)) {
1261+
input = await this.inferDefaultsFromInputSchema(input);
12491262
}
1263+
12501264
return input;
12511265
}
12521266

@@ -2299,4 +2313,35 @@ export class Actor<Data extends Dictionary = Dictionary> {
22992313
].join('\n'),
23002314
);
23012315
}
2316+
2317+
private async inferDefaultsFromInputSchema<T extends Dictionary>(
2318+
input: T,
2319+
): Promise<T> {
2320+
// TODO: https://github.com/apify/apify-shared-js/issues/547
2321+
2322+
// On platform, this is already handled
2323+
if (this.isAtHome()) {
2324+
return input;
2325+
}
2326+
2327+
// On local, we can get the input schema from the local config
2328+
const inputSchema = readInputSchema();
2329+
2330+
// Don't emit warning if there is no input schema defined
2331+
if (inputSchema === noActorInputSchemaDefinedMarker) {
2332+
return input;
2333+
}
2334+
2335+
if (!inputSchema) {
2336+
log.warning(
2337+
'Failed to find the input schema for the local run of this Actor. Your input will be missing fields that have default values set if they are missing from the input you are using.',
2338+
);
2339+
2340+
return input;
2341+
}
2342+
2343+
const defaults = getDefaultsFromInputSchema(inputSchema);
2344+
2345+
return { ...defaults, ...input };
2346+
}
23022347
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// TODO: https://github.com/apify/apify-shared-js/issues/547
2+
3+
import { existsSync, readFileSync } from 'node:fs';
4+
import { join } from 'node:path';
5+
import process from 'node:process';
6+
7+
import type { Dictionary } from '@crawlee/utils';
8+
9+
// These paths are used *if* there is no `input` field in the actor.json configuration file!
10+
const DEFAULT_INPUT_SCHEMA_PATHS = [
11+
['.actor', 'INPUT_SCHEMA.json'],
12+
['INPUT_SCHEMA.json'],
13+
['.actor', 'input_schema.json'],
14+
['input_schema.json'],
15+
];
16+
17+
const ACTOR_SPECIFICATION_FOLDER = '.actor';
18+
19+
const LOCAL_CONFIG_NAME = 'actor.json';
20+
21+
const readJSONIfExists = (path: string): Dictionary | null => {
22+
if (existsSync(path)) {
23+
const content = readFileSync(path, 'utf8');
24+
return JSON.parse(content);
25+
}
26+
27+
return null;
28+
};
29+
30+
/**
31+
* @ignore
32+
*/
33+
export const noActorInputSchemaDefinedMarker = Symbol.for(
34+
'apify.noActorInputSchemaDefined',
35+
);
36+
37+
export const readInputSchema = ():
38+
| Dictionary
39+
| null
40+
| typeof noActorInputSchemaDefinedMarker => {
41+
const localConfig = readJSONIfExists(
42+
join(process.cwd(), ACTOR_SPECIFICATION_FOLDER, LOCAL_CONFIG_NAME),
43+
);
44+
45+
// Input schema nested in the actor config
46+
if (typeof localConfig?.input === 'object') {
47+
return localConfig.input;
48+
}
49+
50+
// Input schema path from the actor config
51+
if (typeof localConfig?.input === 'string') {
52+
const fullPath = join(
53+
process.cwd(),
54+
ACTOR_SPECIFICATION_FOLDER,
55+
localConfig.input,
56+
);
57+
58+
return readJSONIfExists(fullPath);
59+
}
60+
61+
// Try to find it from possible default paths
62+
for (const path of DEFAULT_INPUT_SCHEMA_PATHS) {
63+
const fullPath = join(process.cwd(), ...path);
64+
65+
const result = readJSONIfExists(fullPath);
66+
67+
if (result) {
68+
return result;
69+
}
70+
}
71+
72+
// If we are in an Actor context, BUT we do not have an input schema defined, we want to skip the warning
73+
if (!localConfig?.input) {
74+
return noActorInputSchemaDefinedMarker;
75+
}
76+
77+
return null;
78+
};
79+
80+
export const getDefaultsFromInputSchema = (inputSchema: any) => {
81+
const defaults: Record<string, unknown> = {};
82+
83+
for (const [key, fieldSchema] of Object.entries<any>(
84+
inputSchema.properties,
85+
)) {
86+
if (fieldSchema.default !== undefined) {
87+
defaults[key] = fieldSchema.default;
88+
}
89+
}
90+
91+
return defaults;
92+
};

test/e2e/runSdkTests.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ const basePath = join(rootPath, 'sdk');
1616
const actorBasePath = join(basePath, 'actorBase');
1717

1818
async function run() {
19+
if (!process.env.APIFY_TOKEN) {
20+
log.error('APIFY_TOKEN is not set in the environment variables.');
21+
return;
22+
}
23+
1924
log.info(`Running E2E SDK tests`);
2025

2126
const paths = await readdir(basePath, { withFileTypes: true });
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
FROM node:22 AS builder
2+
3+
COPY /package*.json ./
4+
RUN npm --quiet set progress=false \
5+
&& npm install --only=prod --no-optional --no-audit \
6+
&& npm update
7+
8+
COPY /apify.tgz /apify.tgz
9+
RUN npm --quiet install /apify.tgz
10+
11+
FROM apify/actor-node:22
12+
13+
RUN rm -r node_modules
14+
COPY --from=builder /node_modules ./node_modules
15+
COPY --from=builder /package*.json ./
16+
COPY /.actor ./.actor
17+
COPY /src ./src
18+
19+
RUN echo "Installed NPM packages:" \
20+
&& (npm list --only=prod --no-optional --all || true) \
21+
&& echo "Node.js version:" \
22+
&& node --version \
23+
&& echo "NPM version:" \
24+
&& npm --version
25+
26+
CMD npm start --silent
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"actorSpecification": 1,
3+
"name": "apify-sdk-js-test-input",
4+
"version": "0.0",
5+
"input": {
6+
"title": "Actor Input",
7+
"description": "Test input",
8+
"type": "object",
9+
"schemaVersion": 1,
10+
"properties": {
11+
"foo": {
12+
"title": "Foo",
13+
"type": "string",
14+
"description": "Foo",
15+
"default": "bar",
16+
"editor": "textfield"
17+
}
18+
}
19+
}
20+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"name": "apify-sdk-js-test-harness",
3+
"version": "0.0.1",
4+
"type": "module",
5+
"description": "This is an example of an Apify actor.",
6+
"engines": {
7+
"node": ">=22.0.0"
8+
},
9+
"dependencies": {
10+
"apify": "*"
11+
},
12+
"scripts": {
13+
"start": "node ./src/main.mjs"
14+
},
15+
"author": "It's not you it's me",
16+
"license": "ISC"
17+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Actor, log } from 'apify';
2+
3+
await Actor.init();
4+
5+
const input = await Actor.getInput();
6+
7+
log.info(`Input: ${JSON.stringify(input)}`);
8+
9+
await Actor.setValue('RECEIVED_INPUT', input);
10+
11+
await Actor.exit();

test/e2e/sdk/actorInput/test.mjs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import assert from 'node:assert/strict';
2+
import test from 'node:test';
3+
4+
import { ApifyClient, KeyValueStore } from 'apify';
5+
import { sleep } from 'crawlee';
6+
7+
const client = new ApifyClient({
8+
token: process.env.APIFY_TOKEN,
9+
});
10+
11+
const actor = client.actor(process.argv[2]);
12+
13+
const runActor = async (input, options) => {
14+
const { id: runId } = await actor.call(input, options);
15+
await client.run(runId).waitForFinish();
16+
await sleep(6000); // wait for updates to propagate to MongoDB
17+
return await client.run(runId).get();
18+
};
19+
20+
test('defaults work', async () => {
21+
const run = await runActor({}, {});
22+
23+
assert.strictEqual(run.status, 'SUCCEEDED');
24+
25+
const store = await KeyValueStore.open(run.defaultKeyValueStoreId, {
26+
storageClient: client,
27+
});
28+
29+
const receivedInput = await store.getValue('RECEIVED_INPUT');
30+
assert.deepEqual(receivedInput, { foo: 'bar' });
31+
});
32+
33+
test('input is passed through', async () => {
34+
const run = await runActor({ foo: 'baz' }, {});
35+
36+
assert.strictEqual(run.status, 'SUCCEEDED');
37+
38+
const store = await KeyValueStore.open(run.defaultKeyValueStoreId, {
39+
storageClient: client,
40+
});
41+
42+
const receivedInput = await store.getValue('RECEIVED_INPUT');
43+
assert.deepEqual(receivedInput, { foo: 'baz' });
44+
});

0 commit comments

Comments
 (0)