Skip to content

feat!: generate all output models from the OpenAPI spec - #985

Merged
vdusek merged 4 commits into
v3from
feat/openapi-generated-models
Aug 27, 2026
Merged

feat!: generate all output models from the OpenAPI spec#985
vdusek merged 4 commits into
v3from
feat/openapi-generated-models

Conversation

@vdusek

@vdusek vdusek commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Closes: #1026

Description

  • API response types are now generated from the OpenAPI specification instead of hand-written, using openapi-typescript.
  • pnpm generate:types downloads the specification into git-ignored tmp/ and writes src/generated/api.ts. Only the specification version is committed, in package.json.
  • Nothing re-exports the generated file: src/models.ts declares each published model on top of a generated schema, and src/spec_guards.ts asserts every deviation at compile time.
  • A nightly CI workflow regenerates and opens a pull request if anything changed.
  • It follows the same approach as the Python API client.

Breaking

  • Several types were outright wrong, and many fields gained null or became optional to match what the API actually returns.
  • Two runtime changes: parseDateFields() depth 3 -> 4, and the key-value store's next-key check widens to != null.
  • Everything is described in the v3 upgrading guide.

Downstream

Open

  • notify_on_failure needs a SLACK_WEBHOOK_URL secret this repo does not have.

✍️ Drafted by Claude Code

@vdusek vdusek added adhoc Ad-hoc unplanned task added during the sprint. t-tooling Issues with this label are in the ownership of the tooling team. labels Jul 30, 2026
@vdusek vdusek self-assigned this Jul 30, 2026
@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

⚠️ There are broken links in the documentation.

See more at https://github.com/apify/apify-client-js/actions/runs/33062899625#summary-98485802003

@vdusek
vdusek force-pushed the feat/openapi-generated-models branch from db33e50 to 8ca8555 Compare August 3, 2026 15:22
@vdusek
vdusek marked this pull request as ready for review August 3, 2026 15:25
@vdusek
vdusek marked this pull request as draft August 3, 2026 15:26
@vdusek
vdusek force-pushed the feat/openapi-generated-models branch from 8ca8555 to 057a750 Compare August 3, 2026 15:31
@vdusek

vdusek commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Regarding the single generated file size; openapi-typescript itself doesn't support splitting one spec's generated types into multiple files.

What we can do:

  • add the generated file to the ignores in oxlint.config.ts, to not affect linter performance;
  • add it to the .gitattributes, to always hide the diff in GitHub;
  • regarding the language server, there shouldn't be much impact, according to measurements by Claude:

Full clean tsc --noEmit over the whole project (485 files) takes 0.89s. Per-file trace attribution shows generated/api.ts is the single most expensive file in the program at 78ms (11ms parse + 20ms bind + 46ms check) — about 10% of the summed per-file cost, but under 0.1s in absolute terms. For comparison, tiny hand-written files like http_client.ts (53ms check) and interceptors.ts (49ms check) have comparable or higher check cost from generic/retry-decorator logic despite being a few hundred lines — proving check cost tracks type complexity, not line count.

Let me know @B4nan WDYT.

@B4nan

B4nan commented Aug 5, 2026

Copy link
Copy Markdown
Member

Let's leave it, it's true that it's just a huge file, but the code is far away from complex.

@vdusek

vdusek commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Let's wait for #995 and then rebase upon it, so that we can better test this.

@vdusek
vdusek changed the base branch from v3 to feat/replace-ow-with-zod August 18, 2026 07:47
@vdusek
vdusek force-pushed the feat/openapi-generated-models branch 2 times, most recently from d9b0c0e to 238b51d Compare August 18, 2026 12:16
vdusek added a commit that referenced this pull request Aug 18, 2026
Port of the integration test suite from the Python API client.

Adds an integration test suite that executes the client against the live
Apify API. This is the same approach as in the Python API client: real
API calls with a test user token, resources created under unique names
and cleaned up afterwards, and eventual consistency handled by polling
helpers rather than sleeps or retries.

