opencodegen
v0.3.1
Published
Generate typed API clients from OpenAPI specifications
Downloads
466
Maintainers
Readme
OpenCodegen
Generate typed TypeScript API clients from OpenAPI 3.0 / 3.1 specifications.
OpenCodegen is opinionated. The output is a single fixed shape — interface per schema, class per OpenAPI tag inheriting from a shared BaseClient, native fetch, no barrel index.ts, constObject enums by default. The config options listed below are the only knobs. If you need a different layout (Axios, hooks, alternate enum styles, split request/response types), this isn't the tool.
Install
npm install --save-dev opencodegen
# or
bun add -d opencodegenQuick start
Create opencodegen.config.ts in your project root:
import { defineConfig } from 'opencodegen';
export default defineConfig({
source: 'https://api.example.com/openapi.json',
target: './src/generated',
codegen: {
dateType: 'string',
enumType: 'constObject',
propertyNameStyle: 'original',
nullableType: 'null',
},
});Then run:
npx opencodegenRun npx opencodegen --help for flag details (custom config path, verbose output).
Generated output
src/generated/
├── base.ts # BaseClient, ApiConfig, ApiError
├── types.ts # All interfaces
├── pets-client.ts # PetsClient extends BaseClient
└── store-client.ts # StoreClient extends BaseClientUse it:
import { PetsClient } from './generated/pets-client.js';
import { ApiError } from './generated/base.js';
const client = new PetsClient({ baseUrl: 'https://api.example.com' });
try {
const pet = await client.getPet(123);
} catch (err) {
if (err instanceof ApiError) {
// err.body is typed as ApiErrorBody — the union of declared 4xx/5xx response schemas
console.error(err.status, err.body?.message);
}
}Authentication
BaseClient accepts a headers option that's resolved on every request. It can be a static object, a sync function, or an async function — pick whichever fits your token source.
Static — API key or pre-issued bearer
const client = new PetsClient({
baseUrl: 'https://api.example.com',
headers: { Authorization: `Bearer ${API_TOKEN}` },
});Dynamic — sync
For tokens you can read synchronously (env var, in-memory store, redux selector):
const client = new PetsClient({
baseUrl: 'https://api.example.com',
headers: () => ({
Authorization: `Bearer ${tokenStore.current()}`,
}),
});Dynamic — async (MSAL, Auth0, refresh flows)
The function runs on every request. Rely on your auth library's own token cache so you're not hitting the network each call:
const client = new PetsClient({
baseUrl: 'https://api.example.com',
headers: async () => {
// acquireTokenSilent caches internally and only refreshes near expiry
const result = await msalInstance.acquireTokenSilent({ scopes });
return { Authorization: `Bearer ${result.accessToken}` };
},
});Per-request overrides
Every generated method accepts a final requestOptions argument. Per-request headers are merged on top of config headers (last-write-wins), so you can override or add headers for a single call without rebuilding the client:
await client.getPet(123, {
headers: { 'X-Correlation-ID': crypto.randomUUID() },
});Cancellation
The same requestOptions shape carries an AbortSignal:
const ctrl = new AbortController();
const promise = client.listPets({ signal: ctrl.signal });
ctrl.abort(); // rejects `promise` with an AbortErrorSharing config across clients
The ApiConfig object is just a plain object — build it once and pass the same reference to every client. The auth library's token cache stays shared, so a refresh triggered by PetsClient is reused by StoreClient:
// src/api.ts
import { type ApiConfig } from './generated/base.js';
import { PetsClient } from './generated/pets-client.js';
import { StoreClient } from './generated/store-client.js';
export const apiConfig: ApiConfig = {
baseUrl: 'https://api.example.com',
headers: async () => {
const result = await msalInstance.acquireTokenSilent({ scopes });
return { Authorization: `Bearer ${result.accessToken}` };
},
};
export const pets = new PetsClient(apiConfig);
export const store = new StoreClient(apiConfig);Mutating the shared config later (e.g. swapping baseUrl for a different environment) is reflected by every client on its next request, since each client reads from this.config per call.
Method signatures
Generated methods follow a fixed argument order so the call site stays predictable:
client.<operationId>(
...pathParams, // required, positional, in path order
...requiredHeaderParams, // required headers/cookies, positional
body, // if the operation has a request body
params, // optional object — query parameters
headerParams, // optional object — optional headers
cookieParams, // optional object — optional cookies
requestOptions, // optional — { headers?, signal? }
);Example for PUT /pets/{id} with a body, an optional ?expand= query, and a required X-Tenant-Id header:
const pet = await client.updatePet(
123, // path param
'tenant-a', // required X-Tenant-Id header
{ name: 'Rex', tag: 'dog' }, // body
{ expand: 'owner' }, // optional query params
undefined, // no optional headers
undefined, // no optional cookies
{ signal: ctrl.signal }, // requestOptions
);Config options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| source | string | required | Path or URL to OpenAPI spec (JSON or YAML) |
| target | string | required | Output directory |
| codegen.dateType | 'string' \| 'Date' \| 'dayjs' | 'string' | How to type format: date/date-time |
| codegen.enumType | 'constObject' \| 'union' \| 'enum' | 'constObject' | How to generate enums |
| codegen.propertyNameStyle | 'original' \| 'camelCase' | 'original' | Property naming |
| codegen.nullableType | 'null' \| 'undefined' | 'null' | How to represent nullable fields |
| codegen.int64Type | 'number' \| 'bigint' \| 'string' | 'number' | How to type format: int64 |
| codegen.clientSuffix | 'Client' \| 'Api' | 'Client' | Suffix on generated class names |
Features
- OpenAPI 3.0 and 3.1 support (including
$dynamicRefgenerics,prefixItems,const, type-array nullability) allOfinheritance,oneOf/anyOfunions,discriminatortagged unions- Multipart /
FormDatauploads, binary (Blob) responses - Async dynamic headers (e.g. token refresh), per-request header overrides,
AbortSignalcancellation - Typed error responses (
ApiErrorBody) andApiErrorclass - Optional
dayjsdate handling with auto-generated reviver/replacer - JSDoc comments from OpenAPI descriptions, including
@deprecated
Working with .NET specs
Method names from operationId. Methods are named after the spec's operationId. When it's absent (common in NSwag / Microsoft.AspNetCore.OpenApi output), the name falls back to the last non-template path segment, camelCased — /api/Dalux/GetMeters becomes getMeters.
Integer enum names (x-enum-varnames). OpenCodegen uses the x-enum-varnames extension to name integer enum members. Without it, members fall back to synthetic _0, _1, ... names.
{
"Status": {
"type": "integer",
"enum": [0, 1, 2],
"x-enum-varnames": ["Pending", "Active", "Archived"]
}
}→
export const Status = { Pending: 0, Active: 1, Archived: 2 } as const;
export type Status = typeof Status[keyof typeof Status];Microsoft.AspNetCore.OpenApi doesn't emit x-enum-varnames out of the box — add a schema transformer that writes the C# enum member names into the extension before the document is serialized. NSwag's x-enumNames is not supported; pick a transformer that emits the OpenAPI Generator convention so the two ends agree.
Limitations
These cases are detected and rejected with a descriptive error rather than silently producing wrong output:
- External file
$ref— only internal#/components/...references are resolved. Inline external schemas or pre-merge them. - URL
$ref— same; inline the referenced schema. - Swagger 2.0 — detected and rejected with a hint to convert via
swagger2openapi. OpenAPI 3.0+ only. - Query parameter
style: deepObject— not supported. Other styles (form,spaceDelimited,pipeDelimited) work. - Multipart request bodies with nested objects —
FormDatacan't represent nested objects; flatten the schema or sendapplication/jsoninstead. Top-level scalars,Blob, and arrays of those are fine.
These are deliberate scope cuts at this stage — file an issue if one is blocking real work.
readOnly / writeOnly are currently treated as informational only. The same generated type is used for both request and response bodies, so server-generated fields like id or createdAt (commonly marked readOnly) appear on request types as well. Pass them as undefined on create requests, or use Omit<T, 'id'> at the call site.
License
MIT
