This document explains how the OpenAPI generation scripts work, how to run them, how to extend them, and where things break.
The script reads source code from the Wacht monorepo and produces two OpenAPI 3.1.0 specs:
public/openapi/frontend-api.json— the Go-based frontend/runtime APIpublic/openapi/platform-api.json— the Rust-based platform/management API
It also produces two sidebar manifests used by the docs site navigation:
public/openapi/frontend-api-manifest.jsonpublic/openapi/platform-api-manifest.json
No annotations, decorators, or doc comments are required in the source code. The script infers everything from routing files, handler function signatures, and struct/DTO definitions.
pnpm generate:openapiThis runs tsx scripts/generate-openapi/index.ts. It expects the wacht monorepo to be the parent directory of wacht-docs (i.e., wacht-docs lives at wacht/wacht-docs).
scripts/generate-openapi/
├── index.ts Entry point — orchestrates parsing and writing
├── config.ts Monorepo paths and output paths
├── types.ts Shared TypeScript types (Route, OpenAPISpec, etc.)
├── go-parser.ts Parses Go router files and handler signatures (Frontend API)
├── rust-parser.ts Parses Rust router, handlers, and DTO structs (Platform API)
├── ts-schema.ts Parses TypeScript interfaces/types from react-sdk
├── openapi-builder.ts Assembles the final OpenAPI specs from parsed data
└── HANDOFF.md This document
go-parser: parseGoRoutes() → Route[] (method, path, handlerFn, tag)
go-parser: parseGoSchemas() → Map<name, schema> (struct definitions)
go-parser: parseGoHandlers() → Map<fn, info> (query params, body fields, response type)
ts-schema: parseTypeScriptSchemas() → Map<name, schema> (react-sdk type definitions)
↓
openapi-builder: buildFrontendApiSpec() → OpenAPISpec
rust-parser: parseRustRoutes() → Route[] (method, path, handlerFn, tag)
rust-parser: parseRustHandlers() → Map<fn, info> (body type, query type, response type)
rust-parser: parseRustDTOs() → Map<name, struct> (DTO and model struct definitions)
↓
openapi-builder: buildPlatformApiSpec() → OpenAPISpec
Route parsing (parseGoRoutes):
- Reads every
.gofile in therouter/directory - Resolves group chains:
v1 := app.Group("/v1"),auth := v1.Group("/auth")→/v1/auth - Converts Fiber path params (
:id) to OpenAPI format ({id}) - Infers the tag from the first meaningful path segment (
/users/...→users)
Handler parsing (parseGoHandlers):
- Reads every
.gofile in thehandler/directory - For each function matching a known route handler name, extracts:
c.Query("key")calls → query parametersc.Params("key")calls → path parameters (fallback if not already inferred from path)handler.Validate[TypeName](c)→ the request body typec.Bind().Body(&struct{...})→ inline body fieldsc.FormValue("key")/c.FormFile("key")→ form fields and file uploadsSendSuccess(c, TypeName{})→ response type name
Schema parsing (parseGoSchemas):
- Reads Go structs from handler and model files
- Extracts fields with their
json:tags, types, andomitempty(→ optional) - Converts Go types to JSON Schema primitives:
string,bool,int/int64/uint*→ string/boolean/integerfloat64→ numbertime.Time→{ type: string, format: date-time }[]T→{ type: array, items: ... }map[string]T→{ type: object, additionalProperties: ... }*T→ nullable refinterface{}/JSONMap/JSONB→{ type: object }
Route parsing (parseRustRoutes):
- Reads
.rsfiles in the router subpath - Resolves nested
Router::new().nest("/prefix", sub_router)chains - Also handles function-based routers:
fn routes() -> Routerthat call.route("/path", method(handler)) - Converts Axum path params (
:id) to OpenAPI format ({id})
Handler parsing (parseRustHandlers):
- Reads
.rsfiles in the API subpath - For each
async fn handler_name(...)it extracts extractor types:Json(body): Json<TypeName>→jsonBodyTypeQuery(params): Query<TypeName>→queryParamsTypePath(params): Path<TypeName>→pathParamsTypeMultipart→hasMultipart: true
- Extracts the response type from the return type annotation or from explicit type use in the function body
DTO parsing (parseRustDTOs):
- Scans multiple subpaths:
dto/src,models/src, andextraStructSubpathsfrom config - For each
.rsfile, extracts:- Structs with their fields, Rust types, and serde attributes (
rename,rename_all,skip,default) - Enums with their variants (applying
rename_allcasing:snake_case,camelCase, etc.) Option<T>fields →required: false
- Structs with their fields, Rust types, and serde attributes (
rustTypeToJsonSchema()converts Rust types to JSON Schema:- All integer variants (
i64,u32,FlexibleI64,StatusCode, etc.) → integer Decimal→ numberUuid→{ type: string, format: uuid }DateTime<Utc>/NaiveDate→{ type: string, format: date-time }Vec<T>→ array,Option<T>→ nullable,HashMap<K,V>→ object- Unknown named types →
$refto#/components/schemas/TypeName - Infrastructure types (
AppState,S3Client, etc.) → ignored / opaque{}
- All integer variants (
buildFrontendApiSpec and buildPlatformApiSpec both follow the same pattern:
- Build operations — for each route, look up the handler info and assemble an
OperationObjectwith parameters, request body, and response schemas - Collect schemas — every struct/DTO referenced by a route goes into
components.schemas - Fill dangling refs (
fillDanglingRefs) — any$refin the assembled spec that points to a missing schema gets stubbed as{ type: object }so the spec remains valid - Prune unreachable schemas (
pruneSchemas) — BFS walk from all operation schemas to collect reachable refs; anything not reachable is removed - Security — operations get
security: []on public routes; all others inherit from the spec-level default
Response wrapping: Go API responses are wrapped in the SendSuccess envelope: { status, message, data, session, errors }. The builder applies this automatically via wrapInEnvelope().
Tag display names: The TAG_DISPLAY_OVERRIDES map in openapi-builder.ts controls how raw tag slugs appear (e.g., api-auth → API Auth). Add entries here for any new tags that need special casing.
All paths are relative to the monorepo root (resolved from wacht-docs's position in the repo):
export const config = {
frontendApi: {
dir: '<monorepo>/frontend-api',
routerDir: 'router', // Go router files
handlerDir: 'handler', // Go handler files
modelDir: 'model', // Go model/type files
},
platformApi: {
dir: '<monorepo>/platform-api',
routerSubpath: 'platform/src/application/router',
apiSubpath: 'platform/src/api',
dtoSubpath: 'dto/src',
modelsSubpath: 'models/src',
extraStructSubpaths: [ // Additional paths scanned for struct definitions
'queries/src',
'platform/src/api',
'platform/src/application',
'commands/src',
'common/src',
],
},
reactSdk: {
typesDir: '<monorepo>/react-sdk/wacht-types/src',
},
output: {
dir: 'public/openapi',
frontendApi: 'frontend-api.json',
platformApi: 'platform-api.json',
},
};If the monorepo directory structure changes, update paths here. The MONOREPO_ROOT resolution assumes wacht-docs is one level inside the monorepo root (wacht/wacht-docs).
New routes are picked up automatically as long as they:
- Are registered in a file inside
frontend-api/router/ - Follow the
routerVar.Method("path", ...handler)pattern - Have a handler function in
frontend-api/handler/
The handler function is matched by name. If the handler uses SendSuccess(c, SomeType{}) for its response, the response schema is inferred. If it uses c.JSON(...) directly, the response is marked as raw JSON.
New routes are picked up automatically as long as they:
- Are registered in a file inside
platform/src/application/router/ - Use
.route("/path", method(handler_fn))syntax - Have a corresponding
async fn handler_fn(...)inplatform/src/api/
New DTO types used as Json<T>, Query<T>, or response types are resolved from the DTO/models scan. If a new DTO lives in a directory not currently in extraStructSubpaths, add that path to config.platformApi.extraStructSubpaths.
Symptom: fillDanglingRefs stubs the type as { type: object } — the schema appears in the spec but is empty.
Cause: The struct lives in a directory not scanned by the parser, the type name doesn't match exactly (e.g., module path prefix not stripped), or the type is a generic that couldn't be resolved.
Fix: Add the directory to extraStructSubpaths in config, or add an explicit alias in rustTypeToJsonSchema's typeAliases map.
Symptom: An operation shows no parameters or request body.
Cause (Go): The handler function name in the router doesn't match the function name in the handler file. Often happens with method receivers (handler.Method vs Method).
Cause (Rust): The handler function lives in a file not under apiSubpath, or the async function signature uses unusual extractor patterns.
Fix: Check the parsed handler map by adding a temporary console.log in index.ts after the handler parsing step.
Symptom: An operation appears under the wrong tag, or doesn't appear at all.
Cause: The tag is inferred from the first path segment. A path like /internal/users gets tag internal, not users.
Fix: The tag is set at the Route level. For the Go parser it comes from tagFromPath() — override it per-file if needed. For Rust, same function in rust-parser.ts.
Symptom: Enum values appear as PascalCase instead of snake_case or camelCase.
Cause: The serde rename_all attribute on the Rust enum wasn't picked up.
Fix: Check that the enum's serde attribute block is being parsed. The regex in parseRustEnumVariants looks for #[serde(...)] on the enum definition line. Multi-line attribute blocks may not be captured.
- Descriptions and summaries: Operation summaries are derived from the handler function name. There is no way to add a richer description without annotating the source. Future work: support a
// openapi: descriptioncomment convention. - Deprecation: Not inferred. Would need source annotation.
- Examples: Not generated. The fumadocs-openapi UI generates curl/JS/Python examples from the schema automatically.
- Enum descriptions: Variant meaning is not inferred — only the value itself.
- Conditional / polymorphic schemas:
oneOf/anyOfare only generated for TypeScript union types. Rust enums with data (e.g.,enum Foo { Bar(T), Baz(U) }) are not fully supported — they fall back to{ type: object }.
Run after any of the following:
- New routes added to
frontend-api/router/or the platform router - New handler functions added or existing signatures changed
- New DTO structs or fields added to
dto/srcormodels/src - New TypeScript types added to
react-sdk/wacht-types/src
The specs and manifests are checked into the repo under public/openapi/. Commit the regenerated files alongside your source changes.