Merge this **before** #985, #986 and the rest of the v3 work, so those
changes have some end-to-end test coverage.

## Coverage

196 tests over Actors, Actor versions, Actor env vars, builds, runs,
logs, tasks, schedules, webhooks, webhook dispatches, datasets,
key-value stores, request queues, the store, and users.

## Test tiers

`vitest.config.mts` now defines two projects, so the existing unit tests
stay fast and offline:

- `pnpm test` - unit tests only (unchanged behavior)
- `pnpm test:integration` - integration tier only
- `pnpm test:all` - both

## Credentials

The tier needs `APIFY_TEST_USER_API_TOKEN`.

## CI

A new `integration_tests` job in `check.yaml` runs the tier on Node 26.
It is skipped for fork PRs, where repository secrets are unavailable,
and can be triggered on demand via `workflow_dispatch`.

*✍️ Drafted by Claude Code*
vdusek added a commit that referenced this pull request Aug 19, 2026
Port of the integration test suite from the Python API client.

Adds an integration test suite that executes the client against the live
Apify API. This is the same approach as in the Python API client: real
API calls with a test user token, resources created under unique names
and cleaned up afterwards, and eventual consistency handled by polling
helpers rather than sleeps or retries.

Merge this **before** #985, #986 and the rest of the v3 work, so those
changes have some end-to-end test coverage.

196 tests over Actors, Actor versions, Actor env vars, builds, runs,
logs, tasks, schedules, webhooks, webhook dispatches, datasets,
key-value stores, request queues, the store, and users.

`vitest.config.mts` now defines two projects, so the existing unit tests
stay fast and offline:

- `pnpm test` - unit tests only (unchanged behavior)
- `pnpm test:integration` - integration tier only
- `pnpm test:all` - both

The tier needs `APIFY_TEST_USER_API_TOKEN`.

A new `integration_tests` job in `check.yaml` runs the tier on Node 26.
It is skipped for fork PRs, where repository secrets are unavailable,
and can be triggered on demand via `workflow_dispatch`.

*✍️ Drafted by Claude Code*
@vdusek
vdusek force-pushed the feat/openapi-generated-models branch from 238b51d to e1a5ed4 Compare August 19, 2026 11:09
Base automatically changed from feat/replace-ow-with-zod to v3 August 21, 2026 11:14
@vdusek
vdusek force-pushed the feat/openapi-generated-models branch from e1a5ed4 to cfb40c1 Compare August 21, 2026 17:06
vdusek added a commit that referenced this pull request Aug 25, 2026
Port of the integration test suite from the Python API client.

Adds an integration test suite that executes the client against the live
Apify API. This is the same approach as in the Python API client: real
API calls with a test user token, resources created under unique names
and cleaned up afterwards, and eventual consistency handled by polling
helpers rather than sleeps or retries.

Merge this **before** #985, #986 and the rest of the v3 work, so those
changes have some end-to-end test coverage.

196 tests over Actors, Actor versions, Actor env vars, builds, runs,
logs, tasks, schedules, webhooks, webhook dispatches, datasets,
key-value stores, request queues, the store, and users.

`vitest.config.mts` now defines two projects, so the existing unit tests
stay fast and offline:

- `pnpm test` - unit tests only (unchanged behavior)
- `pnpm test:integration` - integration tier only
- `pnpm test:all` - both

The tier needs `APIFY_TEST_USER_API_TOKEN`.

A new `integration_tests` job in `check.yaml` runs the tier on Node 26.
It is skipped for fork PRs, where repository secrets are unavailable,
and can be triggered on demand via `workflow_dispatch`.

*✍️ Drafted by Claude Code*
Every published output model is now declared on top of a type generated from the published
OpenAPI specification instead of being hand-written. `pnpm generate:types` downloads the
specification into git-ignored `tmp/`, turns it into `src/generated/api.ts` with
`openapi-typescript`, and records the specification's version in `package.json`. The
specification itself is never committed -- only the version it was generated from, matching
apify-client-python.

