{"schema_version":"1.0","publisher":"Primitive SDKs","canonical_url":"https://test.abhinandan.one/openapi-spec-normalization","markdown_url":"https://test.abhinandan.one/openapi-spec-normalization.md","article":{"id":"2e3d1214-c6e2-4639-9c30-02bd0edcdb1a","article_slug":"openapi-spec-normalization","parent_article_slug":null,"parent_article_title":null,"kind":"concept","published_at":"2026-08-11T18:55:04.775775+00:00","keywords":["primitive-api.codegen.json","generate-openapi-artifacts.ts","operation manifest","openapi normalizeForCodegen","PrimitiveOperationManifest","OpenAPI 3.0.3 downgrade"],"meta_description":"A build script rewrites the OpenAPI 3.1 nullable-type unions and unsupported media types into an OpenAPI 3.0.3 JSON file plus a flat operation manifest for codegen.","og_image_url":null,"source_file_paths":["packages/api-core/scripts/generate-openapi-artifacts.ts","openapi/primitive-api.yaml"],"recording_id":null,"replayable":false,"task_name":"OpenAPI Spec Normalization and Codegen Artifacts","category":"API Core / OpenAPI Generation","summary":null,"description":"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.","content_kind":"repo_page","content_markdown":"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.\n\nBoth 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.\n\n<Note>\n\nNever 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](codegen-workflow) for the full pipeline and [Monorepo Structure and Release Process](monorepo-and-releases) for why the spec is the shared source of truth across languages.\n\n</Note>\n\n## Why normalization exists\n\nNormalization 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.\n\n`generate-openapi-artifacts.ts` bridges that gap with `normalizeForCodegen`, which walks the parsed spec recursively and rewrites it before any generator sees it.\n\n## What normalization rewrites\n\nTwo 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`.\n\n\n**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`:\n\n```typescript\n// packages/api-core/scripts/generate-openapi-artifacts.ts\nconst typeValue = next.type;\nif (Array.isArray(typeValue)) {\n  const nonNull = typeValue.filter((item) => item !== \"null\");\n  if (nonNull.length === 1 && nonNull.length !== typeValue.length) {\n    next.type = nonNull[0];\n    next.nullable = true;\n  }\n}\n```\n\n**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`:\n\n```typescript\n// packages/api-core/scripts/generate-openapi-artifacts.ts\nif (content[\"message/rfc822\"]) {\n  content[OCTET_STREAM] = content[\"message/rfc822\"];\n  delete content[\"message/rfc822\"];\n}\nif (content[\"application/gzip\"]) {\n  content[OCTET_STREAM] = content[\"application/gzip\"];\n  delete content[\"application/gzip\"];\n}\n```\n\nAfter normalization, the script forces the top-level version field:\n\n```typescript\n// packages/api-core/scripts/generate-openapi-artifacts.ts\nconst codegenSpec = normalizeForCodegen(structuredClone(rawSpec)) as Record<string, unknown>;\ncodegenSpec.openapi = \"3.0.3\";\n```\n\nThe 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.\n\n## The operation manifest\n\nThe 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](operation-manifest) for the full field list and [Direct API Access and Generic Commands](cli-generic-api-access) for how the CLI consumes it.\n\nEach entry carries:\n\n| Field | Description |\n|---|---|\n| `command` | kebab-case CLI command name, derived from `operationId` |\n| `operationId` / `sdkName` | the original OpenAPI operation id |\n| `method` | uppercase HTTP method (`GET`, `POST`,...) |\n| `path` | the OpenAPI path template, e.g. `/emails/{id}/reply` |\n| `pathParams` / `queryParams` | resolved parameter definitions (name, type, required, enum, min/max, default) |\n| `hasJsonBody` / `bodyRequired` | whether the operation accepts an `application/json` body, and whether it's required |\n| `requestSchema` | the request body's JSON Schema, with every `$ref` inlined |\n| `responseSchema` | the 200/201 response's `data` payload schema, with every `$ref` inlined |\n| `binaryResponse` | true when any response declares `application/octet-stream`, `application/gzip`, or `message/rfc822` |\n| `tag` / `tagCommand` | the operation's primary OpenAPI tag, and its kebab-case form |\n\n### Why schemas are inlined, not referenced\n\nSchemas 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: \"...\" }`.\n\nThat lets an operator or an agent read a complete body shape without cross-referencing the rest of the spec:\n\n```bash\nprimitive list-operations | jq '.[] | select(.command == \"send-email\") | .requestSchema'\n```\n\n### Unwrapping the response envelope\n\n`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.\n\n`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`.\n\n## Consumption by each generator\n\nAll three generators read the same `primitive-api.codegen.json`.\n\n```mermaid\nflowchart LR\n    A[\"openapi/primitive-api.yaml<br/>(OpenAPI 3.1, hand-written)\"] --> B[\"generate-openapi-artifacts.ts<br/>normalizeForCodegen\"]\n    B --> C[\"openapi/primitive-api.codegen.json<br/>(OpenAPI 3.0.3)\"]\n    B --> D[\"operations.generated.ts<br/>operationManifest\"]\n    B --> E[\"openapi.generated.ts<br/>openapiDocument (raw 3.1)\"]\n    C --> F[\"@hey-api/openapi-ts<br/>(TypeScript / sdk-node)\"]\n    C --> G[\"openapi-python-client<br/>(Python / sdk-python)\"]\n    C --> H[\"ogen<br/>(Go / sdk-go)\"]\n```\n\n- **TypeScript**: `@hey-api/openapi-ts` reads `primitive-api.codegen.json` to produce the generated fetch client inside `@primitivedotdev/api-core`. See [Architecture: Shared Codegen Pipeline](codegen-architecture).\n- **Python**: `openapi-python-client` reads the same codegen JSON. See [Non-TypeScript Codegen: Go and Python Clients](non-typescript-codegen).\n- **Go**: `ogen` reads the same codegen JSON to produce `sdk-go/api`. See [Non-TypeScript Codegen: Go and Python Clients](non-typescript-codegen).\n\nAll three generators consume the identical normalized document, which is exactly why the SDKs stay in sync: one spec, one normalization pass, three generated clients.\n\n## Regenerating after a spec change\n\nRun `pnpm generate:openapi` in `packages/api-core` whenever `openapi/primitive-api.yaml` changes, and commit the regenerated artifacts alongside the spec edit:\n\n```bash\ncd packages/api-core\npnpm generate:openapi\n```\n\nOr, to regenerate every downstream SDK client in one pass, run the root Makefile targets described in [Regenerating SDK Code from the OpenAPI Spec](codegen-workflow):\n\n```bash\nmake node-generate python-generate go-generate\n```\n\n<Warning>\n\nNever 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.\n\n</Warning>\n\n## Next steps\n\n<CardGroup cols={2}>\n\n<Card title=\"Regenerating SDK Code from the OpenAPI Spec\" href=\"codegen-workflow\">\n\nRun the full pipeline that turns the spec into typed clients for every SDK language.\n\n</Card>\n\n<Card title=\"Operation Manifest Reference\" href=\"operation-manifest\">\n\nLook up every field on a PrimitiveOperationManifest entry in detail.\n\n</Card>\n\n<Card title=\"Architecture: Shared Codegen Pipeline\" href=\"codegen-architecture\">\n\nSee how api-core fits between the spec and the Node SDK / CLI.\n\n</Card>\n\n<Card title=\"Non-TypeScript Codegen: Go and Python Clients\" href=\"non-typescript-codegen\">\n\nSee how the same codegen JSON feeds ogen and openapi-python-client.\n\n</Card>\n\n</CardGroup>","canonical_base_url":"https://test.abhinandan.one","seo_indexing_enabled":true,"last_modified":"2026-08-21T18:22:43.359885+00:00","video_url":null,"voiceover_url":null,"tools_used":[],"demonstrated_by":[],"steps":[],"related_links":[],"intro":null,"prerequisites":[],"verification":[],"troubleshooting":[],"suggest_edit_url":"https://github.com/abhi-browzer/primitive-sdks/edit/main/packages/api-core/scripts/generate-openapi-artifacts.ts","raise_issue_url":"https://github.com/abhi-browzer/primitive-sdks/issues/new?title=Docs+feedback%3A+OpenAPI+Spec+Normalization+and+Codegen+Artifacts&body=Page%3A+https%3A%2F%2Ftest.abhinandan.one%2Fopenapi-spec-normalization","page_feedback_enabled":true,"verified_ref":null,"verified_at":"2026-08-11T18:38:45.205849+00:00"}}