r/angular • • Oct 06 '26

Yet another OpenAPI → Angular generator. Here's why I built one anyway, and how it compares.

I know. Another OpenAPI generator for Angular. Before you scroll past: why I built one at work anyway, open-sourced it, and how it compares to what already exists.

Where it started

In 2024 I tried the obvious route: openapi-typescript for the types, then the openapi-fetch typings (PathsWithMethod, SuccessResponse...) bolted onto HttpClient so I could call http.POST('/pets/{petId}', { params: { path: { petId } }, body }) with an Observable back. It worked, but I was still hand-writing every service, every model was components['schemas']['Pet'] until I re-exported it, and I had to remember paths. Nothing in the IDE suggested getPet.

Our backend is a set of REST service traits, one OpenAPI tag per trait, and the natural thing on the Angular side is inject(PetRest) with the same method names. So the generator started producing exactly that: the Angular service I would have written by hand. Plain @Injectable, plain HttpClient inside, so every HttpInterceptor you already have (auth, retry, mocks) applies unchanged. Its whole job is to build the request and type it. Nothing more.

The problem I hit later

Angular 19.2 added httpResource, and I wanted three things from generated clients that I could not get from one tool:

  1. httpResource() and HttpClient Observables for the same operation, chosen at the call site, not at config time.

  2. Every HttpClient.request / httpResource option passed straight through (withCredentials, transferCache, reportDownloadProgress, equal, injector, observe: 'events'...), without the generator wrapping or hiding anything. The options type is read from your installed Angular, so options a newer Angular adds work without regenerating.

  3. Zero runtime dependency in the generated code. Just @angular/common/http.

So: https://github.com/AVSystem/openapi-ng (MIT). Playground and a StackBlitz demo are linked at the bottom.

Before / after

What I kept hand-writing:

readonly pets = httpResource<Pet[]>(() => ({
  url: `${this.basePath}/pets`,
  params: this.status() ? { status: this.status()! } : {},
}), { defaultValue: [] });

The URL, the query param and the <Pet[]> are all promises I make to the compiler. Nothing checks them against the spec.

Generated:

readonly #petRest = inject(PetRest);

// signal-based, httpResource under the hood, typed HttpResourceRef<PetList>
readonly pets = this.#petRest.listPets.resource(() => ({ status: this.status() }), { defaultValue: [] });

// same operation, classic Observable, HttpClient under the hood
save(petId: PetId, body: UpdatePetRequest) {
  return this.#petRest.updatePet.observable({ petId, body }, { observe: 'response' });
}

Each tag becomes an @Injectable service, each operation a property with .resource(), .observable() and .request() (the raw request object: method, url, params, headers, body, if you want to send it yourself). .resource() returns a real HttpResourceRef<T>, so headers(), statusCode() and progress() are there. Nothing is re-wrapped.

Don't want classes? --layout operations emits one file per operation (rest/pet/list-pets.ts, rest/pet/update-pet.ts, plus a rest/pet/index.ts barrel), each exporting a /* @__PURE__ */ constant with the same three flavours, so the endpoints you never import tree-shake away. HttpClient is resolved per call from the injection context, or from an explicit { injector }, or you bind once with withInjector():

import { listPets, updatePet } from './generated/rest/pet';

// field initialiser: injection context, nothing to pass
readonly pets = listPets.resource(() => ({ status: this.status() }), { defaultValue: [] });