Nothing re-exports the generated file. `src/models.ts` declares each model with
`interface ... extends` over a generated schema, never as a type alias: the docs plugin only
emits API-reference pages for classes, interfaces and enums, so an alias silently deletes a
model's page. Every deviation from the specification is argued at its declaration and asserted
in `src/spec_guards.ts`, so a spec change that invalidates one fails `pnpm build:node`. That
file is not re-exported from `src/index.ts`, so its exports satisfy `noUnusedLocals` without
growing the public API or the rendered reference. Covered: a field an override block replaces
being dropped or renamed, a documented spec gap being filled, and the shared `@apify/consts`
enums diverging from the spec.

A nightly workflow regenerates on `master` and opens a pull request when `src/generated/api.ts`
changed. Renovate cannot do this -- the specification is a live document, not an npm dependency
-- so without it the generated types never move and no guard can ever fire. Gating on the
generated output rather than on the specification version is what keeps it quiet: `info.version`
is an apify-docs build stamp, not an API version, so it moves on every docs redeploy.

Several published types were wrong, a number of them contradicting the client's own runtime.
`nextExclusiveStartKey` was a required `string` while `listKeys()` has always compared it to
`null`. `Webhook.lastDispatch` was a `string` while the API returns an object.
`Schedule.nextRunAt`, `Schedule.lastRunAt` and `RequestQueueClientRequestSchema.handledAt` were
`string` although `parseDateFields()` had already converted them to `Date`.
`MonthlyUsage.dailyServiceUsages[].date` was a `string` while `UserClient.monthlyUsage()` passes
a matcher that converts it, and `RequestQueue.expireAt` was a `string` although the key ends in
`At`, so `parseDateFields()` has always converted it. `Build.status` omitted `READY` and
`RUNNING`, which `waitForFinish()` documents. `RequestQueueClientGetRequestResult` was a
queue-head projection while the endpoint returns the whole request. And
`UserPlan.enabledPlatformFeatures` used an enum missing three features that appear as keys of
`EffectivePlatformFeatures`. And `batchDeleteRequests()` published the batch *add* result, whose
processed entries carry `requestId`, `wasAlreadyPresent` and `wasAlreadyHandled`, while the delete
endpoint answers with none of the three.

Four places deliberately keep the hand-written shape, each argued at its declaration and
excluded from the width guard. `ActorVersion` keeps its discriminated union, because the flat
spec shape has `sourceType` nullable and all four source locations optional, which leaves every
variant unreachable. `WebhookCondition` keeps its single-id variants, because the flat shape
would let a caller send none of the three ids or all of them. `ActorRun.generalAccess` keeps
`RUN_GENERAL_ACCESS`, because the spec reuses the storage-wide `GeneralAccess`, which also lists
`ANYONE_WITH_NAME_CAN_READ` -- a run has no name to be addressed by. `Schedule.timezone` keeps
the curated IANA union from `src/timezones.ts`, which the spec types as a bare `string`.

`models.ts` classifies each deviation by kind: `*RePointed` for a field aimed at a published type
rather than the generated one, `*SpecNarrowings` for the spec being narrower than the API,
`*ClientNarrowings` for the published type being narrower on purpose, `*ClientConversions` for a
value the client converts before handing it over, and `*SpecGaps` for a field the API returns that
the spec does not describe yet. Each gap is asserted still missing upstream, so the day the spec
covers it the build says so.

Types only, with two runtime exceptions. `parseDateFields()`' depth limit goes from 3 to 4,
because a list response nests one level deeper than the single resource it wraps: at the previous
limit `dispatches().list()` returned `calls[].startedAt` as a raw string while the published type
promised a `Date`, even though the same field came back as a `Date` from
`webhookDispatch(id).get()`. And the key-value store's pagination loop widens its
`nextExclusiveStartKey` check from `!== null` to `!= null`, so an omitted key ends the listing
instead of restarting it.

