@accorudo/openapi
v0.0.2
Published
Extract Accorudo contract bundles into an intermediate representation and emit OpenAPI 3.1 specs.
Maintainers
Readme
@accorudo/openapi
Extracts an Accorudo contract bundle (an api.ts route registry plus its routes/ and models/) into an intermediate representation (IR), and emits that IR as an OpenAPI 3.1 document.
This package is a programmatic API only — the CLI lives in @accorudo/cli (init / import / export / sync), which wraps this package's parseOpenApi/generateContracts (import direction) and extractBundle/emitOpenApi (export direction).
Why an IR
A Route<...> alias only resolves to concrete method / path / pathParams / query / body / response values after TypeScript's checker resolves its generics (CombineRouteFragments, indexed access, imported fragments). Regex or AST-only parsing can't do that, so extraction goes through ts-morph and the real type checker.
Rather than emitting OpenAPI directly off the AST, extraction produces a ContractBundle — operations plus components.{schemas,parameters,responses,requestBodies,securitySchemes} — and OpenAPI is just one emit target on top of that IR. This keeps import, export, and future codegen from re-implementing the same walk.
Install
pnpm add @accorudo/openapiUsage
import { extractBundle, emitOpenApi, stringifyOpenApi } from "@accorudo/openapi";
const bundle = extractBundle("./contracts/main", { name: "main" });
const doc = emitOpenApi(bundle, { info: { title: "Main API", version: "1.2.0" } });
console.log(stringifyOpenApi(doc, "yaml")); // or "json"The reverse direction — generate contract files from an OpenAPI spec:
import { parseOpenApi, parseOpenApiText, generateContracts } from "@accorudo/openapi";
import fs from "node:fs";
const doc = parseOpenApiText(fs.readFileSync("./openapi/main.yaml", "utf8"), "yaml");
const bundle = parseOpenApi(doc, { name: "main" });
for (const file of generateContracts(bundle)) {
console.log(file.path); // "api.ts", "routes/widget.ts", "models/widget.ts", ...
}extractBundle(bundleDir, options) reads bundleDir/api.ts (configurable via entryFile), auto-detects the route registry type exported from it (an object type whose every property structurally has method and path — this is what lets it skip over a co-exported Api<Routes> alias), and walks every operation and every @openapi.component-tagged type reachable under bundleDir.
If the bundle isn't self-contained (it imports app types via ~/types/* or similar), pass tsConfigFilePath so those resolve:
extractBundle("./contracts/main", { tsConfigFilePath: "./tsconfig.json" });By default, components.schemas entries that no operation actually reaches are dropped from the result. Pass includeUnusedComponents: true to keep them.
Annotations
Extraction and emission are driven by @openapi.* JSDoc tags on route aliases, model types, and their properties — @openapi.component, @openapi.type/@openapi.format, @openapi.nullable/@openapi.required, @openapi.inline, @openapi.response CODE [Ref], @openapi.tag, @openapi.security, and more. See OpenAPI annotations for the full reference — this package implements that vocabulary.
Reusable @openapi.component parameters types resolve to $refs automatically wherever they're used as a query/path property's type (e.g. limit?: LimitParameter inside a *QueryParams type) — @openapi.parameter Name on the route alias is only needed for a parameter that isn't otherwise a literal property of pathParams/query (e.g. a shared header).
Public API
| Export | Purpose |
|---|---|
| extractBundle(bundleDir, options?) | Walk a contract bundle into a ContractBundle IR |
| filterBundleByTags(bundle, tags) | Drop operations (and now-unreachable components) not matching any of tags |
| emitOpenApi(bundle, options?) | Convert IR into a plain OpenApiDocument object |
| parseOpenApi(doc, options?) | Convert a raw OpenApiDocument into a ContractBundle IR |
| generateContracts(bundle, options?) | Convert IR into { path, content }[] TypeScript contract files. Pass importStyle: "umbrella" for accorudo/* imports, or "scoped" (default) for @accorudo/*. The CLI picks this automatically from your package.json. |
| stringifyOpenApi(doc, "yaml" \| "json") | Serialize an OpenApiDocument |
| parseOpenApiText(text, "yaml" \| "json") | Parse spec text into an OpenApiDocument |
| parseOpenApiJSDoc + getFlag/getValue/getList/getPair(s)/getMapping/getNumber/omitTag | Low-level @openapi.* tag parsing, if you're building your own emitter |
| walkType, buildComponentIndex, WalkContext | The schema walker, for advanced/custom extraction |
| AccorudoOpenApiError | Thrown on unresolvable registries or bundle type errors |
All IR types (ContractBundle, OperationNode, SchemaNode, ...) are exported from the package root.
Scope
- ✅
contracts/ → ContractBundle → OpenAPI(export) - ✅
OpenAPI → ContractBundle(import) —parseOpenApi - ✅
ContractBundle → contracts/(codegen) —generateContracts - ❌ CLI — lives in
@accorudo/cli(separate package)
Known limitations of parseOpenApi / generateContracts
allOfis flattened into a mergedobjectnode — composition semantics are not preserved.- A formatless
integerschema is parsed with a syntheticformat: "int32"so it round-trips back to"integer"on export, since the IR has no separate"integer"kind. - Inline (non-
$ref) secondary responses are promoted into synthesized, mechanically-namedcomponents.responsesentries during codegen, because the@openapi.response CODE Refannotation can only reference a named response. - Inline
header/cookieparameters are promoted into synthesizedcomponents.parametersentries during parsing, sinceOperationNodeonly has slots for path/query parameters. - Model file grouping (
models/<tag>.tsvsmodels/common.ts) is a deterministic single-tag-reachability rule and may place a schema differently than a human would. - No
CombineRouteFragmentssynthesis (always one flatRouteFragmentper path) and no response-wrapper-generic inference (Success<T>/CollectionSuccess<T>) — generated names and shapes are mechanical. Review generated files before committing, per the CLI's ownimportguidance.
Development
pnpm --filter @accorudo/openapi test # vitest
pnpm --filter @accorudo/openapi build # tsc -> dist/test/fixtures/widget-bundle is a small hand-written bundle exercising components, $ref vs @openapi.inline, path/query parameters (including reusable parameter components), multiple response codes, security schemes, and a multipart route. test/emit.spec.ts extracts it and diffs the result against test/fixtures/widget-bundle.expected.yaml.
