diff --git a/docs/content/docs/guides/angular.mdx b/docs/content/docs/guides/angular.mdx index 9b06218928..9ff7aeaa3c 100644 --- a/docs/content/docs/guides/angular.mdx +++ b/docs/content/docs/guides/angular.mdx @@ -128,6 +128,8 @@ Quick rule of thumb: ## Setting the Backend URL +### Single API + Use an HTTP interceptor to automatically add the API base URL. In modern standalone Angular apps, a functional interceptor keeps the setup compact: @@ -154,6 +156,268 @@ export const appConfig: ApplicationConfig = { }; ``` +An interceptor is global, though: it can only route by sniffing the outgoing +`req.url`. That falls apart once you generate more than one Angular API into +the same app and sit them behind a gateway or proxy that assigns each API its +own path prefix (or host) — the interceptor has no reliable way to know which +generated client a given request came from. + +### DI-based base URL composition (multiple APIs / gateway routing) + +Set `override.angular.baseUrl` to compose the base URL for a specific output +through Angular's dependency injection instead of a global interceptor. Unlike +the interceptor, the base URL is resolved per generated API, so a gateway that +maps different generated clients to different upstream paths can be modeled +directly in DI. + +`apiId` is required and explicit — Orval never derives it from the +specification title or file name — so the generated token and helper names +stay stable across regenerations: + +```ts title="orval.config.ts" +import { defineConfig } from 'orval'; + +export default defineConfig({ + petstore: { + output: { + mode: 'tags-split', + target: 'src/api/petstore.ts', + schemas: 'src/api/model', + client: 'angular', + override: { + angular: { + baseUrl: { apiId: 'petstore' }, + }, + }, + }, + input: { + target: './petstore.yaml', + }, + }, +}); +``` + +#### Generated artifacts + +With `override.angular.baseUrl` set, Orval emits a sibling `.base-url.ts` +file alongside the generated client: + +```ts title="petstore.base-url.ts" +export const PETSTORE_SERVER_URL: string = 'http://petstore.swagger.io/v1'; + +export function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/+$/, ''); +} + +export interface PetstoreBaseUrlResolverContext { + readonly apiId: 'petstore'; + readonly serverUrl: string; +} + +export type PetstoreBaseUrlResolver = ( + context: PetstoreBaseUrlResolverContext, +) => string; + +export const PETSTORE_BASE_URL_RESOLVER = + new InjectionToken('PETSTORE_BASE_URL_RESOLVER', { + providedIn: 'root', + factory: (): PetstoreBaseUrlResolver => (context) => context.serverUrl, + }); + +export const PETSTORE_BASE_URL = new InjectionToken( + 'PETSTORE_BASE_URL', + { + providedIn: 'root', + factory: (): string => { + const resolver = inject(PETSTORE_BASE_URL_RESOLVER); + return normalizeBaseUrl( + resolver({ apiId: 'petstore', serverUrl: PETSTORE_SERVER_URL }), + ); + }, + }, +); + +export function providePetstoreBaseUrl(baseUrl: string): Provider { + return { provide: PETSTORE_BASE_URL, useValue: normalizeBaseUrl(baseUrl) }; +} + +export function providePetstoreBaseUrlResolver( + resolver: PetstoreBaseUrlResolver, +): Provider { + return { provide: PETSTORE_BASE_URL_RESOLVER, useValue: resolver }; +} +``` + +Identifiers are derived from `apiId` alone (`petstore` → `PETSTORE_*` / +`Petstore*` / `providePetstore*`), so they stay collision-free when multiple +outputs with different `apiId`s are generated into the same app. Every +generated `HttpClient` service method and `httpResource` function in this +output injects `PETSTORE_BASE_URL` and prefixes its route with it. + +#### Precedence + +`PETSTORE_BASE_URL` resolves in this order: + +1. A directly provided value via `providePetstoreBaseUrl(...)` — wins outright, + the resolver below is never invoked. +2. A resolver provided via `providePetstoreBaseUrlResolver(...)`. +3. The default resolver factory, which just returns the embedded server URL. +4. The embedded `PETSTORE_SERVER_URL` constant, resolved at generation time + from the specification's `servers` field (empty string `''` when the + specification has no `servers` entry, which yields relative URLs). + +Whatever value wins is passed through `normalizeBaseUrl`, which strips +trailing slashes (`'/api/x/'` → `'/api/x'`, `'https://h/'` → `'https://h'`, +`'/'` → `''`). Generated routes always start with `/`, so plain interpolation +(`` `${baseUrl}${route}` ``) can never double up or drop the separator between +them. + +#### Multiple APIs behind one gateway + +Because `InjectionToken` identity is per output, two generated outputs can't +literally share one token instance without a shared runtime package — Orval's +Angular output has no runtime dependency, by design. Instead, share one +resolver *function* and register it against each output's resolver token. +Type it against a small structural interface so it's assignable to every +generated `BaseUrlResolver`, regardless of `apiId`: + +```ts title="app.config.ts" +import { provideHttpClient } from '@angular/common/http'; +import { ApplicationConfig } from '@angular/core'; + +import { providePetstoreBaseUrlResolver } from './api/petstore.base-url'; +import { provideInventoryBaseUrlResolver } from './api/inventory.base-url'; + +interface GatewayContext { + apiId: string; + serverUrl: string; +} + +// One registry, one resolver function, shared across every generated API. +const gatewayRoutes: Record = { + petstore: '/gateway/petstore', + inventory: '/gateway/inventory', +}; + +const gatewayResolver = (ctx: GatewayContext): string => + gatewayRoutes[ctx.apiId] ?? ctx.serverUrl; + +export const appConfig: ApplicationConfig = { + providers: [ + provideHttpClient(), + providePetstoreBaseUrlResolver(gatewayResolver), + provideInventoryBaseUrlResolver(gatewayResolver), + ], +}; +``` + +Each output still resolves its *own* token independently — `gatewayResolver` +is just dispatched with a different `apiId` depending on which token invoked +it — so requests from the petstore client and the inventory client can land on +different upstream paths (or hosts) through the same gateway. + +#### Testing + +Override the token (or the resolver) in `TestBed` like any other provider: + +```ts title="pets.service.spec.ts" +import { provideHttpClient } from '@angular/common/http'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { + providePetstoreBaseUrl, + providePetstoreBaseUrlResolver, +} from './api/petstore.base-url'; +import { PetsService } from './api/petstore.service'; + +describe('PetsService', () => { + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + providePetstoreBaseUrl('/gateway/petstore'), + // or: providePetstoreBaseUrlResolver((ctx) => `/gateway/${ctx.apiId}`), + ], + }); + + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('prefixes requests with the provided base URL', () => { + TestBed.inject(PetsService) + .createPets({ name: 'Rex', tag: 'dog' }) + .subscribe(); + + httpMock.expectOne('/gateway/petstore/v1/pets').flush(null); + }); +}); +``` + +The same token, provided once in `TestBed`, backs both the `HttpClient` +service and any `httpResource` functions generated for the same output — so a +single provider override is enough to redirect every request in a test. + +#### `httpResource` and injection context + +Generated `httpResource` functions read the token with `inject()` when called +inside an injection context, and fall back to `options.injector.get(...)` when +an explicit `injector` is passed — the same rule that already applies to every +other injected dependency in generated `httpResource` functions: + +```ts title="petstore.resource.ts" +export function showPetByIdResource( + petId: Signal, + accept?: ShowPetByIdAccept, + version?: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef { + const baseUrl = options?.injector + ? options.injector.get(PETSTORE_BASE_URL) + : inject(PETSTORE_BASE_URL); + // ... +} +``` + +Call these functions during Angular's injection context (a constructor, a +field initializer, or `runInInjectionContext`), or pass an explicit +`injector` in `options` when you can't. + +#### Notes + +- **Zod runtime validation is unaffected.** `override.angular.runtimeValidation` + keeps validating responses exactly as before — the base URL token only + changes how the request URL is composed, not how the response is parsed. +- **Custom mutators receive the composed URL.** The `${baseUrl}` prefix is + applied to the route before it's handed to `generateMutatorConfig`, so a + configured `mutator` sees the same fully composed URL a plain `HttpClient` + call would use. +- **MSW mocks stay relative.** Mock route matching is unaffected by + `override.angular.baseUrl` — MSW handlers keep matching on the route path, + not the composed base URL. +- **Mutually exclusive with `output.baseUrl`.** `output.baseUrl` bakes a + static prefix into every generated route string for *all* clients; + combining it with `override.angular.baseUrl` would double-prefix (or + conflict with) every URL, so Orval throws a config-time error if both are + set on the same output. Remove `output.baseUrl` and use + `providePetstoreBaseUrlResolver` if you need the equivalent of a + runtime-configurable prefix. +- **`apiId` is always explicit.** It's never derived from the specification's + `info.title` or the target file name, so renaming your spec or output file + doesn't silently rename the generated DI tokens. +- Only the specification's top-level `servers` field is embedded as the + fallback URL. If your specification sets `servers` per path/operation, + the token still falls back to the spec-level `servers` entry (selected via + `index`/`variables`), not a per-path override. + ## httpResource Output (Angular v19.2+) Enable the `httpResource` retrieval mode with `override.angular.retrievalClient`. diff --git a/docs/content/docs/reference/configuration/output.mdx b/docs/content/docs/reference/configuration/output.mdx index a3a319a1f0..c3f6be11b7 100644 --- a/docs/content/docs/reference/configuration/output.mdx +++ b/docs/content/docs/reference/configuration/output.mdx @@ -442,6 +442,15 @@ export default defineConfig({ }); ``` + +For the `angular` client, prefer +[`override.angular.baseUrl`](#baseurl-1) when you need the base URL resolved +through Angular's dependency injection (for example, per-API gateway routing +or `TestBed` overrides) instead of baked into every generated route string. +`baseUrl` and `override.angular.baseUrl` are mutually exclusive on the same +output. + + ### runtime **Type:** `String` @@ -1986,6 +1995,89 @@ Raw expression passed to `HttpResourceOptions.injector`. Raw expression passed to `HttpResourceOptions.equal`. +### baseUrl + +**Type:** `Object` + +```ts title="orval.config.ts" +export default defineConfig({ + petstore: { + output: { + override: { + angular: { + baseUrl: { + apiId: 'petstore', + }, + }, + }, + }, + }, +}); +``` + +Opt-in: compose this output's runtime base URL through Angular dependency +injection (an `InjectionToken`) instead of baking a static prefix into every +generated route string. See the +[Angular guide](/docs/guides/angular#di-based-base-url-composition-multiple-apis--gateway-routing) +for the full precedence chain, the generated artifacts, and a multi-API +gateway-routing example. + + +`angular`-client only. Setting `baseUrl` on any other client logs a warning +and has no effect. + + +#### apiId + +**Type:** `String` (required) + +Explicit, stable identifier for this API. Must match +`/^[A-Za-z][A-Za-z0-9_-]*$/`; Orval throws a config-time error otherwise. +`apiId` is **never** derived from the specification's `info.title` or the +target file name — it drives every generated identifier, so it needs to stay +stable across regenerations: + +| Generated identifier | Derivation | +|---|---| +| `_SERVER_URL` | Embedded fallback URL constant | +| `_BASE_URL_RESOLVER` | `InjectionToken` for the runtime resolver hook | +| `_BASE_URL` | `InjectionToken` for the composed, normalized base URL | +| `BaseUrlResolverContext` | Resolver context type (`{ apiId, serverUrl }`) | +| `BaseUrlResolver` | Resolver function type | +| `provideBaseUrl(baseUrl)` | Directly provides the base URL, bypassing the resolver | +| `provideBaseUrlResolver(resolver)` | Provides a custom resolver | + +`` is `apiId` upper-snake-cased (e.g. `petstore` → `PETSTORE`); +`` is `apiId` PascalCased (e.g. `petstore` → `Petstore`). + +#### index + +**Type:** `Number` +**Default:** `0` + +Which entry of the specification's `servers` array to embed as the default +fallback URL, same semantics as [`baseUrl.index`](#index) on the top-level +`baseUrl` option. + +#### variables + +**Type:** `Record` + +Values for any `{variable}` placeholders in the selected server URL. + +#### Error and warning behavior + +- **Missing/invalid `apiId`** — throws + `` `override.angular.baseUrl.apiId` must be a non-empty string matching /^[A-Za-z][A-Za-z0-9_-]*$/ `` at config-normalization time. +- **Combined with `output.baseUrl`** — throws: `` `override.angular.baseUrl` cannot be combined with the top-level `output.baseUrl` ``. + Remove `output.baseUrl` from the output; the token's fallback already reads + the specification's `servers` field, and a runtime override belongs in a + provided resolver. +- **Set on a non-`angular` client** — logs a warning and is otherwise ignored. +- **Set under `override.operations[...].angular` or `override.tags[...].angular`** + — logs a warning and is ignored. `baseUrl` is an output-level concern + configured once via `override.angular.baseUrl`, not per operation or tag. + --- ## override.hono diff --git a/packages/angular/src/base-url.test.ts b/packages/angular/src/base-url.test.ts new file mode 100644 index 0000000000..f4974d9ad3 --- /dev/null +++ b/packages/angular/src/base-url.test.ts @@ -0,0 +1,407 @@ +import type { ContextSpec, NormalizedOutputOptions } from '@orval/core'; +import { describe, expect, it } from 'vitest'; + +import { + buildAngularBaseUrlFileContent, + generateAngularBaseUrlExtraFiles, + getAngularBaseUrlFilePath, + getAngularBaseUrlImportSpecifier, + getBaseUrlConstantPrefix, + getBaseUrlResolverTokenName, + getBaseUrlServerUrlConstantName, + getBaseUrlTokenName, + getProvideBaseUrlName, + getProvideBaseUrlResolverName, +} from './base-url'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +const angularOverride = { + provideIn: 'root', + client: 'httpClient', + runtimeValidation: false, + queryObjectSerialization: 'spec', +} as const; + +const createOutput = ( + overrides: Partial = {}, +): NormalizedOutputOptions => { + const output = { + target: '/tmp/pet.ts', + schemas: '/tmp/schemas', + operationSchemas: undefined, + namingConvention: 'camelCase', + fileExtension: '.ts', + schemaFileExtension: '.ts', + mode: 'single', + mock: { indexMockFiles: false, generators: [] }, + override: { + operations: {}, + tags: {}, + query: {}, + jsDoc: {}, + header: false, + hono: { + handlerGenerationStrategy: 'smart', + compositeRoute: '', + validator: true, + validatorOutputPath: '', + }, + formData: { disabled: true, arrayHandling: 'serialize' }, + formUrlEncoded: true, + paramsSerializerOptions: undefined, + requestOptions: true, + namingConvention: {}, + components: { + schemas: { suffix: 'Schema', itemSuffix: 'Item' }, + responses: { suffix: 'Response' }, + parameters: { suffix: 'Parameters' }, + requestBodies: { suffix: 'Body' }, + }, + angular: angularOverride, + swr: {}, + zod: { + version: 'auto', + variant: 'classic', + strict: { + param: false, + query: false, + header: false, + body: false, + response: false, + }, + generate: { + param: true, + query: true, + header: true, + body: true, + response: true, + }, + coerce: { + param: false, + query: false, + header: false, + body: false, + response: false, + }, + generateEachHttpStatus: false, + generateReusableSchemas: false, + generateMeta: false, + generateDiscriminatedUnion: false, + useBrandedTypes: false, + exactOptional: false, + dateTimeOptions: {}, + timeOptions: {}, + }, + effect: { + strict: { + param: false, + query: false, + header: false, + body: false, + response: false, + }, + generate: { + param: true, + query: true, + header: true, + body: true, + response: true, + }, + generateEachHttpStatus: false, + useBrandedTypes: false, + exactOptional: false, + }, + fetch: { + includeHttpResponseReturnType: true, + forceSuccessResponse: false, + runtimeValidation: false, + useRuntimeFetcher: false, + serializeResponseHeaders: false, + }, + enumGenerationType: 'const', + splitByContentType: false, + aliasCombinedTypes: false, + suppressReadonlyModifier: false, + mcp: {}, + }, + client: 'angular', + httpClient: 'angular', + clean: false, + docs: false, + formatter: undefined, + tsconfig: {}, + packageJson: {}, + headers: false, + indexFiles: true, + baseUrl: undefined, + allParamsOptional: false, + urlEncodeParameters: false, + optionsParamRequired: false, + unionAddMissingProperties: false, + propertySortOrder: 'Specification', + tagsSplitDeduplication: false, + commonTypesFileName: 'common-types', + factoryMethods: { + functionNamePrefix: 'create', + mode: 'single', + outputDirectory: '', + includeOptionalProperty: false, + }, + ...overrides, + } satisfies NormalizedOutputOptions; + + return output; +}; + +const createContextSpec = ( + output: NormalizedOutputOptions, + servers?: ContextSpec['spec']['servers'], +): ContextSpec => { + const spec = { + openapi: '3.1.0', + info: { title: 'Pets', version: '1.0.0' }, + paths: {}, + ...(servers ? { servers } : {}), + } satisfies ContextSpec['spec']; + + return { + output, + projectName: 'pets', + target: output.target, + workspace: output.workspace ?? '/tmp', + spec, + } satisfies ContextSpec; +}; + +// --------------------------------------------------------------------------- +// Naming derivation +// --------------------------------------------------------------------------- + +describe('naming helpers', () => { + it('derives CONSTANT_CASE names from a kebab-case apiId', () => { + expect(getBaseUrlConstantPrefix('example-api')).toBe('EXAMPLE_API'); + expect(getBaseUrlServerUrlConstantName('example-api')).toBe( + 'EXAMPLE_API_SERVER_URL', + ); + expect(getBaseUrlTokenName('example-api')).toBe('EXAMPLE_API_BASE_URL'); + expect(getBaseUrlResolverTokenName('example-api')).toBe( + 'EXAMPLE_API_BASE_URL_RESOLVER', + ); + }); + + it('derives PascalCase provide-helper names from a kebab-case apiId', () => { + expect(getProvideBaseUrlName('example-api')).toBe( + 'provideExampleApiBaseUrl', + ); + expect(getProvideBaseUrlResolverName('example-api')).toBe( + 'provideExampleApiBaseUrlResolver', + ); + }); +}); + +// --------------------------------------------------------------------------- +// buildAngularBaseUrlFileContent +// --------------------------------------------------------------------------- + +describe('buildAngularBaseUrlFileContent', () => { + it('emits the tokens, precedence wiring, and provide helpers', () => { + const content = buildAngularBaseUrlFileContent({ + apiId: 'example-api', + serverUrl: 'http://example.com/v1', + }); + + expect(content).toContain( + "import { InjectionToken, inject, type Provider } from '@angular/core';", + ); + expect(content).toContain( + 'export const EXAMPLE_API_SERVER_URL: string = "http://example.com/v1";', + ); + expect(content).toContain('export function normalizeBaseUrl('); + expect(content).toContain( + 'export const EXAMPLE_API_BASE_URL_RESOLVER = new InjectionToken(', + ); + expect(content).toContain( + 'factory: (): ExampleApiBaseUrlResolver => (context) => context.serverUrl,', + ); + expect(content).toContain( + 'export const EXAMPLE_API_BASE_URL = new InjectionToken(', + ); + expect(content).toContain( + 'const resolver = inject(EXAMPLE_API_BASE_URL_RESOLVER);', + ); + expect(content).toContain( + 'resolver({ apiId: "example-api", serverUrl: EXAMPLE_API_SERVER_URL }),', + ); + expect(content).toContain( + 'export function provideExampleApiBaseUrl(baseUrl: string): Provider {', + ); + expect(content).toContain( + 'export function provideExampleApiBaseUrlResolver(', + ); + expect(content).not.toMatch(/\bany\b/); + }); + + it('embeds an empty serverUrl fallback verbatim', () => { + const content = buildAngularBaseUrlFileContent({ + apiId: 'example-api', + serverUrl: '', + }); + + expect(content).toContain( + 'export const EXAMPLE_API_SERVER_URL: string = "";', + ); + }); +}); + +// --------------------------------------------------------------------------- +// normalizeBaseUrl (trailing-slash matrix) +// --------------------------------------------------------------------------- + +// Mirrors the emitted `normalizeBaseUrl` body (`baseUrl.replace(/\/+$/, '')`) +// exactly. Kept as a literal, independently-written implementation (rather +// than executing the generated source) so the test never evaluates +// dynamically constructed code. +const stripTrailingSlashes = (baseUrl: string): string => + baseUrl.replace(/\/+$/, ''); + +describe('normalizeBaseUrl trailing-slash matrix', () => { + it("emits exactly `return baseUrl.replace(/\\/+$/, '');`", () => { + const content = buildAngularBaseUrlFileContent({ + apiId: 'example-api', + serverUrl: '', + }); + + expect(content).toContain( + "export function normalizeBaseUrl(baseUrl: string): string {\n return baseUrl.replace(/\\/+$/, '');\n}", + ); + }); + + it.each([ + ['', ''], + ['/', ''], + ['/api/x/', '/api/x'], + ['https://h', 'https://h'], + ['https://h/', 'https://h'], + ])('normalizes %j to %j', (input, expected) => { + expect(stripTrailingSlashes(input)).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// getAngularBaseUrlFilePath / getAngularBaseUrlImportSpecifier +// --------------------------------------------------------------------------- + +describe('getAngularBaseUrlFilePath', () => { + it('is mode-independent: always /.base-url.ts', () => { + for (const mode of ['single', 'split', 'tags', 'tags-split'] as const) { + const output = createOutput({ target: '/tmp/api/pet.ts', mode }); + expect(getAngularBaseUrlFilePath(output)).toBe( + '/tmp/api/pet.base-url.ts', + ); + } + }); +}); + +describe('getAngularBaseUrlImportSpecifier', () => { + // Always authored relative to `dirname` (as if the importer were a + // sibling), for every mode including `tags-split` — the `tags-split` + // writer (`writers/split-tags-mode.ts`) generically re-resolves every + // relative `GeneratorImport.importPath` against the operation's actual + // nested file location, so this function must NOT also apply a `'../'` + // shift for that mode or the writer would double-apply it. + it('is mode-independent: always "./.base-url"', () => { + for (const mode of ['single', 'split', 'tags', 'tags-split'] as const) { + const output = createOutput({ target: '/tmp/api/pet.ts', mode }); + expect(getAngularBaseUrlImportSpecifier(output)).toBe('./pet.base-url'); + } + }); +}); + +// --------------------------------------------------------------------------- +// generateAngularBaseUrlExtraFiles +// --------------------------------------------------------------------------- + +describe('generateAngularBaseUrlExtraFiles', () => { + it('returns [] when override.angular.baseUrl is unset', async () => { + const output = createOutput(); + const files = await generateAngularBaseUrlExtraFiles( + {}, + output, + createContextSpec(output), + ); + + expect(files).toEqual([]); + }); + + it('embeds the first server URL from the spec when servers are present', async () => { + const output = createOutput({ + override: { + ...createOutput().override, + angular: { ...angularOverride, baseUrl: { apiId: 'example-api' } }, + }, + }); + const context = createContextSpec(output, [ + { url: 'http://petstore.swagger.io/v1' }, + { url: 'http://other.example.com' }, + ]); + + const files = await generateAngularBaseUrlExtraFiles({}, output, context); + + expect(files).toHaveLength(1); + expect(files[0].path).toBe('/tmp/pet.base-url.ts'); + expect(files[0].content).toContain( + 'export const EXAMPLE_API_SERVER_URL: string = "http://petstore.swagger.io/v1";', + ); + }); + + it('selects servers[index] and resolves variables when configured', async () => { + const output = createOutput({ + override: { + ...createOutput().override, + angular: { + ...angularOverride, + baseUrl: { + apiId: 'example-api', + index: 1, + variables: { port: '8080' }, + }, + }, + }, + }); + const context = createContextSpec(output, [ + { url: 'http://primary.example.com' }, + { + url: 'http://secondary.example.com:{port}', + variables: { port: { default: '443' } }, + }, + ]); + + const files = await generateAngularBaseUrlExtraFiles({}, output, context); + + expect(files[0].content).toContain( + 'export const EXAMPLE_API_SERVER_URL: string = "http://secondary.example.com:8080";', + ); + }); + + it('embeds an empty serverUrl fallback when the spec has no servers', async () => { + const output = createOutput({ + override: { + ...createOutput().override, + angular: { ...angularOverride, baseUrl: { apiId: 'example-api' } }, + }, + }); + + const files = await generateAngularBaseUrlExtraFiles( + {}, + output, + createContextSpec(output), + ); + + expect(files[0].content).toContain( + 'export const EXAMPLE_API_SERVER_URL: string = "";', + ); + }); +}); diff --git a/packages/angular/src/base-url.ts b/packages/angular/src/base-url.ts new file mode 100644 index 0000000000..d47ec21abf --- /dev/null +++ b/packages/angular/src/base-url.ts @@ -0,0 +1,258 @@ +import { + type AngularBaseUrlOptions, + type ClientExtraFilesBuilder, + type ClientFileBuilder, + type ContextSpec, + getFileInfo, + getImportExtension, + jsDoc, + type NormalizedOutputOptions, + type OpenApiInfoObject, + pascal, + resolveServerUrl, + snake, + upath, +} from '@orval/core'; + +/** + * Reads the file-level JSDoc header configured via `output.override.header`. + * + * Mirrors the identically-named helper in `http-resource.ts` — duplicated + * (rather than imported) to keep this module free of a dependency on the + * httpResource generator. + */ +const getHeader = ( + option: false | ((info: OpenApiInfoObject) => string | string[]), + info: OpenApiInfoObject | undefined, +): string => { + if (!option || !info) { + return ''; + } + + const header = option(info); + + return Array.isArray(header) ? jsDoc({ description: header }) : header; +}; + +/** `example-api` -> `EXAMPLE_API` — the shared constant-case prefix for every generated identifier. */ +export const getBaseUrlConstantPrefix = (apiId: string): string => + snake(apiId).toUpperCase(); + +/** `example-api` -> `EXAMPLE_API_SERVER_URL` */ +export const getBaseUrlServerUrlConstantName = (apiId: string): string => + `${getBaseUrlConstantPrefix(apiId)}_SERVER_URL`; + +/** `example-api` -> `EXAMPLE_API_BASE_URL` */ +export const getBaseUrlTokenName = (apiId: string): string => + `${getBaseUrlConstantPrefix(apiId)}_BASE_URL`; + +/** `example-api` -> `EXAMPLE_API_BASE_URL_RESOLVER` */ +export const getBaseUrlResolverTokenName = (apiId: string): string => + `${getBaseUrlConstantPrefix(apiId)}_BASE_URL_RESOLVER`; + +/** `example-api` -> `ExampleApiBaseUrlResolver` (resolver function type name) */ +export const getBaseUrlResolverTypeName = (apiId: string): string => + `${pascal(apiId)}BaseUrlResolver`; + +/** `example-api` -> `ExampleApiBaseUrlResolverContext` (resolver context type name) */ +export const getBaseUrlResolverContextTypeName = (apiId: string): string => + `${pascal(apiId)}BaseUrlResolverContext`; + +/** `example-api` -> `provideExampleApiBaseUrl` */ +export const getProvideBaseUrlName = (apiId: string): string => + `provide${pascal(apiId)}BaseUrl`; + +/** `example-api` -> `provideExampleApiBaseUrlResolver` */ +export const getProvideBaseUrlResolverName = (apiId: string): string => + `provide${pascal(apiId)}BaseUrlResolver`; + +/** + * Builds the full generated source for a `.base-url.ts` file. + * + * The emitted module exposes, purely through Angular DI, the precedence chain + * documented in `override.angular.baseUrl`'s guide: + * + * 1. A directly provided `_BASE_URL` token value (`provideXBaseUrl`) — + * wins outright; the resolver below is never invoked. + * 2. A directly provided `_BASE_URL_RESOLVER` (`provideXBaseUrlResolver`). + * 3. The default resolver factory, which returns the embedded spec server URL. + * 4. The embedded `_SERVER_URL` constant (`''` when the specification + * has no `servers` entry), passed to whichever resolver above ends up running. + * + * All exported members carry explicit return types and no `any`, matching the + * rest of the generated Angular output. + */ +export const buildAngularBaseUrlFileContent = ({ + apiId, + serverUrl, +}: { + apiId: string; + serverUrl: string; +}): string => { + const serverUrlConstantName = getBaseUrlServerUrlConstantName(apiId); + const tokenName = getBaseUrlTokenName(apiId); + const resolverTokenName = getBaseUrlResolverTokenName(apiId); + const resolverTypeName = getBaseUrlResolverTypeName(apiId); + const contextTypeName = getBaseUrlResolverContextTypeName(apiId); + const provideBaseUrlName = getProvideBaseUrlName(apiId); + const provideBaseUrlResolverName = getProvideBaseUrlResolverName(apiId); + + return `import { InjectionToken, inject, type Provider } from '@angular/core'; + +/** + * Embedded fallback base URL for the \`${apiId}\` API, resolved at generation + * time from the OpenAPI specification's \`servers\` field (\`''\` when the + * specification has no servers). + */ +export const ${serverUrlConstantName}: string = ${JSON.stringify(serverUrl)}; + +/** + * Strips trailing slashes from a base URL. + * + * Generated routes always start with \`/\`, so normalizing here at the token + * boundary guarantees \`\${baseUrl}\${route}\` can never double or drop the + * separator between them, for either \`HttpClient\` services or \`httpResource\` + * functions. + */ +export function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\\/+$/, ''); +} + +/** Context passed to a \`${resolverTypeName}\` when it is invoked. */ +export interface ${contextTypeName} { + /** The explicit \`apiId\` configured via \`override.angular.baseUrl\`. */ + readonly apiId: ${JSON.stringify(apiId)}; + /** The embedded fallback server URL (\`${serverUrlConstantName}\`). */ + readonly serverUrl: string; +} + +/** Resolves the runtime base URL for the \`${apiId}\` API. */ +export type ${resolverTypeName} = (context: ${contextTypeName}) => string; + +/** + * Injectable hook for resolving the \`${apiId}\` API's base URL at runtime + * (e.g. from a gateway route registry). Overridden via + * \`${provideBaseUrlResolverName}\`; defaults to the embedded specification + * server URL. + */ +export const ${resolverTokenName} = new InjectionToken<${resolverTypeName}>( + ${JSON.stringify(resolverTokenName)}, + { + providedIn: 'root', + factory: (): ${resolverTypeName} => (context) => context.serverUrl, + }, +); + +/** + * Runtime base URL for the \`${apiId}\` API, composed via Angular DI. + * + * Precedence: a directly provided value (\`${provideBaseUrlName}\`) wins + * outright; otherwise the \`${resolverTokenName}\` resolver (default or + * provided via \`${provideBaseUrlResolverName}\`) is invoked with the embedded + * \`${serverUrlConstantName}\` fallback. The result is always normalized. + */ +export const ${tokenName} = new InjectionToken(${JSON.stringify(tokenName)}, { + providedIn: 'root', + factory: (): string => { + const resolver = inject(${resolverTokenName}); + return normalizeBaseUrl( + resolver({ apiId: ${JSON.stringify(apiId)}, serverUrl: ${serverUrlConstantName} }), + ); + }, +}); + +/** Directly provides the \`${apiId}\` API's base URL, bypassing the resolver. */ +export function ${provideBaseUrlName}(baseUrl: string): Provider { + return { provide: ${tokenName}, useValue: normalizeBaseUrl(baseUrl) }; +} + +/** Provides a custom resolver for the \`${apiId}\` API's base URL. */ +export function ${provideBaseUrlResolverName}( + resolver: ${resolverTypeName}, +): Provider { + return { provide: ${resolverTokenName}, useValue: resolver }; +} +`; +}; + +/** + * Path of the generated `.base-url.ts` file for the current output. + * + * Unlike the `httpResource` extra-file mechanism (one sibling file per tag in + * `tags` / `tags-split` mode), there is exactly one base-URL file per output — + * the DI tokens it exports are shared by every generated file regardless of mode. + */ +export const getAngularBaseUrlFilePath = ( + output: NormalizedOutputOptions, +): string => { + const { dirname, filename, extension } = getFileInfo(output.target, { + extension: output.fileExtension, + }); + + return upath.joinSafe(dirname, `${filename}.base-url${extension}`); +}; + +/** + * Import specifier a generated implementation file uses to reach the + * base-URL file produced by {@link getAngularBaseUrlFilePath}. + * + * Always authored as if the importing file sat next to the base-URL file + * (i.e. directly in ``) — this matches `single`/`split`/`tags` mode, + * where implementation files are in fact siblings. `tags-split` mode nests + * implementation files one directory below (`//.ts`), but + * the `tags-split` writer (`writers/split-tags-mode.ts`) already generically + * re-resolves every relative `GeneratorImport.importPath` — originally + * authored relative to `dirname` — against the operation's actual nested + * file location. Special-casing `'../'` here as well would double-apply that + * shift and produce a broken `../../` import. + */ +export const getAngularBaseUrlImportSpecifier = ( + output: NormalizedOutputOptions, +): string => { + const { filename, extension } = getFileInfo(output.target, { + extension: output.fileExtension, + }); + const importExtension = getImportExtension(extension, output.tsconfig); + + return `./${filename}.base-url${importExtension}`; +}; + +const buildBaseUrlExtraFile = ( + baseUrl: AngularBaseUrlOptions, + output: NormalizedOutputOptions, + context: ContextSpec, + header: string, +): ClientFileBuilder => { + const serverUrl = resolveServerUrl(context.spec.servers, { + index: baseUrl.index, + variables: baseUrl.variables, + }); + + return { + path: getAngularBaseUrlFilePath(output), + content: `${header}${buildAngularBaseUrlFileContent({ apiId: baseUrl.apiId, serverUrl })}`, + }; +}; + +/** + * Emits the opt-in `.base-url.ts` extra file when + * `override.angular.baseUrl` is configured; a zero-cost no-op (`[]`) otherwise. + * + * @returns Zero or one `ClientFileBuilder` describing the generated base-URL file. + */ +export const generateAngularBaseUrlExtraFiles: ClientExtraFilesBuilder = ( + _verbOptions, + output, + context, +) => { + const baseUrl = output.override.angular.baseUrl; + if (!baseUrl) { + return Promise.resolve([]); + } + + const header = getHeader(output.override.header, context.spec.info); + + return Promise.resolve([ + buildBaseUrlExtraFile(baseUrl, output, context, header), + ]); +}; diff --git a/packages/angular/src/constants.ts b/packages/angular/src/constants.ts index f97c655a4c..a60bdc00c3 100644 --- a/packages/angular/src/constants.ts +++ b/packages/angular/src/constants.ts @@ -39,7 +39,11 @@ export const ANGULAR_HTTP_RESOURCE_DEPENDENCIES = [ dependency: '@angular/common/http', }, { - exports: [{ name: 'Signal' }, { name: 'ResourceStatus' }], + exports: [ + { name: 'Signal' }, + { name: 'ResourceStatus' }, + { name: 'inject', values: true }, + ], dependency: '@angular/core', }, ] as const satisfies readonly GeneratorDependency[]; diff --git a/packages/angular/src/http-client.test.ts b/packages/angular/src/http-client.test.ts index a9467f478f..f988460ced 100644 --- a/packages/angular/src/http-client.test.ts +++ b/packages/angular/src/http-client.test.ts @@ -348,15 +348,17 @@ describe('angular HttpClient generator', () => { describe('generateAngularHeader', () => { it('generates @Injectable class with provideIn root', () => { - const header = generateAngularHeader({ - title: 'PetService', - isRequestOptions: true, - isMutator: false, - isGlobalMutator: false, - provideIn: 'root', - hasAwaitedType: false, - verbOptions: {}, - } as never); + const header = generateAngularHeader( + createHeaderParams({ + title: 'PetService', + isRequestOptions: true, + isMutator: false, + isGlobalMutator: false, + provideIn: 'root', + hasAwaitedType: false, + verbOptions: {}, + }), + ); expect(header).toContain("@Injectable({ providedIn: 'root' })"); expect(header).toContain('export class PetService'); @@ -364,44 +366,50 @@ describe('angular HttpClient generator', () => { }); it('generates @Injectable without provideIn when set to false', () => { - const header = generateAngularHeader({ - title: 'PetService', - isRequestOptions: true, - isMutator: false, - isGlobalMutator: false, - provideIn: false, - hasAwaitedType: false, - verbOptions: {}, - } as never); + const header = generateAngularHeader( + createHeaderParams({ + title: 'PetService', + isRequestOptions: true, + isMutator: false, + isGlobalMutator: false, + provideIn: false, + hasAwaitedType: false, + verbOptions: {}, + }), + ); expect(header).toContain('@Injectable()'); expect(header).not.toContain('providedIn'); }); it('generates @Injectable with custom provideIn value', () => { - const header = generateAngularHeader({ - title: 'PetService', - isRequestOptions: true, - isMutator: false, - isGlobalMutator: false, - provideIn: 'any', - hasAwaitedType: false, - verbOptions: {}, - } as never); + const header = generateAngularHeader( + createHeaderParams({ + title: 'PetService', + isRequestOptions: true, + isMutator: false, + isGlobalMutator: false, + provideIn: 'any', + hasAwaitedType: false, + verbOptions: {}, + }), + ); expect(header).toContain("@Injectable({ providedIn: 'any' })"); }); it('includes HttpClientOptions interface when isRequestOptions is true', () => { - const header = generateAngularHeader({ - title: 'PetService', - isRequestOptions: true, - isMutator: false, - isGlobalMutator: false, - provideIn: 'root', - hasAwaitedType: false, - verbOptions: {}, - } as never); + const header = generateAngularHeader( + createHeaderParams({ + title: 'PetService', + isRequestOptions: true, + isMutator: false, + isGlobalMutator: false, + provideIn: 'root', + hasAwaitedType: false, + verbOptions: {}, + }), + ); expect(header).toContain('interface HttpClientOptions'); expect(header).toContain('readonly headers?: HttpHeaders'); @@ -410,57 +418,65 @@ describe('angular HttpClient generator', () => { }); it('omits HttpClientOptions when isRequestOptions is false', () => { - const header = generateAngularHeader({ - title: 'PetService', - isRequestOptions: false, - isMutator: false, - isGlobalMutator: false, - provideIn: 'root', - hasAwaitedType: false, - verbOptions: {}, - } as never); + const header = generateAngularHeader( + createHeaderParams({ + title: 'PetService', + isRequestOptions: false, + isMutator: false, + isGlobalMutator: false, + provideIn: 'root', + hasAwaitedType: false, + verbOptions: {}, + }), + ); expect(header).not.toContain('interface HttpClientOptions'); }); it('omits HttpClientOptions when isGlobalMutator is true', () => { - const header = generateAngularHeader({ - title: 'PetService', - isRequestOptions: true, - isMutator: false, - isGlobalMutator: true, - provideIn: 'root', - hasAwaitedType: false, - verbOptions: {}, - } as never); + const header = generateAngularHeader( + createHeaderParams({ + title: 'PetService', + isRequestOptions: true, + isMutator: false, + isGlobalMutator: true, + provideIn: 'root', + hasAwaitedType: false, + verbOptions: {}, + }), + ); expect(header).not.toContain('interface HttpClientOptions'); }); it('includes ThirdParameter when isMutator is true', () => { - const header = generateAngularHeader({ - title: 'PetService', - isRequestOptions: true, - isMutator: true, - isGlobalMutator: false, - provideIn: 'root', - hasAwaitedType: false, - verbOptions: {}, - } as never); + const header = generateAngularHeader( + createHeaderParams({ + title: 'PetService', + isRequestOptions: true, + isMutator: true, + isGlobalMutator: false, + provideIn: 'root', + hasAwaitedType: false, + verbOptions: {}, + }), + ); expect(header).toContain('type ThirdParameter'); }); it('omits ThirdParameter when isMutator is false', () => { - const header = generateAngularHeader({ - title: 'PetService', - isRequestOptions: true, - isMutator: false, - isGlobalMutator: false, - provideIn: 'root', - hasAwaitedType: false, - verbOptions: {}, - } as never); + const header = generateAngularHeader( + createHeaderParams({ + title: 'PetService', + isRequestOptions: true, + isMutator: false, + isGlobalMutator: false, + provideIn: 'root', + hasAwaitedType: false, + verbOptions: {}, + }), + ); expect(header).not.toContain('type ThirdParameter'); }); @@ -2177,4 +2193,121 @@ describe('angular HttpClient generator', () => { expect(impl).not.toContain('url: `/api/pets/${petId}`'); }); }); + + // ── override.angular.baseUrl (DI base-url token) ───────────────────── + + describe('override.angular.baseUrl', () => { + const outputWithBaseUrl = () => + createOutput({ + override: { + ...createOutput().override, + angular: { ...angularOverride, baseUrl: { apiId: 'example-api' } }, + }, + }); + + it('injects a `baseUrl` field in the header when configured', () => { + const header = generateAngularHeader( + createHeaderParams({ output: outputWithBaseUrl() }), + ); + + expect(header).toContain( + 'private readonly baseUrl = inject(EXAMPLE_API_BASE_URL);', + ); + }); + + it('omits the `baseUrl` field when the option is unset (byte-identical to today)', () => { + const withOption = generateAngularHeader( + createHeaderParams({ output: outputWithBaseUrl() }), + ); + const withoutOption = generateAngularHeader(createHeaderParams()); + + expect(withoutOption).not.toContain('EXAMPLE_API_BASE_URL'); + expect(withoutOption).not.toContain('baseUrl'); + expect(withOption).not.toBe(withoutOption); + }); + + it('prefixes the route template with `${this.baseUrl}`', () => { + const output = outputWithBaseUrl(); + const options = createGeneratorOptions({ + route: '/api/pets/${petId}', + context: createContextSpec(output), + override: output.override, + }); + const verbOption = createVerbOption(); + + const impl = generateHttpClientImplementation(verbOption, options); + + expect(impl).toContain('`${this.baseUrl}/api/pets/${petId}`'); + }); + + it('does not wrap the `this.baseUrl` prefix in encodeURIComponent when urlEncodeParameters is also set', () => { + const output = createOutput({ + urlEncodeParameters: true, + override: { + ...createOutput().override, + angular: { ...angularOverride, baseUrl: { apiId: 'example-api' } }, + }, + }); + const options = createGeneratorOptions({ + route: '/api/pets/${petId}', + context: createContextSpec(output), + override: output.override, + }); + const verbOption = createVerbOption(); + + const impl = generateHttpClientImplementation(verbOption, options); + + expect(impl).toContain( + '`${this.baseUrl}/api/pets/${encodeURIComponent(String(petId))}`', + ); + expect(impl).not.toContain('encodeURIComponent(String(this.baseUrl))'); + }); + + it('composes the prefixed route into the mutator config', () => { + const output = outputWithBaseUrl(); + const verbOption = createVerbOption({ + mutator: { + name: 'customInstance', + path: './mutator', + default: true, + hasThirdArg: false, + hasSecondArg: false, + } as GeneratorVerbOptions['mutator'], + }); + const options = createGeneratorOptions({ + route: '/api/pets/${petId}', + context: createContextSpec(output), + override: output.override, + }); + + const impl = generateHttpClientImplementation(verbOption, options); + + expect(impl).toContain('${this.baseUrl}/api/pets/${petId}'); + }); + + it('adds the base-url token import in generateAngular', async () => { + const output = outputWithBaseUrl(); + const verbOption = createVerbOption(); + const options = createGeneratorOptions({ + route: '/api/pets/${petId}', + context: createContextSpec(output), + override: output.override, + }); + + const { imports } = await generateAngular( + verbOption, + options, + 'angular', + output, + ); + + expect(imports).toContainEqual( + expect.objectContaining({ + name: 'EXAMPLE_API_BASE_URL', + values: true, + importPath: './pet.base-url', + }), + ); + }); + }); }); diff --git a/packages/angular/src/http-client.ts b/packages/angular/src/http-client.ts index b6fc97fe76..a72e7687d1 100644 --- a/packages/angular/src/http-client.ts +++ b/packages/angular/src/http-client.ts @@ -27,6 +27,10 @@ import { EnumGeneration, } from '@orval/core'; +import { + getAngularBaseUrlImportSpecifier, + getBaseUrlTokenName, +} from './base-url'; import { ANGULAR_HTTP_CLIENT_DEPENDENCIES } from './constants'; import { HTTP_CLIENT_OBSERVE_OPTIONS_TEMPLATE, @@ -270,7 +274,12 @@ ${acceptHelpers} @Injectable(${provideIn ? `{ providedIn: '${isBoolean(provideIn) ? 'root' : provideIn}' }` : ''}) export class ${title} { private readonly http = inject(HttpClient); -`; +${ + output.override.angular.baseUrl + ? ` private readonly baseUrl = inject(${getBaseUrlTokenName(output.override.angular.baseUrl.apiId)}); +` + : '' +}`; }; /** @@ -342,6 +351,13 @@ export const generateHttpClientImplementation = ( if (context.output.urlEncodeParameters) { route = makeRouteSafe(route); } + // MUST run after the urlEncodeParameters/makeRouteSafe step above: + // wrapRouteParameters (invoked by makeRouteSafe) rewrites every `${...}` + // segment of the route, so prefixing before it would wrap `this.baseUrl` + // in `encodeURIComponent(String(...))`. + if (context.output.override.angular.baseUrl) { + route = '${this.baseUrl}' + route; + } const isRequestOptions = override.requestOptions !== false; const isFormData = !override.formData.disabled; @@ -919,11 +935,24 @@ export const generateAngular: ClientBuilder = (verbOptions, options) => { options, ); + const baseUrl = options.context.output.override.angular.baseUrl; + const imports = [ ...generateVerbImports(normalizedVerbOptions), ...(implementation.includes('.pipe(map(') ? [{ name: 'map', values: true, importPath: 'rxjs' }] : []), + ...(baseUrl + ? [ + { + name: getBaseUrlTokenName(baseUrl.apiId), + values: true, + importPath: getAngularBaseUrlImportSpecifier( + options.context.output, + ), + }, + ] + : []), ]; return { implementation, imports }; diff --git a/packages/angular/src/http-resource.test.ts b/packages/angular/src/http-resource.test.ts index 4520696584..f6b0043d41 100644 --- a/packages/angular/src/http-resource.test.ts +++ b/packages/angular/src/http-resource.test.ts @@ -3441,4 +3441,245 @@ describe('angular httpResource generator', () => { expect(header).not.toContain('encodeURIComponent'); }); }); + + // ── override.angular.baseUrl (DI base-url token) ───────────────────── + + describe('override.angular.baseUrl', () => { + const outputWithBaseUrl = ( + overrides: Partial = {}, + ) => + createOutput({ + override: { + ...createOutput().override, + angular: { + ...angularOverride('httpResource'), + baseUrl: { apiId: 'example-api' }, + }, + }, + ...overrides, + }); + + it('injects an injector-aware baseUrl const in the URL-only resource shape', () => { + const verbOption = createVerbOption(); + routeRegistry.set('getPetById', '/api/pets/${petId}'); + + const header = generateHttpResourceHeader( + createHeaderParams({ + output: outputWithBaseUrl(), + verbOptions: { getPetById: verbOption }, + }), + ); + + expect(header).toContain( + 'const baseUrl = options?.injector ? options.injector.get(EXAMPLE_API_BASE_URL) : inject(EXAMPLE_API_BASE_URL);', + ); + expect(header).toContain('`${baseUrl}/api/pets/${petId()}`'); + }); + + it('injects the baseUrl const in the default (body-form) resource shape', () => { + // `search*` is retrieval-classified by name even though it's a POST + // with a body (see `isRetrievalVerb`), landing it in the non-url-only + // "default" resource shape. + const searchVerb = createVerbOption({ + operationId: 'searchPets', + operationName: 'searchPets', + verb: 'post', + route: '/pets/search', + pathRoute: '/pets/search', + body: { + implementation: 'searchPetsBody', + definition: 'SearchPetsBody', + imports: [], + schemas: [], + originalSchema: { type: 'object' }, + contentType: 'application/json', + formData: '', + formUrlEncoded: '', + isOptional: false, + isBlob: false, + }, + props: [ + { + name: 'searchPetsBody', + definition: 'searchPetsBody: SearchPetsBody', + implementation: 'searchPetsBody: SearchPetsBody', + default: false, + required: true, + type: GetterPropType.BODY, + }, + ], + params: [], + }); + routeRegistry.set('searchPets', '/api/pets/search'); + + const header = generateHttpResourceHeader( + createHeaderParams({ + output: outputWithBaseUrl(), + verbOptions: { searchPets: searchVerb }, + }), + ); + + expect(header).toContain( + 'const baseUrl = options?.injector ? options.injector.get(EXAMPLE_API_BASE_URL) : inject(EXAMPLE_API_BASE_URL);', + ); + expect(header).toContain('`${baseUrl}/api/pets/search`'); + }); + + it('injects the baseUrl const in the multi-content-type resource shape', () => { + const verbOption = createVerbOption({ + response: baseResponse({ + definition: { success: 'string | Pet', errors: 'Error' }, + types: { + success: [ + createSuccessType('string', 'text/plain'), + createSuccessType('Pet', 'application/json'), + ], + errors: [], + }, + contentTypes: ['text/plain', 'application/json'], + }), + }); + routeRegistry.set('getPetById', '/api/pets/${petId}'); + + const header = generateHttpResourceHeader( + createHeaderParams({ + output: outputWithBaseUrl(), + verbOptions: { getPetById: verbOption }, + }), + ); + + expect(header).toContain( + 'const baseUrl = options?.injector ? options.injector.get(EXAMPLE_API_BASE_URL) : inject(EXAMPLE_API_BASE_URL);', + ); + expect(header).toContain('`${baseUrl}/api/pets/${petId()}`'); + }); + + it('does not wrap the `${baseUrl}` prefix in encodeURIComponent when urlEncodeParameters is also set', () => { + const verbOption = createVerbOption(); + routeRegistry.set('getPetById', '/api/pets/${petId}'); + + const header = generateHttpResourceHeader( + createHeaderParams({ + output: outputWithBaseUrl({ urlEncodeParameters: true }), + verbOptions: { getPetById: verbOption }, + }), + ); + + expect(header).toContain( + '`${baseUrl}/api/pets/${encodeURIComponent(String(petId()))}`', + ); + expect(header).not.toContain('encodeURIComponent(String(baseUrl))'); + }); + + it('injects a `baseUrl` field into the mutation service class shell (both mode)', () => { + const getVerb = createVerbOption(); + const postVerb = createVerbOption({ + operationId: 'createPet', + operationName: 'createPet', + verb: 'post', + route: '/pets', + pathRoute: '/pets', + body: { + implementation: 'createPetBody', + definition: 'CreatePetBody', + imports: [], + schemas: [], + originalSchema: { type: 'object' }, + contentType: 'application/json', + formData: '', + formUrlEncoded: '', + isOptional: false, + isBlob: false, + }, + props: [ + { + name: 'createPetBody', + definition: 'createPetBody: CreatePetBody', + implementation: 'createPetBody: CreatePetBody', + default: false, + required: true, + type: GetterPropType.BODY, + }, + ], + params: [], + }); + routeRegistry.set('getPetById', '/api/pets/${petId}'); + routeRegistry.set('createPet', '/api/pets'); + + const output = outputWithBaseUrl({ + override: { + ...createOutput().override, + angular: { + ...angularOverride('both'), + baseUrl: { apiId: 'example-api' }, + }, + }, + }); + + const header = generateHttpResourceHeader( + createHeaderParams({ + output, + verbOptions: { getPetById: getVerb, createPet: postVerb }, + }), + ); + + expect(header).toContain( + 'private readonly baseUrl = inject(EXAMPLE_API_BASE_URL);', + ); + }); + + it('adds the base-url token dependency to the both-mode resource extra file', async () => { + const verb = createVerbOption(); + + const output = outputWithBaseUrl({ target: '/tmp/pets.ts' }); + const context = createContextSpec(output, { + workspace: '/tmp', + target: '/tmp/pets.ts', + projectName: 'pets', + }); + + const extraFiles = await generateHttpResourceExtraFiles( + { getPetById: verb }, + output, + context, + ); + + expect(extraFiles[0].content).toMatch( + /import\s*\{\s*EXAMPLE_API_BASE_URL\s*\}\s*from\s+['"]\.\/pets\.base-url['"]/, + ); + }); + + it('coexists with zod runtime validation (parse hooks unaffected)', () => { + const verbOption = createVerbOption({ + response: baseResponse({ + imports: [{ name: 'Pet' }], + }), + }); + routeRegistry.set('getPetById', '/api/pets/${petId}'); + + const output = outputWithBaseUrl({ + schemas: { type: 'zod', path: '/tmp/schemas' } as never, + override: { + ...createOutput().override, + angular: { + ...angularOverride('httpResource', true), + baseUrl: { apiId: 'example-api' }, + }, + }, + }); + + const header = generateHttpResourceHeader( + createHeaderParams({ + output, + verbOptions: { getPetById: verbOption }, + }), + ); + + expect(header).toContain('`${baseUrl}/api/pets/${petId()}`'); + expect(header).toContain('parse: Pet.parse'); + expect(header).toContain( + 'const baseUrl = options?.injector ? options.injector.get(EXAMPLE_API_BASE_URL) : inject(EXAMPLE_API_BASE_URL);', + ); + }); + }); }); diff --git a/packages/angular/src/http-resource.ts b/packages/angular/src/http-resource.ts index c466c68327..d0c4ff13b6 100644 --- a/packages/angular/src/http-resource.ts +++ b/packages/angular/src/http-resource.ts @@ -35,6 +35,11 @@ import { getImportExtension, } from '@orval/core'; +import { + getAngularBaseUrlFilePath, + getAngularBaseUrlImportSpecifier, + getBaseUrlTokenName, +} from './base-url'; import { ANGULAR_HTTP_CLIENT_DEPENDENCIES, ANGULAR_HTTP_RESOURCE_DEPENDENCIES, @@ -928,9 +933,19 @@ const buildHttpResourceFunction = ( // to rewrite it to its signal form (e.g. `${param()}`), so encoding first // would stop the substitution from matching. Wrapping the already-rewritten // form yields `${encodeURIComponent(String(param()))}`, which is correct. - const encodedRoute = output.urlEncodeParameters + let encodedRoute = output.urlEncodeParameters ? makeRouteSafe(signalRoute) : signalRoute; + // MUST run after the urlEncodeParameters/makeRouteSafe step above (see the + // comment on `encodedRoute` for why): prefixing before it would wrap + // `baseUrl` in `encodeURIComponent(String(...))`. + const baseUrlOption = output.override.angular.baseUrl; + if (baseUrlOption) { + encodedRoute = '${baseUrl}' + encodedRoute; + } + const baseUrlDeclaration = baseUrlOption + ? `const baseUrl = options?.injector ? options.injector.get(${getBaseUrlTokenName(baseUrlOption.apiId)}) : inject(${getBaseUrlTokenName(baseUrlOption.apiId)});\n ` + : ''; const signalProps = buildSignalProps(props, params); const args = toObjectString(signalProps, 'implementation'); @@ -1104,7 +1119,7 @@ ${branchOverloads} export function ${resourceName}( ${implementationArgsWithDefault} ): HttpResourceRef<${unionReturnType} | undefined> { - const buildRequest = (): HttpResourceRequest => { + ${baseUrlDeclaration}const buildRequest = (): HttpResourceRequest => { ${bodyForm ? `${bodyForm};` : ''} const request = ${request}; ${normalizeRequest} @@ -1194,7 +1209,7 @@ export function ${resourceName}( */ ${functionSignatures}; export function ${resourceName}(${implementationArgs}): HttpResourceRef<${resourceValueType}> { - return ${resourceFactory}<${parsedDataType}>(() => applyOrvalRequestExtension(${request}, options)${resourceCallOptions}); + ${baseUrlDeclaration}return ${resourceFactory}<${parsedDataType}>(() => applyOrvalRequestExtension(${request}, options)${resourceCallOptions}); } `; } @@ -1211,7 +1226,7 @@ export function ${resourceName}(${implementationArgs}): HttpResourceRef<${resour */ ${functionSignatures}; export function ${resourceName}(${implementationArgs}): HttpResourceRef<${resourceValueType}> { - return ${resourceFactory}<${parsedDataType}>(() => { + ${baseUrlDeclaration}return ${resourceFactory}<${parsedDataType}>(() => { ${factoryPrelude} const request = ${request}; return applyOrvalRequestExtension(${returnExpression}, options); @@ -1472,6 +1487,7 @@ export const generateHttpResourceHeader: ClientHeaderBuilder = ({ }) .join('\n'); + const baseUrlOption = output.override.angular.baseUrl; const classImplementation = mutationImplementation ? ` ${buildServiceClassOpen({ @@ -1482,6 +1498,9 @@ ${buildServiceClassOpen({ provideIn, hasQueryParams: hasMutationBuiltInFilteredQueryParams && !hasBuiltInFilteredQueryParams, + baseUrlFieldInitializer: baseUrlOption + ? `private readonly baseUrl = inject(${getBaseUrlTokenName(baseUrlOption.apiId)});` + : undefined, hasObjectParams: mutations.some(hasGatedObjectQueryParamStrategies), })} ${mutationImplementation} @@ -1524,10 +1543,21 @@ export const generateHttpResourceClient: ClientBuilder = ( options, ) => { routeRegistry.set(verbOptions.operationName, options.route); - const imports = getHttpResourceVerbImports( - verbOptions, - options.context.output, - ); + const baseUrlOption = options.context.output.override.angular.baseUrl; + const imports = [ + ...getHttpResourceVerbImports(verbOptions, options.context.output), + ...(baseUrlOption + ? [ + { + name: getBaseUrlTokenName(baseUrlOption.apiId), + values: true, + importPath: getAngularBaseUrlImportSpecifier( + options.context.output, + ), + }, + ] + : []), + ]; return { implementation: '\n', imports }; }; @@ -1728,9 +1758,32 @@ const buildHttpResourceExtraFile = ( ); const dependencies = getAngularHttpResourceOnlyDependencies(false, false); + const baseUrlOption = output.override.angular.baseUrl; + const baseUrlDependency = baseUrlOption + ? [ + { + exports: [ + { name: getBaseUrlTokenName(baseUrlOption.apiId), values: true }, + ], + // Only include a literal extension for non-`.ts` output (mirrors + // `getHttpResourceRelativeSchemasPath` above): TS5097 forbids a + // `.ts` import specifier unless `allowImportingTsExtensions` is set. + dependency: upath.getRelativeImportPath( + outputPath, + getAngularBaseUrlFilePath(output), + output.fileExtension !== '.ts', + ), + }, + ] + : []; const importImplementation = generateDependencyImports( implementation, - [...schemaImports, ...externalVerbImports, ...dependencies], + [ + ...schemaImports, + ...externalVerbImports, + ...dependencies, + ...baseUrlDependency, + ], context.projectName, !!output.schemas, isSyntheticDefaultImportsAllow(output.tsconfig), diff --git a/packages/angular/src/index.test.ts b/packages/angular/src/index.test.ts index 0d41b6d714..18d282b37d 100644 --- a/packages/angular/src/index.test.ts +++ b/packages/angular/src/index.test.ts @@ -12,12 +12,14 @@ describe('builder', () => { expect(result.client).toBeDefined(); expect(result.header).toBeDefined(); expect(result.footer).toBeDefined(); - expect(result).not.toHaveProperty('extraFiles'); + // extraFiles is always present (wired for the opt-in base-url-token + // feature) but is a zero-cost no-op ([]) when the option is unset. + expect(result.extraFiles).toBeDefined(); }); it('returns httpClientBuilder for client "httpClient"', () => { const result = builder()({ client: 'httpClient' }); - expect(result).not.toHaveProperty('extraFiles'); + expect(result.extraFiles).toBeDefined(); }); it('returns httpResourceBuilder for client "httpResource"', () => { @@ -25,7 +27,7 @@ describe('builder', () => { expect(result.client).toBeDefined(); expect(result.header).toBeDefined(); expect(result.footer).toBeDefined(); - expect(result).not.toHaveProperty('extraFiles'); + expect(result.extraFiles).toBeDefined(); }); it('returns bothClientBuilder for client "both"', () => { diff --git a/packages/angular/src/index.ts b/packages/angular/src/index.ts index bab722eb9d..31d6d55e0a 100644 --- a/packages/angular/src/index.ts +++ b/packages/angular/src/index.ts @@ -1,5 +1,6 @@ import type { AngularOptions, ClientGeneratorsBuilder } from '@orval/core'; +import { generateAngularBaseUrlExtraFiles } from './base-url'; import { generateAngular, generateAngularFooter, @@ -15,6 +16,7 @@ import { getAngularHttpResourceDependencies, } from './http-resource'; +export * from './base-url'; export * from './constants'; export * from './http-client'; export * from './http-resource'; @@ -27,6 +29,7 @@ const httpClientBuilder: ClientGeneratorsBuilder = { dependencies: getAngularDependencies, footer: generateAngularFooter, title: generateAngularTitle, + extraFiles: generateAngularBaseUrlExtraFiles, }; const httpResourceBuilder: ClientGeneratorsBuilder = { @@ -35,11 +38,15 @@ const httpResourceBuilder: ClientGeneratorsBuilder = { dependencies: getAngularHttpResourceDependencies, footer: generateHttpResourceFooter, title: generateAngularTitle, + extraFiles: generateAngularBaseUrlExtraFiles, }; const bothClientBuilder: ClientGeneratorsBuilder = { ...httpClientBuilder, - extraFiles: generateHttpResourceExtraFiles, + extraFiles: async (verbOptions, output, context) => [ + ...(await generateHttpResourceExtraFiles(verbOptions, output, context)), + ...(await generateAngularBaseUrlExtraFiles(verbOptions, output, context)), + ], }; export const builder = () => (options?: AngularOptions) => { diff --git a/packages/angular/src/utils.ts b/packages/angular/src/utils.ts index 9f5a46c447..02f0092dab 100644 --- a/packages/angular/src/utils.ts +++ b/packages/angular/src/utils.ts @@ -84,6 +84,7 @@ export const buildServiceClassOpen = ({ isGlobalMutator, provideIn, hasQueryParams, + baseUrlFieldInitializer, hasObjectParams = false, }: { title: string; @@ -92,6 +93,12 @@ export const buildServiceClassOpen = ({ isGlobalMutator: boolean; provideIn: string | boolean | undefined; hasQueryParams: boolean; + /** + * When set, injected as an additional `private readonly baseUrl = ...;` + * class field — used by `httpResource`-mode mutation-service classes to + * pick up the same base-URL DI token as their sibling `HttpClient` output. + */ + baseUrlFieldInitializer?: string; /** * Whether the emitted helper needs the object-serialization overload * (issue #3705). Only meaningful when `hasQueryParams` is `true`. @@ -118,7 +125,7 @@ ${isRequestOptions && isMutator ? THIRD_PARAMETER_TEMPLATE : ''} @Injectable(${provideInValue}) export class ${title} { private readonly http = inject(HttpClient); -`; +${baseUrlFieldInitializer ? ` ${baseUrlFieldInitializer}\n` : ''}`; }; /** diff --git a/packages/core/src/getters/route.test.ts b/packages/core/src/getters/route.test.ts index 64bd084b81..9fa480c63c 100644 --- a/packages/core/src/getters/route.test.ts +++ b/packages/core/src/getters/route.test.ts @@ -131,6 +131,24 @@ describe('getFullRoute getter', () => { }, 'eu.prod.example.com/path', ], + [ + '/path', + [ + { + url: '{environment}.example.com', + variables: { + environment: { + default: 'dev', + }, + }, + }, + ], + { + getBaseUrlFromSpecification: true, + variables: { environment: '' }, + }, + '.example.com/path', + ], ] as [string, OpenApiServerObject[] | undefined, BaseUrlFromSpec, string][]) { it(`should make path ${path} with config ${JSON.stringify(config)} and servers ${JSON.stringify(servers)} be ${expected}`, () => { expect(getFullRoute(path, servers, config)).toBe(expected); diff --git a/packages/core/src/getters/route.ts b/packages/core/src/getters/route.ts index 432d83fb47..1038f9ac0b 100644 --- a/packages/core/src/getters/route.ts +++ b/packages/core/src/getters/route.ts @@ -97,6 +97,54 @@ export function getRoute(route: string) { .join(''); } +/** + * Resolves a concrete base URL from an OpenAPI specification's `servers` + * field, applying the same `index`/`variables` selection semantics as + * `BaseUrlFromSpec`. + * + * Unlike `getFullRoute`'s `BaseUrlFromSpec` handling, this returns `''` when + * `servers` is missing or empty instead of throwing — callers that want a + * relative-URL fallback (e.g. the Angular DI base-url token) can use this + * directly, while `getFullRoute` still throws for its own + * `getBaseUrlFromSpecification` path to preserve existing behavior. + * + * The returned value is NOT escaped for embedding in a template literal — + * callers that splice it into generated backtick source (e.g. `getFullRoute`) + * must escape it themselves; callers that embed it via `JSON.stringify` + * (e.g. the Angular DI base-url token file) should use the raw value as-is. + */ +export function resolveServerUrl( + servers: OpenApiServerObject[] | undefined, + options: { index?: number; variables?: Record }, +): string { + if (!servers || servers.length === 0) return ''; + + const server = servers.at(Math.min(options.index ?? 0, servers.length - 1)); + if (!server) return ''; + const serverUrl = server.url ?? ''; + if (!server.variables) return serverUrl; + + let url = serverUrl; + const variables = options.variables; + for (const variableKey of Object.keys(server.variables)) { + const variable = server.variables[variableKey]; + if (variables?.[variableKey] !== undefined) { + if ( + variable.enum && + !variable.enum.some((e) => e == variables[variableKey]) + ) { + throw new Error( + `Invalid variable value '${variables[variableKey]}' for variable '${variableKey}' when resolving ${serverUrl}. Valid values are: ${variable.enum.join(', ')}.`, + ); + } + url = url.replaceAll(`{${variableKey}}`, variables[variableKey]); + } else { + url = url.replaceAll(`{${variableKey}}`, String(variable.default)); + } + } + return url; +} + /** * Prepends a base URL to an already-processed route. * @@ -127,33 +175,14 @@ export function getFullRoute( "Orval is configured to use baseUrl from the specifications 'servers' field, but there exist no servers in the specification.", ); } - const server = servers.at( - Math.min(baseUrl.index ?? 0, servers.length - 1), + // `resolveServerUrl` returns the raw (unescaped) URL; escape it here + // for safe embedding in the generated backtick template literal. + return esc( + resolveServerUrl(servers, { + index: baseUrl.index, + variables: baseUrl.variables, + }), ); - if (!server) return ''; - const serverUrl = server.url ?? ''; - if (!server.variables) - return jsesc(serverUrl, { quotes: 'backtick', wrap: false }); - - let url = serverUrl; - const variables = baseUrl.variables; - for (const variableKey of Object.keys(server.variables)) { - const variable = server.variables[variableKey]; - if (variables?.[variableKey]) { - if ( - variable.enum && - !variable.enum.some((e) => e == variables[variableKey]) - ) { - throw new Error( - `Invalid variable value '${variables[variableKey]}' for variable '${variableKey}' when resolving ${serverUrl}. Valid values are: ${variable.enum.join(', ')}.`, - ); - } - url = url.replaceAll(`{${variableKey}}`, variables[variableKey]); - } else { - url = url.replaceAll(`{${variableKey}}`, String(variable.default)); - } - } - return jsesc(url, { quotes: 'backtick', wrap: false }); } return baseUrl.baseUrl; }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f7551877ee..a58ea0f390 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -267,6 +267,27 @@ export interface BaseUrlRuntime { baseUrl?: never; } +/** + * Opt-in config for `override.angular.baseUrl`: composes a runtime base URL + * for a generated Angular output via Angular DI (an `InjectionToken`) rather + * than baking a static prefix into every route at generation time. + * + * Angular-client only; mutually exclusive with the top-level `output.baseUrl` + * (which bakes a prefix into every generated route string for ALL clients). + * `apiId` is required and explicit — never derived from the spec — so the + * generated identifiers (`_BASE_URL`, `provideBaseUrl`, ...) are + * stable across regenerations and collision-free when multiple outputs are + * generated into the same app. + */ +export interface AngularBaseUrlOptions { + /** Explicit, stable identifier for this API, used to derive all generated DI token/helper names. Must match `/^[A-Za-z][A-Za-z0-9_-]*$/`. */ + apiId: string; + /** Index into the specification's `servers` array to embed as the default fallback URL. Defaults to `0`. */ + index?: number; + /** Values for any `{variable}` placeholders in the selected server URL. */ + variables?: Record; +} + export const PropertySortOrder = { ALPHABETICAL: 'Alphabetical', SPECIFICATION: 'Specification', @@ -1168,6 +1189,13 @@ export interface AngularOptions { client?: 'httpClient' | 'httpResource' | 'both'; runtimeValidation?: boolean; httpResource?: AngularHttpResourceOptions; + /** + * Opt-in: compose the runtime base URL for this output via Angular DI + * (an `InjectionToken`) instead of baking a static prefix into every + * route at generation time. Angular-client only; mutually exclusive with + * the top-level `output.baseUrl`. + */ + baseUrl?: AngularBaseUrlOptions; /** * Controls how object-typed query parameters are serialized when no * `paramsSerializer` is configured. @@ -1192,6 +1220,7 @@ export interface NormalizedAngularOptions { client: 'httpClient' | 'httpResource' | 'both'; runtimeValidation: boolean; httpResource?: AngularHttpResourceOptions; + baseUrl?: AngularBaseUrlOptions; queryObjectSerialization: 'spec' | 'legacy'; } diff --git a/packages/orval/src/utils/options.test.ts b/packages/orval/src/utils/options.test.ts index d0073aa42a..1c77ddd89a 100644 --- a/packages/orval/src/utils/options.test.ts +++ b/packages/orval/src/utils/options.test.ts @@ -593,6 +593,252 @@ describe('normalizeOptions', () => { } }); + describe('override.angular.baseUrl', () => { + it('normalizes a valid apiId through', async () => { + const workspace = await createTempWorkspace(); + + try { + const normalized = await normalizeOptions( + { + input: { + target: { + openapi: '3.1.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {}, + }, + }, + output: { + target: './generated.ts', + client: 'angular', + override: { + angular: { + baseUrl: { apiId: 'example-api' }, + }, + }, + }, + }, + workspace, + ); + + expect(normalized.output.override.angular.baseUrl).toEqual({ + apiId: 'example-api', + }); + } finally { + await rm(workspace, { recursive: true, force: true }); + } + }); + + it('carries through index and variables', async () => { + const workspace = await createTempWorkspace(); + + try { + const normalized = await normalizeOptions( + { + input: { + target: { + openapi: '3.1.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {}, + }, + }, + output: { + target: './generated.ts', + client: 'angular', + override: { + angular: { + baseUrl: { + apiId: 'example-api', + index: 1, + variables: { port: '8080' }, + }, + }, + }, + }, + }, + workspace, + ); + + expect(normalized.output.override.angular.baseUrl).toEqual({ + apiId: 'example-api', + index: 1, + variables: { port: '8080' }, + }); + } finally { + await rm(workspace, { recursive: true, force: true }); + } + }); + + it('throws for an invalid apiId', async () => { + const workspace = await createTempWorkspace(); + + try { + await expect( + normalizeOptions( + { + input: { + target: { + openapi: '3.1.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {}, + }, + }, + output: { + target: './generated.ts', + client: 'angular', + override: { + angular: { + baseUrl: { apiId: '1-not-valid' }, + }, + }, + }, + }, + workspace, + ), + ).rejects.toThrow(/apiId/); + } finally { + await rm(workspace, { recursive: true, force: true }); + } + }); + + it('throws when combined with the top-level output.baseUrl', async () => { + const workspace = await createTempWorkspace(); + + try { + await expect( + normalizeOptions( + { + input: { + target: { + openapi: '3.1.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {}, + }, + }, + output: { + target: './generated.ts', + client: 'angular', + baseUrl: 'https://example.com', + override: { + angular: { + baseUrl: { apiId: 'example-api' }, + }, + }, + }, + }, + workspace, + ), + ).rejects.toThrow(/output\.baseUrl/); + } finally { + await rm(workspace, { recursive: true, force: true }); + } + }); + + it('throws when combined with an empty-string output.baseUrl', async () => { + const workspace = await createTempWorkspace(); + + try { + await expect( + normalizeOptions( + { + input: { + target: { + openapi: '3.1.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {}, + }, + }, + output: { + target: './generated.ts', + client: 'angular', + baseUrl: '', + override: { + angular: { + baseUrl: { apiId: 'example-api' }, + }, + }, + }, + }, + workspace, + ), + ).rejects.toThrow(/output\.baseUrl/); + } finally { + await rm(workspace, { recursive: true, force: true }); + } + }); + + it('warns when configured for a non-Angular client', async () => { + const workspace = await createTempWorkspace(); + logWarningSpy.mockClear(); + + try { + await normalizeOptions( + { + input: { + target: { + openapi: '3.1.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {}, + }, + }, + output: { + target: './generated.ts', + client: 'axios', + override: { + angular: { + baseUrl: { apiId: 'example-api' }, + }, + }, + }, + }, + workspace, + ); + + expect(logWarningSpy).toHaveBeenCalled(); + } finally { + await rm(workspace, { recursive: true, force: true }); + } + }); + + it('warns and drops a per-operation angular.baseUrl override', async () => { + const workspace = await createTempWorkspace(); + logWarningSpy.mockClear(); + + try { + const normalized = await normalizeOptions( + { + input: { + target: { + openapi: '3.1.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {}, + }, + }, + output: { + target: './generated.ts', + client: 'angular', + override: { + angular: { baseUrl: { apiId: 'example-api' } }, + operations: { + searchPets: { + angular: { baseUrl: { apiId: 'other-api' } } as never, + }, + }, + }, + }, + }, + workspace, + ); + + expect(logWarningSpy).toHaveBeenCalled(); + expect( + normalized.output.override.operations.searchPets?.angular, + ).not.toHaveProperty('baseUrl'); + } finally { + await rm(workspace, { recursive: true, force: true }); + } + }); + }); + it('defaults angular queryObjectSerialization to spec (issue #3705)', async () => { const workspace = await createTempWorkspace(); diff --git a/packages/orval/src/utils/options.ts b/packages/orval/src/utils/options.ts index 7ab0abfe58..37b415f371 100644 --- a/packages/orval/src/utils/options.ts +++ b/packages/orval/src/utils/options.ts @@ -5,6 +5,7 @@ import nodePath from 'node:path'; import { styleText } from 'node:util'; import { + type AngularBaseUrlOptions, type ConfigExternal, type EffectOptions, FormDataArrayHandling, @@ -273,6 +274,55 @@ function normalizeEffectOptions( }; } +const ANGULAR_BASE_URL_API_ID_REGEX = /^[A-Za-z][A-Za-z0-9_-]*$/; + +/** + * Normalizes and validates `override.angular.baseUrl` (opt-in Angular DI + * base-URL composition, see `AngularBaseUrlOptions`). + * + * - `apiId` must be an explicit, stable identifier (`/^[A-Za-z][A-Za-z0-9_-]*$/`). + * - Mutually exclusive with the top-level `output.baseUrl`, which bakes a + * static prefix into every generated route for ALL clients — combining + * both would double-prefix (or conflict with) every URL. + * - Only meaningful for the `angular` client; warns (without dropping the + * option) when configured for any other client. + */ +function normalizeAngularBaseUrl( + baseUrl: AngularBaseUrlOptions, + outputClient: unknown, + outputBaseUrl: unknown, +): AngularBaseUrlOptions { + if (!baseUrl.apiId || !ANGULAR_BASE_URL_API_ID_REGEX.test(baseUrl.apiId)) { + throw new Error( + styleText( + 'red', + `\`override.angular.baseUrl.apiId\` must be a non-empty string matching /^[A-Za-z][A-Za-z0-9_-]*$/ (got: ${JSON.stringify(baseUrl.apiId)}).`, + ), + ); + } + + if (outputBaseUrl !== undefined) { + throw new Error( + styleText( + 'red', + "`override.angular.baseUrl` cannot be combined with the top-level `output.baseUrl`. Remove `output.baseUrl` — the base-URL token's server-URL fallback is resolved from the specification's `servers` field, or provide a custom resolver via the generated `provideBaseUrlResolver` helper.", + ), + ); + } + + if (outputClient !== OutputClient.ANGULAR) { + logWarning( + `⚠️ \`override.angular.baseUrl\` is only supported by the \`angular\` client. It has no effect for other clients.`, + ); + } + + return { + apiId: baseUrl.apiId, + ...(baseUrl.index !== undefined ? { index: baseUrl.index } : {}), + ...(baseUrl.variables ? { variables: baseUrl.variables } : {}), + }; +} + export async function normalizeOptions( optionsExport: OptionsExport, workspace = process.cwd(), @@ -693,6 +743,15 @@ export async function normalizeOptions( ...(outputOptions.override?.angular?.httpResource ? { httpResource: outputOptions.override.angular.httpResource } : {}), + ...(outputOptions.override?.angular?.baseUrl + ? { + baseUrl: normalizeAngularBaseUrl( + outputOptions.override.angular.baseUrl, + outputOptions.client ?? client, + outputOptions.baseUrl, + ), + } + : {}), }, fetch: { // Spread first so an explicit `undefined` cannot erase a default below. @@ -1065,6 +1124,12 @@ function normalizeOperationsAndTags( zod.params !== undefined || zod.useBrandedTypes !== undefined); + if (angular?.baseUrl) { + logWarning( + `⚠️ override.${source}.${key}.angular.baseUrl is not supported — \`baseUrl\` is an output-level concern and is configured via \`override.angular.baseUrl\`. Ignoring.`, + ); + } + return [ key, { diff --git a/samples/angular-app/__snapshots__/api/base-url-token/model/createPetsBody.ts b/samples/angular-app/__snapshots__/api/base-url-token/model/createPetsBody.ts new file mode 100644 index 0000000000..8272f8286d --- /dev/null +++ b/samples/angular-app/__snapshots__/api/base-url-token/model/createPetsBody.ts @@ -0,0 +1,26 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { CreatePetsBodyStatus } from './createPetsBodyStatus'; + +export type CreatePetsBody = { + /** + * Name of the pet + * @minLength 1 + * @maxLength 100 + */ + name: string; + /** + * Classification tag + * @minLength 1 + * @maxLength 50 + */ + tag: string; + /** Owner contact email */ + email?: string; + /** Initial adoption status */ + status?: CreatePetsBodyStatus; +}; diff --git a/samples/angular-app/__snapshots__/api/base-url-token/model/createPetsBodyStatus.ts b/samples/angular-app/__snapshots__/api/base-url-token/model/createPetsBodyStatus.ts new file mode 100644 index 0000000000..d488150a1a --- /dev/null +++ b/samples/angular-app/__snapshots__/api/base-url-token/model/createPetsBodyStatus.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +/** + * Initial adoption status + */ +export type CreatePetsBodyStatus = + (typeof CreatePetsBodyStatus)[keyof typeof CreatePetsBodyStatus]; + +export const CreatePetsBodyStatus = { + available: 'available', + pending: 'pending', + sold: 'sold', +} as const; diff --git a/samples/angular-app/__snapshots__/api/base-url-token/model/error.ts b/samples/angular-app/__snapshots__/api/base-url-token/model/error.ts new file mode 100644 index 0000000000..311e15e4c3 --- /dev/null +++ b/samples/angular-app/__snapshots__/api/base-url-token/model/error.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export interface Error { + /** + * HTTP-like error code + * @minimum 100 + * @maximum 600 + */ + code: number; + /** + * Human-readable error message + * @minLength 1 + */ + message: string; +} diff --git a/samples/angular-app/__snapshots__/api/base-url-token/model/index.ts b/samples/angular-app/__snapshots__/api/base-url-token/model/index.ts new file mode 100644 index 0000000000..ad51b4f93f --- /dev/null +++ b/samples/angular-app/__snapshots__/api/base-url-token/model/index.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export * from './createPetsBody'; +export * from './createPetsBodyStatus'; +export * from './error'; +export * from './listPetsParams'; +export * from './pet'; +export * from './pets'; +export * from './petStatus'; +export * from './searchPetsParams'; +export * from './searchPetsStatus'; diff --git a/samples/angular-app/__snapshots__/api/base-url-token/model/listPetsParams.ts b/samples/angular-app/__snapshots__/api/base-url-token/model/listPetsParams.ts new file mode 100644 index 0000000000..0630dae489 --- /dev/null +++ b/samples/angular-app/__snapshots__/api/base-url-token/model/listPetsParams.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type ListPetsParams = { + /** + * How many items to return at one time (max 100) + */ + limit?: string; +}; diff --git a/samples/angular-app/__snapshots__/api/base-url-token/model/pet.ts b/samples/angular-app/__snapshots__/api/base-url-token/model/pet.ts new file mode 100644 index 0000000000..366a5e7252 --- /dev/null +++ b/samples/angular-app/__snapshots__/api/base-url-token/model/pet.ts @@ -0,0 +1,52 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { PetStatus } from './petStatus'; + +export interface Pet { + /** + * Unique identifier for the pet + * @minimum 1 + */ + id: number; + /** + * Name of the pet + * @minLength 1 + * @maxLength 100 + */ + name: string; + /** + * Optional classification tag + * @minLength 1 + * @maxLength 50 + */ + tag?: string; + /** Owner contact email */ + email?: string; + /** Current adoption status */ + status?: PetStatus; + /** + * Age of the pet in years + * @minimum 0 + * @maximum 30 + */ + age?: number; + /** + * Average customer rating + * @minimum 0 + * @maximum 5 + */ + rating?: number; + /** + * Contact phone in E.164 format + * @pattern ^\+?[1-9]\d{1,14}$ + */ + phone?: string; + /** @nullable */ + requiredNullableString: string | null; + /** @nullable */ + optionalNullableString?: string | null; +} diff --git a/samples/angular-app/__snapshots__/api/base-url-token/model/petStatus.ts b/samples/angular-app/__snapshots__/api/base-url-token/model/petStatus.ts new file mode 100644 index 0000000000..1810b3cb25 --- /dev/null +++ b/samples/angular-app/__snapshots__/api/base-url-token/model/petStatus.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +/** + * Current adoption status + */ +export type PetStatus = (typeof PetStatus)[keyof typeof PetStatus]; + +export const PetStatus = { + available: 'available', + pending: 'pending', + sold: 'sold', +} as const; diff --git a/samples/angular-app/__snapshots__/api/base-url-token/model/pets.ts b/samples/angular-app/__snapshots__/api/base-url-token/model/pets.ts new file mode 100644 index 0000000000..4d5dc2ebf2 --- /dev/null +++ b/samples/angular-app/__snapshots__/api/base-url-token/model/pets.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { Pet } from './pet'; + +/** + * @maxItems 100 + */ +export type Pets = Pet[]; diff --git a/samples/angular-app/__snapshots__/api/base-url-token/model/searchPetsParams.ts b/samples/angular-app/__snapshots__/api/base-url-token/model/searchPetsParams.ts new file mode 100644 index 0000000000..06099895c3 --- /dev/null +++ b/samples/angular-app/__snapshots__/api/base-url-token/model/searchPetsParams.ts @@ -0,0 +1,32 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { SearchPetsStatus } from './searchPetsStatus'; + +export type SearchPetsParams = { + /** + * @nullable + */ + requirednullableString: string | null; + /** + * @nullable + */ + requirednullableStringTwo: string | null; + /** + * @nullable + */ + nonRequirednullableString?: string | null; + /** + * Filter by adoption status + */ + status?: SearchPetsStatus; + /** + * Maximum number of results to return + * @minimum 1 + * @maximum 100 + */ + limit?: number; +}; diff --git a/samples/angular-app/__snapshots__/api/base-url-token/model/searchPetsStatus.ts b/samples/angular-app/__snapshots__/api/base-url-token/model/searchPetsStatus.ts new file mode 100644 index 0000000000..c0673d593c --- /dev/null +++ b/samples/angular-app/__snapshots__/api/base-url-token/model/searchPetsStatus.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type SearchPetsStatus = + (typeof SearchPetsStatus)[keyof typeof SearchPetsStatus]; + +export const SearchPetsStatus = { + available: 'available', + pending: 'pending', + sold: 'sold', +} as const; diff --git a/samples/angular-app/__snapshots__/api/base-url-token/pets/pets.resource.ts b/samples/angular-app/__snapshots__/api/base-url-token/pets/pets.resource.ts new file mode 100644 index 0000000000..ff4285a01a --- /dev/null +++ b/samples/angular-app/__snapshots__/api/base-url-token/pets/pets.resource.ts @@ -0,0 +1,462 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { ListPetsParams, Pet, Pets, SearchPetsParams } from '../model'; + +import { HttpHeaders, httpResource } from '@angular/common/http'; +import type { + HttpContext, + HttpResourceOptions, + HttpResourceRef, + HttpResourceRequest, +} from '@angular/common/http'; + +import { inject } from '@angular/core'; +import type { ResourceStatus, Signal } from '@angular/core'; + +import { PETSTORE_BASE_URL } from '../petstore.base-url'; + +export interface OrvalHttpResourceRequestExtension { + /** Extra headers merged over generated headers. Pass a function to read signals reactively. */ + headers?: + | HttpResourceRequest['headers'] + | (() => HttpResourceRequest['headers']); + /** Angular HttpContext forwarded to the underlying request. Pass a function to derive it reactively. */ + context?: HttpContext | (() => HttpContext); + /** Last-resort escape hatch: transform the final request descriptor. Runs inside the resource's reactive context. */ + request?: (request: HttpResourceRequest) => HttpResourceRequest; +} + +export type OrvalHttpResourceOptions< + TValue, + TRaw = unknown, + TOmitParse extends boolean = false, +> = (TOmitParse extends true + ? Omit, 'parse'> + : HttpResourceOptions) & + OrvalHttpResourceRequestExtension; + +function mergeOrvalResourceHeaders( + base: HttpResourceRequest['headers'], + extra: HttpResourceRequest['headers'], +): HttpResourceRequest['headers'] { + if (!base) return extra; + if (!extra) return base; + if (base instanceof HttpHeaders || extra instanceof HttpHeaders) { + const toHeaderValue = ( + value: string | readonly string[], + ): string | string[] => + Array.isArray(value) ? Array.from(value, String) : String(value); + let merged = + base instanceof HttpHeaders + ? base + : Object.entries(base).reduce( + (headers, [key, value]) => headers.set(key, toHeaderValue(value)), + new HttpHeaders(), + ); + const extraRecord = + extra instanceof HttpHeaders + ? extra.keys().reduce>((record, key) => { + const values = extra.getAll(key); + if (values) record[key] = values; + return record; + }, {}) + : extra; + for (const [key, value] of Object.entries(extraRecord)) { + merged = merged.set(key, toHeaderValue(value)); + } + return merged; + } + return { ...base, ...extra }; +} + +export function applyOrvalRequestExtension( + request: string | HttpResourceRequest, + options?: OrvalHttpResourceRequestExtension, +): HttpResourceRequest { + const base: HttpResourceRequest = + typeof request === 'string' ? { url: request } : request; + if ( + !options || + (options.headers === undefined && + options.context === undefined && + options.request === undefined) + ) { + return base; + } + let next: HttpResourceRequest = { ...base }; + const extraHeaders = + typeof options.headers === 'function' ? options.headers() : options.headers; + if (extraHeaders !== undefined) { + next = { + ...next, + headers: mergeOrvalResourceHeaders(next.headers, extraHeaders), + }; + } + const context = + typeof options.context === 'function' ? options.context() : options.context; + if (context !== undefined) { + next = { ...next, context }; + } + return options.request ? options.request(next) : next; +} + +type AngularHttpParamValue = + | string + | number + | boolean + | Array; +type AngularHttpParamValueWithNullable = AngularHttpParamValue | null; + +function filterParams( + params: Record, + requiredNullableKeys?: ReadonlySet, + preserveRequiredNullables?: false, + passthroughKeys?: undefined, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet | undefined, + preserveRequiredNullables: true, + passthroughKeys?: undefined, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet | undefined, + preserveRequiredNullables: boolean | undefined, + passthroughKeys: ReadonlySet, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet = new Set(), + preserveRequiredNullables = false, + passthroughKeys: ReadonlySet = new Set(), +): Record { + const filteredParams: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (passthroughKeys.has(key)) { + if (value !== undefined) { + filteredParams[key] = value; + } + continue; + } + if (Array.isArray(value)) { + const filtered = value.filter( + (item) => + item != null && + (typeof item === 'string' || + typeof item === 'number' || + typeof item === 'boolean'), + ) as Array; + if (filtered.length) { + filteredParams[key] = filtered; + } + } else if (value === null && requiredNullableKeys.has(key)) { + // With a paramsSerializer (preserveRequiredNullables) the literal null + // is passed through for it to consume; without one, emit an empty + // string so the required key still reaches the wire as `?key=` + // instead of being silently dropped. See #3712. + filteredParams[key] = preserveRequiredNullables ? null : ''; + } else if ( + value != null && + (typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean') + ) { + filteredParams[key] = value; + } + } + return filteredParams; +} +export type ListPetsAccept = + (typeof ListPetsAccept)[keyof typeof ListPetsAccept]; + +export const ListPetsAccept = { + application_json: 'application/json', + application_xml: 'application/xml', +} as const; + +export type ShowPetByIdAccept = + (typeof ShowPetByIdAccept)[keyof typeof ShowPetByIdAccept]; + +export const ShowPetByIdAccept = { + text_plain: 'text/plain', + application_xml: 'application/xml', + application_json: 'application/json', +} as const; + +/** + * @experimental httpResource is experimental (Angular v19.2+) + */ +export function searchPetsResource( + params: Signal, + version: Signal | undefined, + options: OrvalHttpResourceOptions & { + defaultValue: NoInfer; + }, +): HttpResourceRef; +export function searchPetsResource( + params: Signal, + version?: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef; +export function searchPetsResource( + params: Signal, + version?: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef { + const baseUrl = options?.injector + ? options.injector.get(PETSTORE_BASE_URL) + : inject(PETSTORE_BASE_URL); + return httpResource(() => { + const request = { + url: `${baseUrl}/v${version?.() ?? 1}/search`, + params: filterParams( + params?.() ?? {}, + new Set([ + 'requirednullableString', + 'requirednullableStringTwo', + ]), + ), + }; + return applyOrvalRequestExtension(request, options); + }, options); +} + +/** + * @experimental httpResource is experimental (Angular v19.2+) + */ +export function listPetsResource( + accept: 'application/json', + params?: Signal, + version?: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef; +export function listPetsResource( + accept: 'application/xml', + params?: Signal, + version?: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef; +export function listPetsResource( + accept: ListPetsAccept = 'application/json', + params?: Signal, + version?: Signal, + options?: + | OrvalHttpResourceOptions + | OrvalHttpResourceOptions, +): HttpResourceRef { + const baseUrl = options?.injector + ? options.injector.get(PETSTORE_BASE_URL) + : inject(PETSTORE_BASE_URL); + const buildRequest = (): HttpResourceRequest => { + const request = { + url: `${baseUrl}/v${version?.() ?? 1}/pets`, + params: filterParams(params?.() ?? {}, new Set([])), + }; + const normalizedRequest: HttpResourceRequest = request; + const extendedRequest = applyOrvalRequestExtension( + normalizedRequest, + options, + ); + return { + ...extendedRequest, + headers: + extendedRequest.headers instanceof HttpHeaders + ? extendedRequest.headers.set('Accept', accept) + : { ...(extendedRequest.headers ?? {}), Accept: accept }, + }; + }; + + if (accept.includes('json') || accept.includes('+json')) { + return httpResource( + buildRequest, + options as unknown as OrvalHttpResourceOptions, + ); + } + + if (accept.startsWith('text/') || accept.includes('xml')) { + return httpResource.text( + buildRequest, + options as unknown as OrvalHttpResourceOptions, + ); + } + + return httpResource( + buildRequest, + options as unknown as OrvalHttpResourceOptions, + ); +} + +/** + * @experimental httpResource is experimental (Angular v19.2+) + */ +export function showPetByIdResource( + petId: Signal, + accept: 'text/plain', + version?: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef; +export function showPetByIdResource( + petId: Signal, + accept: 'application/xml', + version?: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef; +export function showPetByIdResource( + petId: Signal, + accept: 'application/json', + version?: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef; +export function showPetByIdResource( + petId: Signal, + accept: ShowPetByIdAccept = 'application/json', + version?: Signal, + options?: + | OrvalHttpResourceOptions + | OrvalHttpResourceOptions, +): HttpResourceRef { + const baseUrl = options?.injector + ? options.injector.get(PETSTORE_BASE_URL) + : inject(PETSTORE_BASE_URL); + const buildRequest = (): HttpResourceRequest => { + const request = `${baseUrl}/v${version?.() ?? 1}/pets/${petId()}`; + const normalizedRequest: HttpResourceRequest = { url: request }; + const extendedRequest = applyOrvalRequestExtension( + normalizedRequest, + options, + ); + return { + ...extendedRequest, + headers: + extendedRequest.headers instanceof HttpHeaders + ? extendedRequest.headers.set('Accept', accept) + : { ...(extendedRequest.headers ?? {}), Accept: accept }, + }; + }; + + if (accept.includes('json') || accept.includes('+json')) { + return httpResource( + buildRequest, + options as unknown as OrvalHttpResourceOptions, + ); + } + + if (accept.startsWith('text/') || accept.includes('xml')) { + return httpResource.text( + buildRequest, + options as unknown as OrvalHttpResourceOptions, + ); + } + + return httpResource( + buildRequest, + options as unknown as OrvalHttpResourceOptions, + ); +} + +/** + * @experimental httpResource is experimental (Angular v19.2+) + */ +export function showPetTextResource( + petId: Signal, + version: Signal | undefined, + options: OrvalHttpResourceOptions & { + defaultValue: NoInfer; + }, +): HttpResourceRef; +export function showPetTextResource( + petId: Signal, + version?: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef; +export function showPetTextResource( + petId: Signal, + version?: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef { + const baseUrl = options?.injector + ? options.injector.get(PETSTORE_BASE_URL) + : inject(PETSTORE_BASE_URL); + return httpResource.text( + () => + applyOrvalRequestExtension( + `${baseUrl}/v${version?.() ?? 1}/pets/${petId()}/text`, + options, + ), + options, + ); +} + +/** + * @experimental httpResource is experimental (Angular v19.2+) + */ +export function downloadFileResource( + petId: Signal, + version: Signal | undefined, + options: OrvalHttpResourceOptions & { + defaultValue: NoInfer; + }, +): HttpResourceRef; +export function downloadFileResource( + petId: Signal, + version?: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef; +export function downloadFileResource( + petId: Signal, + version?: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef { + const baseUrl = options?.injector + ? options.injector.get(PETSTORE_BASE_URL) + : inject(PETSTORE_BASE_URL); + return httpResource.blob( + () => + applyOrvalRequestExtension( + `${baseUrl}/v${version?.() ?? 1}/pet/${petId()}/downloadImage`, + options, + ), + options, + ); +} + +export type SearchPetsResourceResult = NonNullable; +export type ListPetsResourceResult = NonNullable; +export type ShowPetByIdResourceResult = NonNullable; +export type ShowPetTextResourceResult = NonNullable; +export type DownloadFileResourceResult = NonNullable; + +/** + * Utility type for httpResource results with status tracking. + * Inspired by @angular-architects/ngrx-toolkit withResource pattern. + * + * Uses `globalThis.Error` to avoid collision with API model types named `Error`. + */ +export interface ResourceState { + readonly value: Signal; + readonly status: Signal; + readonly error: Signal; + readonly isLoading: Signal; + readonly hasValue: () => boolean; + readonly reload: () => boolean; +} + +/** + * Wraps an HttpResourceRef to expose a consistent ResourceState interface. + * Useful when integrating with NgRx SignalStore via withResource(). + */ +export function toResourceState(ref: HttpResourceRef): ResourceState { + return { + value: ref.value, + status: ref.status, + error: ref.error, + isLoading: ref.isLoading, + hasValue: () => ref.hasValue(), + reload: () => ref.reload(), + }; +} diff --git a/samples/angular-app/__snapshots__/api/base-url-token/pets/pets.service.ts b/samples/angular-app/__snapshots__/api/base-url-token/pets/pets.service.ts new file mode 100644 index 0000000000..e8b426ac05 --- /dev/null +++ b/samples/angular-app/__snapshots__/api/base-url-token/pets/pets.service.ts @@ -0,0 +1,700 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import { + HttpClient, + HttpHeaders, + HttpResponse as AngularHttpResponse, +} from '@angular/common/http'; +import type { HttpContext, HttpEvent, HttpParams } from '@angular/common/http'; + +import { Injectable, inject } from '@angular/core'; + +import { Observable } from 'rxjs'; + +import type { + CreatePetsBody, + ListPetsParams, + Pet, + Pets, + SearchPetsParams, +} from '../model'; + +import { PETSTORE_BASE_URL } from '../petstore.base-url'; + +interface HttpClientOptions { + readonly headers?: HttpHeaders | Record; + readonly context?: HttpContext; + readonly params?: + | HttpParams + | Record< + string, + string | number | boolean | Array + >; + readonly reportProgress?: boolean; + readonly withCredentials?: boolean; + readonly credentials?: RequestCredentials; + readonly keepalive?: boolean; + readonly priority?: RequestPriority; + readonly cache?: RequestCache; + readonly mode?: RequestMode; + readonly redirect?: RequestRedirect; + readonly referrer?: string; + readonly integrity?: string; + readonly referrerPolicy?: ReferrerPolicy; + readonly transferCache?: { includeHeaders?: string[] } | boolean; + readonly timeout?: number; +} + +type HttpClientBodyOptions = HttpClientOptions & { + readonly observe?: 'body'; +}; + +type HttpClientEventOptions = HttpClientOptions & { + readonly observe: 'events'; +}; + +type HttpClientResponseOptions = HttpClientOptions & { + readonly observe: 'response'; +}; + +type HttpClientObserveOptions = HttpClientOptions & { + readonly observe?: 'body' | 'events' | 'response'; +}; + +type AngularHttpParamValue = + | string + | number + | boolean + | Array; +type AngularHttpParamValueWithNullable = AngularHttpParamValue | null; + +function filterParams( + params: Record, + requiredNullableKeys?: ReadonlySet, + preserveRequiredNullables?: false, + passthroughKeys?: undefined, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet | undefined, + preserveRequiredNullables: true, + passthroughKeys?: undefined, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet | undefined, + preserveRequiredNullables: boolean | undefined, + passthroughKeys: ReadonlySet, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet = new Set(), + preserveRequiredNullables = false, + passthroughKeys: ReadonlySet = new Set(), +): Record { + const filteredParams: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (passthroughKeys.has(key)) { + if (value !== undefined) { + filteredParams[key] = value; + } + continue; + } + if (Array.isArray(value)) { + const filtered = value.filter( + (item) => + item != null && + (typeof item === 'string' || + typeof item === 'number' || + typeof item === 'boolean'), + ) as Array; + if (filtered.length) { + filteredParams[key] = filtered; + } + } else if (value === null && requiredNullableKeys.has(key)) { + // With a paramsSerializer (preserveRequiredNullables) the literal null + // is passed through for it to consume; without one, emit an empty + // string so the required key still reaches the wire as `?key=` + // instead of being silently dropped. See #3712. + filteredParams[key] = preserveRequiredNullables ? null : ''; + } else if ( + value != null && + (typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean') + ) { + filteredParams[key] = value; + } + } + return filteredParams; +} + +export type ListPetsAccept = + (typeof ListPetsAccept)[keyof typeof ListPetsAccept]; + +export const ListPetsAccept = { + application_json: 'application/json', + application_xml: 'application/xml', +} as const; + +export type ShowPetByIdAccept = + (typeof ShowPetByIdAccept)[keyof typeof ShowPetByIdAccept]; + +export const ShowPetByIdAccept = { + text_plain: 'text/plain', + application_xml: 'application/xml', + application_json: 'application/json', +} as const; + +export type UpdatePetByIdAccept = + (typeof UpdatePetByIdAccept)[keyof typeof UpdatePetByIdAccept]; + +export const UpdatePetByIdAccept = { + application_json: 'application/json', + text_plain: 'text/plain', +} as const; + +export type PatchPetByIdAccept = + (typeof PatchPetByIdAccept)[keyof typeof PatchPetByIdAccept]; + +export const PatchPetByIdAccept = { + application_json: 'application/json', + text_plain: 'text/plain', +} as const; + +@Injectable({ providedIn: 'root' }) +export class PetsService { + private readonly http = inject(HttpClient); + private readonly baseUrl = inject(PETSTORE_BASE_URL); + /** + * @summary Search pets by query params + */ + searchPets( + params: SearchPetsParams, + version?: number, + options?: HttpClientBodyOptions, + ): Observable; + searchPets( + params: SearchPetsParams, + version?: number, + options?: HttpClientEventOptions, + ): Observable>; + searchPets( + params: SearchPetsParams, + version?: number, + options?: HttpClientResponseOptions, + ): Observable>; + searchPets( + params: SearchPetsParams, + version: number = 1, + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + const filteredParams = filterParams( + { ...params, ...options?.params }, + new Set(['requirednullableString', 'requirednullableStringTwo']), + ); + + if (options?.observe === 'events') { + return this.http.get(`${this.baseUrl}/v${version}/search`, { + ...(options as Omit, 'observe'>), + observe: 'events', + params: filteredParams, + }); + } + + if (options?.observe === 'response') { + return this.http.get(`${this.baseUrl}/v${version}/search`, { + ...(options as Omit, 'observe'>), + observe: 'response', + params: filteredParams, + }); + } + + return this.http.get(`${this.baseUrl}/v${version}/search`, { + ...(options as Omit, 'observe'>), + observe: 'body', + params: filteredParams, + }); + } + /** + * @summary List all pets + */ + listPets( + accept: 'application/json', + params?: ListPetsParams, + version?: number, + options?: HttpClientOptions, + ): Observable; + listPets( + accept: 'application/xml', + params?: ListPetsParams, + version?: number, + options?: HttpClientOptions, + ): Observable; + listPets( + accept?: ListPetsAccept, + params?: ListPetsParams, + version?: number, + options?: HttpClientOptions, + ): Observable; + listPets( + accept: ListPetsAccept = 'application/json', + params?: ListPetsParams, + version: number = 1, + options?: HttpClientOptions, + ): Observable { + const filteredParams = filterParams( + { ...params, ...options?.params }, + new Set([]), + ); + + const headers = + options?.headers instanceof HttpHeaders + ? options.headers.set('Accept', accept) + : { ...(options?.headers ?? {}), Accept: accept }; + + if (accept.includes('json') || accept.includes('+json')) { + return this.http.get(`${this.baseUrl}/v${version}/pets`, { + ...options, + responseType: 'json', + headers, + params: filteredParams, + }); + } else if (accept.startsWith('text/') || accept.includes('xml')) { + return this.http.get(`${this.baseUrl}/v${version}/pets`, { + ...options, + responseType: 'text', + headers, + params: filteredParams, + }) as Observable; + } + + return this.http.get(`${this.baseUrl}/v${version}/pets`, { + ...options, + responseType: 'json', + headers, + params: filteredParams, + }); + } + /** + * @summary Create a pet + */ + createPets( + createPetsBody: CreatePetsBody, + version?: number, + options?: HttpClientBodyOptions, + ): Observable; + createPets( + createPetsBody: CreatePetsBody, + version?: number, + options?: HttpClientEventOptions, + ): Observable>; + createPets( + createPetsBody: CreatePetsBody, + version?: number, + options?: HttpClientResponseOptions, + ): Observable>; + createPets( + createPetsBody: CreatePetsBody, + version: number = 1, + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.post( + `${this.baseUrl}/v${version}/pets`, + createPetsBody, + { + ...(options as Omit, 'observe'>), + observe: 'events', + }, + ); + } + + if (options?.observe === 'response') { + return this.http.post( + `${this.baseUrl}/v${version}/pets`, + createPetsBody, + { + ...(options as Omit, 'observe'>), + observe: 'response', + }, + ); + } + + return this.http.post( + `${this.baseUrl}/v${version}/pets`, + createPetsBody, + { + ...(options as Omit, 'observe'>), + observe: 'body', + }, + ); + } + /** + * @summary Info for a specific pet + */ + showPetById( + petId: string, + accept: 'text/plain', + version?: number, + options?: HttpClientOptions, + ): Observable; + showPetById( + petId: string, + accept: 'application/xml', + version?: number, + options?: HttpClientOptions, + ): Observable; + showPetById( + petId: string, + accept: 'application/json', + version?: number, + options?: HttpClientOptions, + ): Observable; + showPetById( + petId: string, + accept?: ShowPetByIdAccept, + version?: number, + options?: HttpClientOptions, + ): Observable; + showPetById( + petId: string, + accept: ShowPetByIdAccept = 'application/json', + version: number = 1, + options?: HttpClientOptions, + ): Observable { + const headers = + options?.headers instanceof HttpHeaders + ? options.headers.set('Accept', accept) + : { ...(options?.headers ?? {}), Accept: accept }; + + if (accept.includes('json') || accept.includes('+json')) { + return this.http.get(`${this.baseUrl}/v${version}/pets/${petId}`, { + ...options, + responseType: 'json', + headers, + }); + } else if (accept.startsWith('text/') || accept.includes('xml')) { + return this.http.get(`${this.baseUrl}/v${version}/pets/${petId}`, { + ...options, + responseType: 'text', + headers, + }) as Observable; + } + + return this.http.get(`${this.baseUrl}/v${version}/pets/${petId}`, { + ...options, + responseType: 'json', + headers, + }); + } + /** + * @summary Replace a pet (required body, multi-content response) + */ + updatePetById( + petId: string, + pet: Pet, + accept: 'application/json', + version?: number, + options?: HttpClientOptions, + ): Observable; + updatePetById( + petId: string, + pet: Pet, + accept: 'text/plain', + version?: number, + options?: HttpClientOptions, + ): Observable; + updatePetById( + petId: string, + pet: Pet, + accept?: UpdatePetByIdAccept, + version?: number, + options?: HttpClientOptions, + ): Observable; + updatePetById( + petId: string, + pet: Pet, + accept: UpdatePetByIdAccept = 'application/json', + version: number = 1, + options?: HttpClientOptions, + ): Observable { + const headers = + options?.headers instanceof HttpHeaders + ? options.headers.set('Accept', accept) + : { ...(options?.headers ?? {}), Accept: accept }; + + if (accept.includes('json') || accept.includes('+json')) { + return this.http.put( + `${this.baseUrl}/v${version}/pets/${petId}/update`, + pet, + { + ...options, + responseType: 'json', + headers, + }, + ); + } else if (accept.startsWith('text/') || accept.includes('xml')) { + return this.http.put( + `${this.baseUrl}/v${version}/pets/${petId}/update`, + pet, + { + ...options, + responseType: 'text', + headers, + }, + ) as Observable; + } + + return this.http.put( + `${this.baseUrl}/v${version}/pets/${petId}/update`, + pet, + { + ...options, + responseType: 'json', + headers, + }, + ); + } + /** + * @summary Partially update a pet (optional body, multi-content response) + */ + patchPetById( + petId: string, + pet: Pet | undefined, + accept: 'application/json', + version?: number, + options?: HttpClientOptions, + ): Observable; + patchPetById( + petId: string, + pet: Pet | undefined, + accept: 'text/plain', + version?: number, + options?: HttpClientOptions, + ): Observable; + patchPetById( + petId: string, + pet?: Pet, + accept?: PatchPetByIdAccept, + version?: number, + options?: HttpClientOptions, + ): Observable; + patchPetById( + petId: string, + pet?: Pet, + accept: PatchPetByIdAccept = 'application/json', + version: number = 1, + options?: HttpClientOptions, + ): Observable { + const headers = + options?.headers instanceof HttpHeaders + ? options.headers.set('Accept', accept) + : { ...(options?.headers ?? {}), Accept: accept }; + + if (accept.includes('json') || accept.includes('+json')) { + return this.http.patch( + `${this.baseUrl}/v${version}/pets/${petId}/update`, + pet, + { + ...options, + responseType: 'json', + headers, + }, + ); + } else if (accept.startsWith('text/') || accept.includes('xml')) { + return this.http.patch( + `${this.baseUrl}/v${version}/pets/${petId}/update`, + pet, + { + ...options, + responseType: 'text', + headers, + }, + ) as Observable; + } + + return this.http.patch( + `${this.baseUrl}/v${version}/pets/${petId}/update`, + pet, + { + ...options, + responseType: 'json', + headers, + }, + ); + } + /** + * @summary Info for a specific pet as plain text + */ + showPetText( + petId: string, + version?: number, + options?: HttpClientBodyOptions, + ): Observable; + showPetText( + petId: string, + version?: number, + options?: HttpClientEventOptions, + ): Observable>; + showPetText( + petId: string, + version?: number, + options?: HttpClientResponseOptions, + ): Observable>; + showPetText( + petId: string, + version: number = 1, + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.get(`${this.baseUrl}/v${version}/pets/${petId}/text`, { + responseType: 'text', + ...(options as Omit, 'observe'>), + observe: 'events', + }) as Observable>; + } + + if (options?.observe === 'response') { + return this.http.get(`${this.baseUrl}/v${version}/pets/${petId}/text`, { + responseType: 'text', + ...(options as Omit, 'observe'>), + observe: 'response', + }) as Observable>; + } + + return this.http.get(`${this.baseUrl}/v${version}/pets/${petId}/text`, { + responseType: 'text', + ...(options as Omit, 'observe'>), + observe: 'body', + }) as Observable; + } + /** + * Upload image of the pet. + * @summary Uploads an image. + */ + uploadFile( + petId: number, + uploadFileBody?: Blob, + version?: number, + options?: HttpClientBodyOptions, + ): Observable; + uploadFile( + petId: number, + uploadFileBody?: Blob, + version?: number, + options?: HttpClientEventOptions, + ): Observable>; + uploadFile( + petId: number, + uploadFileBody?: Blob, + version?: number, + options?: HttpClientResponseOptions, + ): Observable>; + uploadFile( + petId: number, + uploadFileBody?: Blob, + version: number = 1, + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.post( + `${this.baseUrl}/v${version}/pet/${petId}/uploadImage`, + uploadFileBody, + { + ...(options as Omit, 'observe'>), + observe: 'events', + }, + ); + } + + if (options?.observe === 'response') { + return this.http.post( + `${this.baseUrl}/v${version}/pet/${petId}/uploadImage`, + uploadFileBody, + { + ...(options as Omit, 'observe'>), + observe: 'response', + }, + ); + } + + return this.http.post( + `${this.baseUrl}/v${version}/pet/${petId}/uploadImage`, + uploadFileBody, + { + ...(options as Omit, 'observe'>), + observe: 'body', + }, + ); + } + /** + * Download image of the pet. + * @summary Download an image. + */ + downloadFile( + petId: number, + version?: number, + options?: HttpClientBodyOptions, + ): Observable; + downloadFile( + petId: number, + version?: number, + options?: HttpClientEventOptions, + ): Observable>; + downloadFile( + petId: number, + version?: number, + options?: HttpClientResponseOptions, + ): Observable>; + downloadFile( + petId: number, + version: number = 1, + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.get( + `${this.baseUrl}/v${version}/pet/${petId}/downloadImage`, + { + responseType: 'blob', + ...(options as Omit, 'observe'>), + observe: 'events', + }, + ) as Observable>; + } + + if (options?.observe === 'response') { + return this.http.get( + `${this.baseUrl}/v${version}/pet/${petId}/downloadImage`, + { + responseType: 'blob', + ...(options as Omit, 'observe'>), + observe: 'response', + }, + ) as Observable>; + } + + return this.http.get( + `${this.baseUrl}/v${version}/pet/${petId}/downloadImage`, + { + responseType: 'blob', + ...(options as Omit, 'observe'>), + observe: 'body', + }, + ) as Observable; + } +} + +export type SearchPetsClientResult = NonNullable; +export type ListPetsClientResult = NonNullable; +export type CreatePetsClientResult = NonNullable; +export type ShowPetByIdClientResult = NonNullable; +export type UpdatePetByIdClientResult = NonNullable; +export type PatchPetByIdClientResult = NonNullable; +export type ShowPetTextClientResult = NonNullable; +export type UploadFileClientResult = NonNullable; +export type DownloadFileClientResult = NonNullable; diff --git a/samples/angular-app/__snapshots__/api/base-url-token/petstore.base-url.ts b/samples/angular-app/__snapshots__/api/base-url-token/petstore.base-url.ts new file mode 100644 index 0000000000..63d2a0b34b --- /dev/null +++ b/samples/angular-app/__snapshots__/api/base-url-token/petstore.base-url.ts @@ -0,0 +1,84 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import { InjectionToken, inject, type Provider } from '@angular/core'; + +/** + * Embedded fallback base URL for the `petstore` API, resolved at generation + * time from the OpenAPI specification's `servers` field (`''` when the + * specification has no servers). + */ +export const PETSTORE_SERVER_URL: string = 'http://petstore.swagger.io/v1'; + +/** + * Strips trailing slashes from a base URL. + * + * Generated routes always start with `/`, so normalizing here at the token + * boundary guarantees `${baseUrl}${route}` can never double or drop the + * separator between them, for either `HttpClient` services or `httpResource` + * functions. + */ +export function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/+$/, ''); +} + +/** Context passed to a `PetstoreBaseUrlResolver` when it is invoked. */ +export interface PetstoreBaseUrlResolverContext { + /** The explicit `apiId` configured via `override.angular.baseUrl`. */ + readonly apiId: 'petstore'; + /** The embedded fallback server URL (`PETSTORE_SERVER_URL`). */ + readonly serverUrl: string; +} + +/** Resolves the runtime base URL for the `petstore` API. */ +export type PetstoreBaseUrlResolver = ( + context: PetstoreBaseUrlResolverContext, +) => string; + +/** + * Injectable hook for resolving the `petstore` API's base URL at runtime + * (e.g. from a gateway route registry). Overridden via + * `providePetstoreBaseUrlResolver`; defaults to the embedded specification + * server URL. + */ +export const PETSTORE_BASE_URL_RESOLVER = + new InjectionToken('PETSTORE_BASE_URL_RESOLVER', { + providedIn: 'root', + factory: (): PetstoreBaseUrlResolver => (context) => context.serverUrl, + }); + +/** + * Runtime base URL for the `petstore` API, composed via Angular DI. + * + * Precedence: a directly provided value (`providePetstoreBaseUrl`) wins + * outright; otherwise the `PETSTORE_BASE_URL_RESOLVER` resolver (default or + * provided via `providePetstoreBaseUrlResolver`) is invoked with the embedded + * `PETSTORE_SERVER_URL` fallback. The result is always normalized. + */ +export const PETSTORE_BASE_URL = new InjectionToken( + 'PETSTORE_BASE_URL', + { + providedIn: 'root', + factory: (): string => { + const resolver = inject(PETSTORE_BASE_URL_RESOLVER); + return normalizeBaseUrl( + resolver({ apiId: 'petstore', serverUrl: PETSTORE_SERVER_URL }), + ); + }, + }, +); + +/** Directly provides the `petstore` API's base URL, bypassing the resolver. */ +export function providePetstoreBaseUrl(baseUrl: string): Provider { + return { provide: PETSTORE_BASE_URL, useValue: normalizeBaseUrl(baseUrl) }; +} + +/** Provides a custom resolver for the `petstore` API's base URL. */ +export function providePetstoreBaseUrlResolver( + resolver: PetstoreBaseUrlResolver, +): Provider { + return { provide: PETSTORE_BASE_URL_RESOLVER, useValue: resolver }; +} diff --git a/samples/angular-app/orval.config.ts b/samples/angular-app/orval.config.ts index 2804d2342b..e52145a77d 100644 --- a/samples/angular-app/orval.config.ts +++ b/samples/angular-app/orval.config.ts @@ -300,4 +300,32 @@ export default defineConfig({ }, }, }, + petstoreBaseUrlToken: { + output: { + mode: 'tags-split', + target: 'src/api/base-url-token/petstore.ts', + schemas: 'src/api/base-url-token/model', + client: 'angular', + // Test-driven output for the `override.angular.baseUrl` DI token + // feature (issue #3702, see `src/app/base-url-token.spec.ts`): no MSW + // mocks are wired up since the token composes an absolute/gateway + // prefix that MSW's relative-route matching doesn't need to see. + mock: false, + tsconfig: './tsconfig.app.json', + formatter: 'prettier', + clean: true, + override: { + angular: { + retrievalClient: 'both', + baseUrl: { apiId: 'petstore' }, + }, + }, + }, + input: { + target: './petstore.yaml', + override: { + transformer: 'src/orval/transformer/add-version.ts', + }, + }, + }, }); diff --git a/samples/angular-app/src/api/base-url-token/model/createPetsBody.ts b/samples/angular-app/src/api/base-url-token/model/createPetsBody.ts new file mode 100644 index 0000000000..8272f8286d --- /dev/null +++ b/samples/angular-app/src/api/base-url-token/model/createPetsBody.ts @@ -0,0 +1,26 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { CreatePetsBodyStatus } from './createPetsBodyStatus'; + +export type CreatePetsBody = { + /** + * Name of the pet + * @minLength 1 + * @maxLength 100 + */ + name: string; + /** + * Classification tag + * @minLength 1 + * @maxLength 50 + */ + tag: string; + /** Owner contact email */ + email?: string; + /** Initial adoption status */ + status?: CreatePetsBodyStatus; +}; diff --git a/samples/angular-app/src/api/base-url-token/model/createPetsBodyStatus.ts b/samples/angular-app/src/api/base-url-token/model/createPetsBodyStatus.ts new file mode 100644 index 0000000000..d488150a1a --- /dev/null +++ b/samples/angular-app/src/api/base-url-token/model/createPetsBodyStatus.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +/** + * Initial adoption status + */ +export type CreatePetsBodyStatus = + (typeof CreatePetsBodyStatus)[keyof typeof CreatePetsBodyStatus]; + +export const CreatePetsBodyStatus = { + available: 'available', + pending: 'pending', + sold: 'sold', +} as const; diff --git a/samples/angular-app/src/api/base-url-token/model/error.ts b/samples/angular-app/src/api/base-url-token/model/error.ts new file mode 100644 index 0000000000..311e15e4c3 --- /dev/null +++ b/samples/angular-app/src/api/base-url-token/model/error.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export interface Error { + /** + * HTTP-like error code + * @minimum 100 + * @maximum 600 + */ + code: number; + /** + * Human-readable error message + * @minLength 1 + */ + message: string; +} diff --git a/samples/angular-app/src/api/base-url-token/model/index.ts b/samples/angular-app/src/api/base-url-token/model/index.ts new file mode 100644 index 0000000000..ad51b4f93f --- /dev/null +++ b/samples/angular-app/src/api/base-url-token/model/index.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export * from './createPetsBody'; +export * from './createPetsBodyStatus'; +export * from './error'; +export * from './listPetsParams'; +export * from './pet'; +export * from './pets'; +export * from './petStatus'; +export * from './searchPetsParams'; +export * from './searchPetsStatus'; diff --git a/samples/angular-app/src/api/base-url-token/model/listPetsParams.ts b/samples/angular-app/src/api/base-url-token/model/listPetsParams.ts new file mode 100644 index 0000000000..0630dae489 --- /dev/null +++ b/samples/angular-app/src/api/base-url-token/model/listPetsParams.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type ListPetsParams = { + /** + * How many items to return at one time (max 100) + */ + limit?: string; +}; diff --git a/samples/angular-app/src/api/base-url-token/model/pet.ts b/samples/angular-app/src/api/base-url-token/model/pet.ts new file mode 100644 index 0000000000..366a5e7252 --- /dev/null +++ b/samples/angular-app/src/api/base-url-token/model/pet.ts @@ -0,0 +1,52 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { PetStatus } from './petStatus'; + +export interface Pet { + /** + * Unique identifier for the pet + * @minimum 1 + */ + id: number; + /** + * Name of the pet + * @minLength 1 + * @maxLength 100 + */ + name: string; + /** + * Optional classification tag + * @minLength 1 + * @maxLength 50 + */ + tag?: string; + /** Owner contact email */ + email?: string; + /** Current adoption status */ + status?: PetStatus; + /** + * Age of the pet in years + * @minimum 0 + * @maximum 30 + */ + age?: number; + /** + * Average customer rating + * @minimum 0 + * @maximum 5 + */ + rating?: number; + /** + * Contact phone in E.164 format + * @pattern ^\+?[1-9]\d{1,14}$ + */ + phone?: string; + /** @nullable */ + requiredNullableString: string | null; + /** @nullable */ + optionalNullableString?: string | null; +} diff --git a/samples/angular-app/src/api/base-url-token/model/petStatus.ts b/samples/angular-app/src/api/base-url-token/model/petStatus.ts new file mode 100644 index 0000000000..1810b3cb25 --- /dev/null +++ b/samples/angular-app/src/api/base-url-token/model/petStatus.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +/** + * Current adoption status + */ +export type PetStatus = (typeof PetStatus)[keyof typeof PetStatus]; + +export const PetStatus = { + available: 'available', + pending: 'pending', + sold: 'sold', +} as const; diff --git a/samples/angular-app/src/api/base-url-token/model/pets.ts b/samples/angular-app/src/api/base-url-token/model/pets.ts new file mode 100644 index 0000000000..4d5dc2ebf2 --- /dev/null +++ b/samples/angular-app/src/api/base-url-token/model/pets.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { Pet } from './pet'; + +/** + * @maxItems 100 + */ +export type Pets = Pet[]; diff --git a/samples/angular-app/src/api/base-url-token/model/searchPetsParams.ts b/samples/angular-app/src/api/base-url-token/model/searchPetsParams.ts new file mode 100644 index 0000000000..06099895c3 --- /dev/null +++ b/samples/angular-app/src/api/base-url-token/model/searchPetsParams.ts @@ -0,0 +1,32 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { SearchPetsStatus } from './searchPetsStatus'; + +export type SearchPetsParams = { + /** + * @nullable + */ + requirednullableString: string | null; + /** + * @nullable + */ + requirednullableStringTwo: string | null; + /** + * @nullable + */ + nonRequirednullableString?: string | null; + /** + * Filter by adoption status + */ + status?: SearchPetsStatus; + /** + * Maximum number of results to return + * @minimum 1 + * @maximum 100 + */ + limit?: number; +}; diff --git a/samples/angular-app/src/api/base-url-token/model/searchPetsStatus.ts b/samples/angular-app/src/api/base-url-token/model/searchPetsStatus.ts new file mode 100644 index 0000000000..c0673d593c --- /dev/null +++ b/samples/angular-app/src/api/base-url-token/model/searchPetsStatus.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type SearchPetsStatus = + (typeof SearchPetsStatus)[keyof typeof SearchPetsStatus]; + +export const SearchPetsStatus = { + available: 'available', + pending: 'pending', + sold: 'sold', +} as const; diff --git a/samples/angular-app/src/api/base-url-token/pets/pets.resource.ts b/samples/angular-app/src/api/base-url-token/pets/pets.resource.ts new file mode 100644 index 0000000000..ff4285a01a --- /dev/null +++ b/samples/angular-app/src/api/base-url-token/pets/pets.resource.ts @@ -0,0 +1,462 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { ListPetsParams, Pet, Pets, SearchPetsParams } from '../model'; + +import { HttpHeaders, httpResource } from '@angular/common/http'; +import type { + HttpContext, + HttpResourceOptions, + HttpResourceRef, + HttpResourceRequest, +} from '@angular/common/http'; + +import { inject } from '@angular/core'; +import type { ResourceStatus, Signal } from '@angular/core'; + +import { PETSTORE_BASE_URL } from '../petstore.base-url'; + +export interface OrvalHttpResourceRequestExtension { + /** Extra headers merged over generated headers. Pass a function to read signals reactively. */ + headers?: + | HttpResourceRequest['headers'] + | (() => HttpResourceRequest['headers']); + /** Angular HttpContext forwarded to the underlying request. Pass a function to derive it reactively. */ + context?: HttpContext | (() => HttpContext); + /** Last-resort escape hatch: transform the final request descriptor. Runs inside the resource's reactive context. */ + request?: (request: HttpResourceRequest) => HttpResourceRequest; +} + +export type OrvalHttpResourceOptions< + TValue, + TRaw = unknown, + TOmitParse extends boolean = false, +> = (TOmitParse extends true + ? Omit, 'parse'> + : HttpResourceOptions) & + OrvalHttpResourceRequestExtension; + +function mergeOrvalResourceHeaders( + base: HttpResourceRequest['headers'], + extra: HttpResourceRequest['headers'], +): HttpResourceRequest['headers'] { + if (!base) return extra; + if (!extra) return base; + if (base instanceof HttpHeaders || extra instanceof HttpHeaders) { + const toHeaderValue = ( + value: string | readonly string[], + ): string | string[] => + Array.isArray(value) ? Array.from(value, String) : String(value); + let merged = + base instanceof HttpHeaders + ? base + : Object.entries(base).reduce( + (headers, [key, value]) => headers.set(key, toHeaderValue(value)), + new HttpHeaders(), + ); + const extraRecord = + extra instanceof HttpHeaders + ? extra.keys().reduce>((record, key) => { + const values = extra.getAll(key); + if (values) record[key] = values; + return record; + }, {}) + : extra; + for (const [key, value] of Object.entries(extraRecord)) { + merged = merged.set(key, toHeaderValue(value)); + } + return merged; + } + return { ...base, ...extra }; +} + +export function applyOrvalRequestExtension( + request: string | HttpResourceRequest, + options?: OrvalHttpResourceRequestExtension, +): HttpResourceRequest { + const base: HttpResourceRequest = + typeof request === 'string' ? { url: request } : request; + if ( + !options || + (options.headers === undefined && + options.context === undefined && + options.request === undefined) + ) { + return base; + } + let next: HttpResourceRequest = { ...base }; + const extraHeaders = + typeof options.headers === 'function' ? options.headers() : options.headers; + if (extraHeaders !== undefined) { + next = { + ...next, + headers: mergeOrvalResourceHeaders(next.headers, extraHeaders), + }; + } + const context = + typeof options.context === 'function' ? options.context() : options.context; + if (context !== undefined) { + next = { ...next, context }; + } + return options.request ? options.request(next) : next; +} + +type AngularHttpParamValue = + | string + | number + | boolean + | Array; +type AngularHttpParamValueWithNullable = AngularHttpParamValue | null; + +function filterParams( + params: Record, + requiredNullableKeys?: ReadonlySet, + preserveRequiredNullables?: false, + passthroughKeys?: undefined, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet | undefined, + preserveRequiredNullables: true, + passthroughKeys?: undefined, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet | undefined, + preserveRequiredNullables: boolean | undefined, + passthroughKeys: ReadonlySet, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet = new Set(), + preserveRequiredNullables = false, + passthroughKeys: ReadonlySet = new Set(), +): Record { + const filteredParams: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (passthroughKeys.has(key)) { + if (value !== undefined) { + filteredParams[key] = value; + } + continue; + } + if (Array.isArray(value)) { + const filtered = value.filter( + (item) => + item != null && + (typeof item === 'string' || + typeof item === 'number' || + typeof item === 'boolean'), + ) as Array; + if (filtered.length) { + filteredParams[key] = filtered; + } + } else if (value === null && requiredNullableKeys.has(key)) { + // With a paramsSerializer (preserveRequiredNullables) the literal null + // is passed through for it to consume; without one, emit an empty + // string so the required key still reaches the wire as `?key=` + // instead of being silently dropped. See #3712. + filteredParams[key] = preserveRequiredNullables ? null : ''; + } else if ( + value != null && + (typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean') + ) { + filteredParams[key] = value; + } + } + return filteredParams; +} +export type ListPetsAccept = + (typeof ListPetsAccept)[keyof typeof ListPetsAccept]; + +export const ListPetsAccept = { + application_json: 'application/json', + application_xml: 'application/xml', +} as const; + +export type ShowPetByIdAccept = + (typeof ShowPetByIdAccept)[keyof typeof ShowPetByIdAccept]; + +export const ShowPetByIdAccept = { + text_plain: 'text/plain', + application_xml: 'application/xml', + application_json: 'application/json', +} as const; + +/** + * @experimental httpResource is experimental (Angular v19.2+) + */ +export function searchPetsResource( + params: Signal, + version: Signal | undefined, + options: OrvalHttpResourceOptions & { + defaultValue: NoInfer; + }, +): HttpResourceRef; +export function searchPetsResource( + params: Signal, + version?: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef; +export function searchPetsResource( + params: Signal, + version?: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef { + const baseUrl = options?.injector + ? options.injector.get(PETSTORE_BASE_URL) + : inject(PETSTORE_BASE_URL); + return httpResource(() => { + const request = { + url: `${baseUrl}/v${version?.() ?? 1}/search`, + params: filterParams( + params?.() ?? {}, + new Set([ + 'requirednullableString', + 'requirednullableStringTwo', + ]), + ), + }; + return applyOrvalRequestExtension(request, options); + }, options); +} + +/** + * @experimental httpResource is experimental (Angular v19.2+) + */ +export function listPetsResource( + accept: 'application/json', + params?: Signal, + version?: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef; +export function listPetsResource( + accept: 'application/xml', + params?: Signal, + version?: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef; +export function listPetsResource( + accept: ListPetsAccept = 'application/json', + params?: Signal, + version?: Signal, + options?: + | OrvalHttpResourceOptions + | OrvalHttpResourceOptions, +): HttpResourceRef { + const baseUrl = options?.injector + ? options.injector.get(PETSTORE_BASE_URL) + : inject(PETSTORE_BASE_URL); + const buildRequest = (): HttpResourceRequest => { + const request = { + url: `${baseUrl}/v${version?.() ?? 1}/pets`, + params: filterParams(params?.() ?? {}, new Set([])), + }; + const normalizedRequest: HttpResourceRequest = request; + const extendedRequest = applyOrvalRequestExtension( + normalizedRequest, + options, + ); + return { + ...extendedRequest, + headers: + extendedRequest.headers instanceof HttpHeaders + ? extendedRequest.headers.set('Accept', accept) + : { ...(extendedRequest.headers ?? {}), Accept: accept }, + }; + }; + + if (accept.includes('json') || accept.includes('+json')) { + return httpResource( + buildRequest, + options as unknown as OrvalHttpResourceOptions, + ); + } + + if (accept.startsWith('text/') || accept.includes('xml')) { + return httpResource.text( + buildRequest, + options as unknown as OrvalHttpResourceOptions, + ); + } + + return httpResource( + buildRequest, + options as unknown as OrvalHttpResourceOptions, + ); +} + +/** + * @experimental httpResource is experimental (Angular v19.2+) + */ +export function showPetByIdResource( + petId: Signal, + accept: 'text/plain', + version?: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef; +export function showPetByIdResource( + petId: Signal, + accept: 'application/xml', + version?: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef; +export function showPetByIdResource( + petId: Signal, + accept: 'application/json', + version?: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef; +export function showPetByIdResource( + petId: Signal, + accept: ShowPetByIdAccept = 'application/json', + version?: Signal, + options?: + | OrvalHttpResourceOptions + | OrvalHttpResourceOptions, +): HttpResourceRef { + const baseUrl = options?.injector + ? options.injector.get(PETSTORE_BASE_URL) + : inject(PETSTORE_BASE_URL); + const buildRequest = (): HttpResourceRequest => { + const request = `${baseUrl}/v${version?.() ?? 1}/pets/${petId()}`; + const normalizedRequest: HttpResourceRequest = { url: request }; + const extendedRequest = applyOrvalRequestExtension( + normalizedRequest, + options, + ); + return { + ...extendedRequest, + headers: + extendedRequest.headers instanceof HttpHeaders + ? extendedRequest.headers.set('Accept', accept) + : { ...(extendedRequest.headers ?? {}), Accept: accept }, + }; + }; + + if (accept.includes('json') || accept.includes('+json')) { + return httpResource( + buildRequest, + options as unknown as OrvalHttpResourceOptions, + ); + } + + if (accept.startsWith('text/') || accept.includes('xml')) { + return httpResource.text( + buildRequest, + options as unknown as OrvalHttpResourceOptions, + ); + } + + return httpResource( + buildRequest, + options as unknown as OrvalHttpResourceOptions, + ); +} + +/** + * @experimental httpResource is experimental (Angular v19.2+) + */ +export function showPetTextResource( + petId: Signal, + version: Signal | undefined, + options: OrvalHttpResourceOptions & { + defaultValue: NoInfer; + }, +): HttpResourceRef; +export function showPetTextResource( + petId: Signal, + version?: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef; +export function showPetTextResource( + petId: Signal, + version?: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef { + const baseUrl = options?.injector + ? options.injector.get(PETSTORE_BASE_URL) + : inject(PETSTORE_BASE_URL); + return httpResource.text( + () => + applyOrvalRequestExtension( + `${baseUrl}/v${version?.() ?? 1}/pets/${petId()}/text`, + options, + ), + options, + ); +} + +/** + * @experimental httpResource is experimental (Angular v19.2+) + */ +export function downloadFileResource( + petId: Signal, + version: Signal | undefined, + options: OrvalHttpResourceOptions & { + defaultValue: NoInfer; + }, +): HttpResourceRef; +export function downloadFileResource( + petId: Signal, + version?: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef; +export function downloadFileResource( + petId: Signal, + version?: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef { + const baseUrl = options?.injector + ? options.injector.get(PETSTORE_BASE_URL) + : inject(PETSTORE_BASE_URL); + return httpResource.blob( + () => + applyOrvalRequestExtension( + `${baseUrl}/v${version?.() ?? 1}/pet/${petId()}/downloadImage`, + options, + ), + options, + ); +} + +export type SearchPetsResourceResult = NonNullable; +export type ListPetsResourceResult = NonNullable; +export type ShowPetByIdResourceResult = NonNullable; +export type ShowPetTextResourceResult = NonNullable; +export type DownloadFileResourceResult = NonNullable; + +/** + * Utility type for httpResource results with status tracking. + * Inspired by @angular-architects/ngrx-toolkit withResource pattern. + * + * Uses `globalThis.Error` to avoid collision with API model types named `Error`. + */ +export interface ResourceState { + readonly value: Signal; + readonly status: Signal; + readonly error: Signal; + readonly isLoading: Signal; + readonly hasValue: () => boolean; + readonly reload: () => boolean; +} + +/** + * Wraps an HttpResourceRef to expose a consistent ResourceState interface. + * Useful when integrating with NgRx SignalStore via withResource(). + */ +export function toResourceState(ref: HttpResourceRef): ResourceState { + return { + value: ref.value, + status: ref.status, + error: ref.error, + isLoading: ref.isLoading, + hasValue: () => ref.hasValue(), + reload: () => ref.reload(), + }; +} diff --git a/samples/angular-app/src/api/base-url-token/pets/pets.service.ts b/samples/angular-app/src/api/base-url-token/pets/pets.service.ts new file mode 100644 index 0000000000..e8b426ac05 --- /dev/null +++ b/samples/angular-app/src/api/base-url-token/pets/pets.service.ts @@ -0,0 +1,700 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import { + HttpClient, + HttpHeaders, + HttpResponse as AngularHttpResponse, +} from '@angular/common/http'; +import type { HttpContext, HttpEvent, HttpParams } from '@angular/common/http'; + +import { Injectable, inject } from '@angular/core'; + +import { Observable } from 'rxjs'; + +import type { + CreatePetsBody, + ListPetsParams, + Pet, + Pets, + SearchPetsParams, +} from '../model'; + +import { PETSTORE_BASE_URL } from '../petstore.base-url'; + +interface HttpClientOptions { + readonly headers?: HttpHeaders | Record; + readonly context?: HttpContext; + readonly params?: + | HttpParams + | Record< + string, + string | number | boolean | Array + >; + readonly reportProgress?: boolean; + readonly withCredentials?: boolean; + readonly credentials?: RequestCredentials; + readonly keepalive?: boolean; + readonly priority?: RequestPriority; + readonly cache?: RequestCache; + readonly mode?: RequestMode; + readonly redirect?: RequestRedirect; + readonly referrer?: string; + readonly integrity?: string; + readonly referrerPolicy?: ReferrerPolicy; + readonly transferCache?: { includeHeaders?: string[] } | boolean; + readonly timeout?: number; +} + +type HttpClientBodyOptions = HttpClientOptions & { + readonly observe?: 'body'; +}; + +type HttpClientEventOptions = HttpClientOptions & { + readonly observe: 'events'; +}; + +type HttpClientResponseOptions = HttpClientOptions & { + readonly observe: 'response'; +}; + +type HttpClientObserveOptions = HttpClientOptions & { + readonly observe?: 'body' | 'events' | 'response'; +}; + +type AngularHttpParamValue = + | string + | number + | boolean + | Array; +type AngularHttpParamValueWithNullable = AngularHttpParamValue | null; + +function filterParams( + params: Record, + requiredNullableKeys?: ReadonlySet, + preserveRequiredNullables?: false, + passthroughKeys?: undefined, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet | undefined, + preserveRequiredNullables: true, + passthroughKeys?: undefined, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet | undefined, + preserveRequiredNullables: boolean | undefined, + passthroughKeys: ReadonlySet, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet = new Set(), + preserveRequiredNullables = false, + passthroughKeys: ReadonlySet = new Set(), +): Record { + const filteredParams: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (passthroughKeys.has(key)) { + if (value !== undefined) { + filteredParams[key] = value; + } + continue; + } + if (Array.isArray(value)) { + const filtered = value.filter( + (item) => + item != null && + (typeof item === 'string' || + typeof item === 'number' || + typeof item === 'boolean'), + ) as Array; + if (filtered.length) { + filteredParams[key] = filtered; + } + } else if (value === null && requiredNullableKeys.has(key)) { + // With a paramsSerializer (preserveRequiredNullables) the literal null + // is passed through for it to consume; without one, emit an empty + // string so the required key still reaches the wire as `?key=` + // instead of being silently dropped. See #3712. + filteredParams[key] = preserveRequiredNullables ? null : ''; + } else if ( + value != null && + (typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean') + ) { + filteredParams[key] = value; + } + } + return filteredParams; +} + +export type ListPetsAccept = + (typeof ListPetsAccept)[keyof typeof ListPetsAccept]; + +export const ListPetsAccept = { + application_json: 'application/json', + application_xml: 'application/xml', +} as const; + +export type ShowPetByIdAccept = + (typeof ShowPetByIdAccept)[keyof typeof ShowPetByIdAccept]; + +export const ShowPetByIdAccept = { + text_plain: 'text/plain', + application_xml: 'application/xml', + application_json: 'application/json', +} as const; + +export type UpdatePetByIdAccept = + (typeof UpdatePetByIdAccept)[keyof typeof UpdatePetByIdAccept]; + +export const UpdatePetByIdAccept = { + application_json: 'application/json', + text_plain: 'text/plain', +} as const; + +export type PatchPetByIdAccept = + (typeof PatchPetByIdAccept)[keyof typeof PatchPetByIdAccept]; + +export const PatchPetByIdAccept = { + application_json: 'application/json', + text_plain: 'text/plain', +} as const; + +@Injectable({ providedIn: 'root' }) +export class PetsService { + private readonly http = inject(HttpClient); + private readonly baseUrl = inject(PETSTORE_BASE_URL); + /** + * @summary Search pets by query params + */ + searchPets( + params: SearchPetsParams, + version?: number, + options?: HttpClientBodyOptions, + ): Observable; + searchPets( + params: SearchPetsParams, + version?: number, + options?: HttpClientEventOptions, + ): Observable>; + searchPets( + params: SearchPetsParams, + version?: number, + options?: HttpClientResponseOptions, + ): Observable>; + searchPets( + params: SearchPetsParams, + version: number = 1, + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + const filteredParams = filterParams( + { ...params, ...options?.params }, + new Set(['requirednullableString', 'requirednullableStringTwo']), + ); + + if (options?.observe === 'events') { + return this.http.get(`${this.baseUrl}/v${version}/search`, { + ...(options as Omit, 'observe'>), + observe: 'events', + params: filteredParams, + }); + } + + if (options?.observe === 'response') { + return this.http.get(`${this.baseUrl}/v${version}/search`, { + ...(options as Omit, 'observe'>), + observe: 'response', + params: filteredParams, + }); + } + + return this.http.get(`${this.baseUrl}/v${version}/search`, { + ...(options as Omit, 'observe'>), + observe: 'body', + params: filteredParams, + }); + } + /** + * @summary List all pets + */ + listPets( + accept: 'application/json', + params?: ListPetsParams, + version?: number, + options?: HttpClientOptions, + ): Observable; + listPets( + accept: 'application/xml', + params?: ListPetsParams, + version?: number, + options?: HttpClientOptions, + ): Observable; + listPets( + accept?: ListPetsAccept, + params?: ListPetsParams, + version?: number, + options?: HttpClientOptions, + ): Observable; + listPets( + accept: ListPetsAccept = 'application/json', + params?: ListPetsParams, + version: number = 1, + options?: HttpClientOptions, + ): Observable { + const filteredParams = filterParams( + { ...params, ...options?.params }, + new Set([]), + ); + + const headers = + options?.headers instanceof HttpHeaders + ? options.headers.set('Accept', accept) + : { ...(options?.headers ?? {}), Accept: accept }; + + if (accept.includes('json') || accept.includes('+json')) { + return this.http.get(`${this.baseUrl}/v${version}/pets`, { + ...options, + responseType: 'json', + headers, + params: filteredParams, + }); + } else if (accept.startsWith('text/') || accept.includes('xml')) { + return this.http.get(`${this.baseUrl}/v${version}/pets`, { + ...options, + responseType: 'text', + headers, + params: filteredParams, + }) as Observable; + } + + return this.http.get(`${this.baseUrl}/v${version}/pets`, { + ...options, + responseType: 'json', + headers, + params: filteredParams, + }); + } + /** + * @summary Create a pet + */ + createPets( + createPetsBody: CreatePetsBody, + version?: number, + options?: HttpClientBodyOptions, + ): Observable; + createPets( + createPetsBody: CreatePetsBody, + version?: number, + options?: HttpClientEventOptions, + ): Observable>; + createPets( + createPetsBody: CreatePetsBody, + version?: number, + options?: HttpClientResponseOptions, + ): Observable>; + createPets( + createPetsBody: CreatePetsBody, + version: number = 1, + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.post( + `${this.baseUrl}/v${version}/pets`, + createPetsBody, + { + ...(options as Omit, 'observe'>), + observe: 'events', + }, + ); + } + + if (options?.observe === 'response') { + return this.http.post( + `${this.baseUrl}/v${version}/pets`, + createPetsBody, + { + ...(options as Omit, 'observe'>), + observe: 'response', + }, + ); + } + + return this.http.post( + `${this.baseUrl}/v${version}/pets`, + createPetsBody, + { + ...(options as Omit, 'observe'>), + observe: 'body', + }, + ); + } + /** + * @summary Info for a specific pet + */ + showPetById( + petId: string, + accept: 'text/plain', + version?: number, + options?: HttpClientOptions, + ): Observable; + showPetById( + petId: string, + accept: 'application/xml', + version?: number, + options?: HttpClientOptions, + ): Observable; + showPetById( + petId: string, + accept: 'application/json', + version?: number, + options?: HttpClientOptions, + ): Observable; + showPetById( + petId: string, + accept?: ShowPetByIdAccept, + version?: number, + options?: HttpClientOptions, + ): Observable; + showPetById( + petId: string, + accept: ShowPetByIdAccept = 'application/json', + version: number = 1, + options?: HttpClientOptions, + ): Observable { + const headers = + options?.headers instanceof HttpHeaders + ? options.headers.set('Accept', accept) + : { ...(options?.headers ?? {}), Accept: accept }; + + if (accept.includes('json') || accept.includes('+json')) { + return this.http.get(`${this.baseUrl}/v${version}/pets/${petId}`, { + ...options, + responseType: 'json', + headers, + }); + } else if (accept.startsWith('text/') || accept.includes('xml')) { + return this.http.get(`${this.baseUrl}/v${version}/pets/${petId}`, { + ...options, + responseType: 'text', + headers, + }) as Observable; + } + + return this.http.get(`${this.baseUrl}/v${version}/pets/${petId}`, { + ...options, + responseType: 'json', + headers, + }); + } + /** + * @summary Replace a pet (required body, multi-content response) + */ + updatePetById( + petId: string, + pet: Pet, + accept: 'application/json', + version?: number, + options?: HttpClientOptions, + ): Observable; + updatePetById( + petId: string, + pet: Pet, + accept: 'text/plain', + version?: number, + options?: HttpClientOptions, + ): Observable; + updatePetById( + petId: string, + pet: Pet, + accept?: UpdatePetByIdAccept, + version?: number, + options?: HttpClientOptions, + ): Observable; + updatePetById( + petId: string, + pet: Pet, + accept: UpdatePetByIdAccept = 'application/json', + version: number = 1, + options?: HttpClientOptions, + ): Observable { + const headers = + options?.headers instanceof HttpHeaders + ? options.headers.set('Accept', accept) + : { ...(options?.headers ?? {}), Accept: accept }; + + if (accept.includes('json') || accept.includes('+json')) { + return this.http.put( + `${this.baseUrl}/v${version}/pets/${petId}/update`, + pet, + { + ...options, + responseType: 'json', + headers, + }, + ); + } else if (accept.startsWith('text/') || accept.includes('xml')) { + return this.http.put( + `${this.baseUrl}/v${version}/pets/${petId}/update`, + pet, + { + ...options, + responseType: 'text', + headers, + }, + ) as Observable; + } + + return this.http.put( + `${this.baseUrl}/v${version}/pets/${petId}/update`, + pet, + { + ...options, + responseType: 'json', + headers, + }, + ); + } + /** + * @summary Partially update a pet (optional body, multi-content response) + */ + patchPetById( + petId: string, + pet: Pet | undefined, + accept: 'application/json', + version?: number, + options?: HttpClientOptions, + ): Observable; + patchPetById( + petId: string, + pet: Pet | undefined, + accept: 'text/plain', + version?: number, + options?: HttpClientOptions, + ): Observable; + patchPetById( + petId: string, + pet?: Pet, + accept?: PatchPetByIdAccept, + version?: number, + options?: HttpClientOptions, + ): Observable; + patchPetById( + petId: string, + pet?: Pet, + accept: PatchPetByIdAccept = 'application/json', + version: number = 1, + options?: HttpClientOptions, + ): Observable { + const headers = + options?.headers instanceof HttpHeaders + ? options.headers.set('Accept', accept) + : { ...(options?.headers ?? {}), Accept: accept }; + + if (accept.includes('json') || accept.includes('+json')) { + return this.http.patch( + `${this.baseUrl}/v${version}/pets/${petId}/update`, + pet, + { + ...options, + responseType: 'json', + headers, + }, + ); + } else if (accept.startsWith('text/') || accept.includes('xml')) { + return this.http.patch( + `${this.baseUrl}/v${version}/pets/${petId}/update`, + pet, + { + ...options, + responseType: 'text', + headers, + }, + ) as Observable; + } + + return this.http.patch( + `${this.baseUrl}/v${version}/pets/${petId}/update`, + pet, + { + ...options, + responseType: 'json', + headers, + }, + ); + } + /** + * @summary Info for a specific pet as plain text + */ + showPetText( + petId: string, + version?: number, + options?: HttpClientBodyOptions, + ): Observable; + showPetText( + petId: string, + version?: number, + options?: HttpClientEventOptions, + ): Observable>; + showPetText( + petId: string, + version?: number, + options?: HttpClientResponseOptions, + ): Observable>; + showPetText( + petId: string, + version: number = 1, + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.get(`${this.baseUrl}/v${version}/pets/${petId}/text`, { + responseType: 'text', + ...(options as Omit, 'observe'>), + observe: 'events', + }) as Observable>; + } + + if (options?.observe === 'response') { + return this.http.get(`${this.baseUrl}/v${version}/pets/${petId}/text`, { + responseType: 'text', + ...(options as Omit, 'observe'>), + observe: 'response', + }) as Observable>; + } + + return this.http.get(`${this.baseUrl}/v${version}/pets/${petId}/text`, { + responseType: 'text', + ...(options as Omit, 'observe'>), + observe: 'body', + }) as Observable; + } + /** + * Upload image of the pet. + * @summary Uploads an image. + */ + uploadFile( + petId: number, + uploadFileBody?: Blob, + version?: number, + options?: HttpClientBodyOptions, + ): Observable; + uploadFile( + petId: number, + uploadFileBody?: Blob, + version?: number, + options?: HttpClientEventOptions, + ): Observable>; + uploadFile( + petId: number, + uploadFileBody?: Blob, + version?: number, + options?: HttpClientResponseOptions, + ): Observable>; + uploadFile( + petId: number, + uploadFileBody?: Blob, + version: number = 1, + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.post( + `${this.baseUrl}/v${version}/pet/${petId}/uploadImage`, + uploadFileBody, + { + ...(options as Omit, 'observe'>), + observe: 'events', + }, + ); + } + + if (options?.observe === 'response') { + return this.http.post( + `${this.baseUrl}/v${version}/pet/${petId}/uploadImage`, + uploadFileBody, + { + ...(options as Omit, 'observe'>), + observe: 'response', + }, + ); + } + + return this.http.post( + `${this.baseUrl}/v${version}/pet/${petId}/uploadImage`, + uploadFileBody, + { + ...(options as Omit, 'observe'>), + observe: 'body', + }, + ); + } + /** + * Download image of the pet. + * @summary Download an image. + */ + downloadFile( + petId: number, + version?: number, + options?: HttpClientBodyOptions, + ): Observable; + downloadFile( + petId: number, + version?: number, + options?: HttpClientEventOptions, + ): Observable>; + downloadFile( + petId: number, + version?: number, + options?: HttpClientResponseOptions, + ): Observable>; + downloadFile( + petId: number, + version: number = 1, + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.get( + `${this.baseUrl}/v${version}/pet/${petId}/downloadImage`, + { + responseType: 'blob', + ...(options as Omit, 'observe'>), + observe: 'events', + }, + ) as Observable>; + } + + if (options?.observe === 'response') { + return this.http.get( + `${this.baseUrl}/v${version}/pet/${petId}/downloadImage`, + { + responseType: 'blob', + ...(options as Omit, 'observe'>), + observe: 'response', + }, + ) as Observable>; + } + + return this.http.get( + `${this.baseUrl}/v${version}/pet/${petId}/downloadImage`, + { + responseType: 'blob', + ...(options as Omit, 'observe'>), + observe: 'body', + }, + ) as Observable; + } +} + +export type SearchPetsClientResult = NonNullable; +export type ListPetsClientResult = NonNullable; +export type CreatePetsClientResult = NonNullable; +export type ShowPetByIdClientResult = NonNullable; +export type UpdatePetByIdClientResult = NonNullable; +export type PatchPetByIdClientResult = NonNullable; +export type ShowPetTextClientResult = NonNullable; +export type UploadFileClientResult = NonNullable; +export type DownloadFileClientResult = NonNullable; diff --git a/samples/angular-app/src/api/base-url-token/petstore.base-url.ts b/samples/angular-app/src/api/base-url-token/petstore.base-url.ts new file mode 100644 index 0000000000..63d2a0b34b --- /dev/null +++ b/samples/angular-app/src/api/base-url-token/petstore.base-url.ts @@ -0,0 +1,84 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import { InjectionToken, inject, type Provider } from '@angular/core'; + +/** + * Embedded fallback base URL for the `petstore` API, resolved at generation + * time from the OpenAPI specification's `servers` field (`''` when the + * specification has no servers). + */ +export const PETSTORE_SERVER_URL: string = 'http://petstore.swagger.io/v1'; + +/** + * Strips trailing slashes from a base URL. + * + * Generated routes always start with `/`, so normalizing here at the token + * boundary guarantees `${baseUrl}${route}` can never double or drop the + * separator between them, for either `HttpClient` services or `httpResource` + * functions. + */ +export function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/+$/, ''); +} + +/** Context passed to a `PetstoreBaseUrlResolver` when it is invoked. */ +export interface PetstoreBaseUrlResolverContext { + /** The explicit `apiId` configured via `override.angular.baseUrl`. */ + readonly apiId: 'petstore'; + /** The embedded fallback server URL (`PETSTORE_SERVER_URL`). */ + readonly serverUrl: string; +} + +/** Resolves the runtime base URL for the `petstore` API. */ +export type PetstoreBaseUrlResolver = ( + context: PetstoreBaseUrlResolverContext, +) => string; + +/** + * Injectable hook for resolving the `petstore` API's base URL at runtime + * (e.g. from a gateway route registry). Overridden via + * `providePetstoreBaseUrlResolver`; defaults to the embedded specification + * server URL. + */ +export const PETSTORE_BASE_URL_RESOLVER = + new InjectionToken('PETSTORE_BASE_URL_RESOLVER', { + providedIn: 'root', + factory: (): PetstoreBaseUrlResolver => (context) => context.serverUrl, + }); + +/** + * Runtime base URL for the `petstore` API, composed via Angular DI. + * + * Precedence: a directly provided value (`providePetstoreBaseUrl`) wins + * outright; otherwise the `PETSTORE_BASE_URL_RESOLVER` resolver (default or + * provided via `providePetstoreBaseUrlResolver`) is invoked with the embedded + * `PETSTORE_SERVER_URL` fallback. The result is always normalized. + */ +export const PETSTORE_BASE_URL = new InjectionToken( + 'PETSTORE_BASE_URL', + { + providedIn: 'root', + factory: (): string => { + const resolver = inject(PETSTORE_BASE_URL_RESOLVER); + return normalizeBaseUrl( + resolver({ apiId: 'petstore', serverUrl: PETSTORE_SERVER_URL }), + ); + }, + }, +); + +/** Directly provides the `petstore` API's base URL, bypassing the resolver. */ +export function providePetstoreBaseUrl(baseUrl: string): Provider { + return { provide: PETSTORE_BASE_URL, useValue: normalizeBaseUrl(baseUrl) }; +} + +/** Provides a custom resolver for the `petstore` API's base URL. */ +export function providePetstoreBaseUrlResolver( + resolver: PetstoreBaseUrlResolver, +): Provider { + return { provide: PETSTORE_BASE_URL_RESOLVER, useValue: resolver }; +} diff --git a/samples/angular-app/src/app/base-url-token.spec.ts b/samples/angular-app/src/app/base-url-token.spec.ts new file mode 100644 index 0000000000..cdeff83c3c --- /dev/null +++ b/samples/angular-app/src/app/base-url-token.spec.ts @@ -0,0 +1,128 @@ +import { provideHttpClient } from '@angular/common/http'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; +import { + provideZonelessChangeDetection, + signal, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { + PETSTORE_BASE_URL, + providePetstoreBaseUrl, + providePetstoreBaseUrlResolver, + type PetstoreBaseUrlResolverContext, +} from '../api/base-url-token/petstore.base-url'; +import { PetsService } from '../api/base-url-token/pets/pets.service'; +import { showPetByIdResource } from '../api/base-url-token/pets/pets.resource'; + +// Exercises the opt-in `override.angular.baseUrl` DI token end to end (issue +// #3702): default resolution from the embedded spec server URL, the two +// provide-helper overrides, and that both consumption surfaces the feature +// targets — generated `HttpClient` service methods and standalone +// `httpResource` functions — read the exact same token value. +describe('base-url-token (override.angular.baseUrl)', () => { + describe('default resolution', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideZonelessChangeDetection()], + }); + }); + + it('resolves to the normalized OpenAPI spec server URL', () => { + const baseUrl = TestBed.inject(PETSTORE_BASE_URL); + expect(baseUrl).toBe('http://petstore.swagger.io/v1'); + }); + }); + + describe('providePetstoreBaseUrl', () => { + it('overrides the token directly and normalizes a trailing slash', () => { + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + providePetstoreBaseUrl('/api/petstore/'), + ], + }); + + expect(TestBed.inject(PETSTORE_BASE_URL)).toBe('/api/petstore'); + }); + + it('takes precedence over any configured resolver', () => { + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + providePetstoreBaseUrlResolver(() => '/from-resolver'), + providePetstoreBaseUrl('/from-direct-provider'), + ], + }); + + expect(TestBed.inject(PETSTORE_BASE_URL)).toBe('/from-direct-provider'); + }); + }); + + describe('providePetstoreBaseUrlResolver', () => { + it('is invoked with the apiId and embedded server URL, and wins over the default', () => { + let receivedContext: PetstoreBaseUrlResolverContext | undefined; + + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + providePetstoreBaseUrlResolver((context) => { + receivedContext = context; + return '/gateway/petstore'; + }), + ], + }); + + expect(TestBed.inject(PETSTORE_BASE_URL)).toBe('/gateway/petstore'); + expect(receivedContext).toEqual({ + apiId: 'petstore', + serverUrl: 'http://petstore.swagger.io/v1', + }); + }); + }); + + describe('shared token across HttpClient and httpResource surfaces', () => { + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + provideHttpClient(), + provideHttpClientTesting(), + providePetstoreBaseUrl('/gateway/petstore'), + ], + }); + + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('prefixes the generated HttpClient service route with the provided base URL', () => { + const service = TestBed.inject(PetsService); + + service.createPets({ name: 'Rex', tag: 'dog' }).subscribe(); + + const req = httpMock.expectOne('/gateway/petstore/v1/pets'); + expect(req.request.method).toBe('POST'); + req.flush(null); + }); + + it('prefixes an httpResource request created inside an injection context with the same base URL', () => { + TestBed.runInInjectionContext(() => { + showPetByIdResource(signal('1'), 'application/json'); + }); + TestBed.tick(); + + const req = httpMock.expectOne('/gateway/petstore/v1/pets/1'); + expect(req.request.method).toBe('GET'); + req.flush({ id: 1, name: 'Rex', requiredNullableString: null }); + }); + }); +}); diff --git a/tests/__snapshots__/angular/base-url-token-both/endpoints.base-url.ts b/tests/__snapshots__/angular/base-url-token-both/endpoints.base-url.ts new file mode 100644 index 0000000000..bc9f3127c4 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-both/endpoints.base-url.ts @@ -0,0 +1,90 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import { InjectionToken, inject, type Provider } from '@angular/core'; + +/** + * Embedded fallback base URL for the `petstore-api` API, resolved at generation + * time from the OpenAPI specification's `servers` field (`''` when the + * specification has no servers). + */ +export const PETSTORE_API_SERVER_URL: string = 'http://petstore.swagger.io/v1'; + +/** + * Strips trailing slashes from a base URL. + * + * Generated routes always start with `/`, so normalizing here at the token + * boundary guarantees `${baseUrl}${route}` can never double or drop the + * separator between them, for either `HttpClient` services or `httpResource` + * functions. + */ +export function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/+$/, ''); +} + +/** Context passed to a `PetstoreApiBaseUrlResolver` when it is invoked. */ +export interface PetstoreApiBaseUrlResolverContext { + /** The explicit `apiId` configured via `override.angular.baseUrl`. */ + readonly apiId: 'petstore-api'; + /** The embedded fallback server URL (`PETSTORE_API_SERVER_URL`). */ + readonly serverUrl: string; +} + +/** Resolves the runtime base URL for the `petstore-api` API. */ +export type PetstoreApiBaseUrlResolver = ( + context: PetstoreApiBaseUrlResolverContext, +) => string; + +/** + * Injectable hook for resolving the `petstore-api` API's base URL at runtime + * (e.g. from a gateway route registry). Overridden via + * `providePetstoreApiBaseUrlResolver`; defaults to the embedded specification + * server URL. + */ +export const PETSTORE_API_BASE_URL_RESOLVER = + new InjectionToken( + 'PETSTORE_API_BASE_URL_RESOLVER', + { + providedIn: 'root', + factory: (): PetstoreApiBaseUrlResolver => (context) => context.serverUrl, + }, + ); + +/** + * Runtime base URL for the `petstore-api` API, composed via Angular DI. + * + * Precedence: a directly provided value (`providePetstoreApiBaseUrl`) wins + * outright; otherwise the `PETSTORE_API_BASE_URL_RESOLVER` resolver (default or + * provided via `providePetstoreApiBaseUrlResolver`) is invoked with the embedded + * `PETSTORE_API_SERVER_URL` fallback. The result is always normalized. + */ +export const PETSTORE_API_BASE_URL = new InjectionToken( + 'PETSTORE_API_BASE_URL', + { + providedIn: 'root', + factory: (): string => { + const resolver = inject(PETSTORE_API_BASE_URL_RESOLVER); + return normalizeBaseUrl( + resolver({ apiId: 'petstore-api', serverUrl: PETSTORE_API_SERVER_URL }), + ); + }, + }, +); + +/** Directly provides the `petstore-api` API's base URL, bypassing the resolver. */ +export function providePetstoreApiBaseUrl(baseUrl: string): Provider { + return { + provide: PETSTORE_API_BASE_URL, + useValue: normalizeBaseUrl(baseUrl), + }; +} + +/** Provides a custom resolver for the `petstore-api` API's base URL. */ +export function providePetstoreApiBaseUrlResolver( + resolver: PetstoreApiBaseUrlResolver, +): Provider { + return { provide: PETSTORE_API_BASE_URL_RESOLVER, useValue: resolver }; +} diff --git a/tests/__snapshots__/angular/base-url-token-both/health/health.resource.ts b/tests/__snapshots__/angular/base-url-token-both/health/health.resource.ts new file mode 100644 index 0000000000..a4b59a68f0 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-both/health/health.resource.ts @@ -0,0 +1,157 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import { HttpHeaders, httpResource } from '@angular/common/http'; +import type { + HttpContext, + HttpResourceOptions, + HttpResourceRef, + HttpResourceRequest, +} from '@angular/common/http'; + +import { inject } from '@angular/core'; +import type { ResourceStatus, Signal } from '@angular/core'; + +import { PETSTORE_API_BASE_URL } from '../endpoints.base-url'; + +export interface OrvalHttpResourceRequestExtension { + /** Extra headers merged over generated headers. Pass a function to read signals reactively. */ + headers?: + | HttpResourceRequest['headers'] + | (() => HttpResourceRequest['headers']); + /** Angular HttpContext forwarded to the underlying request. Pass a function to derive it reactively. */ + context?: HttpContext | (() => HttpContext); + /** Last-resort escape hatch: transform the final request descriptor. Runs inside the resource's reactive context. */ + request?: (request: HttpResourceRequest) => HttpResourceRequest; +} + +export type OrvalHttpResourceOptions< + TValue, + TRaw = unknown, + TOmitParse extends boolean = false, +> = (TOmitParse extends true + ? Omit, 'parse'> + : HttpResourceOptions) & + OrvalHttpResourceRequestExtension; + +function mergeOrvalResourceHeaders( + base: HttpResourceRequest['headers'], + extra: HttpResourceRequest['headers'], +): HttpResourceRequest['headers'] { + if (!base) return extra; + if (!extra) return base; + if (base instanceof HttpHeaders || extra instanceof HttpHeaders) { + const toHeaderValue = ( + value: string | readonly string[], + ): string | string[] => + Array.isArray(value) ? Array.from(value, String) : String(value); + let merged = + base instanceof HttpHeaders + ? base + : Object.entries(base).reduce( + (headers, [key, value]) => headers.set(key, toHeaderValue(value)), + new HttpHeaders(), + ); + const extraRecord = + extra instanceof HttpHeaders + ? extra.keys().reduce>((record, key) => { + const values = extra.getAll(key); + if (values) record[key] = values; + return record; + }, {}) + : extra; + for (const [key, value] of Object.entries(extraRecord)) { + merged = merged.set(key, toHeaderValue(value)); + } + return merged; + } + return { ...base, ...extra }; +} + +export function applyOrvalRequestExtension( + request: string | HttpResourceRequest, + options?: OrvalHttpResourceRequestExtension, +): HttpResourceRequest { + const base: HttpResourceRequest = + typeof request === 'string' ? { url: request } : request; + if ( + !options || + (options.headers === undefined && + options.context === undefined && + options.request === undefined) + ) { + return base; + } + let next: HttpResourceRequest = { ...base }; + const extraHeaders = + typeof options.headers === 'function' ? options.headers() : options.headers; + if (extraHeaders !== undefined) { + next = { + ...next, + headers: mergeOrvalResourceHeaders(next.headers, extraHeaders), + }; + } + const context = + typeof options.context === 'function' ? options.context() : options.context; + if (context !== undefined) { + next = { ...next, context }; + } + return options.request ? options.request(next) : next; +} +/** + * @experimental httpResource is experimental (Angular v19.2+) + */ +export function healthCheckResource( + options: OrvalHttpResourceOptions & { + defaultValue: NoInfer; + }, +): HttpResourceRef; +export function healthCheckResource( + options?: OrvalHttpResourceOptions, +): HttpResourceRef; +export function healthCheckResource( + options?: OrvalHttpResourceOptions, +): HttpResourceRef { + const baseUrl = options?.injector + ? options.injector.get(PETSTORE_API_BASE_URL) + : inject(PETSTORE_API_BASE_URL); + return httpResource.text( + () => applyOrvalRequestExtension(`${baseUrl}/health`, options), + options, + ); +} + +export type HealthCheckResourceResult = NonNullable; + +/** + * Utility type for httpResource results with status tracking. + * Inspired by @angular-architects/ngrx-toolkit withResource pattern. + * + * Uses `globalThis.Error` to avoid collision with API model types named `Error`. + */ +export interface ResourceState { + readonly value: Signal; + readonly status: Signal; + readonly error: Signal; + readonly isLoading: Signal; + readonly hasValue: () => boolean; + readonly reload: () => boolean; +} + +/** + * Wraps an HttpResourceRef to expose a consistent ResourceState interface. + * Useful when integrating with NgRx SignalStore via withResource(). + */ +export function toResourceState(ref: HttpResourceRef): ResourceState { + return { + value: ref.value, + status: ref.status, + error: ref.error, + isLoading: ref.isLoading, + hasValue: () => ref.hasValue(), + reload: () => ref.reload(), + }; +} diff --git a/tests/__snapshots__/angular/base-url-token-both/health/health.service.ts b/tests/__snapshots__/angular/base-url-token-both/health/health.service.ts new file mode 100644 index 0000000000..aa609f1ac2 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-both/health/health.service.ts @@ -0,0 +1,97 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import { + HttpClient, + HttpHeaders, + HttpResponse as AngularHttpResponse, +} from '@angular/common/http'; +import type { HttpContext, HttpEvent, HttpParams } from '@angular/common/http'; + +import { Injectable, inject } from '@angular/core'; + +import { Observable } from 'rxjs'; + +import { PETSTORE_API_BASE_URL } from '../endpoints.base-url'; + +interface HttpClientOptions { + readonly headers?: HttpHeaders | Record; + readonly context?: HttpContext; + readonly params?: + | HttpParams + | Record< + string, + string | number | boolean | Array + >; + readonly reportProgress?: boolean; + readonly withCredentials?: boolean; + readonly credentials?: RequestCredentials; + readonly keepalive?: boolean; + readonly priority?: RequestPriority; + readonly cache?: RequestCache; + readonly mode?: RequestMode; + readonly redirect?: RequestRedirect; + readonly referrer?: string; + readonly integrity?: string; + readonly referrerPolicy?: ReferrerPolicy; + readonly transferCache?: { includeHeaders?: string[] } | boolean; + readonly timeout?: number; +} + +type HttpClientBodyOptions = HttpClientOptions & { + readonly observe?: 'body'; +}; + +type HttpClientEventOptions = HttpClientOptions & { + readonly observe: 'events'; +}; + +type HttpClientResponseOptions = HttpClientOptions & { + readonly observe: 'response'; +}; + +type HttpClientObserveOptions = HttpClientOptions & { + readonly observe?: 'body' | 'events' | 'response'; +}; + +@Injectable({ providedIn: 'root' }) +export class HealthService { + private readonly http = inject(HttpClient); + private readonly baseUrl = inject(PETSTORE_API_BASE_URL); + /** + * @summary health check + */ + healthCheck(options?: HttpClientBodyOptions): Observable; + healthCheck(options?: HttpClientEventOptions): Observable>; + healthCheck( + options?: HttpClientResponseOptions, + ): Observable>; + healthCheck( + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.get(`${this.baseUrl}/health`, { + responseType: 'text', + ...(options as Omit, 'observe'>), + observe: 'events', + }) as Observable>; + } + + if (options?.observe === 'response') { + return this.http.get(`${this.baseUrl}/health`, { + responseType: 'text', + ...(options as Omit, 'observe'>), + observe: 'response', + }) as Observable>; + } + + return this.http.get(`${this.baseUrl}/health`, { + responseType: 'text', + ...(options as Omit, 'observe'>), + observe: 'body', + }) as Observable; + } +} diff --git a/tests/__snapshots__/angular/base-url-token-both/model/cat.ts b/tests/__snapshots__/angular/base-url-token-both/model/cat.ts new file mode 100644 index 0000000000..2e583f1c7f --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-both/model/cat.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { CatType } from './catType'; + +export interface Cat { + petsRequested?: number; + type: CatType; +} diff --git a/tests/__snapshots__/angular/base-url-token-both/model/catType.ts b/tests/__snapshots__/angular/base-url-token-both/model/catType.ts new file mode 100644 index 0000000000..8bfea55ae3 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-both/model/catType.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type CatType = (typeof CatType)[keyof typeof CatType]; + +export const CatType = { + cat: 'cat', +} as const; diff --git a/tests/__snapshots__/angular/base-url-token-both/model/createPetsBody.ts b/tests/__snapshots__/angular/base-url-token-both/model/createPetsBody.ts new file mode 100644 index 0000000000..0365da24c7 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-both/model/createPetsBody.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type CreatePetsBody = { + name: string; + tag: string; +}; diff --git a/tests/__snapshots__/angular/base-url-token-both/model/createPetsParams.ts b/tests/__snapshots__/angular/base-url-token-both/model/createPetsParams.ts new file mode 100644 index 0000000000..daf5e2d6f4 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-both/model/createPetsParams.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { CreatePetsSort } from './createPetsSort'; + +export type CreatePetsParams = { + /** + * How many items to return at one time (max 100) + */ + limit?: string; + /** + * Which property to sort by? + * Example: name sorts ASC while -name sorts DESC. + */ + sort: CreatePetsSort; +}; diff --git a/tests/__snapshots__/angular/base-url-token-both/model/createPetsSort.ts b/tests/__snapshots__/angular/base-url-token-both/model/createPetsSort.ts new file mode 100644 index 0000000000..d7cb2b867a --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-both/model/createPetsSort.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type CreatePetsSort = + (typeof CreatePetsSort)[keyof typeof CreatePetsSort]; + +export const CreatePetsSort = { + name: 'name', + '-name': '-name', + email: 'email', + '-email': '-email', +} as const; diff --git a/tests/__snapshots__/angular/base-url-token-both/model/dachshund.ts b/tests/__snapshots__/angular/base-url-token-both/model/dachshund.ts new file mode 100644 index 0000000000..1b834d647d --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-both/model/dachshund.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { DachshundBreed } from './dachshundBreed'; + +export interface Dachshund { + length: number; + breed: DachshundBreed; +} diff --git a/tests/__snapshots__/angular/base-url-token-both/model/dachshundBreed.ts b/tests/__snapshots__/angular/base-url-token-both/model/dachshundBreed.ts new file mode 100644 index 0000000000..2c0d62ae5a --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-both/model/dachshundBreed.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type DachshundBreed = + (typeof DachshundBreed)[keyof typeof DachshundBreed]; + +export const DachshundBreed = { + Dachshund: 'Dachshund', +} as const; diff --git a/tests/__snapshots__/angular/base-url-token-both/model/dog.ts b/tests/__snapshots__/angular/base-url-token-both/model/dog.ts new file mode 100644 index 0000000000..847b6d3903 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-both/model/dog.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { Dachshund } from './dachshund'; +import type { DogType } from './dogType'; +import type { Labradoodle } from './labradoodle'; + +export type Dog = + | (Labradoodle & { + barksPerMinute?: number; + type: DogType; + }) + | (Dachshund & { + barksPerMinute?: number; + type: DogType; + }); diff --git a/tests/__snapshots__/angular/base-url-token-both/model/dogType.ts b/tests/__snapshots__/angular/base-url-token-both/model/dogType.ts new file mode 100644 index 0000000000..fabdd40e3e --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-both/model/dogType.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type DogType = (typeof DogType)[keyof typeof DogType]; + +export const DogType = { + dog: 'dog', +} as const; diff --git a/tests/__snapshots__/angular/base-url-token-both/model/error.ts b/tests/__snapshots__/angular/base-url-token-both/model/error.ts new file mode 100644 index 0000000000..b4168d9717 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-both/model/error.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export interface Error { + code: number; + message: string; +} diff --git a/tests/__snapshots__/angular/base-url-token-both/model/index.ts b/tests/__snapshots__/angular/base-url-token-both/model/index.ts new file mode 100644 index 0000000000..bec5f9fd03 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-both/model/index.ts @@ -0,0 +1,26 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export * from './cat'; +export * from './catType'; +export * from './createPetsBody'; +export * from './createPetsParams'; +export * from './createPetsSort'; +export * from './dachshund'; +export * from './dachshundBreed'; +export * from './dog'; +export * from './dogType'; +export * from './error'; +export * from './labradoodle'; +export * from './labradoodleBreed'; +export * from './listPetsParams'; +export * from './listPetsSort'; +export * from './pet'; +export * from './petCallingCode'; +export * from './petCountry'; +export * from './pets'; +export * from './petWithTag'; diff --git a/tests/__snapshots__/angular/base-url-token-both/model/labradoodle.ts b/tests/__snapshots__/angular/base-url-token-both/model/labradoodle.ts new file mode 100644 index 0000000000..2bb4217d0a --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-both/model/labradoodle.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { LabradoodleBreed } from './labradoodleBreed'; + +export interface Labradoodle { + cuteness: number; + breed: LabradoodleBreed; +} diff --git a/tests/__snapshots__/angular/base-url-token-both/model/labradoodleBreed.ts b/tests/__snapshots__/angular/base-url-token-both/model/labradoodleBreed.ts new file mode 100644 index 0000000000..7d953cea51 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-both/model/labradoodleBreed.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type LabradoodleBreed = + (typeof LabradoodleBreed)[keyof typeof LabradoodleBreed]; + +export const LabradoodleBreed = { + Labradoodle: 'Labradoodle', +} as const; diff --git a/tests/__snapshots__/angular/base-url-token-both/model/listPetsParams.ts b/tests/__snapshots__/angular/base-url-token-both/model/listPetsParams.ts new file mode 100644 index 0000000000..02ceb51e58 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-both/model/listPetsParams.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { ListPetsSort } from './listPetsSort'; + +export type ListPetsParams = { + /** + * How many items to return at one time (max 100) + */ + limit?: string; + /** + * Which property to sort by? + * Example: name sorts ASC while -name sorts DESC. + */ + sort: ListPetsSort; +}; diff --git a/tests/__snapshots__/angular/base-url-token-both/model/listPetsSort.ts b/tests/__snapshots__/angular/base-url-token-both/model/listPetsSort.ts new file mode 100644 index 0000000000..68f6176379 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-both/model/listPetsSort.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type ListPetsSort = (typeof ListPetsSort)[keyof typeof ListPetsSort]; + +export const ListPetsSort = { + name: 'name', + '-name': '-name', + email: 'email', + '-email': '-email', +} as const; diff --git a/tests/__snapshots__/angular/base-url-token-both/model/pet.ts b/tests/__snapshots__/angular/base-url-token-both/model/pet.ts new file mode 100644 index 0000000000..61da13a024 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-both/model/pet.ts @@ -0,0 +1,30 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { Cat } from './cat'; +import type { Dog } from './dog'; +import type { PetCallingCode } from './petCallingCode'; +import type { PetCountry } from './petCountry'; + +export type Pet = + | (Dog & { + '@id'?: string; + id: number; + name: string; + tag?: string; + email?: string; + callingCode?: PetCallingCode; + country?: PetCountry; + }) + | (Cat & { + '@id'?: string; + id: number; + name: string; + tag?: string; + email?: string; + callingCode?: PetCallingCode; + country?: PetCountry; + }); diff --git a/tests/__snapshots__/angular/base-url-token-both/model/petCallingCode.ts b/tests/__snapshots__/angular/base-url-token-both/model/petCallingCode.ts new file mode 100644 index 0000000000..0fec3eddea --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-both/model/petCallingCode.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type PetCallingCode = + (typeof PetCallingCode)[keyof typeof PetCallingCode]; + +export const PetCallingCode = { + '+33': '+33', + '+420': '+420', +} as const; diff --git a/tests/__snapshots__/angular/base-url-token-both/model/petCountry.ts b/tests/__snapshots__/angular/base-url-token-both/model/petCountry.ts new file mode 100644 index 0000000000..57424fd0e5 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-both/model/petCountry.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type PetCountry = (typeof PetCountry)[keyof typeof PetCountry]; + +export const PetCountry = { + "People's_Republic_of_China": "People's Republic of China", + Uruguay: 'Uruguay', +} as const; diff --git a/tests/__snapshots__/angular/base-url-token-both/model/petWithTag.ts b/tests/__snapshots__/angular/base-url-token-both/model/petWithTag.ts new file mode 100644 index 0000000000..76d8ca5089 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-both/model/petWithTag.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { Pet } from './pet'; + +export interface PetWithTag { + tag: string; + pet: Pet | null; +} diff --git a/tests/__snapshots__/angular/base-url-token-both/model/pets.ts b/tests/__snapshots__/angular/base-url-token-both/model/pets.ts new file mode 100644 index 0000000000..b2fd3d3288 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-both/model/pets.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { Pet } from './pet'; + +export type Pets = Pet[]; diff --git a/tests/__snapshots__/angular/base-url-token-both/pets/pets.resource.ts b/tests/__snapshots__/angular/base-url-token-both/pets/pets.resource.ts new file mode 100644 index 0000000000..7473a45195 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-both/pets/pets.resource.ts @@ -0,0 +1,288 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { ListPetsParams, Pet, PetWithTag, Pets } from '../model'; + +import { HttpHeaders, httpResource } from '@angular/common/http'; +import type { + HttpContext, + HttpResourceOptions, + HttpResourceRef, + HttpResourceRequest, +} from '@angular/common/http'; + +import { inject } from '@angular/core'; +import type { ResourceStatus, Signal } from '@angular/core'; + +import { PETSTORE_API_BASE_URL } from '../endpoints.base-url'; + +export interface OrvalHttpResourceRequestExtension { + /** Extra headers merged over generated headers. Pass a function to read signals reactively. */ + headers?: + | HttpResourceRequest['headers'] + | (() => HttpResourceRequest['headers']); + /** Angular HttpContext forwarded to the underlying request. Pass a function to derive it reactively. */ + context?: HttpContext | (() => HttpContext); + /** Last-resort escape hatch: transform the final request descriptor. Runs inside the resource's reactive context. */ + request?: (request: HttpResourceRequest) => HttpResourceRequest; +} + +export type OrvalHttpResourceOptions< + TValue, + TRaw = unknown, + TOmitParse extends boolean = false, +> = (TOmitParse extends true + ? Omit, 'parse'> + : HttpResourceOptions) & + OrvalHttpResourceRequestExtension; + +function mergeOrvalResourceHeaders( + base: HttpResourceRequest['headers'], + extra: HttpResourceRequest['headers'], +): HttpResourceRequest['headers'] { + if (!base) return extra; + if (!extra) return base; + if (base instanceof HttpHeaders || extra instanceof HttpHeaders) { + const toHeaderValue = ( + value: string | readonly string[], + ): string | string[] => + Array.isArray(value) ? Array.from(value, String) : String(value); + let merged = + base instanceof HttpHeaders + ? base + : Object.entries(base).reduce( + (headers, [key, value]) => headers.set(key, toHeaderValue(value)), + new HttpHeaders(), + ); + const extraRecord = + extra instanceof HttpHeaders + ? extra.keys().reduce>((record, key) => { + const values = extra.getAll(key); + if (values) record[key] = values; + return record; + }, {}) + : extra; + for (const [key, value] of Object.entries(extraRecord)) { + merged = merged.set(key, toHeaderValue(value)); + } + return merged; + } + return { ...base, ...extra }; +} + +export function applyOrvalRequestExtension( + request: string | HttpResourceRequest, + options?: OrvalHttpResourceRequestExtension, +): HttpResourceRequest { + const base: HttpResourceRequest = + typeof request === 'string' ? { url: request } : request; + if ( + !options || + (options.headers === undefined && + options.context === undefined && + options.request === undefined) + ) { + return base; + } + let next: HttpResourceRequest = { ...base }; + const extraHeaders = + typeof options.headers === 'function' ? options.headers() : options.headers; + if (extraHeaders !== undefined) { + next = { + ...next, + headers: mergeOrvalResourceHeaders(next.headers, extraHeaders), + }; + } + const context = + typeof options.context === 'function' ? options.context() : options.context; + if (context !== undefined) { + next = { ...next, context }; + } + return options.request ? options.request(next) : next; +} + +type AngularHttpParamValue = + | string + | number + | boolean + | Array; +type AngularHttpParamValueWithNullable = AngularHttpParamValue | null; + +function filterParams( + params: Record, + requiredNullableKeys?: ReadonlySet, + preserveRequiredNullables?: false, + passthroughKeys?: undefined, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet | undefined, + preserveRequiredNullables: true, + passthroughKeys?: undefined, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet | undefined, + preserveRequiredNullables: boolean | undefined, + passthroughKeys: ReadonlySet, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet = new Set(), + preserveRequiredNullables = false, + passthroughKeys: ReadonlySet = new Set(), +): Record { + const filteredParams: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (passthroughKeys.has(key)) { + if (value !== undefined) { + filteredParams[key] = value; + } + continue; + } + if (Array.isArray(value)) { + const filtered = value.filter( + (item) => + item != null && + (typeof item === 'string' || + typeof item === 'number' || + typeof item === 'boolean'), + ) as Array; + if (filtered.length) { + filteredParams[key] = filtered; + } + } else if (value === null && requiredNullableKeys.has(key)) { + // With a paramsSerializer (preserveRequiredNullables) the literal null + // is passed through for it to consume; without one, emit an empty + // string so the required key still reaches the wire as `?key=` + // instead of being silently dropped. See #3712. + filteredParams[key] = preserveRequiredNullables ? null : ''; + } else if ( + value != null && + (typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean') + ) { + filteredParams[key] = value; + } + } + return filteredParams; +} +/** + * @experimental httpResource is experimental (Angular v19.2+) + */ +export function listPetsResource( + params: Signal, + options: OrvalHttpResourceOptions & { + defaultValue: NoInfer; + }, +): HttpResourceRef; +export function listPetsResource( + params: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef; +export function listPetsResource( + params: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef { + const baseUrl = options?.injector + ? options.injector.get(PETSTORE_API_BASE_URL) + : inject(PETSTORE_API_BASE_URL); + return httpResource(() => { + const request = { + url: `${baseUrl}/pets`, + params: filterParams(params?.() ?? {}, new Set([])), + }; + return applyOrvalRequestExtension(request, options); + }, options); +} + +/** + * @experimental httpResource is experimental (Angular v19.2+) + */ +export function showPetByIdResource( + petId: Signal, + options: OrvalHttpResourceOptions & { + defaultValue: NoInfer; + }, +): HttpResourceRef; +export function showPetByIdResource( + petId: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef; +export function showPetByIdResource( + petId: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef { + const baseUrl = options?.injector + ? options.injector.get(PETSTORE_API_BASE_URL) + : inject(PETSTORE_API_BASE_URL); + return httpResource( + () => applyOrvalRequestExtension(`${baseUrl}/pets/${petId()}`, options), + options, + ); +} + +/** + * @experimental httpResource is experimental (Angular v19.2+) + */ +export function showPetWithOwnerResource( + petId: Signal, + options: OrvalHttpResourceOptions & { + defaultValue: NoInfer; + }, +): HttpResourceRef; +export function showPetWithOwnerResource( + petId: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef; +export function showPetWithOwnerResource( + petId: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef { + const baseUrl = options?.injector + ? options.injector.get(PETSTORE_API_BASE_URL) + : inject(PETSTORE_API_BASE_URL); + return httpResource( + () => + applyOrvalRequestExtension(`${baseUrl}/pets/${petId()}/owner`, options), + options, + ); +} + +export type ListPetsResourceResult = NonNullable; +export type ShowPetByIdResourceResult = NonNullable; +export type ShowPetWithOwnerResourceResult = NonNullable; + +/** + * Utility type for httpResource results with status tracking. + * Inspired by @angular-architects/ngrx-toolkit withResource pattern. + * + * Uses `globalThis.Error` to avoid collision with API model types named `Error`. + */ +export interface ResourceState { + readonly value: Signal; + readonly status: Signal; + readonly error: Signal; + readonly isLoading: Signal; + readonly hasValue: () => boolean; + readonly reload: () => boolean; +} + +/** + * Wraps an HttpResourceRef to expose a consistent ResourceState interface. + * Useful when integrating with NgRx SignalStore via withResource(). + */ +export function toResourceState(ref: HttpResourceRef): ResourceState { + return { + value: ref.value, + status: ref.status, + error: ref.error, + isLoading: ref.isLoading, + hasValue: () => ref.hasValue(), + reload: () => ref.reload(), + }; +} diff --git a/tests/__snapshots__/angular/base-url-token-both/pets/pets.service.ts b/tests/__snapshots__/angular/base-url-token-both/pets/pets.service.ts new file mode 100644 index 0000000000..51d8a554b2 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-both/pets/pets.service.ts @@ -0,0 +1,357 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import { + HttpClient, + HttpHeaders, + HttpResponse as AngularHttpResponse, +} from '@angular/common/http'; +import type { HttpContext, HttpEvent, HttpParams } from '@angular/common/http'; + +import { Injectable, inject } from '@angular/core'; + +import { Observable } from 'rxjs'; + +import { PETSTORE_API_BASE_URL } from '../endpoints.base-url'; + +import type { + CreatePetsBody, + CreatePetsParams, + ListPetsParams, + Pet, + PetWithTag, + Pets, +} from '../model'; + +interface HttpClientOptions { + readonly headers?: HttpHeaders | Record; + readonly context?: HttpContext; + readonly params?: + | HttpParams + | Record< + string, + string | number | boolean | Array + >; + readonly reportProgress?: boolean; + readonly withCredentials?: boolean; + readonly credentials?: RequestCredentials; + readonly keepalive?: boolean; + readonly priority?: RequestPriority; + readonly cache?: RequestCache; + readonly mode?: RequestMode; + readonly redirect?: RequestRedirect; + readonly referrer?: string; + readonly integrity?: string; + readonly referrerPolicy?: ReferrerPolicy; + readonly transferCache?: { includeHeaders?: string[] } | boolean; + readonly timeout?: number; +} + +type HttpClientBodyOptions = HttpClientOptions & { + readonly observe?: 'body'; +}; + +type HttpClientEventOptions = HttpClientOptions & { + readonly observe: 'events'; +}; + +type HttpClientResponseOptions = HttpClientOptions & { + readonly observe: 'response'; +}; + +type HttpClientObserveOptions = HttpClientOptions & { + readonly observe?: 'body' | 'events' | 'response'; +}; + +type AngularHttpParamValue = + | string + | number + | boolean + | Array; +type AngularHttpParamValueWithNullable = AngularHttpParamValue | null; + +function filterParams( + params: Record, + requiredNullableKeys?: ReadonlySet, + preserveRequiredNullables?: false, + passthroughKeys?: undefined, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet | undefined, + preserveRequiredNullables: true, + passthroughKeys?: undefined, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet | undefined, + preserveRequiredNullables: boolean | undefined, + passthroughKeys: ReadonlySet, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet = new Set(), + preserveRequiredNullables = false, + passthroughKeys: ReadonlySet = new Set(), +): Record { + const filteredParams: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (passthroughKeys.has(key)) { + if (value !== undefined) { + filteredParams[key] = value; + } + continue; + } + if (Array.isArray(value)) { + const filtered = value.filter( + (item) => + item != null && + (typeof item === 'string' || + typeof item === 'number' || + typeof item === 'boolean'), + ) as Array; + if (filtered.length) { + filteredParams[key] = filtered; + } + } else if (value === null && requiredNullableKeys.has(key)) { + // With a paramsSerializer (preserveRequiredNullables) the literal null + // is passed through for it to consume; without one, emit an empty + // string so the required key still reaches the wire as `?key=` + // instead of being silently dropped. See #3712. + filteredParams[key] = preserveRequiredNullables ? null : ''; + } else if ( + value != null && + (typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean') + ) { + filteredParams[key] = value; + } + } + return filteredParams; +} + +@Injectable({ providedIn: 'root' }) +export class PetsService { + private readonly http = inject(HttpClient); + private readonly baseUrl = inject(PETSTORE_API_BASE_URL); + /** + * @summary List all pets + */ + listPets( + params: ListPetsParams, + options?: HttpClientBodyOptions, + ): Observable; + listPets( + params: ListPetsParams, + options?: HttpClientEventOptions, + ): Observable>; + listPets( + params: ListPetsParams, + options?: HttpClientResponseOptions, + ): Observable>; + listPets( + params: ListPetsParams, + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + const filteredParams = filterParams( + { ...params, ...options?.params }, + new Set([]), + ); + + if (options?.observe === 'events') { + return this.http.get(`${this.baseUrl}/pets`, { + ...(options as Omit, 'observe'>), + observe: 'events', + params: filteredParams, + }); + } + + if (options?.observe === 'response') { + return this.http.get(`${this.baseUrl}/pets`, { + ...(options as Omit, 'observe'>), + observe: 'response', + params: filteredParams, + }); + } + + return this.http.get(`${this.baseUrl}/pets`, { + ...(options as Omit, 'observe'>), + observe: 'body', + params: filteredParams, + }); + } + /** + * @summary Create a pet + */ + createPets( + createPetsBody: CreatePetsBody, + params: CreatePetsParams, + options?: HttpClientBodyOptions, + ): Observable; + createPets( + createPetsBody: CreatePetsBody, + params: CreatePetsParams, + options?: HttpClientEventOptions, + ): Observable>; + createPets( + createPetsBody: CreatePetsBody, + params: CreatePetsParams, + options?: HttpClientResponseOptions, + ): Observable>; + createPets( + createPetsBody: CreatePetsBody, + params: CreatePetsParams, + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + const filteredParams = filterParams( + { ...params, ...options?.params }, + new Set([]), + ); + + if (options?.observe === 'events') { + return this.http.post(`${this.baseUrl}/pets`, createPetsBody, { + ...(options as Omit, 'observe'>), + observe: 'events', + params: filteredParams, + }); + } + + if (options?.observe === 'response') { + return this.http.post(`${this.baseUrl}/pets`, createPetsBody, { + ...(options as Omit, 'observe'>), + observe: 'response', + params: filteredParams, + }); + } + + return this.http.post(`${this.baseUrl}/pets`, createPetsBody, { + ...(options as Omit, 'observe'>), + observe: 'body', + params: filteredParams, + }); + } + /** + * @summary Info for a specific pet + */ + showPetById( + petId: string, + options?: HttpClientBodyOptions, + ): Observable; + showPetById( + petId: string, + options?: HttpClientEventOptions, + ): Observable>; + showPetById( + petId: string, + options?: HttpClientResponseOptions, + ): Observable>; + showPetById( + petId: string, + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.get(`${this.baseUrl}/pets/${petId}`, { + ...(options as Omit, 'observe'>), + observe: 'events', + }); + } + + if (options?.observe === 'response') { + return this.http.get(`${this.baseUrl}/pets/${petId}`, { + ...(options as Omit, 'observe'>), + observe: 'response', + }); + } + + return this.http.get(`${this.baseUrl}/pets/${petId}`, { + ...(options as Omit, 'observe'>), + observe: 'body', + }); + } + /** + * @summary Deletes a specific pet + */ + deletePetById( + petId: string, + options?: HttpClientBodyOptions, + ): Observable; + deletePetById( + petId: string, + options?: HttpClientEventOptions, + ): Observable>; + deletePetById( + petId: string, + options?: HttpClientResponseOptions, + ): Observable>; + deletePetById( + petId: string, + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.delete(`${this.baseUrl}/pets/${petId}`, { + ...(options as Omit, 'observe'>), + observe: 'events', + }); + } + + if (options?.observe === 'response') { + return this.http.delete(`${this.baseUrl}/pets/${petId}`, { + ...(options as Omit, 'observe'>), + observe: 'response', + }); + } + + return this.http.delete(`${this.baseUrl}/pets/${petId}`, { + ...(options as Omit, 'observe'>), + observe: 'body', + }); + } + /** + * @summary combinate nullable and $ref + */ + showPetWithOwner( + petId: string, + options?: HttpClientBodyOptions, + ): Observable; + showPetWithOwner( + petId: string, + options?: HttpClientEventOptions, + ): Observable>; + showPetWithOwner( + petId: string, + options?: HttpClientResponseOptions, + ): Observable>; + showPetWithOwner( + petId: string, + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.get(`${this.baseUrl}/pets/${petId}/owner`, { + ...(options as Omit, 'observe'>), + observe: 'events', + }); + } + + if (options?.observe === 'response') { + return this.http.get(`${this.baseUrl}/pets/${petId}/owner`, { + ...(options as Omit, 'observe'>), + observe: 'response', + }); + } + + return this.http.get(`${this.baseUrl}/pets/${petId}/owner`, { + ...(options as Omit, 'observe'>), + observe: 'body', + }); + } +} + +export type ListPetsClientResult = NonNullable; +export type CreatePetsClientResult = NonNullable; +export type ShowPetByIdClientResult = NonNullable; +export type DeletePetByIdClientResult = NonNullable; +export type ShowPetWithOwnerClientResult = NonNullable; diff --git a/tests/__snapshots__/angular/base-url-token-http-resource/endpoints.base-url.ts b/tests/__snapshots__/angular/base-url-token-http-resource/endpoints.base-url.ts new file mode 100644 index 0000000000..bc9f3127c4 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-http-resource/endpoints.base-url.ts @@ -0,0 +1,90 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import { InjectionToken, inject, type Provider } from '@angular/core'; + +/** + * Embedded fallback base URL for the `petstore-api` API, resolved at generation + * time from the OpenAPI specification's `servers` field (`''` when the + * specification has no servers). + */ +export const PETSTORE_API_SERVER_URL: string = 'http://petstore.swagger.io/v1'; + +/** + * Strips trailing slashes from a base URL. + * + * Generated routes always start with `/`, so normalizing here at the token + * boundary guarantees `${baseUrl}${route}` can never double or drop the + * separator between them, for either `HttpClient` services or `httpResource` + * functions. + */ +export function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/+$/, ''); +} + +/** Context passed to a `PetstoreApiBaseUrlResolver` when it is invoked. */ +export interface PetstoreApiBaseUrlResolverContext { + /** The explicit `apiId` configured via `override.angular.baseUrl`. */ + readonly apiId: 'petstore-api'; + /** The embedded fallback server URL (`PETSTORE_API_SERVER_URL`). */ + readonly serverUrl: string; +} + +/** Resolves the runtime base URL for the `petstore-api` API. */ +export type PetstoreApiBaseUrlResolver = ( + context: PetstoreApiBaseUrlResolverContext, +) => string; + +/** + * Injectable hook for resolving the `petstore-api` API's base URL at runtime + * (e.g. from a gateway route registry). Overridden via + * `providePetstoreApiBaseUrlResolver`; defaults to the embedded specification + * server URL. + */ +export const PETSTORE_API_BASE_URL_RESOLVER = + new InjectionToken( + 'PETSTORE_API_BASE_URL_RESOLVER', + { + providedIn: 'root', + factory: (): PetstoreApiBaseUrlResolver => (context) => context.serverUrl, + }, + ); + +/** + * Runtime base URL for the `petstore-api` API, composed via Angular DI. + * + * Precedence: a directly provided value (`providePetstoreApiBaseUrl`) wins + * outright; otherwise the `PETSTORE_API_BASE_URL_RESOLVER` resolver (default or + * provided via `providePetstoreApiBaseUrlResolver`) is invoked with the embedded + * `PETSTORE_API_SERVER_URL` fallback. The result is always normalized. + */ +export const PETSTORE_API_BASE_URL = new InjectionToken( + 'PETSTORE_API_BASE_URL', + { + providedIn: 'root', + factory: (): string => { + const resolver = inject(PETSTORE_API_BASE_URL_RESOLVER); + return normalizeBaseUrl( + resolver({ apiId: 'petstore-api', serverUrl: PETSTORE_API_SERVER_URL }), + ); + }, + }, +); + +/** Directly provides the `petstore-api` API's base URL, bypassing the resolver. */ +export function providePetstoreApiBaseUrl(baseUrl: string): Provider { + return { + provide: PETSTORE_API_BASE_URL, + useValue: normalizeBaseUrl(baseUrl), + }; +} + +/** Provides a custom resolver for the `petstore-api` API's base URL. */ +export function providePetstoreApiBaseUrlResolver( + resolver: PetstoreApiBaseUrlResolver, +): Provider { + return { provide: PETSTORE_API_BASE_URL_RESOLVER, useValue: resolver }; +} diff --git a/tests/__snapshots__/angular/base-url-token-http-resource/endpoints.ts b/tests/__snapshots__/angular/base-url-token-http-resource/endpoints.ts new file mode 100644 index 0000000000..0784b79251 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-http-resource/endpoints.ts @@ -0,0 +1,461 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import { + HttpClient, + HttpHeaders, + HttpResponse as AngularHttpResponse, + httpResource, +} from '@angular/common/http'; +import type { + HttpContext, + HttpEvent, + HttpParams, + HttpResourceOptions, + HttpResourceRef, + HttpResourceRequest, +} from '@angular/common/http'; + +import { Injectable, inject } from '@angular/core'; +import type { ResourceStatus, Signal } from '@angular/core'; + +import { Observable } from 'rxjs'; + +import { PETSTORE_API_BASE_URL } from './endpoints.base-url'; + +import type { + CreatePetsBody, + CreatePetsParams, + ListPetsParams, + Pet, + PetWithTag, + Pets, +} from './model'; + +export interface OrvalHttpResourceRequestExtension { + /** Extra headers merged over generated headers. Pass a function to read signals reactively. */ + headers?: + | HttpResourceRequest['headers'] + | (() => HttpResourceRequest['headers']); + /** Angular HttpContext forwarded to the underlying request. Pass a function to derive it reactively. */ + context?: HttpContext | (() => HttpContext); + /** Last-resort escape hatch: transform the final request descriptor. Runs inside the resource's reactive context. */ + request?: (request: HttpResourceRequest) => HttpResourceRequest; +} + +export type OrvalHttpResourceOptions< + TValue, + TRaw = unknown, + TOmitParse extends boolean = false, +> = (TOmitParse extends true + ? Omit, 'parse'> + : HttpResourceOptions) & + OrvalHttpResourceRequestExtension; + +function mergeOrvalResourceHeaders( + base: HttpResourceRequest['headers'], + extra: HttpResourceRequest['headers'], +): HttpResourceRequest['headers'] { + if (!base) return extra; + if (!extra) return base; + if (base instanceof HttpHeaders || extra instanceof HttpHeaders) { + const toHeaderValue = ( + value: string | readonly string[], + ): string | string[] => + Array.isArray(value) ? Array.from(value, String) : String(value); + let merged = + base instanceof HttpHeaders + ? base + : Object.entries(base).reduce( + (headers, [key, value]) => headers.set(key, toHeaderValue(value)), + new HttpHeaders(), + ); + const extraRecord = + extra instanceof HttpHeaders + ? extra.keys().reduce>((record, key) => { + const values = extra.getAll(key); + if (values) record[key] = values; + return record; + }, {}) + : extra; + for (const [key, value] of Object.entries(extraRecord)) { + merged = merged.set(key, toHeaderValue(value)); + } + return merged; + } + return { ...base, ...extra }; +} + +export function applyOrvalRequestExtension( + request: string | HttpResourceRequest, + options?: OrvalHttpResourceRequestExtension, +): HttpResourceRequest { + const base: HttpResourceRequest = + typeof request === 'string' ? { url: request } : request; + if ( + !options || + (options.headers === undefined && + options.context === undefined && + options.request === undefined) + ) { + return base; + } + let next: HttpResourceRequest = { ...base }; + const extraHeaders = + typeof options.headers === 'function' ? options.headers() : options.headers; + if (extraHeaders !== undefined) { + next = { + ...next, + headers: mergeOrvalResourceHeaders(next.headers, extraHeaders), + }; + } + const context = + typeof options.context === 'function' ? options.context() : options.context; + if (context !== undefined) { + next = { ...next, context }; + } + return options.request ? options.request(next) : next; +} + +type AngularHttpParamValue = + | string + | number + | boolean + | Array; +type AngularHttpParamValueWithNullable = AngularHttpParamValue | null; + +function filterParams( + params: Record, + requiredNullableKeys?: ReadonlySet, + preserveRequiredNullables?: false, + passthroughKeys?: undefined, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet | undefined, + preserveRequiredNullables: true, + passthroughKeys?: undefined, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet | undefined, + preserveRequiredNullables: boolean | undefined, + passthroughKeys: ReadonlySet, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet = new Set(), + preserveRequiredNullables = false, + passthroughKeys: ReadonlySet = new Set(), +): Record { + const filteredParams: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (passthroughKeys.has(key)) { + if (value !== undefined) { + filteredParams[key] = value; + } + continue; + } + if (Array.isArray(value)) { + const filtered = value.filter( + (item) => + item != null && + (typeof item === 'string' || + typeof item === 'number' || + typeof item === 'boolean'), + ) as Array; + if (filtered.length) { + filteredParams[key] = filtered; + } + } else if (value === null && requiredNullableKeys.has(key)) { + // With a paramsSerializer (preserveRequiredNullables) the literal null + // is passed through for it to consume; without one, emit an empty + // string so the required key still reaches the wire as `?key=` + // instead of being silently dropped. See #3712. + filteredParams[key] = preserveRequiredNullables ? null : ''; + } else if ( + value != null && + (typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean') + ) { + filteredParams[key] = value; + } + } + return filteredParams; +} +/** + * @experimental httpResource is experimental (Angular v19.2+) + */ +export function listPetsResource( + params: Signal, + options: OrvalHttpResourceOptions & { + defaultValue: NoInfer; + }, +): HttpResourceRef; +export function listPetsResource( + params: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef; +export function listPetsResource( + params: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef { + const baseUrl = options?.injector + ? options.injector.get(PETSTORE_API_BASE_URL) + : inject(PETSTORE_API_BASE_URL); + return httpResource(() => { + const request = { + url: `${baseUrl}/pets`, + params: filterParams(params?.() ?? {}, new Set([])), + }; + return applyOrvalRequestExtension(request, options); + }, options); +} + +/** + * @experimental httpResource is experimental (Angular v19.2+) + */ +export function showPetByIdResource( + petId: Signal, + options: OrvalHttpResourceOptions & { + defaultValue: NoInfer; + }, +): HttpResourceRef; +export function showPetByIdResource( + petId: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef; +export function showPetByIdResource( + petId: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef { + const baseUrl = options?.injector + ? options.injector.get(PETSTORE_API_BASE_URL) + : inject(PETSTORE_API_BASE_URL); + return httpResource( + () => applyOrvalRequestExtension(`${baseUrl}/pets/${petId()}`, options), + options, + ); +} + +/** + * @experimental httpResource is experimental (Angular v19.2+) + */ +export function healthCheckResource( + options: OrvalHttpResourceOptions & { + defaultValue: NoInfer; + }, +): HttpResourceRef; +export function healthCheckResource( + options?: OrvalHttpResourceOptions, +): HttpResourceRef; +export function healthCheckResource( + options?: OrvalHttpResourceOptions, +): HttpResourceRef { + const baseUrl = options?.injector + ? options.injector.get(PETSTORE_API_BASE_URL) + : inject(PETSTORE_API_BASE_URL); + return httpResource.text( + () => applyOrvalRequestExtension(`${baseUrl}/health`, options), + options, + ); +} + +/** + * @experimental httpResource is experimental (Angular v19.2+) + */ +export function showPetWithOwnerResource( + petId: Signal, + options: OrvalHttpResourceOptions & { + defaultValue: NoInfer; + }, +): HttpResourceRef; +export function showPetWithOwnerResource( + petId: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef; +export function showPetWithOwnerResource( + petId: Signal, + options?: OrvalHttpResourceOptions, +): HttpResourceRef { + const baseUrl = options?.injector + ? options.injector.get(PETSTORE_API_BASE_URL) + : inject(PETSTORE_API_BASE_URL); + return httpResource( + () => + applyOrvalRequestExtension(`${baseUrl}/pets/${petId()}/owner`, options), + options, + ); +} + +interface HttpClientOptions { + readonly headers?: HttpHeaders | Record; + readonly context?: HttpContext; + readonly params?: + | HttpParams + | Record< + string, + string | number | boolean | Array + >; + readonly reportProgress?: boolean; + readonly withCredentials?: boolean; + readonly credentials?: RequestCredentials; + readonly keepalive?: boolean; + readonly priority?: RequestPriority; + readonly cache?: RequestCache; + readonly mode?: RequestMode; + readonly redirect?: RequestRedirect; + readonly referrer?: string; + readonly integrity?: string; + readonly referrerPolicy?: ReferrerPolicy; + readonly transferCache?: { includeHeaders?: string[] } | boolean; + readonly timeout?: number; +} + +type HttpClientBodyOptions = HttpClientOptions & { + readonly observe?: 'body'; +}; + +type HttpClientEventOptions = HttpClientOptions & { + readonly observe: 'events'; +}; + +type HttpClientResponseOptions = HttpClientOptions & { + readonly observe: 'response'; +}; + +type HttpClientObserveOptions = HttpClientOptions & { + readonly observe?: 'body' | 'events' | 'response'; +}; + +@Injectable({ providedIn: 'root' }) +export class SwaggerPetstoreService { + private readonly http = inject(HttpClient); + private readonly baseUrl = inject(PETSTORE_API_BASE_URL); + + createPets( + createPetsBody: CreatePetsBody, + params: CreatePetsParams, + options?: HttpClientBodyOptions, + ): Observable; + createPets( + createPetsBody: CreatePetsBody, + params: CreatePetsParams, + options?: HttpClientEventOptions, + ): Observable>; + createPets( + createPetsBody: CreatePetsBody, + params: CreatePetsParams, + options?: HttpClientResponseOptions, + ): Observable>; + createPets( + createPetsBody: CreatePetsBody, + params: CreatePetsParams, + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + const filteredParams = filterParams( + { ...params, ...options?.params }, + new Set([]), + ); + + if (options?.observe === 'events') { + return this.http.post(`${this.baseUrl}/pets`, createPetsBody, { + ...(options as Omit, 'observe'>), + observe: 'events', + params: filteredParams, + }); + } + + if (options?.observe === 'response') { + return this.http.post(`${this.baseUrl}/pets`, createPetsBody, { + ...(options as Omit, 'observe'>), + observe: 'response', + params: filteredParams, + }); + } + + return this.http.post(`${this.baseUrl}/pets`, createPetsBody, { + ...(options as Omit, 'observe'>), + observe: 'body', + params: filteredParams, + }); + } + + deletePetById( + petId: string, + options?: HttpClientBodyOptions, + ): Observable; + deletePetById( + petId: string, + options?: HttpClientEventOptions, + ): Observable>; + deletePetById( + petId: string, + options?: HttpClientResponseOptions, + ): Observable>; + deletePetById( + petId: string, + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.delete(`${this.baseUrl}/pets/${petId}`, { + ...(options as Omit, 'observe'>), + observe: 'events', + }); + } + + if (options?.observe === 'response') { + return this.http.delete(`${this.baseUrl}/pets/${petId}`, { + ...(options as Omit, 'observe'>), + observe: 'response', + }); + } + + return this.http.delete(`${this.baseUrl}/pets/${petId}`, { + ...(options as Omit, 'observe'>), + observe: 'body', + }); + } +} + +export type ListPetsResourceResult = NonNullable; +export type ShowPetByIdResourceResult = NonNullable; +export type HealthCheckResourceResult = NonNullable; +export type ShowPetWithOwnerResourceResult = NonNullable; + +export type CreatePetsClientResult = NonNullable; +export type DeletePetByIdClientResult = NonNullable; + +/** + * Utility type for httpResource results with status tracking. + * Inspired by @angular-architects/ngrx-toolkit withResource pattern. + * + * Uses `globalThis.Error` to avoid collision with API model types named `Error`. + */ +export interface ResourceState { + readonly value: Signal; + readonly status: Signal; + readonly error: Signal; + readonly isLoading: Signal; + readonly hasValue: () => boolean; + readonly reload: () => boolean; +} + +/** + * Wraps an HttpResourceRef to expose a consistent ResourceState interface. + * Useful when integrating with NgRx SignalStore via withResource(). + */ +export function toResourceState(ref: HttpResourceRef): ResourceState { + return { + value: ref.value, + status: ref.status, + error: ref.error, + isLoading: ref.isLoading, + hasValue: () => ref.hasValue(), + reload: () => ref.reload(), + }; +} diff --git a/tests/__snapshots__/angular/base-url-token-http-resource/model/cat.ts b/tests/__snapshots__/angular/base-url-token-http-resource/model/cat.ts new file mode 100644 index 0000000000..2e583f1c7f --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-http-resource/model/cat.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { CatType } from './catType'; + +export interface Cat { + petsRequested?: number; + type: CatType; +} diff --git a/tests/__snapshots__/angular/base-url-token-http-resource/model/catType.ts b/tests/__snapshots__/angular/base-url-token-http-resource/model/catType.ts new file mode 100644 index 0000000000..8bfea55ae3 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-http-resource/model/catType.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type CatType = (typeof CatType)[keyof typeof CatType]; + +export const CatType = { + cat: 'cat', +} as const; diff --git a/tests/__snapshots__/angular/base-url-token-http-resource/model/createPetsBody.ts b/tests/__snapshots__/angular/base-url-token-http-resource/model/createPetsBody.ts new file mode 100644 index 0000000000..0365da24c7 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-http-resource/model/createPetsBody.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type CreatePetsBody = { + name: string; + tag: string; +}; diff --git a/tests/__snapshots__/angular/base-url-token-http-resource/model/createPetsParams.ts b/tests/__snapshots__/angular/base-url-token-http-resource/model/createPetsParams.ts new file mode 100644 index 0000000000..daf5e2d6f4 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-http-resource/model/createPetsParams.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { CreatePetsSort } from './createPetsSort'; + +export type CreatePetsParams = { + /** + * How many items to return at one time (max 100) + */ + limit?: string; + /** + * Which property to sort by? + * Example: name sorts ASC while -name sorts DESC. + */ + sort: CreatePetsSort; +}; diff --git a/tests/__snapshots__/angular/base-url-token-http-resource/model/createPetsSort.ts b/tests/__snapshots__/angular/base-url-token-http-resource/model/createPetsSort.ts new file mode 100644 index 0000000000..d7cb2b867a --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-http-resource/model/createPetsSort.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type CreatePetsSort = + (typeof CreatePetsSort)[keyof typeof CreatePetsSort]; + +export const CreatePetsSort = { + name: 'name', + '-name': '-name', + email: 'email', + '-email': '-email', +} as const; diff --git a/tests/__snapshots__/angular/base-url-token-http-resource/model/dachshund.ts b/tests/__snapshots__/angular/base-url-token-http-resource/model/dachshund.ts new file mode 100644 index 0000000000..1b834d647d --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-http-resource/model/dachshund.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { DachshundBreed } from './dachshundBreed'; + +export interface Dachshund { + length: number; + breed: DachshundBreed; +} diff --git a/tests/__snapshots__/angular/base-url-token-http-resource/model/dachshundBreed.ts b/tests/__snapshots__/angular/base-url-token-http-resource/model/dachshundBreed.ts new file mode 100644 index 0000000000..2c0d62ae5a --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-http-resource/model/dachshundBreed.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type DachshundBreed = + (typeof DachshundBreed)[keyof typeof DachshundBreed]; + +export const DachshundBreed = { + Dachshund: 'Dachshund', +} as const; diff --git a/tests/__snapshots__/angular/base-url-token-http-resource/model/dog.ts b/tests/__snapshots__/angular/base-url-token-http-resource/model/dog.ts new file mode 100644 index 0000000000..847b6d3903 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-http-resource/model/dog.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { Dachshund } from './dachshund'; +import type { DogType } from './dogType'; +import type { Labradoodle } from './labradoodle'; + +export type Dog = + | (Labradoodle & { + barksPerMinute?: number; + type: DogType; + }) + | (Dachshund & { + barksPerMinute?: number; + type: DogType; + }); diff --git a/tests/__snapshots__/angular/base-url-token-http-resource/model/dogType.ts b/tests/__snapshots__/angular/base-url-token-http-resource/model/dogType.ts new file mode 100644 index 0000000000..fabdd40e3e --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-http-resource/model/dogType.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type DogType = (typeof DogType)[keyof typeof DogType]; + +export const DogType = { + dog: 'dog', +} as const; diff --git a/tests/__snapshots__/angular/base-url-token-http-resource/model/error.ts b/tests/__snapshots__/angular/base-url-token-http-resource/model/error.ts new file mode 100644 index 0000000000..b4168d9717 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-http-resource/model/error.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export interface Error { + code: number; + message: string; +} diff --git a/tests/__snapshots__/angular/base-url-token-http-resource/model/index.ts b/tests/__snapshots__/angular/base-url-token-http-resource/model/index.ts new file mode 100644 index 0000000000..bec5f9fd03 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-http-resource/model/index.ts @@ -0,0 +1,26 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export * from './cat'; +export * from './catType'; +export * from './createPetsBody'; +export * from './createPetsParams'; +export * from './createPetsSort'; +export * from './dachshund'; +export * from './dachshundBreed'; +export * from './dog'; +export * from './dogType'; +export * from './error'; +export * from './labradoodle'; +export * from './labradoodleBreed'; +export * from './listPetsParams'; +export * from './listPetsSort'; +export * from './pet'; +export * from './petCallingCode'; +export * from './petCountry'; +export * from './pets'; +export * from './petWithTag'; diff --git a/tests/__snapshots__/angular/base-url-token-http-resource/model/labradoodle.ts b/tests/__snapshots__/angular/base-url-token-http-resource/model/labradoodle.ts new file mode 100644 index 0000000000..2bb4217d0a --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-http-resource/model/labradoodle.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { LabradoodleBreed } from './labradoodleBreed'; + +export interface Labradoodle { + cuteness: number; + breed: LabradoodleBreed; +} diff --git a/tests/__snapshots__/angular/base-url-token-http-resource/model/labradoodleBreed.ts b/tests/__snapshots__/angular/base-url-token-http-resource/model/labradoodleBreed.ts new file mode 100644 index 0000000000..7d953cea51 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-http-resource/model/labradoodleBreed.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type LabradoodleBreed = + (typeof LabradoodleBreed)[keyof typeof LabradoodleBreed]; + +export const LabradoodleBreed = { + Labradoodle: 'Labradoodle', +} as const; diff --git a/tests/__snapshots__/angular/base-url-token-http-resource/model/listPetsParams.ts b/tests/__snapshots__/angular/base-url-token-http-resource/model/listPetsParams.ts new file mode 100644 index 0000000000..02ceb51e58 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-http-resource/model/listPetsParams.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { ListPetsSort } from './listPetsSort'; + +export type ListPetsParams = { + /** + * How many items to return at one time (max 100) + */ + limit?: string; + /** + * Which property to sort by? + * Example: name sorts ASC while -name sorts DESC. + */ + sort: ListPetsSort; +}; diff --git a/tests/__snapshots__/angular/base-url-token-http-resource/model/listPetsSort.ts b/tests/__snapshots__/angular/base-url-token-http-resource/model/listPetsSort.ts new file mode 100644 index 0000000000..68f6176379 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-http-resource/model/listPetsSort.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type ListPetsSort = (typeof ListPetsSort)[keyof typeof ListPetsSort]; + +export const ListPetsSort = { + name: 'name', + '-name': '-name', + email: 'email', + '-email': '-email', +} as const; diff --git a/tests/__snapshots__/angular/base-url-token-http-resource/model/pet.ts b/tests/__snapshots__/angular/base-url-token-http-resource/model/pet.ts new file mode 100644 index 0000000000..61da13a024 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-http-resource/model/pet.ts @@ -0,0 +1,30 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { Cat } from './cat'; +import type { Dog } from './dog'; +import type { PetCallingCode } from './petCallingCode'; +import type { PetCountry } from './petCountry'; + +export type Pet = + | (Dog & { + '@id'?: string; + id: number; + name: string; + tag?: string; + email?: string; + callingCode?: PetCallingCode; + country?: PetCountry; + }) + | (Cat & { + '@id'?: string; + id: number; + name: string; + tag?: string; + email?: string; + callingCode?: PetCallingCode; + country?: PetCountry; + }); diff --git a/tests/__snapshots__/angular/base-url-token-http-resource/model/petCallingCode.ts b/tests/__snapshots__/angular/base-url-token-http-resource/model/petCallingCode.ts new file mode 100644 index 0000000000..0fec3eddea --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-http-resource/model/petCallingCode.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type PetCallingCode = + (typeof PetCallingCode)[keyof typeof PetCallingCode]; + +export const PetCallingCode = { + '+33': '+33', + '+420': '+420', +} as const; diff --git a/tests/__snapshots__/angular/base-url-token-http-resource/model/petCountry.ts b/tests/__snapshots__/angular/base-url-token-http-resource/model/petCountry.ts new file mode 100644 index 0000000000..57424fd0e5 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-http-resource/model/petCountry.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type PetCountry = (typeof PetCountry)[keyof typeof PetCountry]; + +export const PetCountry = { + "People's_Republic_of_China": "People's Republic of China", + Uruguay: 'Uruguay', +} as const; diff --git a/tests/__snapshots__/angular/base-url-token-http-resource/model/petWithTag.ts b/tests/__snapshots__/angular/base-url-token-http-resource/model/petWithTag.ts new file mode 100644 index 0000000000..76d8ca5089 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-http-resource/model/petWithTag.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { Pet } from './pet'; + +export interface PetWithTag { + tag: string; + pet: Pet | null; +} diff --git a/tests/__snapshots__/angular/base-url-token-http-resource/model/pets.ts b/tests/__snapshots__/angular/base-url-token-http-resource/model/pets.ts new file mode 100644 index 0000000000..b2fd3d3288 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-http-resource/model/pets.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { Pet } from './pet'; + +export type Pets = Pet[]; diff --git a/tests/__snapshots__/angular/base-url-token-zod/endpoints.base-url.ts b/tests/__snapshots__/angular/base-url-token-zod/endpoints.base-url.ts new file mode 100644 index 0000000000..bc9f3127c4 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-zod/endpoints.base-url.ts @@ -0,0 +1,90 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import { InjectionToken, inject, type Provider } from '@angular/core'; + +/** + * Embedded fallback base URL for the `petstore-api` API, resolved at generation + * time from the OpenAPI specification's `servers` field (`''` when the + * specification has no servers). + */ +export const PETSTORE_API_SERVER_URL: string = 'http://petstore.swagger.io/v1'; + +/** + * Strips trailing slashes from a base URL. + * + * Generated routes always start with `/`, so normalizing here at the token + * boundary guarantees `${baseUrl}${route}` can never double or drop the + * separator between them, for either `HttpClient` services or `httpResource` + * functions. + */ +export function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/+$/, ''); +} + +/** Context passed to a `PetstoreApiBaseUrlResolver` when it is invoked. */ +export interface PetstoreApiBaseUrlResolverContext { + /** The explicit `apiId` configured via `override.angular.baseUrl`. */ + readonly apiId: 'petstore-api'; + /** The embedded fallback server URL (`PETSTORE_API_SERVER_URL`). */ + readonly serverUrl: string; +} + +/** Resolves the runtime base URL for the `petstore-api` API. */ +export type PetstoreApiBaseUrlResolver = ( + context: PetstoreApiBaseUrlResolverContext, +) => string; + +/** + * Injectable hook for resolving the `petstore-api` API's base URL at runtime + * (e.g. from a gateway route registry). Overridden via + * `providePetstoreApiBaseUrlResolver`; defaults to the embedded specification + * server URL. + */ +export const PETSTORE_API_BASE_URL_RESOLVER = + new InjectionToken( + 'PETSTORE_API_BASE_URL_RESOLVER', + { + providedIn: 'root', + factory: (): PetstoreApiBaseUrlResolver => (context) => context.serverUrl, + }, + ); + +/** + * Runtime base URL for the `petstore-api` API, composed via Angular DI. + * + * Precedence: a directly provided value (`providePetstoreApiBaseUrl`) wins + * outright; otherwise the `PETSTORE_API_BASE_URL_RESOLVER` resolver (default or + * provided via `providePetstoreApiBaseUrlResolver`) is invoked with the embedded + * `PETSTORE_API_SERVER_URL` fallback. The result is always normalized. + */ +export const PETSTORE_API_BASE_URL = new InjectionToken( + 'PETSTORE_API_BASE_URL', + { + providedIn: 'root', + factory: (): string => { + const resolver = inject(PETSTORE_API_BASE_URL_RESOLVER); + return normalizeBaseUrl( + resolver({ apiId: 'petstore-api', serverUrl: PETSTORE_API_SERVER_URL }), + ); + }, + }, +); + +/** Directly provides the `petstore-api` API's base URL, bypassing the resolver. */ +export function providePetstoreApiBaseUrl(baseUrl: string): Provider { + return { + provide: PETSTORE_API_BASE_URL, + useValue: normalizeBaseUrl(baseUrl), + }; +} + +/** Provides a custom resolver for the `petstore-api` API's base URL. */ +export function providePetstoreApiBaseUrlResolver( + resolver: PetstoreApiBaseUrlResolver, +): Provider { + return { provide: PETSTORE_API_BASE_URL_RESOLVER, useValue: resolver }; +} diff --git a/tests/__snapshots__/angular/base-url-token-zod/endpoints.ts b/tests/__snapshots__/angular/base-url-token-zod/endpoints.ts new file mode 100644 index 0000000000..d838c7c09d --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-zod/endpoints.ts @@ -0,0 +1,462 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import { + HttpClient, + HttpHeaders, + HttpResponse as AngularHttpResponse, +} from '@angular/common/http'; +import type { HttpContext, HttpEvent, HttpParams } from '@angular/common/http'; + +import { Injectable, inject } from '@angular/core'; + +import { Observable } from 'rxjs'; + +import { Pet, PetWithTag, Pets } from './model'; +import type { + CreatePetsBody, + CreatePetsParams, + ListPetsParams, + PetOutput, + PetWithTagOutput, + PetsOutput, +} from './model'; + +import { map } from 'rxjs'; + +import { PETSTORE_API_BASE_URL } from './endpoints.base-url'; + +interface HttpClientOptions { + readonly headers?: HttpHeaders | Record; + readonly context?: HttpContext; + readonly params?: + | HttpParams + | Record< + string, + string | number | boolean | Array + >; + readonly reportProgress?: boolean; + readonly withCredentials?: boolean; + readonly credentials?: RequestCredentials; + readonly keepalive?: boolean; + readonly priority?: RequestPriority; + readonly cache?: RequestCache; + readonly mode?: RequestMode; + readonly redirect?: RequestRedirect; + readonly referrer?: string; + readonly integrity?: string; + readonly referrerPolicy?: ReferrerPolicy; + readonly transferCache?: { includeHeaders?: string[] } | boolean; + readonly timeout?: number; +} + +type HttpClientBodyOptions = HttpClientOptions & { + readonly observe?: 'body'; +}; + +type HttpClientEventOptions = HttpClientOptions & { + readonly observe: 'events'; +}; + +type HttpClientResponseOptions = HttpClientOptions & { + readonly observe: 'response'; +}; + +type HttpClientObserveOptions = HttpClientOptions & { + readonly observe?: 'body' | 'events' | 'response'; +}; + +type AngularHttpParamValue = + | string + | number + | boolean + | Array; +type AngularHttpParamValueWithNullable = AngularHttpParamValue | null; + +function filterParams( + params: Record, + requiredNullableKeys?: ReadonlySet, + preserveRequiredNullables?: false, + passthroughKeys?: undefined, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet | undefined, + preserveRequiredNullables: true, + passthroughKeys?: undefined, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet | undefined, + preserveRequiredNullables: boolean | undefined, + passthroughKeys: ReadonlySet, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet = new Set(), + preserveRequiredNullables = false, + passthroughKeys: ReadonlySet = new Set(), +): Record { + const filteredParams: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (passthroughKeys.has(key)) { + if (value !== undefined) { + filteredParams[key] = value; + } + continue; + } + if (Array.isArray(value)) { + const filtered = value.filter( + (item) => + item != null && + (typeof item === 'string' || + typeof item === 'number' || + typeof item === 'boolean'), + ) as Array; + if (filtered.length) { + filteredParams[key] = filtered; + } + } else if (value === null && requiredNullableKeys.has(key)) { + // With a paramsSerializer (preserveRequiredNullables) the literal null + // is passed through for it to consume; without one, emit an empty + // string so the required key still reaches the wire as `?key=` + // instead of being silently dropped. See #3712. + filteredParams[key] = preserveRequiredNullables ? null : ''; + } else if ( + value != null && + (typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean') + ) { + filteredParams[key] = value; + } + } + return filteredParams; +} + +@Injectable({ providedIn: 'root' }) +export class SwaggerPetstoreService { + private readonly http = inject(HttpClient); + private readonly baseUrl = inject(PETSTORE_API_BASE_URL); + /** + * @summary List all pets + */ + listPets( + params: ListPetsParams, + options?: HttpClientBodyOptions, + ): Observable; + listPets( + params: ListPetsParams, + options?: HttpClientEventOptions, + ): Observable>; + listPets( + params: ListPetsParams, + options?: HttpClientResponseOptions, + ): Observable>; + listPets( + params: ListPetsParams, + options?: HttpClientObserveOptions, + ): Observable< + PetsOutput | HttpEvent | AngularHttpResponse + > { + const filteredParams = filterParams( + { ...params, ...options?.params }, + new Set([]), + ); + + if (options?.observe === 'events') { + return this.http + .get(`${this.baseUrl}/pets`, { + ...(options as Omit, 'observe'>), + observe: 'events', + params: filteredParams, + }) + .pipe( + map((event) => + event instanceof AngularHttpResponse + ? event.clone({ body: Pets.parse(event.body) }) + : event, + ), + ); + } + + if (options?.observe === 'response') { + return this.http + .get(`${this.baseUrl}/pets`, { + ...(options as Omit, 'observe'>), + observe: 'response', + params: filteredParams, + }) + .pipe( + map((response) => + response.clone({ body: Pets.parse(response.body) }), + ), + ); + } + + return this.http + .get(`${this.baseUrl}/pets`, { + ...(options as Omit, 'observe'>), + observe: 'body', + params: filteredParams, + }) + .pipe(map((data) => Pets.parse(data))); + } + + /** + * @summary Create a pet + */ + createPets( + createPetsBody: CreatePetsBody, + params: CreatePetsParams, + options?: HttpClientBodyOptions, + ): Observable; + createPets( + createPetsBody: CreatePetsBody, + params: CreatePetsParams, + options?: HttpClientEventOptions, + ): Observable>; + createPets( + createPetsBody: CreatePetsBody, + params: CreatePetsParams, + options?: HttpClientResponseOptions, + ): Observable>; + createPets( + createPetsBody: CreatePetsBody, + params: CreatePetsParams, + options?: HttpClientObserveOptions, + ): Observable< + PetOutput | HttpEvent | AngularHttpResponse + > { + const filteredParams = filterParams( + { ...params, ...options?.params }, + new Set([]), + ); + + if (options?.observe === 'events') { + return this.http + .post(`${this.baseUrl}/pets`, createPetsBody, { + ...(options as Omit, 'observe'>), + observe: 'events', + params: filteredParams, + }) + .pipe( + map((event) => + event instanceof AngularHttpResponse + ? event.clone({ body: Pet.parse(event.body) }) + : event, + ), + ); + } + + if (options?.observe === 'response') { + return this.http + .post(`${this.baseUrl}/pets`, createPetsBody, { + ...(options as Omit, 'observe'>), + observe: 'response', + params: filteredParams, + }) + .pipe( + map((response) => response.clone({ body: Pet.parse(response.body) })), + ); + } + + return this.http + .post(`${this.baseUrl}/pets`, createPetsBody, { + ...(options as Omit, 'observe'>), + observe: 'body', + params: filteredParams, + }) + .pipe(map((data) => Pet.parse(data))); + } + + /** + * @summary Info for a specific pet + */ + showPetById( + petId: string, + options?: HttpClientBodyOptions, + ): Observable; + showPetById( + petId: string, + options?: HttpClientEventOptions, + ): Observable>; + showPetById( + petId: string, + options?: HttpClientResponseOptions, + ): Observable>; + showPetById( + petId: string, + options?: HttpClientObserveOptions, + ): Observable< + PetOutput | HttpEvent | AngularHttpResponse + > { + if (options?.observe === 'events') { + return this.http + .get(`${this.baseUrl}/pets/${petId}`, { + ...(options as Omit, 'observe'>), + observe: 'events', + }) + .pipe( + map((event) => + event instanceof AngularHttpResponse + ? event.clone({ body: Pet.parse(event.body) }) + : event, + ), + ); + } + + if (options?.observe === 'response') { + return this.http + .get(`${this.baseUrl}/pets/${petId}`, { + ...(options as Omit, 'observe'>), + observe: 'response', + }) + .pipe( + map((response) => response.clone({ body: Pet.parse(response.body) })), + ); + } + + return this.http + .get(`${this.baseUrl}/pets/${petId}`, { + ...(options as Omit, 'observe'>), + observe: 'body', + }) + .pipe(map((data) => Pet.parse(data))); + } + + /** + * @summary Deletes a specific pet + */ + deletePetById( + petId: string, + options?: HttpClientBodyOptions, + ): Observable; + deletePetById( + petId: string, + options?: HttpClientEventOptions, + ): Observable>; + deletePetById( + petId: string, + options?: HttpClientResponseOptions, + ): Observable>; + deletePetById( + petId: string, + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.delete(`${this.baseUrl}/pets/${petId}`, { + ...(options as Omit, 'observe'>), + observe: 'events', + }); + } + + if (options?.observe === 'response') { + return this.http.delete(`${this.baseUrl}/pets/${petId}`, { + ...(options as Omit, 'observe'>), + observe: 'response', + }); + } + + return this.http.delete(`${this.baseUrl}/pets/${petId}`, { + ...(options as Omit, 'observe'>), + observe: 'body', + }); + } + + /** + * @summary health check + */ + healthCheck(options?: HttpClientBodyOptions): Observable; + healthCheck(options?: HttpClientEventOptions): Observable>; + healthCheck( + options?: HttpClientResponseOptions, + ): Observable>; + healthCheck( + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.get(`${this.baseUrl}/health`, { + responseType: 'text', + ...(options as Omit, 'observe'>), + observe: 'events', + }) as Observable>; + } + + if (options?.observe === 'response') { + return this.http.get(`${this.baseUrl}/health`, { + responseType: 'text', + ...(options as Omit, 'observe'>), + observe: 'response', + }) as Observable>; + } + + return this.http.get(`${this.baseUrl}/health`, { + responseType: 'text', + ...(options as Omit, 'observe'>), + observe: 'body', + }) as Observable; + } + + /** + * @summary combinate nullable and $ref + */ + showPetWithOwner( + petId: string, + options?: HttpClientBodyOptions, + ): Observable; + showPetWithOwner( + petId: string, + options?: HttpClientEventOptions, + ): Observable>; + showPetWithOwner( + petId: string, + options?: HttpClientResponseOptions, + ): Observable>; + showPetWithOwner( + petId: string, + options?: HttpClientObserveOptions, + ): Observable< + | PetWithTagOutput + | HttpEvent + | AngularHttpResponse + > { + if (options?.observe === 'events') { + return this.http + .get(`${this.baseUrl}/pets/${petId}/owner`, { + ...(options as Omit, 'observe'>), + observe: 'events', + }) + .pipe( + map((event) => + event instanceof AngularHttpResponse + ? event.clone({ body: PetWithTag.parse(event.body) }) + : event, + ), + ); + } + + if (options?.observe === 'response') { + return this.http + .get(`${this.baseUrl}/pets/${petId}/owner`, { + ...(options as Omit, 'observe'>), + observe: 'response', + }) + .pipe( + map((response) => + response.clone({ body: PetWithTag.parse(response.body) }), + ), + ); + } + + return this.http + .get(`${this.baseUrl}/pets/${petId}/owner`, { + ...(options as Omit, 'observe'>), + observe: 'body', + }) + .pipe(map((data) => PetWithTag.parse(data))); + } +} diff --git a/tests/__snapshots__/angular/base-url-token-zod/model/cat.zod.ts b/tests/__snapshots__/angular/base-url-token-zod/model/cat.zod.ts new file mode 100644 index 0000000000..d024f2e9a9 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-zod/model/cat.zod.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import * as zod from 'zod'; + +export const Cat = zod.object({ + petsRequested: zod.int().optional(), + type: zod.enum(['cat']), +}); + +export type Cat = zod.input; +export type CatOutput = zod.output; diff --git a/tests/__snapshots__/angular/base-url-token-zod/model/createPetsBody.zod.ts b/tests/__snapshots__/angular/base-url-token-zod/model/createPetsBody.zod.ts new file mode 100644 index 0000000000..ad87abdd9a --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-zod/model/createPetsBody.zod.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import * as zod from 'zod'; + +export const CreatePetsBody = zod.object({ + name: zod.string(), + tag: zod.string(), +}); + +export type CreatePetsBody = zod.input; +export type CreatePetsBodyOutput = zod.output; diff --git a/tests/__snapshots__/angular/base-url-token-zod/model/createPetsHeaders.zod.ts b/tests/__snapshots__/angular/base-url-token-zod/model/createPetsHeaders.zod.ts new file mode 100644 index 0000000000..bac51b178c --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-zod/model/createPetsHeaders.zod.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import * as zod from 'zod'; + +export const CreatePetsHeaders = zod.object({ + 'X-EXAMPLE': zod.enum(['ONE', 'TWO', 'THREE']), +}); + +export type CreatePetsHeaders = zod.input; +export type CreatePetsHeadersOutput = zod.output; diff --git a/tests/__snapshots__/angular/base-url-token-zod/model/createPetsParams.zod.ts b/tests/__snapshots__/angular/base-url-token-zod/model/createPetsParams.zod.ts new file mode 100644 index 0000000000..b4253aa56a --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-zod/model/createPetsParams.zod.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import * as zod from 'zod'; + +export const CreatePetsParams = zod.object({ + limit: zod.string().optional(), + sort: zod.enum(['name', '-name', 'email', '-email']), +}); + +export type CreatePetsParams = zod.input; +export type CreatePetsParamsOutput = zod.output; diff --git a/tests/__snapshots__/angular/base-url-token-zod/model/dachshund.zod.ts b/tests/__snapshots__/angular/base-url-token-zod/model/dachshund.zod.ts new file mode 100644 index 0000000000..2e6ae59a25 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-zod/model/dachshund.zod.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import * as zod from 'zod'; + +export const Dachshund = zod.object({ + length: zod.int(), + breed: zod.enum(['Dachshund']), +}); + +export type Dachshund = zod.input; +export type DachshundOutput = zod.output; diff --git a/tests/__snapshots__/angular/base-url-token-zod/model/dog.zod.ts b/tests/__snapshots__/angular/base-url-token-zod/model/dog.zod.ts new file mode 100644 index 0000000000..509aea053e --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-zod/model/dog.zod.ts @@ -0,0 +1,28 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import * as zod from 'zod'; + +export const Dog = zod + .union([ + zod.object({ + cuteness: zod.int(), + breed: zod.enum(['Labradoodle']), + }), + zod.object({ + length: zod.int(), + breed: zod.enum(['Dachshund']), + }), + ]) + .and( + zod.object({ + barksPerMinute: zod.int().optional(), + type: zod.enum(['dog']), + }), + ); + +export type Dog = zod.input; +export type DogOutput = zod.output; diff --git a/tests/__snapshots__/angular/base-url-token-zod/model/error.zod.ts b/tests/__snapshots__/angular/base-url-token-zod/model/error.zod.ts new file mode 100644 index 0000000000..a4a2b9bcee --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-zod/model/error.zod.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import * as zod from 'zod'; + +export const Error = zod.object({ + code: zod.int(), + message: zod.string(), +}); + +export type Error = zod.input; +export type ErrorOutput = zod.output; diff --git a/tests/__snapshots__/angular/base-url-token-zod/model/index.ts b/tests/__snapshots__/angular/base-url-token-zod/model/index.ts new file mode 100644 index 0000000000..649884921c --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-zod/model/index.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export * from './cat.zod'; +export * from './createPetsBody.zod'; +export * from './createPetsHeaders.zod'; +export * from './createPetsParams.zod'; +export * from './dachshund.zod'; +export * from './dog.zod'; +export * from './error.zod'; +export * from './labradoodle.zod'; +export * from './listPetsHeaders.zod'; +export * from './listPetsParams.zod'; +export * from './pet.zod'; +export * from './petWithTag.zod'; +export * from './pets.zod'; diff --git a/tests/__snapshots__/angular/base-url-token-zod/model/labradoodle.zod.ts b/tests/__snapshots__/angular/base-url-token-zod/model/labradoodle.zod.ts new file mode 100644 index 0000000000..50efdebc9b --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-zod/model/labradoodle.zod.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import * as zod from 'zod'; + +export const Labradoodle = zod.object({ + cuteness: zod.int(), + breed: zod.enum(['Labradoodle']), +}); + +export type Labradoodle = zod.input; +export type LabradoodleOutput = zod.output; diff --git a/tests/__snapshots__/angular/base-url-token-zod/model/listPetsHeaders.zod.ts b/tests/__snapshots__/angular/base-url-token-zod/model/listPetsHeaders.zod.ts new file mode 100644 index 0000000000..68e11dfc98 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-zod/model/listPetsHeaders.zod.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import * as zod from 'zod'; + +export const ListPetsHeaders = zod.object({ + 'X-EXAMPLE': zod.enum(['ONE', 'TWO', 'THREE']), +}); + +export type ListPetsHeaders = zod.input; +export type ListPetsHeadersOutput = zod.output; diff --git a/tests/__snapshots__/angular/base-url-token-zod/model/listPetsParams.zod.ts b/tests/__snapshots__/angular/base-url-token-zod/model/listPetsParams.zod.ts new file mode 100644 index 0000000000..5199b598be --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-zod/model/listPetsParams.zod.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import * as zod from 'zod'; + +export const ListPetsParams = zod.object({ + limit: zod.string().optional(), + sort: zod.enum(['name', '-name', 'email', '-email']), +}); + +export type ListPetsParams = zod.input; +export type ListPetsParamsOutput = zod.output; diff --git a/tests/__snapshots__/angular/base-url-token-zod/model/pet.zod.ts b/tests/__snapshots__/angular/base-url-token-zod/model/pet.zod.ts new file mode 100644 index 0000000000..c5741dc5c5 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-zod/model/pet.zod.ts @@ -0,0 +1,46 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import * as zod from 'zod'; + +export const Pet = zod + .union([ + zod + .union([ + zod.object({ + cuteness: zod.int(), + breed: zod.enum(['Labradoodle']), + }), + zod.object({ + length: zod.int(), + breed: zod.enum(['Dachshund']), + }), + ]) + .and( + zod.object({ + barksPerMinute: zod.int().optional(), + type: zod.enum(['dog']), + }), + ), + zod.object({ + petsRequested: zod.int().optional(), + type: zod.enum(['cat']), + }), + ]) + .and( + zod.object({ + '@id': zod.string().optional(), + id: zod.int(), + name: zod.string(), + tag: zod.string().optional(), + email: zod.email().optional(), + callingCode: zod.enum(['+33', '+420']).optional(), + country: zod.enum(["People's Republic of China", 'Uruguay']).optional(), + }), + ); + +export type Pet = zod.input; +export type PetOutput = zod.output; diff --git a/tests/__snapshots__/angular/base-url-token-zod/model/petWithTag.zod.ts b/tests/__snapshots__/angular/base-url-token-zod/model/petWithTag.zod.ts new file mode 100644 index 0000000000..a1c8a3a7c2 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-zod/model/petWithTag.zod.ts @@ -0,0 +1,50 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import * as zod from 'zod'; + +export const PetWithTag = zod.object({ + tag: zod.string(), + pet: zod + .union([ + zod + .union([ + zod.object({ + cuteness: zod.int(), + breed: zod.enum(['Labradoodle']), + }), + zod.object({ + length: zod.int(), + breed: zod.enum(['Dachshund']), + }), + ]) + .and( + zod.object({ + barksPerMinute: zod.int().optional(), + type: zod.enum(['dog']), + }), + ), + zod.object({ + petsRequested: zod.int().optional(), + type: zod.enum(['cat']), + }), + ]) + .and( + zod.object({ + '@id': zod.string().optional(), + id: zod.int(), + name: zod.string(), + tag: zod.string().optional(), + email: zod.email().optional(), + callingCode: zod.enum(['+33', '+420']).optional(), + country: zod.enum(["People's Republic of China", 'Uruguay']).optional(), + }), + ) + .nullable(), +}); + +export type PetWithTag = zod.input; +export type PetWithTagOutput = zod.output; diff --git a/tests/__snapshots__/angular/base-url-token-zod/model/pets.zod.ts b/tests/__snapshots__/angular/base-url-token-zod/model/pets.zod.ts new file mode 100644 index 0000000000..8340fcf86b --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token-zod/model/pets.zod.ts @@ -0,0 +1,48 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import * as zod from 'zod'; + +export const Pets = zod.array( + zod + .union([ + zod + .union([ + zod.object({ + cuteness: zod.int(), + breed: zod.enum(['Labradoodle']), + }), + zod.object({ + length: zod.int(), + breed: zod.enum(['Dachshund']), + }), + ]) + .and( + zod.object({ + barksPerMinute: zod.int().optional(), + type: zod.enum(['dog']), + }), + ), + zod.object({ + petsRequested: zod.int().optional(), + type: zod.enum(['cat']), + }), + ]) + .and( + zod.object({ + '@id': zod.string().optional(), + id: zod.int(), + name: zod.string(), + tag: zod.string().optional(), + email: zod.email().optional(), + callingCode: zod.enum(['+33', '+420']).optional(), + country: zod.enum(["People's Republic of China", 'Uruguay']).optional(), + }), + ), +); + +export type Pets = zod.input; +export type PetsOutput = zod.output; diff --git a/tests/__snapshots__/angular/base-url-token/endpoints.base-url.ts b/tests/__snapshots__/angular/base-url-token/endpoints.base-url.ts new file mode 100644 index 0000000000..bc9f3127c4 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token/endpoints.base-url.ts @@ -0,0 +1,90 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import { InjectionToken, inject, type Provider } from '@angular/core'; + +/** + * Embedded fallback base URL for the `petstore-api` API, resolved at generation + * time from the OpenAPI specification's `servers` field (`''` when the + * specification has no servers). + */ +export const PETSTORE_API_SERVER_URL: string = 'http://petstore.swagger.io/v1'; + +/** + * Strips trailing slashes from a base URL. + * + * Generated routes always start with `/`, so normalizing here at the token + * boundary guarantees `${baseUrl}${route}` can never double or drop the + * separator between them, for either `HttpClient` services or `httpResource` + * functions. + */ +export function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/+$/, ''); +} + +/** Context passed to a `PetstoreApiBaseUrlResolver` when it is invoked. */ +export interface PetstoreApiBaseUrlResolverContext { + /** The explicit `apiId` configured via `override.angular.baseUrl`. */ + readonly apiId: 'petstore-api'; + /** The embedded fallback server URL (`PETSTORE_API_SERVER_URL`). */ + readonly serverUrl: string; +} + +/** Resolves the runtime base URL for the `petstore-api` API. */ +export type PetstoreApiBaseUrlResolver = ( + context: PetstoreApiBaseUrlResolverContext, +) => string; + +/** + * Injectable hook for resolving the `petstore-api` API's base URL at runtime + * (e.g. from a gateway route registry). Overridden via + * `providePetstoreApiBaseUrlResolver`; defaults to the embedded specification + * server URL. + */ +export const PETSTORE_API_BASE_URL_RESOLVER = + new InjectionToken( + 'PETSTORE_API_BASE_URL_RESOLVER', + { + providedIn: 'root', + factory: (): PetstoreApiBaseUrlResolver => (context) => context.serverUrl, + }, + ); + +/** + * Runtime base URL for the `petstore-api` API, composed via Angular DI. + * + * Precedence: a directly provided value (`providePetstoreApiBaseUrl`) wins + * outright; otherwise the `PETSTORE_API_BASE_URL_RESOLVER` resolver (default or + * provided via `providePetstoreApiBaseUrlResolver`) is invoked with the embedded + * `PETSTORE_API_SERVER_URL` fallback. The result is always normalized. + */ +export const PETSTORE_API_BASE_URL = new InjectionToken( + 'PETSTORE_API_BASE_URL', + { + providedIn: 'root', + factory: (): string => { + const resolver = inject(PETSTORE_API_BASE_URL_RESOLVER); + return normalizeBaseUrl( + resolver({ apiId: 'petstore-api', serverUrl: PETSTORE_API_SERVER_URL }), + ); + }, + }, +); + +/** Directly provides the `petstore-api` API's base URL, bypassing the resolver. */ +export function providePetstoreApiBaseUrl(baseUrl: string): Provider { + return { + provide: PETSTORE_API_BASE_URL, + useValue: normalizeBaseUrl(baseUrl), + }; +} + +/** Provides a custom resolver for the `petstore-api` API's base URL. */ +export function providePetstoreApiBaseUrlResolver( + resolver: PetstoreApiBaseUrlResolver, +): Provider { + return { provide: PETSTORE_API_BASE_URL_RESOLVER, useValue: resolver }; +} diff --git a/tests/__snapshots__/angular/base-url-token/endpoints.ts b/tests/__snapshots__/angular/base-url-token/endpoints.ts new file mode 100644 index 0000000000..1c69b9e6fe --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token/endpoints.ts @@ -0,0 +1,389 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import { + HttpClient, + HttpHeaders, + HttpResponse as AngularHttpResponse, +} from '@angular/common/http'; +import type { HttpContext, HttpEvent, HttpParams } from '@angular/common/http'; + +import { Injectable, inject } from '@angular/core'; + +import { Observable } from 'rxjs'; + +import { PETSTORE_API_BASE_URL } from './endpoints.base-url'; + +import type { + CreatePetsBody, + CreatePetsParams, + ListPetsParams, + Pet, + PetWithTag, + Pets, +} from './model'; + +interface HttpClientOptions { + readonly headers?: HttpHeaders | Record; + readonly context?: HttpContext; + readonly params?: + | HttpParams + | Record< + string, + string | number | boolean | Array + >; + readonly reportProgress?: boolean; + readonly withCredentials?: boolean; + readonly credentials?: RequestCredentials; + readonly keepalive?: boolean; + readonly priority?: RequestPriority; + readonly cache?: RequestCache; + readonly mode?: RequestMode; + readonly redirect?: RequestRedirect; + readonly referrer?: string; + readonly integrity?: string; + readonly referrerPolicy?: ReferrerPolicy; + readonly transferCache?: { includeHeaders?: string[] } | boolean; + readonly timeout?: number; +} + +type HttpClientBodyOptions = HttpClientOptions & { + readonly observe?: 'body'; +}; + +type HttpClientEventOptions = HttpClientOptions & { + readonly observe: 'events'; +}; + +type HttpClientResponseOptions = HttpClientOptions & { + readonly observe: 'response'; +}; + +type HttpClientObserveOptions = HttpClientOptions & { + readonly observe?: 'body' | 'events' | 'response'; +}; + +type AngularHttpParamValue = + | string + | number + | boolean + | Array; +type AngularHttpParamValueWithNullable = AngularHttpParamValue | null; + +function filterParams( + params: Record, + requiredNullableKeys?: ReadonlySet, + preserveRequiredNullables?: false, + passthroughKeys?: undefined, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet | undefined, + preserveRequiredNullables: true, + passthroughKeys?: undefined, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet | undefined, + preserveRequiredNullables: boolean | undefined, + passthroughKeys: ReadonlySet, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet = new Set(), + preserveRequiredNullables = false, + passthroughKeys: ReadonlySet = new Set(), +): Record { + const filteredParams: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (passthroughKeys.has(key)) { + if (value !== undefined) { + filteredParams[key] = value; + } + continue; + } + if (Array.isArray(value)) { + const filtered = value.filter( + (item) => + item != null && + (typeof item === 'string' || + typeof item === 'number' || + typeof item === 'boolean'), + ) as Array; + if (filtered.length) { + filteredParams[key] = filtered; + } + } else if (value === null && requiredNullableKeys.has(key)) { + // With a paramsSerializer (preserveRequiredNullables) the literal null + // is passed through for it to consume; without one, emit an empty + // string so the required key still reaches the wire as `?key=` + // instead of being silently dropped. See #3712. + filteredParams[key] = preserveRequiredNullables ? null : ''; + } else if ( + value != null && + (typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean') + ) { + filteredParams[key] = value; + } + } + return filteredParams; +} + +@Injectable({ providedIn: 'root' }) +export class SwaggerPetstoreService { + private readonly http = inject(HttpClient); + private readonly baseUrl = inject(PETSTORE_API_BASE_URL); + /** + * @summary List all pets + */ + listPets( + params: ListPetsParams, + options?: HttpClientBodyOptions, + ): Observable; + listPets( + params: ListPetsParams, + options?: HttpClientEventOptions, + ): Observable>; + listPets( + params: ListPetsParams, + options?: HttpClientResponseOptions, + ): Observable>; + listPets( + params: ListPetsParams, + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + const filteredParams = filterParams( + { ...params, ...options?.params }, + new Set([]), + ); + + if (options?.observe === 'events') { + return this.http.get(`${this.baseUrl}/pets`, { + ...(options as Omit, 'observe'>), + observe: 'events', + params: filteredParams, + }); + } + + if (options?.observe === 'response') { + return this.http.get(`${this.baseUrl}/pets`, { + ...(options as Omit, 'observe'>), + observe: 'response', + params: filteredParams, + }); + } + + return this.http.get(`${this.baseUrl}/pets`, { + ...(options as Omit, 'observe'>), + observe: 'body', + params: filteredParams, + }); + } + + /** + * @summary Create a pet + */ + createPets( + createPetsBody: CreatePetsBody, + params: CreatePetsParams, + options?: HttpClientBodyOptions, + ): Observable; + createPets( + createPetsBody: CreatePetsBody, + params: CreatePetsParams, + options?: HttpClientEventOptions, + ): Observable>; + createPets( + createPetsBody: CreatePetsBody, + params: CreatePetsParams, + options?: HttpClientResponseOptions, + ): Observable>; + createPets( + createPetsBody: CreatePetsBody, + params: CreatePetsParams, + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + const filteredParams = filterParams( + { ...params, ...options?.params }, + new Set([]), + ); + + if (options?.observe === 'events') { + return this.http.post(`${this.baseUrl}/pets`, createPetsBody, { + ...(options as Omit, 'observe'>), + observe: 'events', + params: filteredParams, + }); + } + + if (options?.observe === 'response') { + return this.http.post(`${this.baseUrl}/pets`, createPetsBody, { + ...(options as Omit, 'observe'>), + observe: 'response', + params: filteredParams, + }); + } + + return this.http.post(`${this.baseUrl}/pets`, createPetsBody, { + ...(options as Omit, 'observe'>), + observe: 'body', + params: filteredParams, + }); + } + + /** + * @summary Info for a specific pet + */ + showPetById( + petId: string, + options?: HttpClientBodyOptions, + ): Observable; + showPetById( + petId: string, + options?: HttpClientEventOptions, + ): Observable>; + showPetById( + petId: string, + options?: HttpClientResponseOptions, + ): Observable>; + showPetById( + petId: string, + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.get(`${this.baseUrl}/pets/${petId}`, { + ...(options as Omit, 'observe'>), + observe: 'events', + }); + } + + if (options?.observe === 'response') { + return this.http.get(`${this.baseUrl}/pets/${petId}`, { + ...(options as Omit, 'observe'>), + observe: 'response', + }); + } + + return this.http.get(`${this.baseUrl}/pets/${petId}`, { + ...(options as Omit, 'observe'>), + observe: 'body', + }); + } + + /** + * @summary Deletes a specific pet + */ + deletePetById( + petId: string, + options?: HttpClientBodyOptions, + ): Observable; + deletePetById( + petId: string, + options?: HttpClientEventOptions, + ): Observable>; + deletePetById( + petId: string, + options?: HttpClientResponseOptions, + ): Observable>; + deletePetById( + petId: string, + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.delete(`${this.baseUrl}/pets/${petId}`, { + ...(options as Omit, 'observe'>), + observe: 'events', + }); + } + + if (options?.observe === 'response') { + return this.http.delete(`${this.baseUrl}/pets/${petId}`, { + ...(options as Omit, 'observe'>), + observe: 'response', + }); + } + + return this.http.delete(`${this.baseUrl}/pets/${petId}`, { + ...(options as Omit, 'observe'>), + observe: 'body', + }); + } + + /** + * @summary health check + */ + healthCheck(options?: HttpClientBodyOptions): Observable; + healthCheck(options?: HttpClientEventOptions): Observable>; + healthCheck( + options?: HttpClientResponseOptions, + ): Observable>; + healthCheck( + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.get(`${this.baseUrl}/health`, { + responseType: 'text', + ...(options as Omit, 'observe'>), + observe: 'events', + }) as Observable>; + } + + if (options?.observe === 'response') { + return this.http.get(`${this.baseUrl}/health`, { + responseType: 'text', + ...(options as Omit, 'observe'>), + observe: 'response', + }) as Observable>; + } + + return this.http.get(`${this.baseUrl}/health`, { + responseType: 'text', + ...(options as Omit, 'observe'>), + observe: 'body', + }) as Observable; + } + + /** + * @summary combinate nullable and $ref + */ + showPetWithOwner( + petId: string, + options?: HttpClientBodyOptions, + ): Observable; + showPetWithOwner( + petId: string, + options?: HttpClientEventOptions, + ): Observable>; + showPetWithOwner( + petId: string, + options?: HttpClientResponseOptions, + ): Observable>; + showPetWithOwner( + petId: string, + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.get(`${this.baseUrl}/pets/${petId}/owner`, { + ...(options as Omit, 'observe'>), + observe: 'events', + }); + } + + if (options?.observe === 'response') { + return this.http.get(`${this.baseUrl}/pets/${petId}/owner`, { + ...(options as Omit, 'observe'>), + observe: 'response', + }); + } + + return this.http.get(`${this.baseUrl}/pets/${petId}/owner`, { + ...(options as Omit, 'observe'>), + observe: 'body', + }); + } +} diff --git a/tests/__snapshots__/angular/base-url-token/model/cat.ts b/tests/__snapshots__/angular/base-url-token/model/cat.ts new file mode 100644 index 0000000000..2e583f1c7f --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token/model/cat.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { CatType } from './catType'; + +export interface Cat { + petsRequested?: number; + type: CatType; +} diff --git a/tests/__snapshots__/angular/base-url-token/model/catType.ts b/tests/__snapshots__/angular/base-url-token/model/catType.ts new file mode 100644 index 0000000000..8bfea55ae3 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token/model/catType.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type CatType = (typeof CatType)[keyof typeof CatType]; + +export const CatType = { + cat: 'cat', +} as const; diff --git a/tests/__snapshots__/angular/base-url-token/model/createPetsBody.ts b/tests/__snapshots__/angular/base-url-token/model/createPetsBody.ts new file mode 100644 index 0000000000..0365da24c7 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token/model/createPetsBody.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type CreatePetsBody = { + name: string; + tag: string; +}; diff --git a/tests/__snapshots__/angular/base-url-token/model/createPetsParams.ts b/tests/__snapshots__/angular/base-url-token/model/createPetsParams.ts new file mode 100644 index 0000000000..daf5e2d6f4 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token/model/createPetsParams.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { CreatePetsSort } from './createPetsSort'; + +export type CreatePetsParams = { + /** + * How many items to return at one time (max 100) + */ + limit?: string; + /** + * Which property to sort by? + * Example: name sorts ASC while -name sorts DESC. + */ + sort: CreatePetsSort; +}; diff --git a/tests/__snapshots__/angular/base-url-token/model/createPetsSort.ts b/tests/__snapshots__/angular/base-url-token/model/createPetsSort.ts new file mode 100644 index 0000000000..d7cb2b867a --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token/model/createPetsSort.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type CreatePetsSort = + (typeof CreatePetsSort)[keyof typeof CreatePetsSort]; + +export const CreatePetsSort = { + name: 'name', + '-name': '-name', + email: 'email', + '-email': '-email', +} as const; diff --git a/tests/__snapshots__/angular/base-url-token/model/dachshund.ts b/tests/__snapshots__/angular/base-url-token/model/dachshund.ts new file mode 100644 index 0000000000..1b834d647d --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token/model/dachshund.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { DachshundBreed } from './dachshundBreed'; + +export interface Dachshund { + length: number; + breed: DachshundBreed; +} diff --git a/tests/__snapshots__/angular/base-url-token/model/dachshundBreed.ts b/tests/__snapshots__/angular/base-url-token/model/dachshundBreed.ts new file mode 100644 index 0000000000..2c0d62ae5a --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token/model/dachshundBreed.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type DachshundBreed = + (typeof DachshundBreed)[keyof typeof DachshundBreed]; + +export const DachshundBreed = { + Dachshund: 'Dachshund', +} as const; diff --git a/tests/__snapshots__/angular/base-url-token/model/dog.ts b/tests/__snapshots__/angular/base-url-token/model/dog.ts new file mode 100644 index 0000000000..847b6d3903 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token/model/dog.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { Dachshund } from './dachshund'; +import type { DogType } from './dogType'; +import type { Labradoodle } from './labradoodle'; + +export type Dog = + | (Labradoodle & { + barksPerMinute?: number; + type: DogType; + }) + | (Dachshund & { + barksPerMinute?: number; + type: DogType; + }); diff --git a/tests/__snapshots__/angular/base-url-token/model/dogType.ts b/tests/__snapshots__/angular/base-url-token/model/dogType.ts new file mode 100644 index 0000000000..fabdd40e3e --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token/model/dogType.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type DogType = (typeof DogType)[keyof typeof DogType]; + +export const DogType = { + dog: 'dog', +} as const; diff --git a/tests/__snapshots__/angular/base-url-token/model/error.ts b/tests/__snapshots__/angular/base-url-token/model/error.ts new file mode 100644 index 0000000000..b4168d9717 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token/model/error.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export interface Error { + code: number; + message: string; +} diff --git a/tests/__snapshots__/angular/base-url-token/model/index.ts b/tests/__snapshots__/angular/base-url-token/model/index.ts new file mode 100644 index 0000000000..bec5f9fd03 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token/model/index.ts @@ -0,0 +1,26 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export * from './cat'; +export * from './catType'; +export * from './createPetsBody'; +export * from './createPetsParams'; +export * from './createPetsSort'; +export * from './dachshund'; +export * from './dachshundBreed'; +export * from './dog'; +export * from './dogType'; +export * from './error'; +export * from './labradoodle'; +export * from './labradoodleBreed'; +export * from './listPetsParams'; +export * from './listPetsSort'; +export * from './pet'; +export * from './petCallingCode'; +export * from './petCountry'; +export * from './pets'; +export * from './petWithTag'; diff --git a/tests/__snapshots__/angular/base-url-token/model/labradoodle.ts b/tests/__snapshots__/angular/base-url-token/model/labradoodle.ts new file mode 100644 index 0000000000..2bb4217d0a --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token/model/labradoodle.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { LabradoodleBreed } from './labradoodleBreed'; + +export interface Labradoodle { + cuteness: number; + breed: LabradoodleBreed; +} diff --git a/tests/__snapshots__/angular/base-url-token/model/labradoodleBreed.ts b/tests/__snapshots__/angular/base-url-token/model/labradoodleBreed.ts new file mode 100644 index 0000000000..7d953cea51 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token/model/labradoodleBreed.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type LabradoodleBreed = + (typeof LabradoodleBreed)[keyof typeof LabradoodleBreed]; + +export const LabradoodleBreed = { + Labradoodle: 'Labradoodle', +} as const; diff --git a/tests/__snapshots__/angular/base-url-token/model/listPetsParams.ts b/tests/__snapshots__/angular/base-url-token/model/listPetsParams.ts new file mode 100644 index 0000000000..02ceb51e58 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token/model/listPetsParams.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { ListPetsSort } from './listPetsSort'; + +export type ListPetsParams = { + /** + * How many items to return at one time (max 100) + */ + limit?: string; + /** + * Which property to sort by? + * Example: name sorts ASC while -name sorts DESC. + */ + sort: ListPetsSort; +}; diff --git a/tests/__snapshots__/angular/base-url-token/model/listPetsSort.ts b/tests/__snapshots__/angular/base-url-token/model/listPetsSort.ts new file mode 100644 index 0000000000..68f6176379 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token/model/listPetsSort.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type ListPetsSort = (typeof ListPetsSort)[keyof typeof ListPetsSort]; + +export const ListPetsSort = { + name: 'name', + '-name': '-name', + email: 'email', + '-email': '-email', +} as const; diff --git a/tests/__snapshots__/angular/base-url-token/model/pet.ts b/tests/__snapshots__/angular/base-url-token/model/pet.ts new file mode 100644 index 0000000000..61da13a024 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token/model/pet.ts @@ -0,0 +1,30 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { Cat } from './cat'; +import type { Dog } from './dog'; +import type { PetCallingCode } from './petCallingCode'; +import type { PetCountry } from './petCountry'; + +export type Pet = + | (Dog & { + '@id'?: string; + id: number; + name: string; + tag?: string; + email?: string; + callingCode?: PetCallingCode; + country?: PetCountry; + }) + | (Cat & { + '@id'?: string; + id: number; + name: string; + tag?: string; + email?: string; + callingCode?: PetCallingCode; + country?: PetCountry; + }); diff --git a/tests/__snapshots__/angular/base-url-token/model/petCallingCode.ts b/tests/__snapshots__/angular/base-url-token/model/petCallingCode.ts new file mode 100644 index 0000000000..0fec3eddea --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token/model/petCallingCode.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type PetCallingCode = + (typeof PetCallingCode)[keyof typeof PetCallingCode]; + +export const PetCallingCode = { + '+33': '+33', + '+420': '+420', +} as const; diff --git a/tests/__snapshots__/angular/base-url-token/model/petCountry.ts b/tests/__snapshots__/angular/base-url-token/model/petCountry.ts new file mode 100644 index 0000000000..57424fd0e5 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token/model/petCountry.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ + +export type PetCountry = (typeof PetCountry)[keyof typeof PetCountry]; + +export const PetCountry = { + "People's_Republic_of_China": "People's Republic of China", + Uruguay: 'Uruguay', +} as const; diff --git a/tests/__snapshots__/angular/base-url-token/model/petWithTag.ts b/tests/__snapshots__/angular/base-url-token/model/petWithTag.ts new file mode 100644 index 0000000000..76d8ca5089 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token/model/petWithTag.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { Pet } from './pet'; + +export interface PetWithTag { + tag: string; + pet: Pet | null; +} diff --git a/tests/__snapshots__/angular/base-url-token/model/pets.ts b/tests/__snapshots__/angular/base-url-token/model/pets.ts new file mode 100644 index 0000000000..b2fd3d3288 --- /dev/null +++ b/tests/__snapshots__/angular/base-url-token/model/pets.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * Swagger Petstore + * OpenAPI spec version: 1.0.0 + */ +import type { Pet } from './pet'; + +export type Pets = Pet[]; diff --git a/tests/configs/angular.config.ts b/tests/configs/angular.config.ts index a60441308c..212771a70d 100644 --- a/tests/configs/angular.config.ts +++ b/tests/configs/angular.config.ts @@ -468,6 +468,85 @@ export default defineConfig({ target: '../specifications/petstore.yaml', }, }, + baseUrlToken: { + output: { + target: '../generated/angular/base-url-token/endpoints.ts', + schemas: '../generated/angular/base-url-token/model', + client: 'angular', + mock: false, + clean: true, + formatter: 'prettier', + override: { + angular: { + baseUrl: { apiId: 'petstore-api' }, + }, + }, + }, + input: { + target: '../specifications/petstore.yaml', + }, + }, + baseUrlTokenHttpResource: { + output: { + target: '../generated/angular/base-url-token-http-resource/endpoints.ts', + schemas: '../generated/angular/base-url-token-http-resource/model', + client: 'angular', + mock: false, + clean: true, + formatter: 'prettier', + override: { + angular: { + retrievalClient: 'httpResource', + baseUrl: { apiId: 'petstore-api' }, + }, + }, + }, + input: { + target: '../specifications/petstore.yaml', + }, + }, + baseUrlTokenBoth: { + output: { + target: '../generated/angular/base-url-token-both/endpoints.ts', + schemas: '../generated/angular/base-url-token-both/model', + client: 'angular', + mode: 'tags-split', + mock: false, + clean: true, + formatter: 'prettier', + override: { + angular: { + retrievalClient: 'both', + baseUrl: { apiId: 'petstore-api' }, + }, + }, + }, + input: { + target: '../specifications/petstore.yaml', + }, + }, + baseUrlTokenZod: { + output: { + target: '../generated/angular/base-url-token-zod/endpoints.ts', + schemas: { + type: 'zod', + path: '../generated/angular/base-url-token-zod/model', + }, + client: 'angular', + mock: false, + clean: true, + formatter: 'prettier', + override: { + angular: { + runtimeValidation: true, + baseUrl: { apiId: 'petstore-api' }, + }, + }, + }, + input: { + target: '../specifications/petstore.yaml', + }, + }, httpResourceHeaders: { output: { target: '../generated/angular/http-resource-headers/endpoints.ts',