From 7a1cdcfd5f13344020e7d221b5a024bed90c42d2 Mon Sep 17 00:00:00 2001 From: pkurcx Date: Sat, 5 Sep 2026 15:21:41 +0200 Subject: [PATCH 1/4] feat: standalone operations layout (`layout: operations | both`) Add a second Angular output layout in which every operation is a top-level `defineOperation(...)` constant in its own file under `rest//`, re-exported by a `rest/.operations.generated.ts` barrel. `both` also emits the per-tag class, built from `ops..withInjector()`, with the `Params`/`Error` interfaces re-exported so type imports keep resolving across layouts. The default `services` output is byte-identical. Runtime: one `OperationImpl` class backs both forms. Standalone `.observable()`/`.resource()` resolve DI per call from `options.injector` or the current injection context and throw a dev-only message with NG0203 as `cause` otherwise; `.request()` is pure unless given an injector. `.withInjector()` and the free `withInjector(record)` return today's `RequestFn`; `requestFactory` is sugar over them. `validateRest` accepts either form via `Resourceful`. Generator: `Layout` option across config, napi, JS wrapper, CLI and config file; per-operation and barrel paths in the plan; reserved words such as `delete` are declared under an alias and exported by name; `default` is rejected with `E_POLICY_VIOLATION`/`reserved-identifier`; two operations resolving to one method name in a group are rejected. Tests: Rust unit tests, labelled layout snapshots, consumer type proof, CLI tests, and a runtime spec that loads the template under Node (`@angular/compiler` dev dependency, `templates/package.json` for ESM). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WJqAgGwh52GqqMx1rfPzGn --- .../angular-consumer/src/standalone-proof.ts | 103 ++++ .../angular-consumer/tsconfig.standalone.json | 4 + __test__/cli-parse.spec.ts | 60 +++ __test__/cli.spec.ts | 68 +++ __test__/generate.snapshot.spec.ts | 67 ++- __test__/generate.spec.ts | 151 ++++++ __test__/package.spec.ts | 2 +- __test__/rest-util-operations.spec.ts | 195 +++++++ ...penapi.yaml.layout-operations.failure.json | 6 + ...ault-method-name.openapi.yaml.success.json | 28 + .../model.generated.ts | 1 + .../rest/pet.rest.generated.ts | 15 + ...penapi.yaml.layout-operations.success.json | 28 + .../rest/pet.operations.generated.ts | 1 + .../rest/pet/list-pets.generated.ts | 19 + ...rich.openapi.yaml.layout-both.success.json | 40 ++ .../model.generated.ts | 24 + .../rest/pet.operations.generated.ts | 3 + .../rest/pet.rest.generated.ts | 13 + .../rest/pet/get-pet.generated.ts | 17 + .../rest/pet/list-pets.generated.ts | 10 + .../rest/pet/update-pet.generated.ts | 21 + ...penapi.yaml.layout-operations.success.json | 37 ++ .../model.generated.ts | 24 + .../rest/pet.operations.generated.ts | 3 + .../rest/pet/get-pet.generated.ts | 17 + .../rest/pet/list-pets.generated.ts | 10 + .../rest/pet/update-pet.generated.ts | 21 + ...name.openapi.yaml.layout-both.success.json | 40 ++ .../model.generated.ts | 10 + .../rest/pet.operations.generated.ts | 3 + .../rest/pet.rest.generated.ts | 26 + .../rest/pet/delete.generated.ts | 21 + .../rest/pet/get-pet.generated.ts | 21 + .../rest/pet/list-pets.generated.ts | 21 + .../static-template/rest.model.ts | 11 +- .../static-template/rest.util.ts | 487 +++++++++++------- .../static-template/rest.validate.ts | 6 +- bin/lib/parse.js | 25 + bin/openapi-ng.js | 15 +- bun.lock | 3 + index.d.ts | 18 + index.d.ts.in | 2 + lib/browser.js | 7 + lib/index.js | 7 + lib/wrapper-core.js | 13 + package.json | 1 + scripts/patch-types.mjs | 34 +- scripts/regen-snapshots.mjs | 41 +- src/bindings.rs | 17 + src/emit/angular/imports.rs | 48 +- src/emit/angular/mod.rs | 8 +- src/emit/angular/operation.rs | 384 ++++++++++++++ src/emit/angular/service.rs | 109 +++- src/emit/typescript.rs | 55 +- src/options.rs | 24 +- src/pipeline.rs | 51 +- src/plan/artifact_plan.rs | 40 +- src/plan/mod.rs | 2 +- src/plan/naming/legacy.rs | 6 + src/plan/naming/mod.rs | 3 +- src/plan/services.rs | 57 +- src/test_support.rs | 2 + templates/angular/rest.model.ts | 11 +- templates/angular/rest.util.ts | 487 +++++++++++------- templates/angular/rest.validate.ts | 6 +- templates/package.json | 3 + .../fixtures/default-method-name.openapi.yaml | 17 + .../reserved-method-name.openapi.yaml | 84 +++ website/src/content/docs/guides/angular.md | 273 +++++++--- website/src/content/docs/guides/cli.md | 8 +- .../src/content/docs/guides/configuration.md | 46 +- .../src/content/docs/reference/diagnostics.md | 29 +- .../src/content/docs/reference/limitations.md | 14 +- .../src/content/docs/reference/node-api.md | 37 +- 75 files changed, 3018 insertions(+), 603 deletions(-) create mode 100644 __test__/angular-consumer/src/standalone-proof.ts create mode 100644 __test__/angular-consumer/tsconfig.standalone.json create mode 100644 __test__/rest-util-operations.spec.ts create mode 100644 __test__/snapshots/generate-native/default-method-name.openapi.yaml.layout-operations.failure.json create mode 100644 __test__/snapshots/generate-native/default-method-name.openapi.yaml.success.json create mode 100644 __test__/snapshots/generate-native/default-method-name.openapi.yaml/model.generated.ts create mode 100644 __test__/snapshots/generate-native/default-method-name.openapi.yaml/rest/pet.rest.generated.ts create mode 100644 __test__/snapshots/generate-native/header-param.openapi.yaml.layout-operations.success.json create mode 100644 __test__/snapshots/generate-native/header-param.openapi.yaml.layout-operations/rest/pet.operations.generated.ts create mode 100644 __test__/snapshots/generate-native/header-param.openapi.yaml.layout-operations/rest/pet/list-pets.generated.ts create mode 100644 __test__/snapshots/generate-native/petstore-rich.openapi.yaml.layout-both.success.json create mode 100644 __test__/snapshots/generate-native/petstore-rich.openapi.yaml.layout-both/model.generated.ts create mode 100644 __test__/snapshots/generate-native/petstore-rich.openapi.yaml.layout-both/rest/pet.operations.generated.ts create mode 100644 __test__/snapshots/generate-native/petstore-rich.openapi.yaml.layout-both/rest/pet.rest.generated.ts create mode 100644 __test__/snapshots/generate-native/petstore-rich.openapi.yaml.layout-both/rest/pet/get-pet.generated.ts create mode 100644 __test__/snapshots/generate-native/petstore-rich.openapi.yaml.layout-both/rest/pet/list-pets.generated.ts create mode 100644 __test__/snapshots/generate-native/petstore-rich.openapi.yaml.layout-both/rest/pet/update-pet.generated.ts create mode 100644 __test__/snapshots/generate-native/petstore-rich.openapi.yaml.layout-operations.success.json create mode 100644 __test__/snapshots/generate-native/petstore-rich.openapi.yaml.layout-operations/model.generated.ts create mode 100644 __test__/snapshots/generate-native/petstore-rich.openapi.yaml.layout-operations/rest/pet.operations.generated.ts create mode 100644 __test__/snapshots/generate-native/petstore-rich.openapi.yaml.layout-operations/rest/pet/get-pet.generated.ts create mode 100644 __test__/snapshots/generate-native/petstore-rich.openapi.yaml.layout-operations/rest/pet/list-pets.generated.ts create mode 100644 __test__/snapshots/generate-native/petstore-rich.openapi.yaml.layout-operations/rest/pet/update-pet.generated.ts create mode 100644 __test__/snapshots/generate-native/reserved-method-name.openapi.yaml.layout-both.success.json create mode 100644 __test__/snapshots/generate-native/reserved-method-name.openapi.yaml.layout-both/model.generated.ts create mode 100644 __test__/snapshots/generate-native/reserved-method-name.openapi.yaml.layout-both/rest/pet.operations.generated.ts create mode 100644 __test__/snapshots/generate-native/reserved-method-name.openapi.yaml.layout-both/rest/pet.rest.generated.ts create mode 100644 __test__/snapshots/generate-native/reserved-method-name.openapi.yaml.layout-both/rest/pet/delete.generated.ts create mode 100644 __test__/snapshots/generate-native/reserved-method-name.openapi.yaml.layout-both/rest/pet/get-pet.generated.ts create mode 100644 __test__/snapshots/generate-native/reserved-method-name.openapi.yaml.layout-both/rest/pet/list-pets.generated.ts create mode 100644 src/emit/angular/operation.rs create mode 100644 templates/package.json create mode 100644 test/fixtures/default-method-name.openapi.yaml create mode 100644 test/fixtures/reserved-method-name.openapi.yaml diff --git a/__test__/angular-consumer/src/standalone-proof.ts b/__test__/angular-consumer/src/standalone-proof.ts new file mode 100644 index 0000000..e75fb3b --- /dev/null +++ b/__test__/angular-consumer/src/standalone-proof.ts @@ -0,0 +1,103 @@ +// Type-proof for the `both` layout: standalone operations, the bound form, +// the record helper, the barrel namespace and the aliased reserved name. +// Same declare-/expectType-based style as service-proof.ts: tsc --noEmit +// gate only, no runtime. Generated from reserved-method-name.openapi.yaml. + +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.generated'; +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.operations.generated'; +import type { + GetPetError, + GetPetParams, + PetRest, +} from '../generated/rest/pet.rest.generated'; +import { + delete as deletePet, + type DeleteParams, +} from '../generated/rest/pet/delete.generated'; +import { getPet } from '../generated/rest/pet/get-pet.generated'; +import { listPets, type ListPetsParams } from '../generated/rest/pet/list-pets.generated'; + +declare function expectType(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>(component.pets); +expectType>(component.remove('x')); + +// `.request()` is pure without options and base-pathed with an injector; +// both return the same descriptor type. +expectType(listPets.request({})); +expectType(listPets.request({ status: 'sold' }, { injector })); + +expectType>(listPets); +expectType>(deletePet); + +// Bound form: today's RequestFn, identical to the `services` class property. +const boundGetPet = getPet.withInjector(injector); +expectType>(boundGetPet); +expectType>(service.getPet); +expectType>(boundGetPet.observable({ petId: 'x' })); +expectType>( + boundGetPet.resource(() => ({ petId: 'x' })), +); + +// Record helper: every entry maps to its RequestFn. +const api = withInjector({ getPet, deletePet, listPets }, injector); +expectType>(api.getPet); +expectType>(api.deletePet); +expectType>(api.listPets); +expectType>(api.deletePet.observable({ petId: 'x' })); + +// Barrel namespace, reserved-word member included. +expectType>(ops.delete); +expectType>(ops.getPet); +expectType(ops.listPets.request({})); + +// The class file re-exports the per-operation interfaces. +declare const notFound: GetPetError; +expectType(notFound[404]); + +// validateRest accepts a standalone operation and a bound one. +schema(path => { + validateRest(path, getPet, { + request: ctx => ({ petId: ctx.value() }), + onError: () => ({ kind: 'validation-unavailable' as const }), + }); + validateRest(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 }); diff --git a/__test__/angular-consumer/tsconfig.standalone.json b/__test__/angular-consumer/tsconfig.standalone.json new file mode 100644 index 0000000..6056be4 --- /dev/null +++ b/__test__/angular-consumer/tsconfig.standalone.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/standalone-proof.ts", "generated/**/*.ts"] +} diff --git a/__test__/cli-parse.spec.ts b/__test__/cli-parse.spec.ts index 9393cb7..60c8682 100644 --- a/__test__/cli-parse.spec.ts +++ b/__test__/cli-parse.spec.ts @@ -836,3 +836,63 @@ const tsNativeAvailable = nodeMajor > 22 || (nodeMajor === 22 && nodeMinor >= 6) }); }, ); + +// ── layout ────────────────────────────────────────────────────────────────── + +test('normalizeLayout accepts the three layouts and trims whitespace', t => { + t.is(parse.normalizeLayout('services'), 'services'); + t.is(parse.normalizeLayout(' operations '), 'operations'); + t.is(parse.normalizeLayout('both'), 'both'); +}); + +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', 'both'")); +}); + +test('parseArgs: --layout sets the layout', t => { + const result = parse.parseArgs(['generate', '--layout', 'both']); + t.is(result.layout, 'both'); +}); + +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: 'both' }); + t.is(merged.layout, 'both'); +}); + +test('mergeConfig: file layout fills in when the cli flag is absent', t => { + const merged = parse.mergeConfig({ layout: 'operations' }, { layout: null }); + t.is(merged.layout, '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'")); +}); diff --git a/__test__/cli.spec.ts b/__test__/cli.spec.ts index 1d13669..db55c65 100644 --- a/__test__/cli.spec.ts +++ b/__test__/cli.spec.ts @@ -793,3 +793,71 @@ test('cli generate --help describes --input accepting path or url', t => { t.is(result.status, 0); t.true(result.stdout.includes('path|url')); }); + +test('cli generate --layout both writes operation files, the barrel and the class', t => { + withTempDir(outputPath => { + const result = runCli([ + 'generate', + '--input', + fixture('reserved-method-name.openapi.yaml'), + '--output', + outputPath, + '--layout', + 'both', + ]); + t.is(result.status, 0); + t.is(result.stderr, ''); + t.true(fs.existsSync(path.join(outputPath, 'rest', 'pet', 'list-pets.generated.ts'))); + t.true(fs.existsSync(path.join(outputPath, 'rest', 'pet', 'delete.generated.ts'))); + t.true(fs.existsSync(path.join(outputPath, 'rest', 'pet.operations.generated.ts'))); + const service = fs.readFileSync( + path.join(outputPath, 'rest', 'pet.rest.generated.ts'), + 'utf8', + ); + t.true(service.includes('ops.delete.withInjector()')); + }); +}); + +test('cli generate --layout operations omits the class file', t => { + withTempDir(outputPath => { + const result = runCli([ + 'generate', + '--input', + fixture('petstore-minimal.openapi.yaml'), + '--output', + outputPath, + '--layout', + 'operations', + ]); + t.is(result.status, 0); + t.true(fs.existsSync(path.join(outputPath, 'rest', 'pet', 'list-pets.generated.ts'))); + t.false(fs.existsSync(path.join(outputPath, 'rest', 'pet.rest.generated.ts'))); + }); +}); + +test('cli generate --layout rejects unknown values', t => { + const result = runCli([ + 'generate', + '--input', + fixture('petstore-minimal.openapi.yaml'), + '--layout', + 'flat', + ]); + t.not(result.status, 0); + t.true(result.stderr.includes("Unknown layout: 'flat'")); +}); + +test('cli generate --emit models --layout both fails with E_INVALID_OPTION', t => { + const result = runCli([ + 'generate', + '--input', + fixture('petstore-minimal.openapi.yaml'), + '--emit', + 'models', + '--layout', + 'both', + ]); + t.not(result.status, 0); + t.true(result.stderr.includes('E_INVALID_OPTION')); + t.true(result.stderr.includes("requires the 'angular' emit target")); +}); diff --git a/__test__/generate.snapshot.spec.ts b/__test__/generate.snapshot.spec.ts index 45f2ef2..6dbad83 100644 --- a/__test__/generate.snapshot.spec.ts +++ b/__test__/generate.snapshot.spec.ts @@ -276,11 +276,54 @@ const successFixtures = [ // so the response is emitted as a typed JSON shape via the default // `requestFactory<…>(…)` (no non-JSON variant). 'response-problem-json.openapi.yaml', + // An operation named `default` is a legal class property under the + // default layout; the `operations` layout rejects it (see the + // reserved-identifier failure snapshot below). + 'default-method-name.openapi.yaml', ] as const; -for (const fixtureName of successFixtures) { - test(`generate preserves full success payload snapshot for ${fixtureName}`, async t => { - t.deepEqual(await successResult(fixtureName), hydrateSuccessSnapshot(fixtureName)); +// Option-parameterised success cases. `label` names the snapshot files: +// `