---
title: "OpenAPI Spec Normalization and Codegen Artifacts"
canonical: "https://test.abhinandan.one/openapi-spec-normalization"
markdown_url: "https://test.abhinandan.one/openapi-spec-normalization.md"
publisher: "Primitive SDKs"
kind: "concept"
content_type: "reference"
category: "API Core / OpenAPI Generation"
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."
keywords: ["primitive-api.codegen.json", "generate-openapi-artifacts.ts", "operation manifest", "openapi normalizeForCodegen", "PrimitiveOperationManifest", "OpenAPI 3.0.3 downgrade"]
last_modified: "2026-08-21T18:22:43.359885+00:00"
published_at: "2026-08-11T18:55:04.775775+00:00"
source_files:
  - "packages/api-core/scripts/generate-openapi-artifacts.ts"
  - "openapi/primitive-api.yaml"
sections:
  - {anchor: "why-normalization-exists", title: "Why normalization exists"}
  - {anchor: "what-normalization-rewrites", title: "What normalization rewrites"}
  - {anchor: "the-operation-manifest", title: "The operation manifest"}
  - {anchor: "why-schemas-are-inlined-not-referenced", title: "Why schemas are inlined, not referenced"}
  - {anchor: "unwrapping-the-response-envelope", title: "Unwrapping the response envelope"}
  - {anchor: "consumption-by-each-generator", title: "Consumption by each generator"}
  - {anchor: "regenerating-after-a-spec-change", title: "Regenerating after a spec change"}
  - {anchor: "next-steps", title: "Next steps"}
---

> Documentation index: https://test.abhinandan.one/llms.txt

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

> **Note:** 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](https://test.abhinandan.one/codegen-workflow.md) for the full pipeline and [Monorepo Structure and Release Process](https://test.abhinandan.one/monorepo-and-releases.md) 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`:

```typescript
// 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`:

```typescript
// 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:

```typescript
// 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](https://test.abhinandan.one/operation-manifest.md) for the full field list and [Direct API Access and Generic Commands](https://test.abhinandan.one/cli-overview/cli-generic-api-access.md) 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:

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

```mermaid
flowchart LR
    A["openapi/primitive-api.yaml<br/>(OpenAPI 3.1, hand-written)"] --> B["generate-openapi-artifacts.ts<br/>normalizeForCodegen"]
    B --> C["openapi/primitive-api.codegen.json<br/>(OpenAPI 3.0.3)"]
    B --> D["operations.generated.ts<br/>operationManifest"]
    B --> E["openapi.generated.ts<br/>openapiDocument (raw 3.1)"]
    C --> F["@hey-api/openapi-ts<br/>(TypeScript / sdk-node)"]
    C --> G["openapi-python-client<br/>(Python / sdk-python)"]
    C --> H["ogen<br/>(Go / sdk-go)"]
```

- **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](https://test.abhinandan.one/codegen-architecture.md).
- **Python**: `openapi-python-client` reads the same codegen JSON. See [Non-TypeScript Codegen: Go and Python Clients](https://test.abhinandan.one/non-typescript-codegen.md).
- **Go**: `ogen` reads the same codegen JSON to produce `sdk-go/api`. See [Non-TypeScript Codegen: Go and Python Clients](https://test.abhinandan.one/non-typescript-codegen.md).

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:

```bash
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](https://test.abhinandan.one/codegen-workflow.md):

```bash
make node-generate python-generate go-generate
```

> **Warning:** 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.
