Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ jobs:
- name: Generate (native)
run: |
bun bin/openapi-ng.js generate --input test/fixtures/petstore-rich.openapi.yaml --output out-native
test -f out-native/model.generated.ts
test -f out-native/model.ts
- name: Generate (WASI forced)
run: |
bun bin/openapi-ng.js generate --input test/fixtures/petstore-rich.openapi.yaml --output out-wasi
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -248,3 +248,6 @@ website/test-results/
website/playwright-report/

stackblitz/.angular/

# Local Claude Code session state.
.claude/
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Try it without installing anything: [playground](https://docs.openapi-ng.dev/pla

- **Rust-powered codegen.** The engine is a native binary loaded via [NAPI-RS](https://napi.rs). The same input always produces identical output.
- **Angular-first output.** Each operation ships with three flavors — `.observable()`, `.resource()`, `.request()` — matching Angular's current HTTP primitives.
- **Two layouts.** Per-tag `@Injectable` services by default, or `--layout operations` for one tree-shakeable constant per operation, callable from any injection context or bound once with `withInjector()`.
- **Strict OpenAPI subset.** A focused 3.x slice with clear diagnostics. No silent misgeneration; see [Assumptions & limitations](https://docs.openapi-ng.dev/reference/limitations/) for the accepted shape.
- **Configurable naming.** Tune method names and service grouping with template + regex rules, via YAML, JSON, or TypeScript config.
- **Thin, pass-through helpers.** Generated methods just build the request (method, URL, query, body) and forward every `HttpClient.request` / `httpResource` option through unchanged — `withCredentials`, `transferCache`, `reportProgress`, `equal`, `injector`, and the rest. The response reaches you untouched.
Expand All @@ -32,18 +33,18 @@ openapi-ng generate --input petstore.openapi.yaml --output ./generated
✓ Generated 5 files from Petstore (3.0.3)
1 path · 1 operation · 1 schema

model.generated.ts
model.ts
rest.model.ts
rest.util.ts
rest.validate.ts
rest/pet.rest.generated.ts
rest/pet.rest.ts
```

Wire a generated service into a component:

```ts
import { Component, inject } from '@angular/core';
import { PetRest } from './generated/rest/pet.rest.generated';
import { PetRest } from './generated/rest/pet.rest';

@Component({/* ... */})
export class PetList {
Expand All @@ -54,6 +55,21 @@ export class PetList {
}
```

Or skip the classes. `--layout operations` emits one file per operation (`rest/pet/list-pets.ts`, `rest/pet/get-pet.ts`, …) plus a barrel `rest/pet/index.ts`, so the endpoints you never import tree-shake away:

```ts
import { Component } from '@angular/core';
import { listPets } from './generated/rest/pet';

@Component({/* ... */})
export class PetList {
// Same three flavors; HttpClient comes from the surrounding injection context.
readonly list = listPets.resource({ defaultValue: [] });
}
```

Both layouts expose the same `.observable()` / `.resource()` / `.request()` surface, and `--layout services,operations` emits the classes on top of the operation files. Details in the [Angular guide](https://docs.openapi-ng.dev/guides/angular/#standalone-operations).

### Signal-forms async validation: `rest.validate.ts`

A `validateRest(path, restMethod, opts)` helper wraps Angular signal-forms `validateAsync` and delegates to the generated `RequestFn.resource()`, preserving request/response typing:
Expand Down
2 changes: 1 addition & 1 deletion __test__/angular-consumer/src/consumer-proof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
ContactPhone,
PetUnion,
PetUnionList,
} from '../generated/model.generated';
} from '../generated/model';

declare const http: HttpClient;

Expand Down
2 changes: 1 addition & 1 deletion __test__/angular-consumer/src/discriminator-proof.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Cat, Dog, PetUnion } from '../generated/model.generated';
import type { Cat, Dog, PetUnion } from '../generated/model';

declare const pet: PetUnion;

Expand Down
16 changes: 5 additions & 11 deletions __test__/angular-consumer/src/form-non-json-proof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,14 @@ import type { Observable } from 'rxjs';

import type { CommonRequest } from '../generated/rest.model';

import type { BinaryRest } from '../generated/rest/binary.rest.generated';
import type { ConfigRest } from '../generated/rest/config.rest.generated';
import type { BinaryRest } from '../generated/rest/binary.rest';
import type { ConfigRest } from '../generated/rest/config.rest';
import type {
DownloadInvoicePdfParams,
InvoiceRest,
} from '../generated/rest/invoice.rest.generated';
import type {
PetRest,
UpdatePetAvatarParams,
} from '../generated/rest/pet.rest.generated';
import type {
SearchRest,
SubmitFormParams,
} from '../generated/rest/search.rest.generated';
} from '../generated/rest/invoice.rest';
import type { PetRest, UpdatePetAvatarParams } from '../generated/rest/pet.rest';
import type { SearchRest, SubmitFormParams } from '../generated/rest/search.rest';

declare const petSvc: PetRest;
declare const searchSvc: SearchRest;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
//
// Expected error: TS2322 — `'string-not-blob'` (a literal string) is not
// assignable to `Blob | File`.
import type { UpdatePetAvatarParams } from '../../generated/rest/pet.rest.generated';
import type { UpdatePetAvatarParams } from '../../generated/rest/pet.rest';

// Construct an UpdatePetAvatarParams whose `avatar` field is a string,
// not a Blob/File. Every other field carries a valid value so the
Expand Down
2 changes: 1 addition & 1 deletion __test__/angular-consumer/src/negative-proof/negative.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// 'cat', so assigning an object with `kind: 'dog'` to a Cat-typed slot fails.
// If the union ever degrades to `any`, this assignment would succeed and tsc
// would exit 0 — causing the negative-compile test to fail and alerting us.
import type { Cat } from '../../generated/model.generated';
import type { Cat } from '../../generated/model';

// Construct an object whose `kind` discriminant is 'dog', not 'cat'.
// This is structurally compatible with Cat except for the literal type on `kind`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// Expected error: TS2322 — `string` is not assignable to
// `DebounceTimer<UpdatePetParams | undefined>` (i.e. `number` or a function).
import { schema } from '@angular/forms/signals';
import type { PetRest } from '../../generated/rest/pet.rest.generated';
import type { PetRest } from '../../generated/rest/pet.rest';
import { validateRest } from '../../generated/rest.validate';

declare const service: PetRest;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
// proof is meant to lock down — instead of a TS2345 argument-type
// error on the `service.updatePet` position.
import { schema } from '@angular/forms/signals';
import type { PetRest } from '../../generated/rest/pet.rest.generated';
import type { PetRest } from '../../generated/rest/pet.rest';
import { validateRest } from '../../generated/rest.validate';

declare const service: PetRest;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
// Expected error: TS2339 — property 'nonExistentField' does not exist on
// type 'Pet'.
import { schema } from '@angular/forms/signals';
import type { PetRest, UpdatePetParams } from '../../generated/rest/pet.rest.generated';
import type { Pet } from '../../generated/model.generated.ts';
import type { PetRest, UpdatePetParams } from '../../generated/rest/pet.rest';
import type { Pet } from '../../generated/model.ts';
import { validateRest } from '../../generated/rest.validate';

declare const service: PetRest;
Expand Down
4 changes: 2 additions & 2 deletions __test__/angular-consumer/src/service-proof.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { PetRest, UpdatePetParams } from '../generated/rest/pet.rest.generated';
import type { PetRest, UpdatePetParams } from '../generated/rest/pet.rest';
import type { HttpEvent, HttpResourceRef, HttpResponse } from '@angular/common/http';
import { Pet, PetList } from '../generated/model.generated.ts';
import { Pet, PetList } from '../generated/model.ts';
import { Observable } from 'rxjs';
import type { ResourceParamsContext } from '@angular/core';
import type {
Expand Down
95 changes: 95 additions & 0 deletions __test__/angular-consumer/src/standalone-proof.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Type-proof for the `services` + `operations` layout, generated from
// reserved-method-name.openapi.yaml: standalone operations, the bound form,
// the record helper, the barrel namespace and the aliased reserved name.

import type { HttpResourceRef } from '@angular/common/http';
import { Injector, inject } from '@angular/core';
import { schema } from '@angular/forms/signals';
import type { Observable } from 'rxjs';
import type { Pet, PetList, Problem } from '../generated/model';
import type { CommonRequest } from '../generated/rest.model';
import { withInjector, type Operation, type RequestFn } from '../generated/rest.util';
import { validateRest } from '../generated/rest.validate';
import * as ops from '../generated/rest/pet';
import type { GetPetError, GetPetParams, PetRest } from '../generated/rest/pet.rest';
import { delete as deletePet, type DeleteParams } from '../generated/rest/pet/delete';
import { getPet } from '../generated/rest/pet/get-pet';
import { listPets, type ListPetsParams } from '../generated/rest/pet/list-pets';

declare function expectType<T>(value: T): void;
declare const injector: Injector;
declare const service: PetRest;

// Standalone form inside an injection context (field initialiser) and
// outside one (handler with an explicit injector).
class PetsComponent {
readonly pets = listPets.resource(() => ({ status: 'available' }), {
defaultValue: [],
});
readonly #injector = inject(Injector);

remove(petId: string) {
return deletePet.observable({ petId }, { injector: this.#injector });
}
}

declare const component: PetsComponent;
expectType<HttpResourceRef<PetList>>(component.pets);
expectType<Observable<void>>(component.remove('x'));

// `.request()` is pure without options and base-pathed with an injector;
// both return the same descriptor type.
expectType<CommonRequest>(listPets.request({}));
expectType<CommonRequest>(listPets.request({ status: 'sold' }, { injector }));

expectType<Operation<ListPetsParams, PetList>>(listPets);
expectType<Operation<DeleteParams, void>>(deletePet);

// Bound form: today's RequestFn, identical to the `services` class property.
const boundGetPet = getPet.withInjector(injector);
expectType<RequestFn<GetPetParams, Pet>>(boundGetPet);
expectType<RequestFn<GetPetParams, Pet>>(service.getPet);
expectType<Observable<Pet>>(boundGetPet.observable({ petId: 'x' }));
expectType<HttpResourceRef<Pet | undefined>>(
boundGetPet.resource(() => ({ petId: 'x' })),
);

// Record helper: every entry maps to its RequestFn.
const api = withInjector({ getPet, deletePet, listPets }, injector);
expectType<RequestFn<GetPetParams, Pet>>(api.getPet);
expectType<RequestFn<DeleteParams, void>>(api.deletePet);
expectType<RequestFn<ListPetsParams, PetList>>(api.listPets);
expectType<Observable<void>>(api.deletePet.observable({ petId: 'x' }));

// Barrel namespace, reserved-word member included.
expectType<Operation<DeleteParams, void>>(ops.delete);
expectType<Operation<GetPetParams, Pet>>(ops.getPet);
expectType<CommonRequest>(ops.listPets.request({}));

// The class file re-exports the per-operation interfaces.
declare const notFound: GetPetError;
expectType<Problem>(notFound[404]);

// validateRest accepts a standalone operation and a bound one.
schema<string>(path => {
validateRest<GetPetParams, Pet, string>(path, getPet, {
request: ctx => ({ petId: ctx.value() }),
onError: () => ({ kind: 'validation-unavailable' as const }),
});
validateRest<GetPetParams, Pet, string>(path, service.getPet, {
request: ctx => ({ petId: ctx.value() }),
onError: () => ({ kind: 'validation-unavailable' as const }),
});
});

// @ts-expect-error — a requestful operation needs its request argument
listPets.observable();

// @ts-expect-error — the bound form does not expose withInjector
boundGetPet.withInjector(injector);

// @ts-expect-error — nor does any entry of a bound record
api.getPet.withInjector(injector);

// @ts-expect-error — the bound `.request()` takes no options
service.listPets.request({}, { injector });
4 changes: 2 additions & 2 deletions __test__/angular-consumer/src/validate-proof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
// just a tsc --noEmit gate.

import { schema } from '@angular/forms/signals';
import type { PetRest, UpdatePetParams } from '../generated/rest/pet.rest.generated';
import type { Pet } from '../generated/model.generated.ts';
import type { PetRest, UpdatePetParams } from '../generated/rest/pet.rest';
import type { Pet } from '../generated/model.ts';
import type { RequestFnVoid } from '../generated/rest.util';
import { validateRest } from '../generated/rest.validate';

Expand Down
4 changes: 4 additions & 0 deletions __test__/angular-consumer/tsconfig.standalone.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["src/standalone-proof.ts", "generated/**/*.ts"]
}
92 changes: 92 additions & 0 deletions __test__/cli-parse.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -836,3 +836,95 @@ const tsNativeAvailable = nodeMajor > 22 || (nodeMajor === 22 && nodeMinor >= 6)
});
},
);

// ── layout ──────────────────────────────────────────────────────────────────

test('normalizeLayout splits a comma-separated string and trims whitespace', t => {
t.deepEqual(parse.normalizeLayout('services'), ['services']);
t.deepEqual(parse.normalizeLayout(' operations '), ['operations']);
t.deepEqual(parse.normalizeLayout('services, operations'), ['services', 'operations']);
});

test('normalizeLayout accepts an array and dedupes it', t => {
t.deepEqual(parse.normalizeLayout(['operations', 'services', 'operations']), [
'operations',
'services',
]);
});

test('normalizeLayout returns null for absent values', t => {
t.is(parse.normalizeLayout(undefined), null);
t.is(parse.normalizeLayout(null), null);
});

test('normalizeLayout rejects unknown values', t => {
const err = t.throws(() => parse.normalizeLayout('flat'));
t.true(err?.message.includes("Unknown layout: 'flat'"));
t.true(err?.message.includes("'services', 'operations'"));
});

test('normalizeLayout rejects non-list values', t => {
const err = t.throws(() => parse.normalizeLayout(42));
t.true(err?.message.includes('Invalid layout value'));
t.true(err?.message.includes('--layout services,operations'));
});

test('parseArgs: --layout sets the layout list', t => {
const result = parse.parseArgs(['generate', '--layout', 'services,operations']);
t.deepEqual(result.layout, ['services', 'operations']);
});

test('parseArgs: repeated --layout flags accumulate', t => {
const result = parse.parseArgs([
'generate',
'--layout',
'services',
'--layout',
'operations',
]);
t.deepEqual(result.layout, ['services', 'operations']);
});

test('parseArgs: layout absent yields null', t => {
const result = parse.parseArgs(['generate']);
t.is(result.layout, null);
});

test('parseArgs: --layout rejects unknown values at parse time', t => {
const err = t.throws(() => parse.parseArgs(['generate', '--layout', 'flat']));
t.true(err?.message.includes("Unknown layout: 'flat'"));
});

test('parseArgs: --layout errors when next token is another flag', t => {
const err = t.throws(() =>
parse.parseArgs(['generate', '--layout', '--input', 'spec.yaml']),
);
t.regex(err!.message, /--layout requires a value/);
});

test('mergeConfig: cli layout wins over file layout', t => {
const merged = parse.mergeConfig(
{ layout: ['operations'] },
{ layout: ['services', 'operations'] },
);
t.deepEqual(merged.layout, ['services', 'operations']);
});

test('mergeConfig: file layout fills in when the cli flag is absent', t => {
const merged = parse.mergeConfig({ layout: ['operations'] }, { layout: null });
t.deepEqual(merged.layout, ['operations']);
});

test('mergeConfig: a file layout given as a string is split like the cli flag', t => {
const merged = parse.mergeConfig({ layout: 'services,operations' }, { layout: null });
t.deepEqual(merged.layout, ['services', 'operations']);
});

test('mergeConfig: layout defaults to null so the generator default applies', t => {
t.is(parse.mergeConfig({}, {}).layout, null);
});

test('mergeConfig: rejects an unknown file-config layout', t => {
const err = t.throws(() => parse.mergeConfig({ layout: ['flat'] }, {}));
t.true(err?.message.includes("Unknown layout: 'flat'"));
});
Loading
Loading