The v3 upgrading guide gains a section walking through the breaks that need more than a null
check.

BREAKING CHANGE: every published output model now follows the specification's nullability and
optionality instead of the previous hand-written shape. Per resource group:

Dataset and WebhookDispatch: Dataset.name, actId and actRunId gain `| null`; Dataset.fields
becomes optional and nullable; Dataset.stats and itemsPublicUrl become optional;
DatasetStatistics.fieldStatistics becomes optional and nullable; FieldStatistics.min, max,
nullCount and emptyCount gain `| null`; WebhookDispatch.calls and eventData become optional; and
WebhookDispatch.webhook changes from `Pick<Webhook, 'requestUrl' | 'isAdHoc'>` to
WebhookDispatchWebhookSummary, which is nullable and also carries actionType and a condition typed
as the same WebhookCondition union Webhook.condition carries.
Newly exposed: Dataset.consoleUrl, Dataset.schema and DatasetStats.inflatedBytes. The deeper
parseDateFields traversal also reaches one level further into caller-owned blobs the API stores
verbatim, so a listed request's `userData.foo.somethingAt` now comes back as a `Date` rather than
the string it was written as.

KeyValueStore: KeyValueStore.name, actId, actRunId and username gain `| null`; userId becomes
optional and nullable; keysPublicUrl becomes optional; KeyValueClientListKeysResult
.exclusiveStartKey and nextExclusiveStartKey become optional and nullable, and the pagination
loop's check widens from `!== null` to `!= null` to match. Newly exposed:
KeyValueStore.consoleUrl, recordsPublicUrl and schema, and KeyValueStoreStats.s3StorageBytes.

RequestQueue: RequestQueue.expireAt changes from `string` to `Date`;
RequestQueueClientRequestSchema.handledAt changes from `string` to `Date | null`, on the way in as
well as out, because the same type describes `updateRequest()`'s argument; and
RequestQueueClientGetRequestResult is the whole request rather than the queue-head projection. Its
id, uniqueKey and url stay required: the spec states them in a `required` list that sits next to a
`$ref`, which `openapi-typescript` drops, so `scripts/spec_transform.mts` hoists that list before
generation rather than patching the published type afterwards. batchDeleteRequests() returns the
new RequestQueueClientBatchDeleteRequestsResult, whose processed entries carry id and uniqueKey,
rather than the batch add result it answered with before. Newly added:
RequestQueueClientRequestToAdd and RequestQueueClientRequestToUpdate, which keep the fields each
submission genuinely requires, and the split of the queue head into HeadRequest and
LockedHeadRequest, so only the locked variant carries lockExpiresAt.

Actor versions and environment variables: BaseActorVersion.versionNumber becomes required;
buildTag, applyEnvVarsToBuild and envVars gain `| null`; ActorVersionSourceFile.format and
content become optional, and `format` is the spec's SourceCodeFileFormat rather than an inline
`'TEXT' | 'BASE64'`; ActorEnvironmentVariable.name becomes required and isSecret gains `| null`;
and ActorVersionSourceFiles.sourceFiles accepts ActorVersionSourceFolder entries as well. Newly
added: ActorSourceType.SourceCode, ActorVersionSourceCode and ActorVersionSourceFolder.

