How-to › Describe your API

How to publish an OpenAPI 3.0 copy beside your 3.1 document#

Keep OpenAPI 3.1 as the source of truth and build a 3.0 copy for older generators, with a guard that fails when the conversion loses or mangles anything.

Audience
Platform team
Level
advanced
Topic
Write an OpenAPI description
Verified

You moved the spec to OpenAPI 3.1, but a partner’s pinned generator only reads 3.0. You run a down-converter, it exits 0, and the copy ships. Weeks later their client accepts a total of zero, because the converter left exclusiveMinimum: 0 in place. A 3.0 reader treats that as a boolean, so the bound disappeared.

What you get

You will end up with a CI step that builds a 3.0 copy from your 3.1 source, refuses any loss you did not name, and labels the copy as derived. This is for you if one consumer’s tooling cannot read 3.1 and you will not give up 3.1 for everyone else.

Short answer

Keep the 3.1 document canonical and generate the 3.0 copy in CI with @apiture/openapi-down-convert, wrapped in a guard. The guard rewrites numeric exclusiveMinimum, refuses keywords 3.0 cannot express unless you name them, fails on 3.1 constructs the converter leaves behind, and stamps the canonical URL into the copy. Then run your oldest generator against the copy and openapi-typescript against the source.

You will need

Node 22 or later and an OpenAPI 3.1 document. Verified against Node 22.22.0. The source here uses type arrays for null, the shape the page on nullable and optional fields in OpenAPI 3.1 sets up. The 3.0.3 Schema Object is a subset of an older JSON Schema draft,1 and anything it does not list is, in its words, “strictly unsupported.”

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
@apiture/openapi-down-convertA hand-written or generated 3.1 file, and a build step you controlA guard you write and own, because it exits 0 on output a 3.0 reader rejectsYour framework already emits both versions from code
Huma dual outputYour API is written in Go with Huma, which serves 3.1 and 3.0.3 side by sideA Go framework, and its rules for type arrays, which keep the last non-null typeThe spec is written by hand, or the service is not Go
A hand-kept 3.0-compatible subsetFew schemas, and no 3.1 feature you need yetYou give up webhooks, const and null in type arrays, the features that justified 3.1You adopted 3.1 for one of those features

The converter is the only option that keeps one hand-written source and a mechanical copy, but it fails silently, so it needs the guard below. Huma writes exclusiveMinimum in the 3.0 form, but only for specs it generated itself. A hand-kept subset needs no build step, at the price of writing 3.0 forever with a 3.1 version number on it.

Wrap the converter in a guard

The converter does most of the work but reports it only when --verbose is on. Run against this page’s sample, version 0.14.2 dropped webhooks and propertyNames, rewrote the type array and const, and left exclusiveMinimum: 0 untouched. It exited 0 each time.

The guard below checks the source before conversion and the copy after it. It fails on any constraint, type or data value it cannot translate exactly. Labels go through: a $ref’s sibling description, every examples entry after the first, $comment, and the license identifier.

export function downConvert(doc31, { canonicalUrl, allowLoss = [] } = {}) {
  if (!/^3\.1\.\d+$/.test(String(doc31.openapi))) {
    throw new DownConvertError(`expected an OpenAPI 3.1 document, got ${doc31.openapi}`, [])
  }
  if (!canonicalUrl) {
    throw new DownConvertError('no canonical URL: the 3.0 copy must say where the source of truth is', [])
  }

  const lossy = lossyKeywords(doc31)
  const refused = lossy.filter((l) => !allowLoss.includes(l.keyword))
  if (refused.length) {
    throw new DownConvertError('3.0 cannot express these, and allowLoss does not name them', refused)
  }

  const webhooks = Object.keys(doc31.webhooks ?? {})
  const doc = new Converter(structuredClone(doc31), { verbose: false }).convert()
  const rewrites = fixExclusiveBounds(doc)

  const left = [...changedProperties(doc31, doc), ...changedData(doc31, doc), ...leftovers(doc)]
  if (left.length) {
    throw new DownConvertError('the 3.0 copy would not say what the source says', left)
  }

  const note = `Generated from the canonical OpenAPI 3.1 document at ${canonicalUrl}. ` +
    'Report problems against that document, not this copy.'
  doc.info.description = doc.info.description ? `${note}\n\n${doc.info.description}` : note
  doc.info['x-derived-from'] = canonicalUrl

  return { doc, report: { lost: lossy, webhooks, rewrites } }
}

fixExclusiveBounds turns exclusiveMinimum: 0 into minimum: 0 with exclusiveMinimum: true, which is how 3.0 writes the same bound.2 When both bounds are set it keeps the stricter one. leftovers walks every schema for type arrays, type: 'null', const, prefixItems, a $ref with siblings and the other 2020-12 keywords. It skips property names, so a field called if is not a keyword. changedData compares every example, default and enum value before and after, because the converter walks those values as though they were schemas and rewrites an examples array or a const key it finds inside one.

Build the copy in CI

Run the build on every change to the source. The first run stops, because the labels map constrains its keys with propertyNames and 3.0 has nothing that says the same thing.

node build-30.mjs spec/openapi.yaml public/openapi-3.0.yaml --canonical https://api.example.com/v2/openapi.json
refused: 3.0 cannot express these, and allowLoss does not name them
  propertyNames at /components/schemas/Order/properties/labels

Decide, then say so in the command. Here the key pattern is a server-side check that a 3.0 client can live without.

