@ttoss/openapi-codegen
Generate an SDK-ready merged OpenAPI document and a CLI route manifest from a directory of per-module OpenAPI spec files.
This package extracts the spec-merging and CLI-route-generation logic that
SOAT's @soat/sdk and @soat/cli packages use to
turn their per-module OpenAPI YAML files into a generated SDK client and a
generated CLI. It's generic: any project that authors one OpenAPI spec file
per resource/module can reuse it to generate its own SDK and CLI.
Installation
pnpm add -D @ttoss/openapi-codegen
mergeOpenApiSpecs
Merges every .yaml/.yml file in a directory into a single OpenAPI
document: paths are combined, components (schemas, responses,
securitySchemes, parameters) are combined (first file to define a name
wins on a collision), the first non-empty servers array found is kept, and
cross-file $refs (e.g. ./widgets.yaml#/components/schemas/Widget) are
rewritten to local refs (#/components/schemas/Widget) so the merged
document is self-contained.
A $ref into a file that is not in specsDir — a shared components file —
pulls the component it names into the merged document, together with whatever
that component refs in turn, so the rewritten local ref has something to point
at. schemas, responses and parameters can be pulled; a cross-file ref
naming anything else, a file that is not there, a component that file does not
declare, or a remote document is rejected rather than rewritten into a
dangling ref. A pulled component keeps its name, so a name a different
definition already holds is an error: the ref that lost would otherwise point
at the definition that won.
import fs from 'node:fs';
import { mergeOpenApiSpecs } from '@ttoss/openapi-codegen';
const merged = mergeOpenApiSpecs({
specsDir: './openapi/v1',
info: { title: 'My API', version: '1.0.0' },
});
fs.writeFileSync('./merged-spec.json', JSON.stringify(merged, null, 2));
Feed the resulting file to a spec-to-client generator such as
@hey-api/openapi-ts to produce a TypeScript SDK.
generateCliRouteManifest / renderCliRoutesSource
Reads the same per-module spec files and builds a map from CLI command name
(kebab-case, derived from operationId) to the SDK service class, HTTP
method, parameters, and request-body flags needed to dispatch and document
that command — without running any HTTP-client codegen.
import fs from 'node:fs';
import {
generateCliRouteManifest,
renderCliRoutesSource,
} from '@ttoss/openapi-codegen';
const routes = generateCliRouteManifest({
specsDir: './openapi/v1',
moduleDocsUrl: (moduleSlug) => `https://my-docs.dev/modules/${moduleSlug}`,
});
fs.writeFileSync('./src/generated/routes.ts', renderCliRoutesSource(routes));
A CLI entry point (e.g. built with commander)
then reads routes.ts and dynamically calls the matching SDK service method
for each command.
Parameters
Every OpenAPI parameter location becomes a CLI flag: path, query,
header, and cookie. Each Route also lists the parameter names per
location (pathParams, queryParams, headerParams, cookieParams) so the
CLI entry point knows where to send each value. Path parameters default to
required; every other location defaults to optional unless the spec says
required: true.
A parameter a spec declares must always reach the CLI, so
generateCliRouteManifest throws — rather than dropping the parameter — when
it meets one it cannot turn into a flag: a $ref it cannot resolve, or an
in value that is not one of the four locations above. Silently dropping
either would leave the flag missing from --help with nothing to explain why.
Request bodies
Each property of a JSON request body becomes a flag with in: 'body', sent
as that property of the body. A body that names no property — a map, a scalar
or an array — would get no flag that way, so it gets one flag with
in: 'body-root', whose value is the entire body. It is named body, or
whatever the body schema's x-cli-flag-name says, and it is required when the
requestBody is:
TagBag:
type: object
additionalProperties:
type: string
x-cli-flag-name: tags # replace-actor-tags --tags '{"team":"finance"}'
A whole-body flag sharing its name with a parameter throws, since the CLI could not tell which one a value is for.
Flag types
Every flag carries the JSON type its schema names, so the CLI knows which
values to parse as JSON before sending them. A schema that constrains the
value to no single type is typed unknown (exported as UNKNOWN_FLAG_TYPE)
rather than guessed as string: in OpenAPI 3.0 an absent type means
unconstrained — 3.0 has no union type, so every oneOf/anyOf schema omits
it — and string is the one type a CLI must not parse as JSON, so guessing
it would send an object flag to the server as text and leave the manifest
unable to say whether the spec really asked for a string.
$refs and composition keywords are read through first: a union whose members
all agree takes their type, and an allOf takes the type of the member that
names one. As with parameters, a schema $ref that cannot be resolved throws,
naming the spec that wrote it and what was missing, rather than silently
degrading the flag it types.
Shared components across files
A $ref may name another file — ./common.yaml#/components/schemas/Metadata
— and is read relative to the spec that wrote it, so a shared components file
can point at a third file of its own. Refs are followed across files wherever
they appear: a path or operation parameter, a request body, a body property, a
oneOf member. Each file is parsed once per run, however many specs point
into it.
The pointer has to name a component — #/components/schemas/... or
#/components/parameters/.... A ref to a whole file, to anything outside
components, or to a remote document is rejected rather than half-resolved.
The same layout feeds the merged SDK document: mergeOpenApiSpecs pulls a
referenced file's components in rather than leaving the ref dangling.
Naming conventions
By default, operationIds are converted to kebab-case commands
(listWidgets → list-widgets) and tags are converted to PascalCase service
class names (AI Providers → AIProviders). Pass operationIdToCommand and
tagToClassName to generateCliRouteManifest to use different conventions.
Notes
- Spec files are read in sorted filename order, so which file wins a
componentsnaming collision is deterministic. - A request body's
oneOfvariants are merged into one flag set; a field is only marked required when every variant requires it. - This package does not generate the SDK client itself — it produces the
merged spec that a client generator like
@hey-api/openapi-tsconsumes.