RFC: Schema-driven OpenAPI generation (Standard Schema → /_openapi.json) #4402
oumarbarry
started this conversation in
Ideas
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
-
This is a concrete proposal for #2974 and #3542: use validation schemas (Zod, Valibot, ArkType, anything Standard Schema) as the single source of truth for both runtime validation and the generated OpenAPI spec.
Motivation
I'm building a payment platform on Nitro. The public API is consumed by third parties, so it needs a proper OpenAPI 3.1 spec we can feed into SDK generators, the way Stripe does it. Everything in the pipeline is typed except one link:
Today that
???is manual duplication.defineRouteMetaonly takes static inline JSON. The AST extraction insrc/build/plugins/route-meta.tsdrops anything that isn't a literal (imported identifiers, function calls), and it does so silently. So the same shape lives twice and drifts:What I'd actually like to write, using APIs h3 v2 already ships:
...and have
/_openapi.jsonpick those schemas up automatically. FastAPI proved this model years ago: define the schema once, get validation, docs and typed clients out of it. Elysia, Hono (hono-openapi), Fastify and tRPC all converged on the same idea.Alternatively, with schema-aware handlers in H3 (future, FastAPI-style)
If h3's handler API keeps moving in this direction, the fully sugared version could look like:
This mirrors FastAPI, where
def create_payment(payment: PaymentCreate) -> PaymentResponsevalidates, types and documents the route in one signature. Compared to today'sdefineValidatedHandlerthe differences are small: a per-statusresponsemap, andopenAPIsitting next toschemainstead of insidemeta. Nothing downstream changes either way, since the OpenAPI route reads the same schema objects off the handler. I see this as the north star to converge on while the experimental APIs stabilize, not a prerequisite. Most of this shape was already discussed in h3js/h3#1088 (more on that below).What already exists (this is closer than it looks)
h3 v2 already has most of the handler API.
defineValidatedHandler(h3js/h3#1097) takesvalidate: { body, headers, query }with Standard Schema support, and an experimentaldefineRoutelanded in h3js/h3#1143 out of the h3js/h3#1088 discussion. SincedefineHandlermerges the definition onto the handler function withObject.assign, the live schema objects are already readable at runtime viahandler.validate. That one detail carries most of this proposal.Response schemas were requested but got deferred. The original h3js/h3#1088 proposal included
outputschemas, and @zsilbi suggested aRecord<StatusCode, StandardSchemaV1>map. pi0 asked for a minimal first version, so h3js/h3#1143 shipped without them. That's the gap Phase 2 fills. The thread already treats OpenAPI as a target use case, too: pi0 brought up Nitro'sdefineRouteMetastatic extraction himself, and later comments point at Hono'szod-openapias the DX to match.Converting schemas to JSON Schema stopped being a per-vendor problem in December. Standard JSON Schema (published in
@standard-schema/spec@1.1.0) defines~standard.jsonSchema.input/output({ target }), with the input/output asymmetry and target selection built in. Zod (4.2+) and ArkType (2.1.28+) implement it natively on the schema itself, verified against zod 4.4.3 and arktype 2.2.2. Valibot's raw schema doesn't carry it, but@valibot/to-json-schema1.5+ exports atoStandardJsonSchema()wrapper that does, which I also verified against 1.7.1. A small fallback still covers whatever's left: libraries on older versions, or ones that haven't adopted the spec at all.On the Nitro side,
src/runtime/internal/routes/openapi.tsalready assembles the document at runtime from thehandlersMetavirtual module, including a$globalmerge for shared components. And h3 vendors the Standard Schema interface internally, so the abstraction-layer question is settled.The missing piece is narrow: the OpenAPI route can't see schemas that are already sitting on the handlers.
Proposal
Phase 1: read
handler.validatein the OpenAPI route (small PR, no breaking change)The
/_openapi.jsonhandler iterates registered routes. For handlers defined viadefineValidatedHandlerordefineRoute, it convertsvalidate.body/validate.query/validate.headersto JSON Schema (through~standard.jsonSchemawhen the schema implements it, vendor fallback otherwise) and merges the result into the operation object next to existingdefineRouteMetadata.Why runtime instead of build-time extraction? The validation schemas are already in the production bundle, because they validate requests. Converting them when the spec is requested costs nothing extra and is always exact: no AST limitations, no sandboxed evaluation. It's also what every framework in the prior art table does. The result can be cached after first generation.
One bonus: shared
components/schemasget much easier. When two routes reference the same schema object, runtime identity detects it and the generator emits a single$ref. AST extraction can only approximate this. It can match import sources, but something likepaymentSchema.omit({...})produces a value no static analysis can prove identical.The trade-off I know about: generating the spec loads route handlers that would otherwise stay lazy. In dev that's fine. In production the endpoint only exists when you enable
openAPI.production, so default production behavior doesn't change.Phase 2: add
response(andparams) tovalidatein h3defineValidatedHandleranddefineRoutecurrently supportbody,headersandquery. Response schemas are the part of h3js/h3#1088's original proposal that was deferred out of h3js/h3#1143, and they're what SDK generation needs most. I'd proposevalidate.responseaccepting either a single schema (shorthand for the success response, like FastAPI'sresponse_model) or a per-status map like{ 200: schema, 404: schema }, which is what @zsilbi suggested in that thread and what Fastify does. This is a small, self-contained PR on the h3 side.Phase 3: emit
openapi.jsonas a build artifactFor SDK pipelines you want a file in CI, not a running server:
nitro build, then.output/openapi.json, thenopenapi-typescript/ hey-api / orval. Instead of sandboxed build-time evaluation, the artifact can be produced the way FastAPI users produce theirs. FastAPI builds its spec at runtime from the app object, and the documented offline pattern is a small script that imports the app and dumpsapp.openapi()to a file. The Nitro equivalent: load the built app (Node preset) after build, call the same runtime generator, write the file. Could be anitro openapicommand or a build hook. This does impose one rule on app code: route modules must be importable without a live production environment. A module that opens a database connection or asserts secrets at the top level (rather than inside its handler) would crash the generation step in CI. FastAPI has the exact same constraint, since dumpingapp.openapi()imports all your route modules too, and its ecosystem has lived with it without much trouble.Build-time evaluation of
defineRouteMeta(#2974 as originally scoped) stays valuable for static and prerendered outputs, and it could reuse the same conversion logic later, but nothing above depends on it. Nothing depends on the bundler either: this works the same under Rollup, Rolldown and Vite.Prior art
@hono/zod-openapi/hono-openapi.input()/.output()defineRouteMetastatic JSONvalidateon h3 handlersFastAPI is the strongest precedent: one Pydantic model validates request and response data at runtime and produces the complete OpenAPI 3.1 spec with zero duplication. Every TypeScript framework in the table converged on that pattern, and all of them generate the spec at runtime from live schema objects. Nitro is the only one extracting statically at build time, which is exactly why it's the only one that can't use validator schemas. With Standard Schema for validation and Standard JSON Schema for conversion, h3 and Nitro can close that gap without coupling to any single library.
Out of scope
validate,meta), Nitro produces the document.@hono/zod-openapi/hono-openapi,@elysiajs/openapi), their spec endpoints are served through the mount already. Unifying everything into one document could be a later follow-up.Open questions
defineValidatedHandler({ validate, meta, handler })works today; the FastAPI-styledefineHandler({ schema, openAPI, handler })sketched above may be where h3 wants to end up. Both read the same way downstream, so this can be decided independently of Phases 1 to 3. (The defineRoute h3js/h3#1088 thread also discussed multi-method route definitions, and pi0 leaned toward supporting both.)~standard.jsonSchemadirectly, so conversion is spec-driven with no per-vendor code. Valibot's schema doesn't, but wrapping it with@valibot/to-json-schema'stoStandardJsonSchema()produces a spec-compliant value, and the prototype does exactly that. The open part is what to do for whatever's left after those three: a generic per-vendor fallback list Nitro maintains, or leaving it to throw with a clear message until a library adopts the spec?validate.responseexists, h3 could check what handlers actually return and catch schema/implementation drift, at some runtime cost. In defineRoute h3js/h3#1088, @zsilbi sketched a response helper for validating outputs before returning them from the handler, which goes in that direction. Or response schemas could stay documentation-only, like Fastify's default. If validation is wanted, is it opt-in, or on by default in dev only?Prototype (working)
I built this as a userland Nitro module to check the idea holds up. Demo repo: oumarbarry/nitro-openapi-schemas. It runs on published
nitro@3.0.x-beta,h3@2.0.1-rc.22andzod@4, without forks or patches.The module itself is about 50 lines. It emits a virtual module that imports each scanned route handler directly, which lets the OpenAPI route read the
validateschemas h3 already attaches to handler functions. The generator is another 200 lines or so, runtime-agnostic, converting through~standard.jsonSchema(native on Zod and ArkType, one wrapper call away on Valibot) with a fallback for whatever's left. Named schemas get hoisted intocomponents/schemasand$refed across routes, using each library's own naming convention (Zod's.meta({ id }), ArkType's.configure({ id }), Valibot'sv.metadata({ id })), including schemas nested inside other schemas: Zod's$defsget hoisted and rewritten the same way.The single-source-of-truth part works as hoped: the schema that rejects
{"amount": -5, "currency": "BTC"}with a 400 is the same one that emitsPaymentCreatewith"enum": ["USD", "EUR", "XOF"]in the spec, and Nitro's own Scalar UI renders it at/_scalar. The build-artifact side works too: a small script boots the built output, snapshotsopenapi.json, andopenapi-typescriptturns that into a typed SDK withcurrency: "USD" | "EUR" | "XOF". The whole Drizzle-to-SDK pipeline runs end to end.Worth naming the trade-off directly: the virtual module statically imports every route handler it collects, which bypasses whatever lazy-loading Nitro would otherwise apply to them. In core this would stay scoped to the OpenAPI chunk (dev by default, opt-in in prod), and the build-artifact path avoids it in production entirely.
Happy to turn this into PRs if the direction sounds right, starting with Phase 1, which is small and backwards compatible.
Related: #2974, #3542, #2912, #2717, discussion #2056 · h3js/h3#1088, h3js/h3#1097, h3js/h3#1143 · standard-schema/standard-schema#134
Beta Was this translation helpful? Give feedback.
All reactions