@pronghorn/openapi
v0.1.0
Published
OpenAPI 3.1 document generation and Swagger UI for Pronghorn, built directly from your routes' Zod schemas via z.toJSONSchema.
Maintainers
Readme
OpenAPI 📘
OpenAPI is a lightweight, TypeScript-first documentation plugin built as an external extension for Pronghorn. It generates a live OpenAPI 3.1 specification and a Swagger UI page directly from your routes' existing Zod schemas, with zero duplicated schema definitions and zero extra dependencies.
Built as a standalone package (
@pronghorn/openapi), readsapp.getRoutes()and converts each route'sschema.body/schema.queryvia Zod 4's nativez.toJSONSchema(), nozod-to-json-schemathird-party dependency required.
Why OpenAPI
Fastify has strong auto-generated API docs via @fastify/swagger, reading directly from a route's schema option. Pronghorn's routes already declare Zod schemas for validation via the schema option, but nothing turned that into browsable documentation. This plugin closes that gap without asking you to define your API shape twice.
- Generates a full OpenAPI 3.1 document from routes you've already registered, no separate spec file to maintain.
- Converts Zod schemas to JSON Schema using Zod 4's built-in
z.toJSONSchema(), keeping validation and documentation perfectly in sync by construction. - Serves a ready-to-use Swagger UI page at a configurable path, no separate static asset bundling needed.
- Path parameters (
:idsegments) are automatically included as OpenAPI path parameters. - Regenerates the spec on every request to the JSON endpoint, so newly registered routes always show up without a rebuild step.
- Zero runtime dependencies beyond
zod(already a Pronghorn dependency).
Installation
bun add @pronghorn/openapiRequires Bun >=1.3.0, pronghorn >=0.1.2 as a peer dependency, and Zod 4 (Pronghorn's own dependency) for z.toJSONSchema() support.
Quick Start
import { createApp } from 'pronghorn'
import { openApiPlugin } from '@pronghorn/openapi'
import { z } from 'zod'
const app = createApp()
app.get('/users/:id', context => context.json({ id: context.params.id }), {
schema: { query: z.object({ include: z.string().optional() }) }
})
app.post('/users', context => context.json({ created: true }), {
schema: { body: z.object({ name: z.string(), email: z.string().email() }) }
})
// Register last, after all routes are defined
await app.register(openApiPlugin, { title: 'My API', version: '1.0.0' })
await app.listen(4000)GET /openapi.json-> the generated OpenAPI 3.1 spec.GET /docs-> an interactive Swagger UI browsing that spec.
Register openApiPlugin after all your routes are defined, it reads app.getRoutes() lazily on each request to /openapi.json, so routes registered afterward are still picked up, but registering the plugin itself before any routes exist would only mean an empty spec until the first request arrives, not a hard requirement.
Core Concepts
Path parameters
Any :param segment in a registered route is automatically converted into an OpenAPI path parameter, typed as a string, no extra configuration needed.
app.get('/orders/:orderId/items/:itemId', handler)
// -> documented as /orders/{orderId}/items/{itemId} with two required path parametersQuery parameters
Declared via a route's schema.query, each top-level Zod field becomes an individual OpenAPI query parameter, with required reflecting whether the field is optional in the schema.
app.get('/search', handler, {
schema: {
query: z.object({
q: z.string().min(1),
page: z.coerce.number().default(1),
tags: z.array(z.string()).optional()
})
}
})Request bodies
Declared via a route's schema.body, converted into an OpenAPI requestBody with a application/json media type and the full JSON Schema representation of your Zod object.
app.post('/orders', handler, {
schema: {
body: z.object({
productId: z.string(),
quantity: z.number().int().positive()
})
}
})Custom metadata
Pass title, version, description, and servers to populate the spec's info and servers sections, shown at the top of the Swagger UI page.
await app.register(openApiPlugin, {
title: 'Storefront API',
version: '2.1.0',
description: 'Public API for the storefront checkout flow',
servers: [
{ url: 'https://api.example.com', description: 'Production' },
{ url: 'http://localhost:4000', description: 'Local development' }
]
})Custom paths
Change where the spec and UI are served if /openapi.json//docs conflict with existing routes.
await app.register(openApiPlugin, {
jsonPath: '/api/spec.json',
docsPath: '/api/docs'
})Plugin Options
| Option | Type | Default | Description |
| ------------- | ----------------------------------------- | ----------------- | ---------------------------------------------------------- |
| title | string | 'API' | Spec title shown in info.title and the Swagger UI header |
| version | string | '1.0.0' | API version shown in info.version |
| description | string | - | Optional longer description shown in info.description |
| jsonPath | string | '/openapi.json' | Path serving the raw OpenAPI JSON document |
| docsPath | string | '/docs' | Path serving the Swagger UI HTML page |
| servers | { url: string; description?: string }[] | [] | Server base URLs listed in the spec |
API Reference
openApiPlugin: PluginFn - register via app.register(openApiPlugin, options).
Lower-level document generation is also exported for advanced use, e.g. writing the spec to a static file at build time instead of serving it dynamically.
import { buildOpenApiDocument } from '@pronghorn/openapi'
const document = buildOpenApiDocument(app.getRoutes(), { title: 'My API', version: '1.0.0' })
await Bun.write('openapi.json', JSON.stringify(document, null, 2))Architecture
OpenAPI is split into two modules, each with a single responsibility.
| Module | Responsibility |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| document.ts | Walks registered routes, converts path segments and Zod schemas into an OpenAPI 3.1 paths object via z.toJSONSchema() |
| plugin.ts | Registers the /openapi.json and /docs routes on the app, serving the generated document and a Swagger UI shell that loads it |
The Swagger UI page itself loads swagger-ui-dist from a CDN (unpkg.com) rather than bundling static assets into the package, keeping the plugin's own footprint minimal. If you need fully offline docs, host swagger-ui-dist yourself and point docsPath at your own HTML instead.
Limitations
- Only
schema.bodyandschema.queryare documented for now. - Response schemas are not inferred meaning every documented endpoint currently shows generic
200/400descriptions without a response body shape yet. - Query parameter nesting (objects within objects) is flattened to top-level parameters only, deeply nested query schemas may not render as expected in Swagger UI.
License
WTFPL (Do What the Fuck You Want to Public License), see LICENSE for details.
