Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/04_upgrading/upgrading_v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,3 +279,27 @@ Two options that carried a `@deprecated` marker throughout v2 have been removed.
`restartOnError` is gone from <ApiLink to="interface/ActorCollectionCreateOptions">`ActorCollectionCreateOptions`</ApiLink>, so <ApiLink to="class/ActorCollectionClient#create">`ActorCollectionClient.create()`</ApiLink> no longer accepts it at the top level. Pass it inside `defaultRunOptions` instead, as the deprecation notice advised.

`exclusiveStartId` is gone from <ApiLink to="class/RequestQueueClient#listRequests">`listRequests()`</ApiLink> and <ApiLink to="class/RequestQueueClient#paginateRequests">`paginateRequests()`</ApiLink>. 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`

<ApiLink to="class/ActorClient#start">`ActorClient.start()`</ApiLink>, <ApiLink to="class/ActorClient#call">`call()`</ApiLink>, <ApiLink to="class/ActorClient#validateInput">`validateInput()`</ApiLink> and <ApiLink to="class/RunClient#metamorph">`RunClient.metamorph()`</ApiLink> 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.
12 changes: 7 additions & 5 deletions docs/public-api/apify-client.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,15 @@ export class ActorClient extends ResourceClient {
constructor(options: ApiClientSubResourceOptions);
build(versionNumber: string, options?: ActorBuildOptions): Promise<Build>;
builds(): BuildCollectionClient;
call(input?: unknown, options?: ActorCallOptions): Promise<ActorRun>;
call(input?: ActorInput, options?: ActorCallOptions): Promise<ActorRun>;
defaultBuild(options?: BuildClientGetOptions): Promise<BuildClient>;
delete(): Promise<void>;
get(): Promise<Actor | undefined>;
lastRun(options?: ActorLastRunOptions): RunClient;
runs(): RunCollectionClient;
start(input?: unknown, options?: ActorStartOptions): Promise<ActorRun>;
start(input?: ActorInput, options?: ActorStartOptions): Promise<ActorRun>;
update(newFields: ActorUpdateOptions): Promise<Actor>;
validateInput(input?: unknown, options?: ActorValidateInputOptions): Promise<boolean>;
validateInput(input?: ActorInput, options?: ActorValidateInputOptions): Promise<boolean>;
version(versionNumber: string): ActorVersionClient;
versions(): ActorVersionCollectionClient;
webhooks(): WebhookCollectionClient;
Expand Down Expand Up @@ -203,6 +203,9 @@ export type ActorEnvVarListResult = Pick<PaginatedList<ActorEnvironmentVariable>
export interface ActorExampleRunInput extends GeneratedExampleRunInput {
}

// @public
export type ActorInput = object;

// @public
export interface ActorLastRunOptions {
origin?: ValueOf_2<typeof META_ORIGINS>;
Expand Down Expand Up @@ -3568,7 +3571,7 @@ export class RunClient extends ResourceClient {
getStreamedLog(options?: GetStreamedLogOptions): Promise<StreamedLog | undefined>;
keyValueStore(): KeyValueStoreClient;
log(): LogClient;
metamorph(targetActorId: string, input: unknown, options?: RunMetamorphOptions): Promise<ActorRun>;
metamorph(targetActorId: string, input?: ActorInput, options?: RunMetamorphOptions): Promise<ActorRun>;
reboot(): Promise<ActorRun>;
requestQueue(): RequestQueueClient;
resurrect(options?: RunResurrectOptions): Promise<ActorRun>;
Expand Down Expand Up @@ -3602,7 +3605,6 @@ export interface RunGetOptions {
export interface RunMetamorphOptions {
// (undocumented)
build?: string;
// (undocumented)
contentType?: string;
}

Expand Down
47 changes: 21 additions & 26 deletions src/resource_clients/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
*
Expand All @@ -188,9 +186,7 @@ export class ActorClient extends ResourceClient {
* );
* ```
*/
async start(input?: unknown, options: ActorStartOptions = {}): Promise<ActorRun> {
// 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<ActorRun> {
const parsed = parseArgument(options, startOptionsSchema, 'ActorStartOptions');

const {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<ActorRun> {
// 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<ActorRun> {
const parsed = parseArgument(options, callOptionsSchema, 'ActorCallOptions');

const { waitSecs, log, ...startOptions } = parsed;
Expand Down Expand Up @@ -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
*
Expand All @@ -326,9 +316,7 @@ export class ActorClient extends ResourceClient {
* ```
* @since Added in 2.24.0
*/
async validateInput(input?: unknown, options: ActorValidateInputOptions = {}): Promise<boolean> {
// 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<boolean> {
const parsed = parseArgument(options, validateInputOptionsSchema, 'ActorValidateInputOptions');

const request: ApifyRequestConfig = {
Expand Down Expand Up @@ -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`).
Expand All @@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down
13 changes: 8 additions & 5 deletions src/resource_clients/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
*
Expand All @@ -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<ActorRun> {
async metamorph(targetActorId: string, input?: ActorInput, options: RunMetamorphOptions = {}): Promise<ActorRun> {
parseArgument(targetActorId, targetActorIdSchema);
// input can be anything, pointless to validate
const parsed = parseArgument(options, metamorphOptionsSchema, 'RunMetamorphOptions');

const safeTargetActorId = this._toSafeId(targetActorId);
Expand Down Expand Up @@ -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;
}
Expand Down
45 changes: 28 additions & 17 deletions test/actors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,44 +156,60 @@ 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,
memory: 256,
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 },
});

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' },
Expand Down Expand Up @@ -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';
Expand All @@ -290,7 +305,6 @@ describe('Actor methods', () => {

mockServer.setResponse({ body });
const res = await client.actor(actorId).call(input, {
contentType,
memory,
timeout,
build,
Expand All @@ -307,16 +321,14 @@ describe('Actor methods', () => {
build,
},
params: { actorId },
body: { some: 'body' },
additionalHeaders: { 'content-type': contentType },
body: input,
});

const callBrowserRes = await page.evaluate(
(id, i, opts) => client.actor(id).call(i, opts),
actorId,
input,
{
contentType,
memory,
timeout,
build,
Expand All @@ -332,8 +344,7 @@ describe('Actor methods', () => {
build,
},
params: { actorId },
body: { some: 'body' },
additionalHeaders: { 'content-type': contentType },
body: input,
});
});

Expand Down
Loading
Loading