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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Try it without installing anything: [playground](https://docs.openapi-ng.dev/pla
## Why openapi-ng

- **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.
- **Angular-first output.** Each operation ships with `.observable()` and `.request()`, and reads also get `.resource()` (by default `GET`s and `search`/`query`/`find`/`lookup` `POST`s, [configurable](https://docs.openapi-ng.dev/guides/configuration/#resources); writes are typed without it, so `deletePet.resource()` fails to compile), 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.
Expand Down Expand Up @@ -63,16 +63,16 @@ import { listPets } from './generated/rest/pet';

@Component({/* ... */})
export class PetList {
// Same three flavors; HttpClient comes from the surrounding injection context.
// Same 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).
Both layouts expose the same surface (`.observable()` and `.request()` on every operation, `.resource()` on reads), 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:
A `validateRest(path, restMethod, opts)` helper wraps Angular signal-forms `validateAsync` and delegates to the generated `RequestFn.resource()`, preserving request/response typing. It needs an operation with `.resource()`; `checkEmail` below is a GET, while a `POST` validator needs `x-openapi-ng-resource: true` or a [`resources`](https://docs.openapi-ng.dev/guides/configuration/#resources) rule:

```ts
import { validateRest } from './generated/rest.validate';
Expand Down
2 changes: 0 additions & 2 deletions __test__/angular-consumer/src/form-non-json-proof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,8 @@ const multipartRequest: UpdatePetAvatarParams = {
};

const multipartObservable = petSvc.updatePetAvatar.observable(multipartRequest);
const multipartResource = petSvc.updatePetAvatar.resource(() => multipartRequest);

expectType<Observable<{ id?: string }>>(multipartObservable);
expectType<HttpResourceRef<{ id?: string } | undefined>>(multipartResource);

/**
* application/x-www-form-urlencoded — scalar + scalar array.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Must not compile: an operation the `resources` rules leave out is typed
// without `.resource()`. tsc must reject each line below with TS2339.
import type { PetRest } from '../../generated/rest/pet.rest';
import type { SessionRest } from '../../generated/rest/session.rest';

declare const service: PetRest;
declare const session: SessionRest;

// A POST the default rules leave out.
export const createPet = service.createPet.resource(() => ({ body: { name: 'rex' } }));
// A DELETE with a `void` response.
export const deletePet = service.deletePet.resource(() => ({ petId: 'p-1' }));
// A zero-arg GET opted out by `x-openapi-ng-resource: false`.
export const logout = session.logout.resource();
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@ import { validateRest } from '../../generated/rest.validate';
declare const service: PetRest;

export const shouldFail = schema<string>(path => {
validateRest(path, service.updatePet, {
validateRest(path, service.getPet, {
request: ctx => ({
petId: ctx.value(),
body: { status: 'available' as const, tagIds: [] },
}),
// `debounce` must be a duration in milliseconds or a debouncer function;
// 'soon' is neither. tsc must reject this option-bag.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ interface WrongRequest {
declare const wrongRequest: WrongRequest;

export const shouldFail = schema<string>(path => {
validateRest(path, service.updatePet, {
// `request` must return UpdatePetParams | undefined; a value of
validateRest(path, service.getPet, {
// `request` must return GetPetParams | undefined; a value of
// type `WrongRequest` does not satisfy it. tsc must reject this
// option-bag with TS2322.
request: () => wrongRequest,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
// Must not compile: `onSuccess`'s `result` stays typed as the endpoint's
// response.
import { schema } from '@angular/forms/signals';
import type { PetRest, UpdatePetParams } from '../../generated/rest/pet.rest';
import type { PetRest, GetPetParams } from '../../generated/rest/pet.rest';
import type { Pet } from '../../generated/model.ts';
import { validateRest } from '../../generated/rest.validate';

declare const service: PetRest;

export const shouldFail = schema<string>(path => {
validateRest<UpdatePetParams, Pet, string>(path, service.updatePet, {
validateRest<GetPetParams, Pet, string>(path, service.getPet, {
request: ctx => ({
petId: ctx.value(),
body: { status: 'available', tagIds: [] },
}),
onSuccess: result => {
// `result` is typed Pet — `nonExistentField` is not on Pet. tsc must
Expand Down
27 changes: 27 additions & 0 deletions __test__/angular-consumer/src/resource-gating-bound-proof.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Compile-time proofs for `layout: ['services', 'operations']` with `application/pdf` as
// `arrayBuffer`: a bound class keeps each mutation's type, so it has no `.resource()`.

import type { HttpResourceRef } from '@angular/common/http';
import type { Observable } from 'rxjs';

import type { Pet } from '../generated/model';
import * as ops from '../generated/rest/pet';
import type { PetRest } from '../generated/rest/pet.rest';
import type { SessionRest } from '../generated/rest/session.rest';

declare const service: PetRest;
declare const session: SessionRest;
declare function expectType<T>(value: T): void;

expectType<HttpResourceRef<Pet[] | undefined>>(service.listPets.resource());
expectType<Observable<ArrayBuffer>>(service.exportPet.observable({ petId: 'p-1' }));
expectType<Observable<void>>(session.logout.observable());

// @ts-expect-error — an `arrayBuffer` write has no `.resource()`
service.exportPet.resource(() => ({ petId: 'p-1' }));

// @ts-expect-error — nor has the operation it is bound from
ops.exportPet.resource(() => ({ petId: 'p-1' }));

// @ts-expect-error — nor has a zero-arg GET opted out by the extension
session.logout.resource();
31 changes: 31 additions & 0 deletions __test__/angular-consumer/src/resource-gating-proof.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Compile-time proofs for the `resources` rules, against the fixture
// `resource-rules.openapi.yaml`: POSTs opted in by name or extension keep
// `.resource()` and work with `validateRest`; non-JSON writes keep their kind.

import type { HttpResourceRef } from '@angular/common/http';
import { schema } from '@angular/forms/signals';
import type { Observable } from 'rxjs';

import type { Pet } from '../generated/model';
import type { PetRest } from '../generated/rest/pet.rest';
import { validateRest } from '../generated/rest.validate';

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

expectType<HttpResourceRef<Pet[] | undefined>>(
service.searchPets.resource(() => ({ name: 'rex' })),
);
expectType<HttpResourceRef<Pet | undefined>>(
service.previewDeletePet.resource(() => ({ petId: 'p-1' })),
);

schema<string>(path => {
validateRest(path, service.searchPets, {
request: ctx => ({ name: ctx.value() }),
onError: () => undefined,
});
});

expectType<Observable<Blob>>(service.exportPet.observable({ petId: 'p-1' }));
expectType<Observable<string>>(service.renderReport.observable());
35 changes: 22 additions & 13 deletions __test__/angular-consumer/src/service-proof.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { PetRest, UpdatePetParams } from '../generated/rest/pet.rest';
import type { GetPetParams, PetRest, UpdatePetParams } from '../generated/rest/pet.rest';
import type {
HttpClient,
HttpEvent,
Expand Down Expand Up @@ -74,16 +74,25 @@ const defaultPet: Pet = {
};
const updatePetRequest = service.updatePet.request(request);
const updatePetObservable = service.updatePet.observable(request);
const updatePetResource = service.updatePet.resource(() => request);

const updatePetResourceDefaultValue = service.updatePet.resource(() => request, {
// @ts-expect-error — a POST outside the `resources` rules has no `.resource()`
service.updatePet.resource(() => request);

/**
* getPet
* */

const getPetRequest: GetPetParams = { petId: 'id' };
const getPetResource = service.getPet.resource(() => getPetRequest);

const getPetResourceDefaultValue = service.getPet.resource(() => getPetRequest, {
defaultValue: defaultPet,
});
const updatePetResourceParse = service.updatePet.resource<number>(() => request, {
const getPetResourceParse = service.getPet.resource<number>(() => getPetRequest, {
parse: raw => raw.tags.length,
});
const updatePetResourceParseDefaultValue = service.updatePet.resource<number>(
() => request,
const getPetResourceParseDefaultValue = service.getPet.resource<number>(
() => getPetRequest,
{ parse: raw => raw.tags.length, defaultValue: 42 },
);

Expand All @@ -95,9 +104,9 @@ expectType<ResourceRequestContext>(paramsContext);
expectType<ResourceParamsContext>(requestContext);

// `chain` from ResourceParamsContext must flow through the resource() wrapper.
const updatePetResourceChained = service.updatePet.resource(context => {
const getPetResourceChained = service.getPet.resource(context => {
const firstPet = context.chain(listPetsResourceDefaultValue).at(0);
return firstPet === undefined ? undefined : { ...request, petId: firstPet.id };
return firstPet === undefined ? undefined : { petId: firstPet.id };
});

const updatePetObservableResponse = service.updatePet.observable(request, {
Expand Down Expand Up @@ -139,11 +148,11 @@ service.updatePet.observable(request, { responseType: 'text' });

expectType<string>(updatePetRequest.url);
expectType<Observable<Pet>>(updatePetObservable);
expectType<HttpResourceRef<Pet | undefined>>(updatePetResource);
expectType<HttpResourceRef<Pet | undefined>>(updatePetResourceChained);
expectType<HttpResourceRef<Pet>>(updatePetResourceDefaultValue);
expectType<HttpResourceRef<number | undefined>>(updatePetResourceParse);
expectType<HttpResourceRef<number>>(updatePetResourceParseDefaultValue);
expectType<HttpResourceRef<Pet | undefined>>(getPetResource);
expectType<HttpResourceRef<Pet | undefined>>(getPetResourceChained);
expectType<HttpResourceRef<Pet>>(getPetResourceDefaultValue);
expectType<HttpResourceRef<number | undefined>>(getPetResourceParse);
expectType<HttpResourceRef<number>>(getPetResourceParseDefaultValue);
expectType<Observable<HttpResponse<Pet>>>(updatePetObservableResponse);
expectType<Observable<HttpEvent<Pet>>>(updatePetObservableEvents);

Expand Down
28 changes: 24 additions & 4 deletions __test__/angular-consumer/src/standalone-proof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ 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 {
withInjector,
type Mutation,
type MutationFn,
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';
Expand Down Expand Up @@ -43,7 +49,7 @@ expectType<CommonRequest>(listPets.request({}));
expectType<CommonRequest>(listPets.request({ status: 'sold' }, { injector }));

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

// Bound form: today's RequestFn, identical to the `services` class property.
const boundGetPet = getPet.withInjector(injector);
Expand All @@ -57,12 +63,12 @@ expectType<HttpResourceRef<Pet | undefined>>(
// 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<MutationFn<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<Mutation<DeleteParams, void>>(ops.delete);
expectType<Operation<GetPetParams, Pet>>(ops.getPet);
expectType<CommonRequest>(ops.listPets.request({}));

Expand Down Expand Up @@ -93,3 +99,17 @@ api.getPet.withInjector(injector);

// @ts-expect-error — the bound `.request()` takes no options
service.listPets.request({}, { injector });

// @ts-expect-error — a DELETE outside the `resources` rules has no `.resource()`
deletePet.resource(() => ({ petId: 'x' }));

// @ts-expect-error — nor does its bound form
api.deletePet.resource(() => ({ petId: 'x' }));

schema<string>(path => {
// @ts-expect-error — so validateRest refuses it
validateRest(path, deletePet, {
request: ctx => ({ petId: ctx.value() }),
onError: () => undefined,
});
});
18 changes: 7 additions & 11 deletions __test__/angular-consumer/src/validate-proof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
// just a tsc --noEmit gate.

import { schema } from '@angular/forms/signals';
import type { PetRest, UpdatePetParams } from '../generated/rest/pet.rest';
import type { PetRest, GetPetParams } 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 All @@ -17,16 +17,15 @@ declare const service: PetRest;
declare function expectType<T>(value: T): void;

// Body-inspecting onSuccess: response is typed as `Pet`, request is typed
// as `UpdatePetParams`, and the validator error shape is the consumer's
// as `GetPetParams`, and the validator error shape is the consumer's
// own discriminated union (here keyed by `kind`).
schema<string>(path => {
validateRest<UpdatePetParams, Pet, string>(path, service.updatePet, {
validateRest<GetPetParams, Pet, string>(path, service.getPet, {
request: ctx => {
const value: string = ctx.value();
expectType<string>(value);
return {
petId: value,
body: { status: 'available', tagIds: [] },
};
},
onSuccess: response => {
Expand All @@ -43,10 +42,9 @@ schema<string>(path => {
// Request param is still strongly typed; we only assert reachability of
// the no-onSuccess overload.
schema<string>(path => {
validateRest<UpdatePetParams, Pet, string>(path, service.updatePet, {
validateRest<GetPetParams, Pet, string>(path, service.getPet, {
request: ctx => ({
petId: ctx.value(),
body: { status: 'available', tagIds: [] },
}),
onError: () => undefined,
});
Expand All @@ -69,10 +67,9 @@ schema<string>(path => {
// Angular's own `AsyncValidatorOptions`, so this call site is what proves they
// resolve to real types rather than to `{}` or `any`.
schema<string>(path => {
validateRest<UpdatePetParams, Pet, string>(path, service.updatePet, {
validateRest<GetPetParams, Pet, string>(path, service.getPet, {
request: ctx => ({
petId: ctx.value(),
body: { status: 'available', tagIds: [] },
}),
debounce: 300,
when: ctx => ctx.value().length > 2,
Expand All @@ -83,13 +80,12 @@ schema<string>(path => {
// Function-form debounce: receives the params signal's value plus the previous
// resource snapshot, and returns a promise gating the request.
schema<string>(path => {
validateRest<UpdatePetParams, Pet, string>(path, service.updatePet, {
validateRest<GetPetParams, Pet, string>(path, service.getPet, {
request: ctx => ({
petId: ctx.value(),
body: { status: 'available', tagIds: [] },
}),
debounce: value => {
expectType<UpdatePetParams | undefined>(value);
expectType<GetPetParams | undefined>(value);
return new Promise<void>(resolve => setTimeout(resolve, 50));
},
onError: () => undefined,
Expand Down
4 changes: 4 additions & 0 deletions __test__/angular-consumer/tsconfig.negative-resource.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["src/negative-proof/resource-rejects-mutation.ts", "generated/**/*.ts"]
}
4 changes: 4 additions & 0 deletions __test__/angular-consumer/tsconfig.resource-gating-bound.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["src/resource-gating-bound-proof.ts", "generated/**/*.ts"]
}
4 changes: 4 additions & 0 deletions __test__/angular-consumer/tsconfig.resource-gating.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["src/resource-gating-proof.ts", "generated/**/*.ts"]
}
24 changes: 24 additions & 0 deletions __test__/browser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,30 @@ wasiTest(
},
);

wasiTest('browser generate applies resources rules like the native build', async t => {
const generate = browserEntry.createGenerate(() => import(wasiCjs));
const options = {
inputContents: fs.readFileSync(
path.join(repoRoot, 'test', 'fixtures', 'resource-rules.openapi.yaml'),
'utf8',
),
displayPath: 'resource-rules.openapi.yaml',
emit: ['models', 'angular'],
resources: ['DELETE', { path: /\/search$/ }],
} satisfies GenerateOptions;
const [fromWasi, fromNative] = await Promise.all([
generate(options),
nativeGenerate(options),
]);
t.deepEqual(
fromWasi.artifacts.map(a => [a.path, a.contents]),
fromNative.artifacts.map(a => [a.path, a.contents]),
);
const pets =
fromWasi.artifacts.find(a => a.path === 'rest/pet.rest.ts')?.contents ?? '';
t.true(pets.includes('readonly searchPets = requestFactory<'));
});

wasiTest('browser generate surfaces typed fatal diagnostics', async t => {
const generate = browserEntry.createGenerate(() => import(wasiCjs));
const err = (await t.throwsAsync(() =>
Expand Down
Loading
Loading