node build-30.mjs spec/openapi.yaml public/openapi-3.0.yaml --canonical https://api.example.com/v2/openapi.json --allow-loss propertyNames
spec/openapi.yaml (3.1.0) -> public/openapi-3.0.yaml (3.0.3)
  canonical   https://api.example.com/v2/openapi.json
  dropped     webhook orderShipped: 3.0 has no webhooks
  lost        propertyNames at /components/schemas/Order/properties/labels (allowed)
  rewrote     exclusiveMinimum: 0 at /components/schemas/Order/properties/total

Publish the copy at a URL that names its version, and keep the canonical one at the plain address. The copy’s info.description opens with the canonical URL, and x-derived-from carries it for tools. A consumer who files a bug quotes a document that tells them where to file it.

Check it worked

Run the slowest-moving generator you support against the copy, and a 3.1-native one against the source. openapi-typescript-codegen is unmaintained and points users at Hey API, which makes it a fair stand-in for the pinned tool. openapi-typescript reads 3.1 directly.

node generate.mjs
generator                   input       couponCode      schemaVersion         file
openapi-typescript-codegen  3.1 source  string | null   any                   string
openapi-typescript-codegen  3.0 copy    string | null   Order.schemaVersion   Blob
openapi-typescript          3.1 source  string | null   2                     string

Read the first row before trusting a generator’s silence. Fed the 3.1 file, the old generator did not reject it. It typed const as any and the upload as string, so the generated method will not take a Blob without a cast. From the copy it produced Blob and an enum. openapi-typescript types binary as string by default, and its Node API documents a transform to Blob.

Then lint the copy against the 3.0 schema. This configuration turns on only Redocly’s struct rule and reference resolution, so style warnings stay out of the result.

rules:
  struct: error
  no-unresolved-refs: error
npx redocly lint public/openapi-3.0.yaml --format=stylish 2>&1
Woohoo! Your API description is valid. 🎉
node --test downconvert.test.mjs
1..15
# tests 15
# suites 0
# pass 15
# fail 0

Each test feeds the guard shapes the converter mishandles and asserts the exact pointers it reports.

When it goes wrong

The converter exits 0 on a copy that a 3.0 validator rejects. Run the converter without the guard and lint what it wrote.

npx openapi-down-convert -i spec/openapi.yaml -o naive-3.0.yaml && npx redocly lint naive-3.0.yaml --format=stylish
naive-3.0.yaml:
  74:29  error    struct  Expected type `boolean` but got `integer`.

That is the total bound from the opening paragraph. Lint every copy before publishing it.

A field disappears from the copy. The converter walks a properties map as though it were a schema, so a property named after a keyword is treated as one.

npx openapi-down-convert -i spec/keyword-names.yaml
components:
  schemas:
    Rule:
      type: object
      properties:
        kind:
          type: string
        enum:
          - type: string

The source had $schema, const and kind. The copy deleted $schema, a property Huma’s schema link transformer adds to its response models, and renamed const to enum. The guard compares property names before and after, so the same input fails with property $schema lost, property const lost and property enum added.

The nullable $ref written as oneOf: [{ $ref }, { type: 'null' }] comes through unchanged and fails 3.0. There is no exact rewrite, because a 3.0 Reference Object ignores anything written beside it, nullable included. Written instead as a $ref with type: [object, 'null'] beside it, the reference converts to nullable: true beside the $ref, which the same rule hides from a 3.0 reader. The guard refuses both forms, and any other $ref sibling that is more than a label, so a person decides what the 3.0 client should assume.

When not to do this

Do not publish both documents without saying which one is canonical. Consumers report bugs against whichever one their tool fetched. A description that names the source turns those reports into pointers rather than arguments.

Do not let the copy become a second source. Never edit public/openapi-3.0.yaml by hand, because the build overwrites it on every run.

Do not add --allow-loss to make a red build green. Each allowed keyword is a rule the 3.0 client will not enforce, so name it only after deciding the server-side check is enough.

Do not keep the copy forever. Ask the consumer which generator version would read 3.1, and give the copy a removal date. If your service is written with Huma, use its own /openapi-3.0.json rather than this pipeline, because the framework has the types the converter has to guess.

Last verified

Verified 2026-10-15 against Node 22.22.0, @apiture/openapi-down-convert 0.14.2, @redocly/cli 2.54.3, openapi-typescript 7.13.0 and openapi-typescript-codegen 0.31.0. Every output block is what the preceding command printed in the page’s code directory after npm ci. The Huma behavior was read from its source and observed with v2.39.1 under Go locally, outside the captured runs.

Footnotes

  1. The 3.0.3 specification calls it “JSON Schema Specification Wright Draft 00.” That names the draft after its editor, Austin Wright, as the Internet-Draft’s header shows. The JSON Schema project’s own specification links file that draft as Draft 5, a cleanup of Draft 4 that kept Draft 4’s meta-schemas. Its draft-06 release notes then ask implementers not to implement or advertise support for draft-05. OpenAPI 3.0 builds on it all the same, by the name that leaves the number out. ↩︎ Back to text

  2. The boolean form is the older one. JSON Schema draft-06 changed both keywords from a boolean to a number. Its release notes give the reason as “the principle of keyword independence.” Since then exclusiveMinimum has not needed minimum beside it to mean anything, so a down-converter has to put the neighbor back. The Huma source does, and on type arrays it settles the matter with a comment: “Last non-null wins.” ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.