Actor: Actor.actorStandby loses the `& { isEnabled: boolean }` intersection and gains `| null`;
deploymentKey and actorPermissionLevel become optional; description, title, seoTitle,
seoDescription, isDeprecated, exampleRunInput and taggedBuilds gain `| null`; every field of
ActorStats and of ActorDefaultRunOptions becomes optional; ActorExampleRunInput.body and
contentType become optional; ActorTaggedBuilds values may be `null`;
ActorDefinition.actorSpecification, name and version become optional;
ActorChargeEvent.eventDescription becomes required while eventPriceUsd becomes optional;
FlatPricePerMonthActorPricingInfo.trialMinutes and PricePerDatasetItemActorPricingInfo.unitName
become required, while the latter's pricePerUnitUsd becomes optional. Newly exposed:
Actor.pictureUrl, standbyUrl, notice, isCritical, isGeneric, isSourceCodeHidden and hasNoDataset;
ActorStats.actorReviewCount, actorReviewRating, bookmarkCount and publicActorRunStats30Days;
ActorDefaultRunOptions.maxItems and forcePermissionLevel; ActorDefinition.defaultMemoryMbytes;
ActorTaggedBuild.buildNumberInt; ActorChargeEvent.isPrimaryEvent and isOneTimeEvent;
ActorCollectionListItem.title and stats; PricePerDatasetItemActorPricingInfo.tieredPricing and
ActorChargeEvent.eventTieredPricingUsd; and the TieredPricingPerDatasetItem and
TieredPricingPerEvent types.

Build: Build.status widens from the four terminal statuses to all eight Actor job statuses;
finishedAt, stats, options, usage, usageUsd, usageTotalUsd, inputSchema, readme and
actorDefinition gain `| null`; BuildMeta.clientIp and userAgent become optional while origin
narrows from `string` to the META_ORIGINS union; every field of BuildStats becomes optional;
BuildUsage.ACTOR_COMPUTE_UNITS and every field of BuildOptions gain `| null`; and
BuildCollectionClientListItem is now derived from the spec's BuildShort, so actId and userId
become optional, meta stays optional and usageTotalUsd and buildNumber become required. A Build
is therefore still not assignable to a BuildCollectionClientListItem, which requires the
usageTotalUsd that only the list endpoint always returns. Newly exposed: Build.actVersion,
BuildStats.imageSizeBytes and BuildCollectionClientListItem.buildNumberInt.

ActorRun: ActorRun no longer extends ActorRunListItem, because the spec describes the run and
the list item as two schemas that genuinely disagree. ActorRun.containerUrl becomes optional;
finishedAt, statusMessage, exitCode, buildNumber, gitBranchName, usage, usageUsd and
usageTotalUsd gain `| null`; ActorRunListItem.finishedAt becomes optional and nullable while
usageTotalUsd becomes required and userId becomes optional; ActorRunMeta.userAgent becomes
optional and gains `| null`, clientIp gains `| null`, and origin narrows from `string` to the
META_ORIGINS union; every field of ActorRunStats becomes optional and inputBodyLen gains
`| null`; ActorRunOptions.maxItems and maxTotalChargeUsd gain `| null`; every field of
ActorRunUsage gains `| null`; and ActorRunStorageIds no longer guarantees a `default` alias in
any of its three groups, nor the groups themselves. Newly exposed:
ActorRun.isStatusMessageTerminal, metamorphs and platformUsageBillingModel;
ActorRunListItem.buildNumberInt; ActorRunMeta.scheduleId and scheduledAt;
ActorRunStats.migrationCount and rebootCount; and the ActorRunMetamorph type.

Task and Store: Task.stats becomes optional and nullable; Task.username, title, options, input
and actorStandby gain `| null`; Task.actorStandby is the full ActorStandby rather than
`Partial<ActorStandby>`; TaskStats.totalRuns becomes optional; every field of TaskOptions gains
`| null`; TaskList is now derived from the spec's TaskShort, so it drops description and
actorStandby, which the list endpoint does not return, and keeps title, which it does, through a
spec-gap block; ActorStoreList.title becomes required while url and currentPricingInfo become
optional, and description, pictureUrl and userPictureUrl gain `| null`. Newly exposed:
Task.removedAt and standbyUrl; TaskOptions.maxItems and maxTotalChargeUsd; TaskList.actName and
actUsername; ActorStoreList.userFullName, categories, notice, isWhiteListedForAgenticPayments,
actorReviewCount, actorReviewRating, bookmarkCount and badge; and the full set of PricingInfo
fields, which was a one-field `{ pricingModel: string }` placeholder before.

