Skip to content

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

Draft
vdusek wants to merge 2 commits into
v3from
feat/openapi-generated-models
Draft

feat!: generate all output models from the OpenAPI spec#985
vdusek wants to merge 2 commits into
v3from
feat/openapi-generated-models

Conversation

@vdusek

@vdusek vdusek commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Generates every output model from the published OpenAPI specification via openapi-typescript instead of hand-writing it.

How it works

  • pnpm generate:types downloads the specification into git-ignored tmp/ and writes src/generated/api.ts. The specification is not committed -- only the version it was generated from, in apify.openapiSpec.version 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, so an invalidating spec change fails pnpm build:node.
  • A nightly workflow regenerates on master and opens a pull request when src/generated/api.ts changed. Not automerged. Same layout as apify-client-python.

Notes

  • Breaking: six published types were outright wrong, two of them contradicting the client's own runtime; four shapes deliberately keep their hand-written form, argued at each declaration. The commit messages have the full breakdown and the BREAKING CHANGE: lists.
  • Types only, except parseDateFields()' depth limit, which goes from 3 to 4 so a list response gets the same Date conversion as the single resource it wraps.
  • No PR-time check that the types match the specification: the input is no longer committed, so a check would have to hit docs.apify.com and would go red on any docs redeploy. The nightly run is the mechanism, as in the Python client.
  • The second commit is a review pass: it fixes two date fields the first commit published as string although the client converts them, closes an optionality hole in the width guard, drops 336 lines of generated output nothing imported, and corrects the first commit's footer where it got dailyServiceUsages[].date wrong.

Important

notify_on_failure needs a SLACK_WEBHOOK_URL repository secret, which this repo does not have yet. Without it a failed nightly run reports nowhere.

Follow-ups

  • RequestQueueClientBatchRequestsOperationResult still types the batch delete path, where the API answers with BatchDeleteResult. Fixing it needs a new published type and a changed return type.

✍️ 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/30831544683#summary-91746449836

@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
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.

Seven published types were wrong, four 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 `Date` that was never converted, because it does
not end in `At`. `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`.

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`.

Types only, with one runtime exception: `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.[y].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()`.

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 condition.
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 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.

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`;
ActorVersionSourceFiles.sourceFiles accepts ActorVersionSourceFolder entries as well; and
FinalActorVersion.buildTag is `string | null` rather than `string`. 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. 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 no longer carries `title` as
an inherited required field; 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`;
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 account limits: User.profile becomes optional; UserPlan.enabledPlatformFeatures changes
from `PlatformFeature[]` to `string[]` and every other field of UserPlan becomes optional;
ProxyGroup.description gains `| null`; UserProfile.bio, pictureUrl, githubUsername, websiteUrl
and twitterUsername gain `| null`; and the daily usage entries carry `date` as a `string` rather
than a `Date`. Newly exposed: UserPlan.tier, apiRateLimitBoosts, maxScheduleCount,
maxConcurrentActorRuns and planPricing; Limits.maxScheduleCount; Current.scheduleCount; and the
UserProfile, EffectivePlatformFeature and EffectivePlatformFeatures types, which were private
interfaces before.

RequestQueue: RequestQueue.name, actId and actRunId gain `| null` and stats becomes optional;
RequestQueueClientListItem.retryCount becomes optional and loses `lockExpiresAt`, which moves to
the new RequestQueueClientLockedListItem where it is required;
RequestQueueClientRequestSchema.id, uniqueKey and url become optional, handledAt changes from
`string` to `Date | null`, payload widens to `string | object | null`, headers to
`object | null`, and errorMessages, noRetry and loadedUrl gain `| null`;
RequestQueueClientGetRequestResult is now the full request schema rather than a queue-head
projection; RequestQueueClientListAndLockHeadResult no longer extends
RequestQueueClientListHeadResult, and its clientKey and queueHasLockedRequests become optional;
and the four batch and add methods take RequestQueueClientRequestToAdd, so `uniqueKey` and `url`
are required on every submitted request. Newly exposed: RequestQueue.consoleUrl,
RequestQueueClientLockedListItem and RequestQueueClientRequestToAdd.
@vdusek
vdusek force-pushed the feat/openapi-generated-models branch from 8ca8555 to 057a750 Compare August 3, 2026 15:31
Two published types were wrong. `MonthlyUsage.dailyServiceUsages[].date` was typed `string`, but
`UserClient.monthlyUsage()` passes a matcher that converts it, so the caller is handed a `Date`;
it is now published as one, in a `*ClientConversions` block, and a guard reports the day the
specification starts typing it as a date-time. `RequestQueue.expireAt` was typed `string` for the
same reason in reverse: the key ends in `At`, so `parseDateFields()` has always converted it, and
`RequestQueueShort` types it as a date-time upstream.

`OverridesStayWider` could not see optionality drift, and its own comment claimed
`OverridesStillExist` covered it -- `keyof` does not distinguish `x` from `x?`, so a field the API
demoted to optional would keep being published as required with the build green. Dropping the
`Required<>` lens is enough: `Pick` keeps each key's optionality, so the assignability check now
answers that question too. No existing override violates it.

`WebhookDispatchGuards` asserted `Equals<WebhookDispatch['eventType'], WebhookEventType>`, which is
true by construction because `eventType` is one of the keys the override block supplies. Deleted;
`EnumGuards` and the width guard already cover the drift, and the docblock already said only
`requestUrl` was asserted.

The `rootTypes` options were emitting 336 exported aliases nothing imports, since both consumers of
the generated file go through `components`. Turned off.

`models.ts` now classifies deviations the way its header claims. Three `stats` re-points moved out
of `*SpecNarrowings`, which is for the spec being narrower than the API, into `*RePointed`; the
three blocks where the published type is narrower than the spec on purpose are `*ClientNarrowings`;
and the conversion above is the fifth kind. Seventeen identical boilerplate comments are gone --
`origin: ValueOf<typeof META_ORIGINS>` already says where it comes from.

The tiered-pricing and service-usage maps are published as interfaces over published entry types
rather than aliases into the generated file, which is what the re-pointing was for in the first
place: as aliases they rendered the entry as an indexed access into `./generated/api`, and the
entries had no reference page at all.

Also: three misplaced or wrong exclusion comments in `spec_guards.ts`; a permanent 4xx no longer
costs three download attempts and 10 seconds; `pathToFileURL` instead of interpolating the cwd into
a `file://` string; the nightly workflow assigns the recorded version before echoing it, so a
failed read cannot pass as an empty one, and says in its close comment that a hand-pushed commit is
not carried onto the rebuilt branch.

BREAKING CHANGE: `RequestQueue.expireAt` changes from `string` to `Date`, and
`MonthlyUsage.monthlyServiceUsage` is the published `ServiceUsage` rather than an inline map.
`MonthlyUsage.dailyServiceUsages[].date` stays a `Date`, contrary to the previous commit's footer,
which wrongly reported it as becoming a `string`. Newly exposed: `UsageItem.priceTiers`, and the
`DailyServiceUsage`, `ServiceUsage`, `UsageItem` and `PriceTier` types, which were private
interfaces before, plus `TieredPricingPerDatasetItemEntry` and `TieredPricingPerEventEntry`.
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.

2 participants