OpenAPI Spec Normalization and Codegen Artifacts
Learn how generate-openapi-artifacts.ts turns the hand-written OpenAPI 3.1 spec into a codegen-friendly 3.0.3 JSON document and a flat operation manifest that every SDK generator and the CLI consume.
Spec normalization is the build step that turns openapi/primitive-api.yaml (hand-written OpenAPI 3.1) into openapi/primitive-api.codegen.json (generated OpenAPI 3.0.3), because none of the three SDK code generators reliably consume 3.1-only schema syntax. The same build step also emits a flat operation manifest: one entry per endpoint, with its command name, HTTP method, path, parameters, and inlined request/response JSON Schemas.
Both artifacts are produced by packages/api-core/scripts/generate-openapi-artifacts.ts, run via pnpm generate:openapi inside packages/api-core, or transitively through the root make node-generate target.
Never hand-edit primitive-api.codegen.json. It's a build artifact. Edit openapi/primitive-api.yaml and regenerate. See Regenerating SDK Code from the OpenAPI Spec for the full pipeline and Monorepo Structure and Release Process for why the spec is the shared source of truth across languages.
Why normalization exists#
Normalization exists because the spec is authored as OpenAPI 3.1 while all three code generators consume a 3.0.3 document. openapi/primitive-api.yaml uses JSON Schema's type: ["string", "null"] array syntax for nullable fields; in 3.0.3, nullability is expressed with a separate nullable: true keyword instead of a type union.
generate-openapi-artifacts.ts bridges that gap with normalizeForCodegen, which walks the parsed spec recursively and rewrites it before any generator sees it.
What normalization rewrites#
Two transformations run on every schema node: nullable type arrays become nullable: true, and the message/rfc822 and application/gzip media types are re-keyed to application/octet-stream.
Nullable type arrays become nullable: true. When a schema's type is an array like ["string", "null"], the script filters out "null", keeps the remaining type as a scalar, and sets nullable: true:
// packages/api-core/scripts/generate-openapi-artifacts.ts
const typeValue = next.type;
if (Array.isArray(typeValue)) {
const nonNull = typeValue.filter((item) => item !== "null");
if (nonNull.length === 1 && nonNull.length !== typeValue.length) {
next.type = nonNull[0];
next.nullable = true;
}
}
Unsupported request/response media types collapse to application/octet-stream. The spec declares message/rfc822 (raw email) and application/gzip (payload bundles) as content types on some operations. The script re-keys their schema under application/octet-stream:
// packages/api-core/scripts/generate-openapi-artifacts.ts
if (content["message/rfc822"]) {
content[OCTET_STREAM] = content["message/rfc822"];
delete content["message/rfc822"];
}
if (content["application/gzip"]) {
content[OCTET_STREAM] = content["application/gzip"];
delete content["application/gzip"];
}
After normalization, the script forces the top-level version field:
// packages/api-core/scripts/generate-openapi-artifacts.ts
const codegenSpec = normalizeForCodegen(structuredClone(rawSpec)) as Record<string, unknown>;
codegenSpec.openapi = "3.0.3";
The result is written to openapi/primitive-api.codegen.json. The un-normalized raw 3.1 document is written separately, verbatim, as a TypeScript constant (openapiDocument in src/openapi/openapi.generated.ts) for consumers that want the original spec rather than the codegen-shaped one.
The operation manifest#
The operation manifest is a flat array with one entry per OpenAPI operation, emitted from the same run. While walking the spec, the script builds operationManifest: an array of PrimitiveOperationManifest entries, one per OpenAPI operation (a unique method+path with an operationId). This manifest is what powers the CLI's generic api-command plumbing and primitive describe, see Operation Manifest Reference for the full field list and Direct API Access and Generic Commands for how the CLI consumes it.
Each entry carries:
| Field | Description |
|---|---|
command | kebab-case CLI command name, derived from operationId |
operationId / sdkName | the original OpenAPI operation id |
method | uppercase HTTP method (GET, POST,...) |
path | the OpenAPI path template, e.g. /emails/{id}/reply |
pathParams / queryParams | resolved parameter definitions (name, type, required, enum, min/max, default) |
hasJsonBody / bodyRequired | whether the operation accepts an application/json body, and whether it's required |
requestSchema | the request body's JSON Schema, with every $ref inlined |
responseSchema | the 200/201 response's data payload schema, with every $ref inlined |
binaryResponse | true when any response declares application/octet-stream, application/gzip, or message/rfc822 |
tag / tagCommand | the operation's primary OpenAPI tag, and its kebab-case form |
Why schemas are inlined, not referenced#
Schemas are inlined so one manifest entry is self-contained: every $ref into components/schemas or components/parameters is resolved and substituted in place by inlineSchemaRefs, with cycles broken by leaving the cyclic reference as a bare { $ref: "..." }.
That lets an operator or an agent read a complete body shape without cross-referencing the rest of the spec:
primitive list-operations | jq '.[] | select(.command == "send-email") | .requestSchema'
Unwrapping the response envelope#
responseSchema carries only the data payload, not the surrounding { success, data, meta } envelope every Primitive API success response uses. A dump of the full response would bury the data shape inside identical boilerplate on every operation.
getResponseSchema special-cases this. The spec expresses success responses as allOf: [SuccessEnvelope, { properties: { data: <shape> } }] (or the list-envelope equivalent). The script walks the allOf members, finds the one that declares a data property, and returns that property's schema directly as responseSchema, skipping the success/meta boilerplate. If an operation doesn't follow the allOf+envelope idiom, the script falls back to the full inlined response schema rather than returning null.
Consumption by each generator#
All three generators read the same primitive-api.codegen.json.
- TypeScript:
@hey-api/openapi-tsreadsprimitive-api.codegen.jsonto produce the generated fetch client inside@primitivedotdev/api-core. See Architecture: Shared Codegen Pipeline. - Python:
openapi-python-clientreads the same codegen JSON. See Non-TypeScript Codegen: Go and Python Clients. - Go:
ogenreads the same codegen JSON to producesdk-go/api. See Non-TypeScript Codegen: Go and Python Clients.
All three generators consume the identical normalized document, which is exactly why the SDKs stay in sync: one spec, one normalization pass, three generated clients.
Regenerating after a spec change#
Run pnpm generate:openapi in packages/api-core whenever openapi/primitive-api.yaml changes, and commit the regenerated artifacts alongside the spec edit:
cd packages/api-core
pnpm generate:openapi
Or, to regenerate every downstream SDK client in one pass, run the root Makefile targets described in Regenerating SDK Code from the OpenAPI Spec:
make node-generate python-generate go-generate
Never hand-edit primitive-api.codegen.json or operations.generated.ts. operations.generated.ts is marked AUTO-GENERATED - DO NOT EDIT, and both files are overwritten on every run. Edit openapi/primitive-api.yaml instead.
Next steps#
Run the full pipeline that turns the spec into typed clients for every SDK language.
Operation Manifest ReferenceLook up every field on a PrimitiveOperationManifest entry in detail.
Architecture: Shared Codegen PipelineSee how api-core fits between the spec and the Node SDK / CLI.
Non-TypeScript Codegen: Go and Python ClientsSee how the same codegen JSON feeds ogen and openapi-python-client.
Was this page helpful?