Webhook: Webhook.lastDispatch changes from `string` to `WebhookLastDispatch | null` and becomes
optional; isAdHoc, doNotRetry, shouldInterpolateStrings, payloadTemplate and requestUrl become
optional and nullable; stats becomes optional and nullable; headersTemplate and description gain
`| null`; and WebhookStats.totalDispatches becomes optional. Newly added: the
WebhookLastDispatch type. WebhookEventType now lives in `src/models.ts` and is re-exported from
`src/resource_clients/webhook`, replacing the duplicate declaration that existed to avoid an
import cycle.

Schedule: Schedule.nextRunAt and lastRunAt change from `string` to `Date | null` and become
optional; title and description gain `| null`; notifications becomes optional and its `email`
becomes optional; ScheduleActionRunActor.runInput and runOptions gain `| null`;
ScheduleActionRunActorTask.input changes from `string` to `object | null`, which is an input
break as well as an output one, because ScheduleCreateOrUpdateData is picked from Schedule;
ScheduledActorRunInput.body and contentType become optional and nullable; and
ScheduledActorRunOptions is now the spec's TaskOptions, so build, timeoutSecs and memoryMbytes
become optional and nullable. Newly exposed: ScheduledActorRunOptions.maxItems and
maxTotalChargeUsd.

User and usage: MonthlyUsage.dailyServiceUsages[].date stays a `Date`, now published as one
through a `*ClientConversions` block rather than as the `string` it was typed as;
MonthlyUsage.monthlyServiceUsage is the published ServiceUsage rather than an inline map; and
UserPlan.enabledPlatformFeatures is `string[]` rather than the PlatformFeature enum, which was
missing three features the platform has. Newly exposed: UsageItem.priceTiers, and the
DailyServiceUsage, ServiceUsage, UsageItem and PriceTier types, which were private interfaces
before, plus TieredPricingPerDatasetItemEntry and TieredPricingPerEventEntry.
@vdusek
vdusek force-pushed the feat/openapi-generated-models branch from cfb40c1 to 2dd4303 Compare August 26, 2026 06:48
@vdusek
vdusek requested a review from B4nan August 26, 2026 10:06
@B4nan
B4nan requested a review from barjin August 26, 2026 13:10
Comment thread package.json Outdated

@barjin barjin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see any major issues, so approving. Thanks @vdusek !

One point about the generated documentation - the OpenAPI tags will leak into the generated API docs, we should render them better. I made an issue about this here

@B4nan B4nan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good to me as well

@vdusek
vdusek merged commit 6114d84 into v3 Aug 27, 2026
8 checks passed
@vdusek
vdusek deleted the feat/openapi-generated-models branch August 27, 2026 11:55
vdusek added a commit that referenced this pull request Sep 8, 2026
Port of the integration test suite from the Python API client.

Adds an integration test suite that executes the client against the live
Apify API. This is the same approach as in the Python API client: real
API calls with a test user token, resources created under unique names
and cleaned up afterwards, and eventual consistency handled by polling
helpers rather than sleeps or retries.

Merge this **before** #985, #986 and the rest of the v3 work, so those
changes have some end-to-end test coverage.

196 tests over Actors, Actor versions, Actor env vars, builds, runs,
logs, tasks, schedules, webhooks, webhook dispatches, datasets,
key-value stores, request queues, the store, and users.

`vitest.config.mts` now defines two projects, so the existing unit tests
stay fast and offline:

- `pnpm test` - unit tests only (unchanged behavior)
- `pnpm test:integration` - integration tier only
- `pnpm test:all` - both

The tier needs `APIFY_TEST_USER_API_TOKEN`.