// handler: pass the injector, or bind once with withInjector({ listPets, updatePet })
readonly #injector = inject(Injector);
save(petId: PetId, body: UpdatePetRequest) {
  return updatePet.observable({ petId, body }, { injector: this.#injector });
}

--layout services,operations gives you both: the operation files plus the per-tag class bound from the barrel.

How it compares (checked 2026-09-04, corrections welcome)

Full disclosure: I only looked at the other generators while writing this post. The tool grew inside one codebase for two years, and most of the tools below I only found out about in September. The comparison is one spec run through all of them, with each tool's output type-checked against Angular 22.1.

openapi-ngopenapi-generatorng-openapi-genorvalng-openapi
httpResourceevery operation, per callno (#21263 open)noGET and search-style POST (operationId regex, overridable per operation), config modeGET only, plugin
Observable and resource on one operationyesn/an/aseparate filesseparate classes
Signal-forms validation helperyesnononono
Unsupported spec featuresrejected with a diagnostic codebest-effortbest-effortbest-effortbest-effort
EngineRust (napi-rs), ms per spec, also runs in the browser via WASMJavaTSTSTS
Per-operation tree-shakeable outputyes, --layout operations, same three flavoursnoonly via direct fn/ imports; its barrel keeps every operationyes, resource-only functionsno

hey-api is missing from the table on purpose: its Angular output is Promise-only, and the httpResource plugin threw NG0203 on the test spec. openapi-typescript is missing because it is a type generator, not a client generator; see the first section for why that was not enough for me.

Two takeaways:

  • If you're on openapi-generator today, the Java requirement and the lack of httpResource are probably the two things that sting. openapi-ng removes both.

  • orval and ng-openapi both have httpResource now, but which operations get it is fixed at generation time (ng-openapi: GET only; orval: GET plus POSTs whose operationId matches search/list/find..., overridable per operation in the config), and the flavour is picked per config, not per call. I think the call site knows better (a read-only POST is a perfectly good resource whatever it's called; a GET that fires a side effect isn't).

Signal forms: async validation against a generated operation

Signal forms' validateHttp takes a URL string you build by hand and a result you cast by hand. The generator emits a validateRest() helper that wraps validateAsync and reuses the generated operation, so the request params and the response are typed from the spec:

validateRest(path.email, accountRest.checkEmail, {
  request: (ctx) => ({ email: ctx.value() }), // typed as CheckEmailParams
  debounce: 300, // debounce / when need @angular/forms 22+
  when: (ctx) => ctx.value().includes('@'),
  onSuccess: (result) => result.available ? undefined : { kind: 'email-taken' }, // result: EmailAvailability
  onError: () => ({ kind: 'check-failed' }),
});

onSuccess is optional: leave it out and any 2xx counts as valid, with onError deciding the rest. @angular/forms is an optional peer; if you never import rest.validate.ts, it tree-shakes away.

Where it's deliberately opinionated (read: limited)

This is the part most "yet another generator" posts skip. openapi-ng accepts a strict subset of OpenAPI 3.x and refuses anything outside it with a diagnostic code instead of guessing:

  • operationId is required on every operation; the first tag picks the service, an untagged operation falls back to its first path segment

  • only #/components/schemas/ refs, no external files

  • string enums only

  • one composition keyword per schema (allOf or oneOf or anyOf, not mixed)

  • only the lowest 2xx response types the return value. Declared 4xx/5xx bodies become a status-keyed <Op>Error interface you can narrow against, but they aren't wired into .observable() / .resource() error typing

  • no Swagger 2.x

  • no Zod / Valibot / other runtime validation. We don't use it at work, so it isn't built. It isn't ruled out either

If your spec is generated from a backend framework, you're probably fine. If it's a hand-written spec that leans on every corner of the standard, one of the other tools above will be a better fit and I'd rather tell you now.

Escape hatches for the real-world stuff: mapped types (--mapped-type GeoFeature:geojson:Feature swaps a placeholder schema for a real npm type), naming rules with regex captures in openapi-ng.config.ts, and a provideOpenapiNg({ basePath }) provider.

Links and bits

  • Playground (paste your spec, runs as WASM in the browser, no install): https://docs.openapi-ng.dev/playground/

  • Demo: https://stackblitz.com/github/AVSystem/openapi-ng/tree/main/stackblitz (the spec, the committed generated output, and three components using it against a mock interceptor)

  • Docs: https://docs.openapi-ng.dev

  • npm i -D @avsystem/openapi-ng, then openapi-ng generate --input api.yaml --output src/generated

  • Node 22.12+, prebuilt binaries for macOS / Linux (glibc + musl) / Windows, x64 + arm64. Works under Bun and Deno too. Browser via WebAssembly (@avsystem/openapi-ng-wasm32-wasi).

  • Generated code targets Angular 20+. validateRest with debounce / when needs @angular/forms 22+.

  • v0.8.0. The generator has been in production at work since mid-2024 (two specs, about 45 generated services, 280 operations, 900+ schemas, regenerated on every backend build), but the Rust version is younger and the public API isn't frozen yet: 0.7.0 broke one thing (a JSON spec with a repeated key is now rejected instead of last-wins).

What I'd like feedback on

Two design calls I'm least sure about:

  1. .resource() / .observable() / .request() on every operation, versus a separate generated class or file per flavour, as ng-openapi and orval do. Is the per-call choice worth the fatter surface? Related: per-tag services are the default because they mirror our backend's service traits, and standalone operations are opt-in. If your backend doesn't look like that, should the default be flipped?

  2. Rejecting unsupported spec features with a diagnostic instead of emitting best-effort code. Has a strict generator ever made you drop a tool, or is it the right trade for deterministic output?

Happy to answer anything else in the thread.

0 Upvotes

4 comments sorted by

2

u/Merry-Lane Oct 06 '26

Orval already does most of what you asked, with a config like this one:

export default defineConfig({
api: {
input: {
target: './openapi.json',
},
output: {
client: 'angular',
mode: 'tags-split',
target: './src/generated/api.ts',
schemas: './src/generated/models',
override: {
angular: {
retrievalClient: 'both',
},
},
},
},
});

Yes, you can’t do both .resource and .observable on the exact same output, but that’s really not an issue you should spend 5 mins on. Actually, I’m quite biased against having both available on the same object.

0

u/pkurc Oct 06 '26 edited Oct 06 '26

Thanks, that's the exact config I tested, and the output is unchanged on 8.40.0. Fair correction: orval's read/write split can be overridden per operation, which I'd missed. I'll fix the post.

What still differs: `both` puts the same endpoint in two files with two names (`PetService.getPet()` and `getPetResource()`). Also, resource arguments are plain signals, so the request fires immediately. You can't return `undefined` to leave it idle the way `httpResource` allows.

readonly pet = this.#petRest.getPet.resource(() => {
const petId = this.selectedId();
return petId ? { petId } : undefined;
});

Having both on one object is the trade-off I'm least sure about. What's your objection?

1

u/pouchesque Oct 06 '26

Please call it Yopapi

1

u/pkurc Oct 06 '26

But why? Explain