diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index d05dfcf..6cba0d3 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -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 diff --git a/.gitignore b/.gitignore index 376ecca..87631ee 100644 --- a/.gitignore +++ b/.gitignore @@ -248,3 +248,6 @@ website/test-results/ website/playwright-report/ stackblitz/.angular/ + +# Local Claude Code session state. +.claude/ diff --git a/README.md b/README.md index d12bebd..6c03451 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 { @@ -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: diff --git a/__test__/angular-consumer/src/consumer-proof.ts b/__test__/angular-consumer/src/consumer-proof.ts index 6ee5403..5d97f21 100644 --- a/__test__/angular-consumer/src/consumer-proof.ts +++ b/__test__/angular-consumer/src/consumer-proof.ts @@ -7,7 +7,7 @@ import type { ContactPhone, PetUnion, PetUnionList, -} from '../generated/model.generated'; +} from '../generated/model'; declare const http: HttpClient; diff --git a/__test__/angular-consumer/src/discriminator-proof.ts b/__test__/angular-consumer/src/discriminator-proof.ts index a2f1aa1..320fdb3 100644 --- a/__test__/angular-consumer/src/discriminator-proof.ts +++ b/__test__/angular-consumer/src/discriminator-proof.ts @@ -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; diff --git a/__test__/angular-consumer/src/form-non-json-proof.ts b/__test__/angular-consumer/src/form-non-json-proof.ts index 629f0f6..97ee2ef 100644 --- a/__test__/angular-consumer/src/form-non-json-proof.ts +++ b/__test__/angular-consumer/src/form-non-json-proof.ts @@ -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; diff --git a/__test__/angular-consumer/src/negative-proof/binary-field-rejects-string.ts b/__test__/angular-consumer/src/negative-proof/binary-field-rejects-string.ts index dfb85ed..a955a56 100644 --- a/__test__/angular-consumer/src/negative-proof/binary-field-rejects-string.ts +++ b/__test__/angular-consumer/src/negative-proof/binary-field-rejects-string.ts @@ -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 diff --git a/__test__/angular-consumer/src/negative-proof/negative.ts b/__test__/angular-consumer/src/negative-proof/negative.ts index f20350b..81a11ab 100644 --- a/__test__/angular-consumer/src/negative-proof/negative.ts +++ b/__test__/angular-consumer/src/negative-proof/negative.ts @@ -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`. diff --git a/__test__/angular-consumer/src/negative-proof/validate-rejects-bad-debounce.ts b/__test__/angular-consumer/src/negative-proof/validate-rejects-bad-debounce.ts index fe70e92..701da1f 100644 --- a/__test__/angular-consumer/src/negative-proof/validate-rejects-bad-debounce.ts +++ b/__test__/angular-consumer/src/negative-proof/validate-rejects-bad-debounce.ts @@ -9,7 +9,7 @@ // Expected error: TS2322 — `string` is not assignable to // `DebounceTimer` (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; diff --git a/__test__/angular-consumer/src/negative-proof/validate-rejects-mismatched-request.ts b/__test__/angular-consumer/src/negative-proof/validate-rejects-mismatched-request.ts index e56dde9..040f0b6 100644 --- a/__test__/angular-consumer/src/negative-proof/validate-rejects-mismatched-request.ts +++ b/__test__/angular-consumer/src/negative-proof/validate-rejects-mismatched-request.ts @@ -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; diff --git a/__test__/angular-consumer/src/negative-proof/validate-rejects-mismatched-response.ts b/__test__/angular-consumer/src/negative-proof/validate-rejects-mismatched-response.ts index 34bc027..a04536a 100644 --- a/__test__/angular-consumer/src/negative-proof/validate-rejects-mismatched-response.ts +++ b/__test__/angular-consumer/src/negative-proof/validate-rejects-mismatched-response.ts @@ -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; diff --git a/__test__/angular-consumer/src/service-proof.ts b/__test__/angular-consumer/src/service-proof.ts index c8345a6..49f42cc 100644 --- a/__test__/angular-consumer/src/service-proof.ts +++ b/__test__/angular-consumer/src/service-proof.ts @@ -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 { diff --git a/__test__/angular-consumer/src/standalone-proof.ts b/__test__/angular-consumer/src/standalone-proof.ts new file mode 100644 index 0000000..84fd51f --- /dev/null +++ b/__test__/angular-consumer/src/standalone-proof.ts @@ -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(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/src/validate-proof.ts b/__test__/angular-consumer/src/validate-proof.ts index e412f20..b6938fb 100644 --- a/__test__/angular-consumer/src/validate-proof.ts +++ b/__test__/angular-consumer/src/validate-proof.ts @@ -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'; 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..2b8f9ad 100644 --- a/__test__/cli-parse.spec.ts +++ b/__test__/cli-parse.spec.ts @@ -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'")); +}); diff --git a/__test__/cli.spec.ts b/__test__/cli.spec.ts index 1d13669..b052c66 100644 --- a/__test__/cli.spec.ts +++ b/__test__/cli.spec.ts @@ -49,11 +49,11 @@ test('cli generate prints human-readable summary with title and file list', t => t.true(result.stdout.includes('2 paths')); t.true(result.stdout.includes('3 operations')); t.true(result.stdout.includes('6 schemas')); - t.true(result.stdout.includes('model.generated.ts')); + t.true(result.stdout.includes('model.ts')); t.true(result.stdout.includes('rest.model.ts')); t.true(result.stdout.includes('rest.util.ts')); t.true(result.stdout.includes('rest.validate.ts')); - t.true(result.stdout.includes('rest/pet.rest.generated.ts')); + t.true(result.stdout.includes('rest/pet.rest.ts')); }); }); @@ -111,10 +111,10 @@ test('cli generate writes model and service files to --output dir', t => { ]); t.is(result.status, 0); t.is(result.stderr, ''); - t.true(fs.existsSync(path.join(outputPath, 'model.generated.ts'))); + t.true(fs.existsSync(path.join(outputPath, 'model.ts'))); t.true(fs.existsSync(path.join(outputPath, 'rest.model.ts'))); t.true(fs.existsSync(path.join(outputPath, 'rest.util.ts'))); - t.true(fs.existsSync(path.join(outputPath, 'rest', 'pet.rest.generated.ts'))); + t.true(fs.existsSync(path.join(outputPath, 'rest', 'pet.rest.ts'))); }); }); @@ -132,7 +132,7 @@ test('cli generate --emit angular auto-includes models (with warning under --ver ]); t.is(result.status, 0); t.is(result.stderr, ''); - t.true(fs.existsSync(path.join(outputPath, 'model.generated.ts'))); + t.true(fs.existsSync(path.join(outputPath, 'model.ts'))); t.true(result.stdout.includes("Auto-included 'models'")); t.true(result.stdout.includes('E_INVALID_OPTION')); }); @@ -151,10 +151,7 @@ test('cli generate --mapped-type replaces schema with import', t => { ]); t.is(result.status, 0); t.is(result.stderr, ''); - const modelContents = fs.readFileSync( - path.join(outputPath, 'model.generated.ts'), - 'utf8', - ); + const modelContents = fs.readFileSync(path.join(outputPath, 'model.ts'), 'utf8'); t.true(modelContents.includes("import type { ExternalPetId } from '@demo/types'")); t.true(modelContents.includes('ExternalPetId')); t.false(modelContents.includes('export type PetId = string;')); @@ -171,7 +168,7 @@ test('cli generate writes 3 artifacts for fixture without operations', t => { outputPath, ]); t.is(result.status, 0); - t.true(fs.existsSync(path.join(outputPath, 'model.generated.ts'))); + t.true(fs.existsSync(path.join(outputPath, 'model.ts'))); t.true(fs.existsSync(path.join(outputPath, 'rest.model.ts'))); t.true(fs.existsSync(path.join(outputPath, 'rest.util.ts'))); t.false(fs.existsSync(path.join(outputPath, 'rest'))); @@ -577,9 +574,9 @@ test('cli generate reads array-form emit from config file', t => { const result = runCli(['generate'], dir); t.is(result.status, 0); t.is(result.stderr, ''); - t.true(result.stdout.includes('model.generated.ts')); - t.true(fs.existsSync(path.join(dir, 'model.generated.ts'))); - t.true(fs.existsSync(path.join(dir, 'rest', 'pet.rest.generated.ts'))); + t.true(result.stdout.includes('model.ts')); + t.true(fs.existsSync(path.join(dir, 'model.ts'))); + t.true(fs.existsSync(path.join(dir, 'rest', 'pet.rest.ts'))); }); }); @@ -615,7 +612,7 @@ test('cli generate reads mappedTypes from config file', t => { const result = runCli(['generate'], dir); t.is(result.status, 0); t.is(result.stderr, ''); - const modelContents = fs.readFileSync(path.join(dir, 'model.generated.ts'), 'utf8'); + const modelContents = fs.readFileSync(path.join(dir, 'model.ts'), 'utf8'); t.true(modelContents.includes("import type { ExternalPetId } from '@demo/types'")); t.false(modelContents.includes('export type PetId = string;')); }); @@ -658,7 +655,7 @@ test('cli generate reads input from openapi-ng.config.ts (end-to-end)', t => { ); const result = runCli(['generate'], dir); t.is(result.status, 0, result.stderr); - t.true(fs.existsSync(path.join(dir, 'out', 'model.generated.ts'))); + t.true(fs.existsSync(path.join(dir, 'out', 'model.ts'))); }); }); @@ -674,7 +671,7 @@ test('cli generate reads input from openapi-ng.config.mjs (end-to-end)', t => { ); const result = runCli(['generate'], dir); t.is(result.status, 0, result.stderr); - t.true(fs.existsSync(path.join(dir, 'out', 'model.generated.ts'))); + t.true(fs.existsSync(path.join(dir, 'out', 'model.ts'))); }); }); @@ -793,3 +790,68 @@ 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 services,operations 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', + 'services,operations', + ]); + t.is(result.status, 0); + t.is(result.stderr, ''); + t.true(fs.existsSync(path.join(outputPath, 'rest', 'pet', 'list-pets.ts'))); + t.true(fs.existsSync(path.join(outputPath, 'rest', 'pet', 'delete.ts'))); + t.true(fs.existsSync(path.join(outputPath, 'rest', 'pet/index.ts'))); + const service = fs.readFileSync(path.join(outputPath, 'rest', 'pet.rest.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.ts'))); + t.false(fs.existsSync(path.join(outputPath, 'rest', 'pet.rest.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 operations fails with E_INVALID_OPTION', t => { + const result = runCli([ + 'generate', + '--input', + fixture('petstore-minimal.openapi.yaml'), + '--emit', + 'models', + '--layout', + 'operations', + ]); + 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..6269c66 100644 --- a/__test__/generate.snapshot.spec.ts +++ b/__test__/generate.snapshot.spec.ts @@ -276,11 +276,55 @@ 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', + // Operations named `default` and `index` are legal class properties + // under the default layout; the `operations` layout rejects them (see + // the reserved-identifier failure snapshots below). + 'default-method-name.openapi.yaml', + 'index-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: +// `