A new `integration_tests` job in `check.yaml` runs the tier on Node 26.
It is skipped for fork PRs, where repository secrets are unavailable,
and can be triggered on demand via `workflow_dispatch`.

*✍️ Drafted by Claude Code*
vdusek added a commit that referenced this pull request Sep 8, 2026
- API response types are now generated from the OpenAPI specification
instead of hand-written, using
[openapi-typescript](https://openapi-ts.dev/).
- `pnpm generate:types` downloads the specification into git-ignored
`tmp/` and writes `src/generated/api.ts`. Only the specification version
is committed, in `package.json`.
- Nothing re-exports the generated file: `src/models.ts` declares each
published model on top of a generated schema, and `src/spec_guards.ts`
asserts every deviation at compile time.
- A nightly CI workflow regenerates and opens a pull request if anything
changed.
- It follows the same approach as the Python API client.

- Several types were outright wrong, and many fields gained null or
became optional to match what the API actually returns.
- Two runtime changes: `parseDateFields()` depth 3 -> 4, and the
key-value store's next-key check widens to `!= null`.
- Everything is described in the v3 upgrading guide.

- apify/apify-sdk-js#702 runs the v4 SDK against this branch: six type
errors, all absorbed by v4's backend adapters. Build, typecheck, unit
tests, lint and format pass.
- `handledAt` and `retryCount` break only on the v3 line, where the SDK
relies on structural `StorageClient` compatibility.

- `notify_on_failure` needs a `SLACK_WEBHOOK_URL` secret this repo does
not have.

*✍️ Drafted by Claude Code*
vdusek added a commit that referenced this pull request Sep 8, 2026
…nAPI spec (#1016)

Closes:  #1027 

## Description

This PR adds runtime validation of API responses using
[Zod](https://zod.dev/) schemas generated from the OpenAPI
specification, similar to how the Python API client validates responses
with [Pydantic](https://pydantic.dev/).

It follows up on #985, which introduced generated TypeScript types via
[openapi-typescript](https://openapi-ts.dev/).

## Schema generation

- `pnpm generate:models` now generates both:
  - `src/generated/api.ts` — TypeScript types
  - `src/generated/schemas.ts` — Zod schemas
- The nightly `regenerate_models.yaml` workflow regenerates both.
- Schemas are generated by a small custom OpenAPI-to-Zod emitter in
`scripts/schema_emitter.mts`.
- The generated schemas intentionally:
  - use loose objects, so unknown response fields are preserved,
  - treat string enums as open, so unknown enum values are accepted,
- use `z.date()` for `date-time`, because `parseDateFields()` runs
before validation,
- fail generation on unsupported JSON Schema keywords instead of
silently ignoring them.

I also considered existing generators - [hey-api](https://heyapi.dev/),
[orval](https://orval.dev/), [kubb](https://kubb.dev/). None of them
supports all the behaviors above natively, so the custom code would not
disappear; it would move into resolvers or post-processing. Since our
specification uses only a small subset of JSON Schema and all 240
generated schemas are cross-checked at compile time against the
independently generated TypeScript types, keeping the emitter local
seems like a better solution, and thanks to that to also avoid another
dependency.

## Validation

- `parseResponse()` now unwraps the API response, parses date fields,
and validates the result. All resource methods in the base clients go
through it.
- Invalid responses throw a new `ResponseValidationError`, which
includes:
  - the request method and URL,
  - validation issues,
  - the original cause,
  - a message identifying the offending fields.
- The API deviations found along the way were fixed in the specification
itself (apify/apify-docs#2932), so there are no hand-written schema
overrides now.

## Tests

- The mock server now uses spec-shaped fixtures generated from OpenAPI
examples (`test/mock_server/fixtures.ts`), with their validity checked
in `fixtures.test.ts`.
- Tests that intentionally use synthetic responses, such as pagination,
timeout, and URL-encoding tests, mock the validation step.
- The integration suite remains the main check for real API/spec drift
and passes on this branch.

## Breaking changes

- Responses that do not match the OpenAPI specification now throw
`ResponseValidationError`.
- `ScheduleClient.getLog()` now returns `ScheduleInvoked[]` instead of
`string`, matching the actual API response.
- `TaskPublicConfig` now follows the specification:
  - `publishedAt` is optional and read-only,
  - `categorization` is removed.

All breaking changes are documented in the v3 upgrading guide.

## Bundle size

The browser bundle grows from **288 kB** to **327 kB** due to the
generated schemas. The limit in `rsbuild.config.ts` is increased to
**360 kB**.

*✍️ Drafted by Claude Code*
vdusek added a commit that referenced this pull request Sep 9, 2026
The four result types #999 reported as incomplete are all typed on `v3`,
so the integration-test casts that worked around them are stale.

`ScheduleClient.getLog()` has returned `Promise<ScheduleInvoked[] |
undefined>` since #1016, so the double `as unknown` in
`schedule.test.ts` collapses to `expect(log).toEqual([])`.
`ActorCollectionListItem.stats`, `BuildShort.actId` and
`RequestBase.userData` have come from the OpenAPI spec since #985, so
`ListItemWithStats`, `ListItemWithActorId` and `RequestWithUserData` are
removed along with their casts.

`actor.test.ts` carried two more local shapes of the same kind,
`TieredPricePerDatasetItem` and `TieredChargeEvent`, under a comment
claiming the tiered-pricing fields were undeclared. They are declared:
`PricePerDatasetItemActorPricingInfo.tieredPricing`, and
`ActorChargeEvent.eventTieredPricingUsd` / `isPrimaryEvent` /
`isOneTimeEvent`. The local `eventTieredPricingUsd?: Record<string,
unknown>` was wider than the real `TieredPricingPerEvent`, so it hid the
field names it claimed to pin. Without them, filtering on `pricingModel`
narrows the pricing union on its own.

I traced each field from the spec through `src/generated/api.ts` and the
generated zod schema to the published type, against
`v2-2026-08-31T125154Z`, the stamp recorded in `package.json`. The four
affected integration files pass against the live API (65 tests). Two of
the assertions tolerate a missing field, so I also probed the listing
endpoints directly: every listed Actor carried `stats.lastRunStartedAt`
as a real `Date`, and the builds carried `actId`.

The tiered-pricing edits are type- and comment-level, so they were not
re-run against the API. Their narrowing is checked by tsc instead:
swapping the discriminant to `FLAT_PRICE_PER_MONTH` or `FREE` makes it
reject `tieredPricing` and `pricingPerEvent`.

Closes #999

*✍️ Drafted by Claude Code*
vdusek added a commit that referenced this pull request Sep 10, 2026
`ActorClient.start()`, `call()`, `validateInput()` and
`RunClient.metamorph()` took `input` as `unknown`, so anything compiled,
including values the client can't send. They now take `ActorInput`, an
alias for `object`: a JSON-serializable object or array. A string, a
number, a boolean, `null` and values typed `unknown` stop compiling.
Nothing changes at runtime. `metamorph()`'s `input` becomes optional
too.

The raw `string` body that v2 accepted alongside `contentType` is left
out on purpose, as non-JSON Actor input is being sunset. The
`contentType` option itself stays.

The issue asked for `Dictionary`, the type `TaskClient` uses. That
rejects anything typed by your own `interface`, which gets no index
signature. Task input keeps `Dictionary` because its overrides merge
into the input saved on the task.

The `ActorStandby` fields from the issue's second comment are already on
`v3` via #985. `apify-sdk-js` forwards `input?: unknown` into this
client, so it needs the same retyping before it bumps.

Closes #818

*✍️ Drafted by Claude Code*
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

adhoc Ad-hoc unplanned task added during the sprint. t-tooling Issues with this label are in the ownership of the tooling team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants