@seothos/openapi-generator
v0.1.0
Published
Generate Angular code from an OpenAPI spec: TypeScript types, HTTP services, NgRx Signal Store stores, signal forms, and native-HTML form components (Angular Material skin optional).
Maintainers
Readme
@seothos/openapi-generator
Generate Angular code from an OpenAPI specification: TypeScript types, HTTP services, NgRx Signal Store stores, signal forms, and ready-to-use form components — plus an optional throwaway test app to preview everything.
All generated UI is native semantic HTML by default (no UI-kit dependency): form components, filter bars, and paginated list views. Prefer Angular Material? Opt in per writer:
import {
MaterialComponentOutputWriter,
MaterialFilterComponentOutputWriter,
MaterialListViewOutputWriter,
} from '@seothos/openapi-generator/generator';
createConfig({
// …
componentOutputWriter: new MaterialComponentOutputWriter(),
filterComponentOutputWriter: new MaterialFilterComponentOutputWriter(),
listViewOutputWriter: new MaterialListViewOutputWriter(),
});The Material skins additionally need @angular/material installed, a theme,
and — for the filter datepickers — the date-adapter provider matching your
date library (e.g. provideLuxonDateAdapter() from
@angular/material-luxon-adapter) in your app config. The Material list views
use cdk virtual scrolling (infinite scroll); the native default paginates
instead.
You point it at a spec (a URL or an inline JSON object) and a config file, run one command, and get a folder of typed, idiomatic Angular code.
Requirements
The generated code targets a modern Angular app:
- Angular 21+ (
@angular/core,@angular/common/http,@angular/forms/signals) @ngrx/signals21+ — only for the opt-instoreEngine: 'ngrx'(an optional peer); the default'signals'engine emits plain classes with no ngrx dependency@angular/material— only for the opt-in Material skins; the default native output has no UI-kit dependency at allluxon— only if you setdateType: 'DateTime'(dates becomeDateTime)
@angular/core and @ngrx/signals are declared as peer dependencies, so in a
normal Angular workspace they're already installed.
Install
npm install --save-dev @seothos/openapi-generator
# or: pnpm add -D @seothos/openapi-generator
# or: yarn add -D @seothos/openapi-generatorQuick start
1. Create a config file
The generator reads a config file that default-exports a config object built
with createConfig. Use a .mjs (or .js) file so Node can run it without any
extra tooling.
Loading the spec straight from a running server:
// openapi.config.mjs
import { createConfig } from '@seothos/openapi-generator/generator';
export default createConfig({
url: 'https://api.example.com/openapi.json',
outputDir: './src/app/generated',
});Or from a local spec file:
// openapi.config.mjs
import { readFileSync } from 'node:fs';
import { createConfig } from '@seothos/openapi-generator/generator';
const spec = JSON.parse(readFileSync('./openapi.json', 'utf-8'));
export default createConfig({
json: spec,
outputDir: './src/app/generated',
});TypeScript config
Prefer a typed config? Use openapi.config.ts — you get autocompletion and
type-checking on every option, and you can import the config enums:
// openapi.config.ts
import { readFileSync } from 'node:fs';
import {
createConfig,
TypeGroupingMode,
} from '@seothos/openapi-generator/generator';
const spec = JSON.parse(readFileSync('./openapi.json', 'utf-8'));
export default createConfig({
json: spec,
outputDir: './src/app/generated',
typeGroupingMode: TypeGroupingMode.NO_GROUPING,
dateType: 'DateTime',
fileHeader: `import { DateTime } from 'luxon';`,
});Node can't import .ts directly, so run it through a TypeScript loader such as
tsx:
npx tsx ./node_modules/@seothos/openapi-generator/bin.js openapi.config.ts2. Run the generator
# .mjs / .js config — no extra tooling
npx openapi-generate openapi.config.mjs
# .ts config — via a TypeScript loader
npx tsx ./node_modules/@seothos/openapi-generator/bin.js openapi.config.tsThat's it — the generated code lands in outputDir.
Add it to your package.json so it's repeatable:
{
"scripts": {
"generate:api": "openapi-generate openapi.config.mjs",
"generate:api:ts": "tsx ./node_modules/@seothos/openapi-generator/bin.js openapi.config.ts"
}
}CLI
openapi-generate <config-file-path> [--verbose | --timings | --quiet]| Flag | Output |
| ------------ | ---------------------------------------------------------------- |
| (none) | Basic progress messages and a completion summary. |
| --verbose | Per-step [START]/[DONE] logs with durations. |
| --timings | Timing summary only (⏱️ <step>: XXms) — useful for profiling. |
| --quiet | Errors only — useful in CI. |
What gets generated
Inside outputDir you'll get (each can be toggled off — see below):
| Output | Description |
| -------------- | ----------------------------------------------------------------------- |
| types | types.gen.ts interfaces, plus defaults.ts with default-value seeds. |
| endpoints | Typed endpoint metadata grouped by tag. |
| services | HttpClient-based services, one method per operation. |
| stores | NgRx Signal Store per resource, with call-state and entity helpers. |
| state | Shared state contracts used by the stores. |
| forms | Signal-forms classes with validation derived from the schema. |
| components | Standalone form components wired to the stores (native HTML by default, Material opt-in). |
| schemas | Component schema metadata. |
| test-app | An optional standalone app that routes to every generated component. |
A top-level index.ts re-exports everything, and runtime helpers
(withEntityCollection, CallState, …) are imported from this package.
Using the generated code
Provide the API base URL
Generated services build request URLs from the API_ENV injection token.
Provide it once in your app config:
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { API_ENV } from '@seothos/openapi-generator';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
{ provide: API_ENV, useValue: { production: false, apiUrl: 'https://api.example.com' } },
],
};Use a generated store or component
Names are derived from the operation and tag, so the exact symbols depend on
your spec. For an operation like POST /account-holders you'd get something
like:
import { Component, inject } from '@angular/core';
import {
PostCreateAccountHolderComponent,
AccountHoldersStore,
} from './generated';
@Component({
selector: 'app-root',
imports: [PostCreateAccountHolderComponent],
template: `
<app-post-create-account-holder-component
[showNotifications]="false"
(submitSuccess)="onCreated($event)"
/>
`,
})
export class App {
protected readonly store = inject(AccountHoldersStore);
onCreated(result: unknown) {
console.log('created', result);
}
}Generated components emit submitSuccess, submitError, and formCancel
outputs, and expose a notifications toggle (default true) so you can suppress
the built-in feedback and drive your own notifications instead: the native
components announce results in an inline aria-live region behind
showNotifications; the Material skin uses MatSnackBar behind showSnackbar.
On a failed submission, server-side validation errors are mapped onto the
form fields automatically: the component's submit() action runs the
response through serverErrorsToFieldErrors (exported from the generated
utilities), which understands the common API shapes —
{ errors: { fieldPath: message } }, { errors: [{ field, message }] }, and
single { field, message } — and each error displays under the field it
names (dotted paths reach nested fields; anything unmapped lands on the form
root). The errors clear when the user edits the field.
Extending a component with your own template
The generated component ships with a default template, but the form
plumbing lives in the TypeScript class — formInstance, the onSubmit() /
onCancel() handlers, isSubmitting(), the hasError() / getErrorMessage()
helpers, the injected store, and the inputs/outputs. Those members are
protected, so you can subclass the generated component and supply your own
template while reusing all of that logic. Nothing needs to be reimplemented —
you only swap the markup.
import { Component } from '@angular/core';
import { FormField } from '@angular/forms/signals';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { PostCreateAccountHolderComponent } from './generated';
@Component({
selector: 'app-account-holder-form',
imports: [FormField, MatFormFieldModule, MatInputModule, MatButtonModule],
// Your markup, bound to the inherited (protected) members:
template: `
<form (ngSubmit)="onSubmit()">
<mat-form-field>
<mat-label>Name</mat-label>
<input matInput [formField]="formInstance.form.name" />
@if (hasError('name')) {
<mat-error>{{ getErrorMessage('name') }}</mat-error>
}
</mat-form-field>
<!-- …the rest of your fields… -->
<button mat-raised-button type="submit" [disabled]="isSubmitting()">
Save
</button>
</form>
`,
})
export class AccountHolderForm extends PostCreateAccountHolderComponent {}Because you're subclassing, the initialValue / notification inputs and the
submitSuccess / submitError / formCancel outputs are inherited too — use
the component exactly like the generated one, just with your markup.
Emitted contracts (for custom writers)
Every writer is pluggable (componentOutputWriter, formOutputWriter, …
options on createConfig; each abstract base has a single write* method the
orchestrator awaits). The generated outputs reference each other by name,
so a from-scratch writer must honor these shapes — or replace the writers that
consume them too:
- Stores (
stores/): per tag,${Tag}Store(entity) and${Tag}ListStore(collection). Every operation is one method named after the PascalCase operationId:store.${PascalOperationId}({ ...pathParams, body, queryParams })returns a coldObservableof the typed response — subscribing triggers the call, store state (item/collection + call state) updates on success, and errors reach the subscriber's error channel. Fire-and-forget is.subscribe(); components bridge to signal-forms'submit()withfirstValueFromat that one framework boundary. - Forms (
forms/):${PascalOperationId}Form—form(the signal-formsFieldTree),isValid(),isDirty(),getValue(),setValue(v),patchValue(v),reset(). Bulk (array-body) forms add master-detail state:selectedIndex(),addItem(),removeItem(i),selectItem(i). A bulk endpoint that accepts a single entity also gets${PascalOperationId}SingleForm. - Endpoints (
endpoints/): query-params type${PascalOperationId}Paramsand a${PascalOperationId}ParamsDefaultseed. - Defaults (
defaults/):${TypeName}Defaultsper named body type — used to seed forms and new array rows. - Naming: snake_case operationIds become PascalCase; file/selector names
are the kebab-case of the class name. The endpoint model hands writers
ep.storeName,ep.bodyModel,ep.body.isArray,ep.collectionMutation?.acceptsSingleetc. — writers should consume those rather than re-deriving.
To re-skin the built-in component pipeline instead of replacing it, extend
DefaultComponentOutputWriter (native HTML) or MaterialComponentOutputWriter
and override its protected seam methods (grep SKIN SEAM in the source).
Material-free by default
Every default writer emits native HTML — a workspace without
@angular/material can use the full output (forms, filters, list views, test
app) with no toggles. Only the opt-in Material* writers reintroduce the
Material dependency.
Configuration reference
Pass these to createConfig. Only outputDir and one of url / json are
required; everything else has a sensible default.
Source (required — pick one)
| Option | Type | Description |
| ------ | -------------- | -------------------------------------------- |
| url | string | Fetch the OpenAPI spec from this URL. |
| json | OpenApiSpec | Use an already-loaded spec object. |
Common options
| Option | Type | Default | Description |
| ------------------------ | --------- | ----------------------------- | -------------------------------------------------------------------------------------------- |
| outputDir | string | (required) | Directory the generated code is written to. |
| clearDirectory | boolean | false | Empty outputDir before writing. |
| dateType | string | 'string' | TS type for date/date-time fields, e.g. 'DateTime' (luxon) or 'Date'. |
| fileHeader | string | '' | Text prepended to generated files — handy for imports your dateType needs. |
| useReadOnlyArrays | boolean | true | Emit readonly T[] for array properties. |
| typeGroupingMode | enum | NO_GROUPING | How generated types are grouped into files. |
| servicePrefixPathMatch | string | '' | Endpoints whose path contains this string go into a separate, prefixed service (e.g. external → ExternalCustomersService). |
| devtools | boolean | false | Compose withDevtools() into each store for Redux DevTools visibility. |
| listClassifier | (endpoint) => boolean \| undefined | (heuristic) | Which endpoint is a tag's list (paginated collection store). false = never a list (demoted to a single-value slot); true = the tag's primary list (first true wins — a tag's list store hosts exactly one collection); undefined = default heuristic (first paginated-wrapper GET per tag, in spec order). Receives the extracted model (PascalCase operationIds). |
| storeEngine | 'signals' \| 'ngrx' | 'signals' | Store engine for the (coupled) state + store writers. signals emits flat @Injectable classes on plain Angular signals — no ngrx dependency, extendable via class MyStore extends UsersListStore. ngrx emits the signalStore-based output (needs the @ngrx/signals peer; required for devtools). Both engines expose the identical public store surface, so switching is config-only. |
| listMode | 'accumulate' \| 'replace' \| (endpoint) => mode | 'accumulate' | How list stores treat loaded pages. accumulate appends into the live collection (infinite-scroll-friendly; the native list view paginates over it client-side). replace keeps only the current page (the native view pages server-side via loadPage(offset)). A function decides per endpoint — note it receives the extracted model, where operationIds are PascalCase. The Material list-view skin supports accumulate only. |
| runtimePackage | string | '@seothos/openapi-generator'| Package the generated code imports runtime helpers from. Change only if you re-publish them. |
Output toggles
All write* options default to true; set to false to skip that output.
writeTypes, writeEndpoints, writeServices, writeStores, writeState,
writeForms, writeComponents, writeFilters, writeListViews,
writeComponentSchemas, writeTypeMapping, writeTestApp.
writeComponents emits a request-body form component per write endpoint.
writeFilters emits a filter-bar component per list (paginated GET) endpoint
that has query filters — a header of controls for the list query, driving the
list store's filter() method. Each filter component emits its assembled filters
via a filtersChanged output (wire it to the store), supports a live input
(debounced auto-emit, hides the Apply/Clear buttons), a hiddenFilters array
input to hide individual filter fields, and an additionalFilters input for
filters supplied from outside the bar (merged into every emit and re-emitted when
they change — e.g. constrain a list to the current parent entity):
<app-list-customers-filter
[live]="true"
[hiddenFilters]="['status']"
[additionalFilters]="{ customer__eq: customerId() }"
(filtersChanged)="store.filter($event)" />writeListViews emits an infinite-scrolling list-view component per list
(paginated GET) endpoint, plus a set of reusable ui-table primitives (a
CDK-virtual-scroll table with Material sortable headers) emitted once into
list-views/ui-table/. Each view renders one column per scalar field of the
list's item type and is emits-only: pass the list store's items()/total()
in, and wire scrolled to loadMore() (fired as the user nears the end) and
sortChange to sortBy():
<app-list-customers-list-view
[items]="store.items()"
[total]="store.total()"
(scrolled)="store.loadMore()"
(sortChange)="store.sortBy($event)" />export default createConfig({
url: 'https://api.example.com/openapi.json',
outputDir: './src/app/generated',
dateType: 'DateTime',
fileHeader: `import { DateTime } from 'luxon';`,
writeTestApp: false, // skip the preview app
});Generated test app
When writeTestApp is enabled (the default), the generator also fills in a
standalone Angular app that routes to every generated form component — a
quick way to click through all your forms without wiring anything up yourself.
It writes app.routes.ts, app.ts, app.html, app.config.ts, and styles,
with one route per operation that has a request body, plus a fallback report
route listing any schema fields it couldn't map to a control. When writeFilters
is on, it also adds a Filters nav section: one route per list endpoint with a
demo host (filter-demos.ts) that wires the filter bar's filtersChanged output
to the list store's filter() and shows the loaded item count. When
writeListViews is on, a Lists nav section does the same for the list views:
a demo host (list-view-demos.ts) injects each list store, loads the first page,
and wires the view's scrolled/sortChange outputs to loadMore()/sortBy().
The generator does not scaffold the Angular app for you — it only fills an app that already exists. Create one first (nx, the Angular CLI, etc.), then re-run the generator. If the target directory isn't there, the test app step is skipped with a hint instead of writing orphan files.
Two one-time additions the generator doesn't make to the app shell: a Material
theme @import in src/styles.css, and — for the filter bars' mat-icons — the
Material Icons font in src/index.html:
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />The routes import the generated components by name, so the test app needs to
resolve them through generatedPackage — set this to whatever specifier your
generated library is exposed as.
| Option | Type | Default | Description |
| ----------------- | --------- | ----------------------------------- | --------------------------------------------------------------------------- |
| writeTestApp | boolean | true | Generate the test app (skipped if the target app dir doesn't exist). |
| testAppDir | string | sibling generated-test-app/src/app| The app's src/app directory. Defaults to a path derived from outputDir. |
| generatedPackage| string | '@frontend-toolbox/generated' | Import specifier the test app uses to import the generated components. |
export default createConfig({
url: 'https://api.example.com/openapi.json',
outputDir: './src/app/generated',
writeTestApp: true,
testAppDir: './apps/api-playground/src/app',
generatedPackage: '@my-org/generated-api',
});Programmatic use
The CLI is a thin wrapper around the exported main(). You can also run the
whole pipeline yourself:
import { main } from '@seothos/openapi-generator/generator';
// reads process.argv: [node, script, <config-path>, ...flags]
await main();@seothos/openapi-generator/generator also exports createConfig, the default
generators/writers, and the config types, so you can swap in custom
implementations of any stage (loader, type generator, output writers, …).
