diff --git a/docs/04_upgrading/upgrading_v3.md b/docs/04_upgrading/upgrading_v3.md index c0671c65..6c301b75 100644 --- a/docs/04_upgrading/upgrading_v3.md +++ b/docs/04_upgrading/upgrading_v3.md @@ -279,3 +279,27 @@ Two options that carried a `@deprecated` marker throughout v2 have been removed. `restartOnError` is gone from `ActorCollectionCreateOptions`, so `ActorCollectionClient.create()` no longer accepts it at the top level. Pass it inside `defaultRunOptions` instead, as the deprecation notice advised. `exclusiveStartId` is gone from `listRequests()` and `paginateRequests()`. Both paginate by `cursor` alone now, and passing `exclusiveStartId` throws an `ArgumentValidationError` about an unrecognized key. In v2 the two were mutually exclusive, so the error about combining them is gone as well. Responses are unaffected, since the API still echoes `exclusiveStartId` back in the request listing. + +## Actor run input is no longer `unknown` + +`ActorClient.start()`, `call()`, `validateInput()` and `RunClient.metamorph()` took their `input` as `unknown`, so any value compiled, including ones the client cannot send. + +The input is now typed `ActorInput`, an alias for `object`, so it's an object or an array that the client serializes into the request body. Any other value stops compiling, including a value typed `unknown`, which has to be narrowed or cast first. To run an Actor without input, omit the argument or pass `undefined`. + +```diff +- await client.actor('my-actor').call(null, { memory: 1024 }); // v2 ++ await client.actor('my-actor').call(undefined, { memory: 1024 }); // v3 +``` + +A raw `string` body stops compiling too, with or without a `contentType`. Pass the input as an object and let the client serialize it: + +```diff +- await client.actor('my-actor').start('some=body', { contentType: 'application/x-www-form-urlencoded' }); // v2 ++ await client.actor('my-actor').start({ some: 'body' }); // v3 +``` + +Dropping the `contentType` sends the body as JSON, so the run's `INPUT` record changes content type with it. To keep the form encoding, pass the object and the option together: the client form-encodes an object whenever `contentType` is `application/x-www-form-urlencoded`. + +`metamorph()`'s `input` is optional now, so `metamorph('target-actor')` compiles where it previously needed an explicit `undefined`. + +Nothing changes at runtime. `TaskClient.start()` and `call()` keep taking a `Dictionary`: a task's input overrides are merged into the input saved on the task, so they are always an object. diff --git a/docs/public-api/apify-client.api.md b/docs/public-api/apify-client.api.md index 24250a2e..5f9a7b35 100644 --- a/docs/public-api/apify-client.api.md +++ b/docs/public-api/apify-client.api.md @@ -88,15 +88,15 @@ export class ActorClient extends ResourceClient { constructor(options: ApiClientSubResourceOptions); build(versionNumber: string, options?: ActorBuildOptions): Promise; builds(): BuildCollectionClient; - call(input?: unknown, options?: ActorCallOptions): Promise; + call(input?: ActorInput, options?: ActorCallOptions): Promise; defaultBuild(options?: BuildClientGetOptions): Promise; delete(): Promise; get(): Promise; lastRun(options?: ActorLastRunOptions): RunClient; runs(): RunCollectionClient; - start(input?: unknown, options?: ActorStartOptions): Promise; + start(input?: ActorInput, options?: ActorStartOptions): Promise; update(newFields: ActorUpdateOptions): Promise; - validateInput(input?: unknown, options?: ActorValidateInputOptions): Promise; + validateInput(input?: ActorInput, options?: ActorValidateInputOptions): Promise; version(versionNumber: string): ActorVersionClient; versions(): ActorVersionCollectionClient; webhooks(): WebhookCollectionClient; @@ -203,6 +203,9 @@ export type ActorEnvVarListResult = Pick export interface ActorExampleRunInput extends GeneratedExampleRunInput { } +// @public +export type ActorInput = object; + // @public export interface ActorLastRunOptions { origin?: ValueOf_2; @@ -3568,7 +3571,7 @@ export class RunClient extends ResourceClient { getStreamedLog(options?: GetStreamedLogOptions): Promise; keyValueStore(): KeyValueStoreClient; log(): LogClient; - metamorph(targetActorId: string, input: unknown, options?: RunMetamorphOptions): Promise; + metamorph(targetActorId: string, input?: ActorInput, options?: RunMetamorphOptions): Promise; reboot(): Promise; requestQueue(): RequestQueueClient; resurrect(options?: RunResurrectOptions): Promise; @@ -3602,7 +3605,6 @@ export interface RunGetOptions { export interface RunMetamorphOptions { // (undocumented) build?: string; - // (undocumented) contentType?: string; } diff --git a/src/resource_clients/actor.ts b/src/resource_clients/actor.ts index 0940d273..b79a52a4 100644 --- a/src/resource_clients/actor.ts +++ b/src/resource_clients/actor.ts @@ -161,8 +161,7 @@ export class ActorClient extends ResourceClient { * asynchronously and this method returns immediately without waiting for completion. * Use the {@link call} method if you want to wait for the Actor to finish. * - * @param input - Input for the Actor. Can be any JSON-serializable value (object, array, string, number). - * If `contentType` is specified in options, input should be a string or Buffer. + * @param input - Input for the Actor, serialized to JSON. Omit it to run the Actor without input. * @param options - Run configuration options * @param options.build - Tag or number of the build to run (e.g., `'beta'` or `'1.2.345'`). If not provided, uses the default build. * @param options.memory - Memory in megabytes allocated for the run. If not provided, uses the Actor's default memory setting. @@ -171,7 +170,6 @@ export class ActorClient extends ResourceClient { * @param options.webhooks - Webhooks to trigger when the Actor run reaches a specific state (e.g., `SUCCEEDED`, `FAILED`). * @param options.maxItems - Maximum number of dataset items that will be charged (only for pay-per-result Actors). * @param options.maxTotalChargeUsd - Maximum cost in USD (only for pay-per-event Actors). - * @param options.contentType - Content type of the input. If specified, input must be a string or Buffer. * @returns The Actor run object with status, usage, and storage IDs * @see https://docs.apify.com/api/v2/act-runs-post * @@ -188,9 +186,7 @@ export class ActorClient extends ResourceClient { * ); * ``` */ - async start(input?: unknown, options: ActorStartOptions = {}): Promise { - // input can be anything, so no point in validating it. E.g. if you set content-type to application/pdf - // then it will process input as a buffer. + async start(input?: ActorInput, options: ActorStartOptions = {}): Promise { const parsed = parseArgument(options, startOptionsSchema, 'ActorStartOptions'); const { @@ -242,8 +238,7 @@ export class ActorClient extends ResourceClient { * by polling the run status. It optionally streams logs to the console or a custom Log instance. * By default, it waits indefinitely unless the `waitSecs` option is provided. * - * @param input - Input for the Actor. Can be any JSON-serializable value (object, array, string, number). - * If `contentType` is specified in options, input should be a string or Buffer. + * @param input - Input for the Actor, serialized to JSON. Omit it to run the Actor without input. * @param options - Run configuration options (extends all options from {@link start}) * @param options.waitSecs - Maximum time to wait for the run to finish, in seconds. If omitted, waits indefinitely. * @param options.log - Log instance for streaming run logs. Use `'default'` for console output, `null` to disable logging, or provide a custom Log instance. @@ -272,9 +267,7 @@ export class ActorClient extends ResourceClient { * const run = await client.actor('my-actor').call({ url: 'https://example.com' }, { log }); * ``` */ - async call(input?: unknown, options: ActorCallOptions = {}): Promise { - // input can be anything, so no point in validating it. E.g. if you set content-type to application/pdf - // then it will process input as a buffer. + async call(input?: ActorInput, options: ActorCallOptions = {}): Promise { const parsed = parseArgument(options, callOptionsSchema, 'ActorCallOptions'); const { waitSecs, log, ...startOptions } = parsed; @@ -303,13 +296,10 @@ export class ActorClient extends ResourceClient { * invalid, the API responds with an error that is thrown as an `ApifyApiError` describing the * validation problem. * - * @param input - Input to validate against the Actor's input schema. Can be any JSON-serializable - * value (object, array, string, number). If `contentType` is specified in options, - * input should be a string or Buffer. + * @param input - Input to validate against the Actor's input schema, serialized to JSON. * @param options - Validation options * @param options.build - Tag or number of the build whose input schema the input is validated against * (e.g., `'latest'` or `'1.2.345'`). If not provided, uses the default build. - * @param options.contentType - Content type of the input. If specified, input must be a string or Buffer. * @returns `true` if the input is valid. Invalid input causes the underlying API call to throw an `ApifyApiError`. * @see https://docs.apify.com/api/v2/act-validate-input-post * @@ -326,9 +316,7 @@ export class ActorClient extends ResourceClient { * ``` * @since Added in 2.24.0 */ - async validateInput(input?: unknown, options: ActorValidateInputOptions = {}): Promise { - // input can be anything, so no point in validating it. E.g. if you set content-type to application/pdf - // then it will process input as a buffer. + async validateInput(input?: ActorInput, options: ActorValidateInputOptions = {}): Promise { const parsed = parseArgument(options, validateInputOptionsSchema, 'ActorValidateInputOptions'); const request: ApifyRequestConfig = { @@ -555,6 +543,15 @@ export type ActorUpdateOptions = Partial< > >; +/** + * Input for an Actor run, as taken by {@link ActorClient.start}, {@link ActorClient.call}, + * {@link ActorClient.validateInput} and {@link RunClient.metamorph}. + * + * An object or an array. Declared as `object` rather than an index-signature type such as + * `Dictionary`, which would reject a caller's own `interface`. + */ +export type ActorInput = object; + export interface ActorStartOptions { /** * Tag or number of the Actor build to run (e.g. `beta` or `1.2.345`). @@ -563,10 +560,9 @@ export interface ActorStartOptions { build?: string; /** - * Content type for the `input`. If not specified, - * `input` is expected to be an object that will be stringified to JSON and content type set to - * `application/json; charset=utf-8`. If `options.contentType` is specified, then `input` must be a - * `String` or `Buffer`. + * Content type of the request body, which becomes the content type of the run's `INPUT` record. + * Without it, an input is serialized to JSON and sent as `application/json`. Pairing an object + * with `application/x-www-form-urlencoded` form-encodes it instead. */ contentType?: string; @@ -664,10 +660,9 @@ export interface ActorValidateInputOptions { build?: string; /** - * Content type for the `input`. If not specified, - * `input` is expected to be an object that will be stringified to JSON and content type set to - * `application/json; charset=utf-8`. If `options.contentType` is specified, then `input` must be a - * `String` or `Buffer`. + * Content type of the request body carrying the input to validate. Without it, the input is + * serialized to JSON and sent as `application/json`. Pairing an object with + * `application/x-www-form-urlencoded` form-encodes it instead. */ contentType?: string; } diff --git a/src/resource_clients/run.ts b/src/resource_clients/run.ts index aeed395f..ebff1830 100644 --- a/src/resource_clients/run.ts +++ b/src/resource_clients/run.ts @@ -9,7 +9,7 @@ import { ResourceClient } from '../base/resource_client.js'; import type { ApifyResponse } from '../http_client.js'; import * as schemas from '../schemas.js'; import { anyObjectSchema, isNode, parseArgument, parseResponse } from '../utils.js'; -import type { ActorRun } from './actor.js'; +import type { ActorInput, ActorRun } from './actor.js'; import { DatasetClient } from './dataset.js'; import { KeyValueStoreClient } from './key_value_store.js'; import { LogClient, LoggerActorRedirect, StreamedLog } from './log.js'; @@ -144,10 +144,9 @@ export class RunClient extends ResourceClient { * This is useful for chaining Actor executions or implementing complex workflows. * * @param targetActorId - ID or username/name of the target Actor - * @param input - Input for the target Actor. Can be any JSON-serializable value. + * @param input - Input for the target Actor, serialized to JSON. Omit it to metamorph without input. * @param options - Metamorph options * @param options.build - Tag or number of the target Actor's build to run. Default is the target Actor's default build. - * @param options.contentType - Content type of the input. If specified, input must be a string or Buffer. * @returns The metamorphed ActorRun object (same ID, but now running the target Actor) * @see https://docs.apify.com/api/v2/actor-run-metamorph-post * @@ -161,9 +160,8 @@ export class RunClient extends ResourceClient { * console.log(`Run ${metamorphedRun.id} is now running ${metamorphedRun.actId}`); * ``` */ - async metamorph(targetActorId: string, input: unknown, options: RunMetamorphOptions = {}): Promise { + async metamorph(targetActorId: string, input?: ActorInput, options: RunMetamorphOptions = {}): Promise { parseArgument(targetActorId, targetActorIdSchema); - // input can be anything, pointless to validate const parsed = parseArgument(options, metamorphOptionsSchema, 'RunMetamorphOptions'); const safeTargetActorId = this._toSafeId(targetActorId); @@ -499,6 +497,11 @@ export interface RunAbortOptions { * Options for metamorphing a Run into another Actor. */ export interface RunMetamorphOptions { + /** + * Content type of the request body, which becomes the content type of the run's `INPUT` record. + * Without it, an input is serialized to JSON and sent as `application/json`. Pairing an object + * with `application/x-www-form-urlencoded` form-encodes it instead. + */ contentType?: string; build?: string; } diff --git a/test/actors.test.ts b/test/actors.test.ts index 134faea0..5f8e832f 100644 --- a/test/actors.test.ts +++ b/test/actors.test.ts @@ -156,8 +156,7 @@ describe('Actor methods', () => { test('start() works', async () => { const actorId = 'some-id'; - const contentType = 'application/x-www-form-urlencoded'; - const input = 'some=body'; + const input = { some: 'body' }; const query = { timeout: 120, @@ -165,10 +164,28 @@ describe('Actor methods', () => { build: '1.2.0', }; - const res = await client.actor(actorId).start(input, { contentType, ...query }); + const res = await client.actor(actorId).start(input, query); expect(res.id).toEqual('run-actor'); - validateRequest({ + validateRequest({ query, params: { actorId }, body: input }); + + const browserRes = await page.evaluate( + (id, i, opts) => client.actor(id).start(i, opts), + actorId, + input, query, + ); + expect(browserRes).toEqual(asBrowserResult(res)); + validateRequest({ query, params: { actorId }, body: input }); + }); + + test('start() passes contentType through as the request header', async () => { + const actorId = 'some-id'; + const contentType = 'application/json; charset=utf-8'; + const input = { some: 'body' }; + + const res = await client.actor(actorId).start(input, { contentType }); + expect(res.id).toEqual('run-actor'); + validateRequest({ params: { actorId }, body: { some: 'body' }, additionalHeaders: { 'content-type': contentType }, @@ -176,24 +193,23 @@ describe('Actor methods', () => { const browserRes = await page.evaluate((id, i, opts) => client.actor(id).start(i, opts), actorId, input, { contentType, - ...query, }); expect(browserRes).toEqual(asBrowserResult(res)); validateRequest({ - query, params: { actorId }, body: { some: 'body' }, additionalHeaders: { 'content-type': contentType }, }); }); - test('start() works with pre-stringified JSON', async () => { + test('start() encodes the input for a non-JSON contentType', async () => { const actorId = 'some-id'; - const contentType = 'application/json; charset=utf-8'; - const input = JSON.stringify({ some: 'body' }); + const contentType = 'application/x-www-form-urlencoded'; + const input = { some: 'body' }; const res = await client.actor(actorId).start(input, { contentType }); expect(res.id).toEqual('run-actor'); + // The mock server parses the form-encoded body back into an object. validateRequest({ params: { actorId }, body: { some: 'body' }, @@ -278,8 +294,7 @@ describe('Actor methods', () => { test('call() works', async () => { const actorId = 'some-id'; - const contentType = 'application/x-www-form-urlencoded'; - const input = 'some=body'; + const input = { some: 'body' }; const timeout = 120; const memory = 256; const build = '1.2.0'; @@ -290,7 +305,6 @@ describe('Actor methods', () => { mockServer.setResponse({ body }); const res = await client.actor(actorId).call(input, { - contentType, memory, timeout, build, @@ -307,8 +321,7 @@ describe('Actor methods', () => { build, }, params: { actorId }, - body: { some: 'body' }, - additionalHeaders: { 'content-type': contentType }, + body: input, }); const callBrowserRes = await page.evaluate( @@ -316,7 +329,6 @@ describe('Actor methods', () => { actorId, input, { - contentType, memory, timeout, build, @@ -332,8 +344,7 @@ describe('Actor methods', () => { build, }, params: { actorId }, - body: { some: 'body' }, - additionalHeaders: { 'content-type': contentType }, + body: input, }); }); diff --git a/test/runs.test.ts b/test/runs.test.ts index 3336d2b1..2c0d8736 100644 --- a/test/runs.test.ts +++ b/test/runs.test.ts @@ -176,14 +176,10 @@ describe('Run methods', () => { test('metamorph() works', async () => { const runId = 'some-run-id'; const targetActorId = 'some-target-id'; - const contentType = 'application/x-www-form-urlencoded'; - const input = 'some=body'; + const input = { some: 'body' }; const build = '1.2.0'; - const options = { - build, - contentType, - }; + const options = { build }; const actualQuery = { targetActorId, @@ -195,8 +191,7 @@ describe('Run methods', () => { endpointId: 'metamorph-run', query: actualQuery, params: { runId }, - body: { some: 'body' }, - additionalHeaders: { 'content-type': contentType }, + body: input, }); const browserRes = await page.evaluate( @@ -212,16 +207,15 @@ describe('Run methods', () => { validateRequest({ query: actualQuery, params: { runId }, - body: { some: 'body' }, - additionalHeaders: { 'content-type': contentType }, + body: input, }); }); - test('metamorph() works with pre-stringified JSON input', async () => { + test('metamorph() passes contentType through as the request header', async () => { const runId = 'some-run-id'; const targetActorId = 'some-target-id'; const contentType = 'application/json; charset=utf-8'; - const input = JSON.stringify({ foo: 'bar' }); + const input = { foo: 'bar' }; const expectedRequest = { query: { targetActorId },