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 },