diff --git a/packages/angular/src/http-client.test.ts b/packages/angular/src/http-client.test.ts index d338583587..91da8e6bc2 100644 --- a/packages/angular/src/http-client.test.ts +++ b/packages/angular/src/http-client.test.ts @@ -18,6 +18,7 @@ import { getHttpClientReturnTypes, resetHttpClientReturnTypes, } from './http-client'; +import { createQueryParams } from './test-helpers'; // --------------------------------------------------------------------------- // Test helpers @@ -180,7 +181,7 @@ const createVerbOption = ( definition: '', imports: [], schemas: [], - originalSchema: {} as never, + originalSchema: { type: 'object' }, contentType: '', formData: '', formUrlEncoded: '', @@ -225,6 +226,21 @@ const createVerbOption = ( ...overrides, }) as GeneratorVerbOptions; +const createHeaderParams = ( + overrides: Partial[0]> = {}, +): Parameters[0] => ({ + title: 'PetService', + isRequestOptions: true, + isMutator: false, + isGlobalMutator: false, + provideIn: 'root', + hasAwaitedType: false, + output: createOutput(), + verbOptions: { getPetById: createVerbOption() }, + clientImplementation: '', + ...overrides, +}); + const createContextSpec = (output: NormalizedOutputOptions): ContextSpec => { const spec = { openapi: '3.1.0', @@ -408,6 +424,69 @@ describe('angular HttpClient generator', () => { expect(header).not.toContain('type ThirdParameter'); }); + + it('emits filterParams helper for untagged operations in tags-split default file (#3103)', () => { + const verbOptionWithQueryParams = createVerbOption({ + tags: [], + queryParams: createQueryParams({ + schema: { name: 'GetApiProductParams', model: '', imports: [] }, + }), + }); + + const header = generateAngularHeader( + createHeaderParams({ + title: 'DefaultService', + verbOptions: { getApiProduct: verbOptionWithQueryParams }, + tag: 'default', + }), + ); + + expect(header).toContain('function filterParams('); + }); + + it('includes both explicit default-tagged and untagged operations in the default bucket', () => { + const untaggedVerb = createVerbOption({ + operationId: 'getUntaggedProduct', + tags: [], + queryParams: createQueryParams({ + schema: { name: 'GetApiProductParams', model: '', imports: [] }, + }), + }); + const explicitDefaultVerb = createVerbOption({ + operationId: 'getTaggedDefaultProduct', + tags: ['default'], + }); + + const header = generateAngularHeader( + createHeaderParams({ + title: 'DefaultService', + verbOptions: { + getUntaggedProduct: untaggedVerb, + getTaggedDefaultProduct: explicitDefaultVerb, + }, + tag: 'default', + }), + ); + + expect(header).toContain('function filterParams('); + }); + + it('does not enable the implicit default bucket when only explicit default tags exist', () => { + const explicitDefaultVerb = createVerbOption({ + operationId: 'getTaggedDefaultProduct', + tags: ['default'], + }); + + const header = generateAngularHeader( + createHeaderParams({ + title: 'DefaultService', + verbOptions: { getTaggedDefaultProduct: explicitDefaultVerb }, + tag: 'default', + }), + ); + + expect(header).not.toContain('function filterParams('); + }); }); // ── Footer ──────────────────────────────────────────────────────────── diff --git a/packages/angular/src/http-client.ts b/packages/angular/src/http-client.ts index 4685782a1f..d877e11abf 100644 --- a/packages/angular/src/http-client.ts +++ b/packages/angular/src/http-client.ts @@ -1,5 +1,4 @@ import { - camel, type ClientBuilder, type ClientDependenciesBuilder, type ClientFooterBuilder, @@ -33,6 +32,7 @@ import { } from './types'; import { createReturnTypesRegistry, + getRelevantVerbOptionsForTag, getSchemaOutputTypeRef, isPrimitiveType, isZodSchemaOutput, @@ -230,11 +230,7 @@ export const generateAngularHeader: ClientHeaderBuilder = ({ }) => { returnTypesRegistry.reset(); - const relevantVerbs = tag - ? Object.values(verbOptions).filter((v) => - v.tags.some((t) => camel(t) === camel(tag)), - ) - : Object.values(verbOptions); + const relevantVerbs = getRelevantVerbOptionsForTag(verbOptions, tag); const hasQueryParams = relevantVerbs.some((v) => v.queryParams); const acceptHelpers = buildAcceptHelpers(relevantVerbs, output); @@ -471,7 +467,6 @@ export const generateHttpClientImplementation = ( let paramsDeclaration = ''; if (angularParamsRef && queryParams) { if (isRequestOptions) { - // Uses the shared filterParams helper emitted in the file header const callExpr = getAngularFilteredParamsCallExpression( '{...params, ...options?.params}', queryParams.requiredNullableKeys ?? [], @@ -480,7 +475,6 @@ export const generateHttpClientImplementation = ( ? `const ${angularParamsRef} = ${paramsSerializer.name}(${callExpr});\n\n ` : `const ${angularParamsRef} = ${callExpr};\n\n `; } else { - // No shared helper available; use inline IIFE filtering const iifeExpr = getAngularFilteredParamsExpression( 'params ?? {}', queryParams.requiredNullableKeys ?? [], diff --git a/packages/angular/src/http-resource.test.ts b/packages/angular/src/http-resource.test.ts index d4fa9a3aa6..62f7cd66ab 100644 --- a/packages/angular/src/http-resource.test.ts +++ b/packages/angular/src/http-resource.test.ts @@ -19,6 +19,7 @@ import { getAngularHttpResourceOnlyDependencies, routeRegistry, } from './http-resource'; +import { createQueryParams } from './test-helpers'; interface AngularOverride { provideIn: 'root' | 'any' | boolean; @@ -226,7 +227,7 @@ const createVerbOption = ( definition: '', imports: [], schemas: [], - originalSchema: {} as never, + originalSchema: { type: 'object' }, contentType: '', formData: '', formUrlEncoded: '', @@ -273,6 +274,21 @@ const createVerbOption = ( } as GeneratorVerbOptions; }; +const createHeaderParams = ( + overrides: Partial[0]> = {}, +): Parameters[0] => ({ + title: 'PetService', + isRequestOptions: true, + isMutator: false, + isGlobalMutator: false, + provideIn: 'root', + hasAwaitedType: false, + output: createOutput(), + verbOptions: { getPetById: createVerbOption() }, + clientImplementation: '', + ...overrides, +}); + describe('angular httpResource generator', () => { beforeEach(() => { routeRegistry.reset(); @@ -1075,6 +1091,71 @@ describe('angular httpResource generator', () => { expect(header).toContain('getPetByIdResource'); expect(header).toContain('healthCheckResource'); }); + + it('emits filterParams helper for untagged operations in tags-split default file (#3103)', () => { + const verbOptionWithQueryParams = createVerbOption({ + tags: [], + queryParams: createQueryParams({ + schema: { name: 'GetApiProductParams', model: '', imports: [] }, + }), + }); + routeRegistry.set('getPetById', '/api/pets/${petId}'); + + const header = generateHttpResourceHeader( + createHeaderParams({ + title: 'DefaultService', + verbOptions: { getPetById: verbOptionWithQueryParams }, + tag: 'default', + }), + ); + + expect(header).toContain('function filterParams('); + }); + + it('includes both explicit default-tagged and untagged operations in the default bucket', () => { + const untaggedVerb = createVerbOption({ + operationId: 'getUntaggedProduct', + tags: [], + queryParams: createQueryParams({ + schema: { name: 'GetApiProductParams', model: '', imports: [] }, + }), + }); + const explicitDefaultVerb = createVerbOption({ + operationId: 'getTaggedDefaultProduct', + tags: ['default'], + }); + + const header = generateHttpResourceHeader( + createHeaderParams({ + title: 'DefaultService', + verbOptions: { + getUntaggedProduct: untaggedVerb, + getTaggedDefaultProduct: explicitDefaultVerb, + }, + tag: 'default', + }), + ); + + expect(header).toContain('function filterParams('); + }); + + it('does not enable the implicit default bucket when only explicit default tags exist', () => { + const explicitDefaultVerb = createVerbOption({ + operationId: 'getTaggedDefaultProduct', + tags: ['default'], + }); + routeRegistry.set('getTaggedDefaultProduct', '/api/products/default'); + + const header = generateHttpResourceHeader( + createHeaderParams({ + title: 'DefaultService', + verbOptions: { getTaggedDefaultProduct: explicitDefaultVerb }, + tag: 'default', + }), + ); + + expect(header).not.toContain('function filterParams('); + }); }); // ─── Response type factories ────────────────────────────────────── diff --git a/packages/angular/src/http-resource.ts b/packages/angular/src/http-resource.ts index 54e9f661f4..a6b3329a75 100644 --- a/packages/angular/src/http-resource.ts +++ b/packages/angular/src/http-resource.ts @@ -1,5 +1,4 @@ import { - camel, type ClientBuilder, type ClientDependenciesBuilder, type ClientExtraFilesBuilder, @@ -51,6 +50,7 @@ import { createReturnTypesRegistry, createRouteRegistry, getDefaultSuccessType, + getRelevantVerbOptionsForTag, getSchemaOutputTypeRef, isMutationVerb, isPrimitiveType, @@ -162,16 +162,6 @@ const resourceReturnTypesRegistry = createReturnTypesRegistry(); /** @internal Exported for testing only */ export const routeRegistry = createRouteRegistry(); -const getRelevantVerbOptions = ( - verbOptions: Record, - tag?: string, -): GeneratorVerbOptions[] => - tag - ? Object.values(verbOptions).filter((verbOption) => - verbOption.tags.some((currentTag) => camel(currentTag) === camel(tag)), - ) - : Object.values(verbOptions); - const getVerbOptionsRecord = ( verbOptions: readonly GeneratorVerbOptions[], ): Record => @@ -1227,7 +1217,7 @@ export const generateHttpResourceHeader: ClientHeaderBuilder = ({ // the shared header duplicates helpers across every tag file and pulls in // type names the file-local `imports` filter never sees, producing missing // schema imports in the generated output. - const relevantVerbOptions = getRelevantVerbOptions(verbOptions, tag); + const relevantVerbOptions = getRelevantVerbOptionsForTag(verbOptions, tag); const retrievals = relevantVerbOptions.filter((verbOption) => isRetrievalVerb( @@ -1616,7 +1606,7 @@ export const generateHttpResourceExtraFiles: ClientExtraFilesBuilder = ( return Promise.resolve([ buildHttpResourceExtraFile( - getVerbOptionsRecord(getRelevantVerbOptions(verbOptions)), + getVerbOptionsRecord(getRelevantVerbOptionsForTag(verbOptions)), getHttpResourceExtraFilePath(output), output, context, diff --git a/packages/angular/src/test-helpers.ts b/packages/angular/src/test-helpers.ts new file mode 100644 index 0000000000..81b0245118 --- /dev/null +++ b/packages/angular/src/test-helpers.ts @@ -0,0 +1,17 @@ +import type { GeneratorVerbOptions } from '@orval/core'; + +/** + * Builds a minimal {@link GeneratorVerbOptions.queryParams} object for use in + * unit tests. Only the fields required by the Angular generators are populated; + * everything else can be overridden via the `overrides` argument. + */ +export const createQueryParams = ( + overrides: Partial> = {}, +): NonNullable => ({ + schema: { name: 'GetPetByIdParams', model: '', imports: [] }, + deps: [], + isOptional: true, + originalSchema: { type: 'object' }, + requiredNullableKeys: [], + ...overrides, +}); diff --git a/packages/angular/src/utils.test.ts b/packages/angular/src/utils.test.ts index c7b9547315..2081d0916e 100644 --- a/packages/angular/src/utils.test.ts +++ b/packages/angular/src/utils.test.ts @@ -1,4 +1,9 @@ -import type { ResReqTypesValue, Verbs } from '@orval/core'; +import type { + GeneratorVerbOptions, + ResReqTypesValue, + Verbs, +} from '@orval/core'; +import { GetterPropType } from '@orval/core'; import { describe, expect, it } from 'vitest'; import { @@ -6,8 +11,13 @@ import { createRouteRegistry, generateAngularTitle, getDefaultSuccessType, + getRelevantVerbOptionsForTag, + getSchemaOutputTypeRef, + isDefined, isMutationVerb, + isPrimitiveType, isRetrievalVerb, + isZodSchemaOutput, } from './utils'; // --------------------------------------------------------------------------- @@ -268,3 +278,226 @@ describe('getDefaultSuccessType', () => { expect(result.value).toBe('FallbackType'); }); }); + +// --------------------------------------------------------------------------- +// isPrimitiveType +// --------------------------------------------------------------------------- + +describe('isPrimitiveType', () => { + it.each(['string', 'number', 'boolean', 'void', 'unknown'])( + 'returns true for primitive "%s"', + (t) => { + expect(isPrimitiveType(t)).toBe(true); + }, + ); + + it.each(['Pet', 'object', 'array', 'null', ''])( + 'returns false for non-primitive "%s"', + (t) => { + expect(isPrimitiveType(t)).toBe(false); + }, + ); + + it('returns false for undefined', () => { + const value = undefined as string | undefined; + expect(isPrimitiveType(value)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// isDefined +// --------------------------------------------------------------------------- + +describe('isDefined', () => { + it('returns true for truthy values', () => { + expect(isDefined('hello')).toBe(true); + expect(isDefined(0)).toBe(true); + expect(isDefined(false)).toBe(true); + }); + + it('returns false for null', () => { + // eslint-disable-next-line unicorn/no-null -- testing null handling explicitly + const value = null as string | null; + expect(isDefined(value)).toBe(false); + }); + + it('returns false for undefined', () => { + const value = undefined as string | undefined; + expect(isDefined(value)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// getSchemaOutputTypeRef +// --------------------------------------------------------------------------- + +describe('getSchemaOutputTypeRef', () => { + it('appends Output to the type name', () => { + expect(getSchemaOutputTypeRef('Pet')).toBe('PetOutput'); + }); + + it('handles already-suffixed names', () => { + expect(getSchemaOutputTypeRef('PetOutput')).toBe('PetOutputOutput'); + }); +}); + +// --------------------------------------------------------------------------- +// isZodSchemaOutput +// --------------------------------------------------------------------------- + +describe('isZodSchemaOutput', () => { + const makeOutput = (schemas: unknown) => + ({ schemas }) as Parameters[0]; + + it('returns true when schemas.type is "zod"', () => { + expect(isZodSchemaOutput(makeOutput({ type: 'zod' }))).toBe(true); + }); + + it('returns false when schemas.type is not "zod"', () => { + expect(isZodSchemaOutput(makeOutput({ type: 'ts' }))).toBe(false); + }); + + it('returns false when schemas is a string path', () => { + expect(isZodSchemaOutput(makeOutput('/tmp/schemas'))).toBe(false); + }); + + it('returns false when schemas is undefined', () => { + const value = undefined as unknown; + expect(isZodSchemaOutput(makeOutput(value))).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// getRelevantVerbOptionsForTag +// --------------------------------------------------------------------------- + +const makeVerb = (operationId: string, tags: string[]): GeneratorVerbOptions => + ({ + operationId, + operationName: operationId, + verb: 'get' as Verbs, + route: `/api/${operationId}`, + pathRoute: `/api/${operationId}`, + tags, + summary: '', + doc: '', + response: { + imports: [], + definition: { success: 'void', errors: '' }, + types: { success: [], errors: [] }, + contentTypes: [], + isBlob: false, + schemas: [], + } as GeneratorVerbOptions['response'], + body: { + implementation: '', + definition: '', + imports: [], + schemas: [], + originalSchema: {}, + contentType: '', + formData: '', + formUrlEncoded: '', + isOptional: true, + }, + headers: undefined, + queryParams: undefined, + params: [], + props: [ + { + name: 'id', + definition: 'id: string', + implementation: 'id: string', + default: false, + required: true, + type: GetterPropType.PARAM, + }, + ], + mutator: undefined, + formData: undefined, + formUrlEncoded: undefined, + paramsSerializer: undefined, + fetchReviver: undefined, + override: { + requestOptions: true, + formData: { disabled: false, arrayHandling: 'serialize' }, + formUrlEncoded: true, + paramsSerializerOptions: undefined, + angular: { + provideIn: 'root', + client: 'httpClient', + runtimeValidation: false, + }, + } as GeneratorVerbOptions['override'], + deprecated: false, + originalOperation: {} as GeneratorVerbOptions['originalOperation'], + }) as GeneratorVerbOptions; + +describe('getRelevantVerbOptionsForTag', () => { + it('returns all verbs when no tag is given', () => { + const verbOptions = { + op1: makeVerb('op1', ['pets']), + op2: makeVerb('op2', ['users']), + }; + expect(getRelevantVerbOptionsForTag(verbOptions)).toHaveLength(2); + }); + + it('filters verbs to those matching the requested tag', () => { + const verbOptions = { + op1: makeVerb('op1', ['pets']), + op2: makeVerb('op2', ['users']), + }; + const result = getRelevantVerbOptionsForTag(verbOptions, 'pets'); + expect(result).toHaveLength(1); + expect(result[0].operationId).toBe('op1'); + }); + + it('includes untagged operations in the implicit default bucket', () => { + const verbOptions = { + tagged: makeVerb('tagged', ['default']), + untagged: makeVerb('untagged', []), + }; + const result = getRelevantVerbOptionsForTag(verbOptions, 'default'); + expect(result).toHaveLength(2); + expect(result.map((v) => v.operationId)).toContain('untagged'); + }); + + it('does not pull in untagged ops when target tag is default but no untagged ops exist', () => { + const verbOptions = { + op1: makeVerb('op1', ['default']), + op2: makeVerb('op2', ['pets']), + }; + const result = getRelevantVerbOptionsForTag(verbOptions, 'default'); + expect(result).toHaveLength(1); + expect(result[0].operationId).toBe('op1'); + }); + + it('does not include untagged operations for non-default tags', () => { + const verbOptions = { + untagged: makeVerb('untagged', []), + op1: makeVerb('op1', ['pets']), + }; + const result = getRelevantVerbOptionsForTag(verbOptions, 'pets'); + expect(result).toHaveLength(1); + expect(result[0].operationId).toBe('op1'); + }); + + it('matches tags case-insensitively via camelCase normalisation', () => { + const verbOptions = { + op1: makeVerb('op1', ['Pet-Store']), + }; + const result = getRelevantVerbOptionsForTag(verbOptions, 'pet-store'); + expect(result).toHaveLength(1); + }); + + it('returns empty array when no verbs match the tag', () => { + const verbOptions = { + op1: makeVerb('op1', ['pets']), + }; + expect(getRelevantVerbOptionsForTag(verbOptions, 'users')).toHaveLength(0); + }); + + it('returns empty array for empty verbOptions', () => { + expect(getRelevantVerbOptionsForTag({}, 'pets')).toHaveLength(0); + }); +}); diff --git a/packages/angular/src/utils.ts b/packages/angular/src/utils.ts index 7bb5e44695..4c4db849dd 100644 --- a/packages/angular/src/utils.ts +++ b/packages/angular/src/utils.ts @@ -1,4 +1,7 @@ import { + camel, + DefaultTag, + type GeneratorVerbOptions, getAngularFilteredParamsHelperBody, getDefaultContentType, isBoolean, @@ -38,13 +41,23 @@ const PRIMITIVE_TYPE_LOOKUP = { unknown: true, } as const satisfies Record; +/** + * Narrows a schema type string to the primitive set supported by the Angular + * generators' query/header helpers. + */ export const isPrimitiveType = (t: string | undefined): t is PrimitiveType => t != undefined && Object.prototype.hasOwnProperty.call(PRIMITIVE_TYPE_LOOKUP, t); +/** + * Indicates whether the configured schema output target is Zod-based. + */ export const isZodSchemaOutput = (output: NormalizedOutputOptions): boolean => isObject(output.schemas) && output.schemas.type === 'zod'; +/** + * Removes `null` and `undefined` from a value in a type-safe way. + */ export const isDefined = (v: T | null | undefined): v is T => v != undefined; /** @@ -53,6 +66,9 @@ export const isDefined = (v: T | null | undefined): v is T => v != undefined; export const getSchemaOutputTypeRef = (typeName: string): string => `${typeName}Output`; +/** + * Converts an operation/tag title into the generated Angular service class name. + */ export const generateAngularTitle = (title: string) => { const sanTitle = sanitize(title); return `${pascal(sanTitle)}Service`; @@ -125,6 +141,38 @@ export const createRouteRegistry = () => { }; }; +/** + * Returns only the operations that belong to the current tag output. + * + * In `tags` / `tags-split` mode the writer may route untagged operations into + * the implicit `default` bucket. When a generated tag file targets that bucket + * we also include operations whose original tag list was empty; a literal + * user-defined `default` tag is treated like any other tag unless untagged + * operations are present in the same output. + */ +export const getRelevantVerbOptionsForTag = ( + verbOptions: Record, + tag?: string, +): GeneratorVerbOptions[] => { + const allVerbOptions = Object.values(verbOptions); + if (!tag) return allVerbOptions; + + const camelTag = camel(tag); + const includeUntaggedOperations = + tag === DefaultTag && + allVerbOptions.some((verbOption) => verbOption.tags.length === 0); + + return allVerbOptions.filter( + (verbOption) => + verbOption.tags.some((currentTag) => camel(currentTag) === camelTag) || + (includeUntaggedOperations && verbOption.tags.length === 0), + ); +}; + +/** + * Tracks deferred `ClientResult` aliases emitted while individual operations + * are rendered, then flushes only the aliases needed by the current file. + */ export const createReturnTypesRegistry = () => { const returnTypesToWrite = new Map(); @@ -201,6 +249,11 @@ export function isMutationVerb( return !isRetrievalVerb(verb, operationName, clientOverride); } +/** + * Selects the preferred success payload type for Angular `httpResource` + * generation, favouring JSON responses and otherwise falling back to the + * generator's default content-type rules. + */ export function getDefaultSuccessType( successTypes: ResReqTypesValue[], fallback: string, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 8739f9aab0..fd1a25297b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -948,6 +948,11 @@ export const Verbs = { HEAD: 'head' as Verbs, }; +/** + * Canonical tag name used for the generated bucket that collects untagged operations. + */ +export const DefaultTag = 'default' as const; + export interface ImportOpenApi { spec: OpenApiDocument; input: NormalizedInputOptions; @@ -990,8 +995,8 @@ export interface Tsconfig { exactOptionalPropertyTypes?: boolean; paths?: Record; target?: TsConfigTarget; - module?: string; - moduleResolution?: string; + module?: TsConfigModule; + moduleResolution?: TsConfigModuleResolution; allowImportingTsExtensions?: boolean; }; } @@ -1013,6 +1018,47 @@ export type TsConfigTarget = | 'es2025' | 'esnext'; // https://www.typescriptlang.org/tsconfig#target +/** Accepts both the canonical casing and the all-lowercase variant of a string literal. */ +type CaseInsensitive = T | Lowercase; + +/** + * Valid values for the TypeScript `compilerOptions.module` setting. + * + * Both title-case (e.g. `"NodeNext"`) and lower-case (e.g. `"nodenext"`) are + * accepted, matching TypeScript's own case-insensitive parsing. + * + * @see {@link https://www.typescriptlang.org/tsconfig#module} + */ +export type TsConfigModule = CaseInsensitive< + | 'None' + | 'CommonJS' + | 'AMD' + | 'UMD' + | 'System' + | 'ES6' + | 'ES2015' + | 'ES2020' + | 'ES2022' + | 'ESNext' + | 'Node16' + | 'Node18' + | 'Node20' + | 'NodeNext' + | 'Preserve' +>; + +/** + * Valid values for the TypeScript `compilerOptions.moduleResolution` setting. + * + * Both title-case (e.g. `"NodeNext"`) and lower-case (e.g. `"nodenext"`) are + * accepted, matching TypeScript's own case-insensitive parsing. + * + * @see https://www.typescriptlang.org/tsconfig#moduleResolution + */ +export type TsConfigModuleResolution = CaseInsensitive< + 'Classic' | 'Node' | 'Node10' | 'Node16' | 'NodeNext' | 'Bundler' +>; + export interface PackageJson { dependencies?: Record; devDependencies?: Record; diff --git a/packages/core/src/utils/doc.test.ts b/packages/core/src/utils/doc.test.ts index c8c22255ac..4fc4433fa3 100644 --- a/packages/core/src/utils/doc.test.ts +++ b/packages/core/src/utils/doc.test.ts @@ -41,6 +41,31 @@ describe('jsDoc', () => { * @items.maxItems 5 * @items.items.minLength 1 */ +`); + }); + + it('stops traversing circular item schemas', () => { + interface CircularItems { + [key: string]: unknown; + items?: CircularItems; + maxLength: number; + type: string; + } + + const circularItems: CircularItems = { + type: 'string', + maxLength: 50, + }; + circularItems.items = circularItems; + + expect( + jsDoc({ + type: 'array', + items: circularItems, + }), + ).toBe(`/** + * @items.maxLength 50 + */ `); }); }); diff --git a/packages/core/src/utils/doc.ts b/packages/core/src/utils/doc.ts index 475b476225..04b0f8ca45 100644 --- a/packages/core/src/utils/doc.ts +++ b/packages/core/src/utils/doc.ts @@ -42,10 +42,15 @@ const itemValidationKeys = [ function getItemValidationDocEntries( schema?: JsDocSchema, prefix = 'items', + visited = new WeakSet(), ): JsDocEntry[] { if (!schema) { return []; } + if (visited.has(schema)) { + return []; + } + visited.add(schema); const entries = itemValidationKeys.flatMap((key) => { const value = schema[key]; @@ -55,7 +60,7 @@ function getItemValidationDocEntries( return [ ...entries, - ...getItemValidationDocEntries(schema.items, `${prefix}.items`), + ...getItemValidationDocEntries(schema.items, `${prefix}.items`, visited), ]; } diff --git a/packages/core/src/writers/target-tags.ts b/packages/core/src/writers/target-tags.ts index 63402bb466..1e19965a97 100644 --- a/packages/core/src/writers/target-tags.ts +++ b/packages/core/src/writers/target-tags.ts @@ -1,4 +1,5 @@ import { + DefaultTag, type GeneratorOperation, type GeneratorTarget, type GeneratorTargetFull, @@ -8,10 +9,16 @@ import { } from '../types'; import { compareVersions, kebab, pascal } from '../utils'; +/** + * Ensures every operation has at least one tag by falling back to the + * {@link DefaultTag} constant for untagged operations, so the tag-routing + * logic in {@link generateTargetTags} always has a bucket to assign the + * operation to. + */ function addDefaultTagIfEmpty(operation: GeneratorOperation) { return { ...operation, - tags: operation.tags.length > 0 ? operation.tags : ['default'], + tags: operation.tags.length > 0 ? operation.tags : [DefaultTag], }; } diff --git a/samples/react-app/docs-html-plugin/assets/icons.svg b/samples/react-app/docs-html-plugin/assets/icons.svg index 10db10be02..be7798fcda 100644 --- a/samples/react-app/docs-html-plugin/assets/icons.svg +++ b/samples/react-app/docs-html-plugin/assets/icons.svg @@ -1 +1 @@ -MMNEPVFCICPMFPCPTTAAATR \ No newline at end of file +MMNEPVFCICPMFPCPTTAAATR \ No newline at end of file diff --git a/samples/react-app/docs-html/assets/icons.svg b/samples/react-app/docs-html/assets/icons.svg index 10db10be02..be7798fcda 100644 --- a/samples/react-app/docs-html/assets/icons.svg +++ b/samples/react-app/docs-html/assets/icons.svg @@ -1 +1 @@ -MMNEPVFCICPMFPCPTTAAATR \ No newline at end of file +MMNEPVFCICPMFPCPTTAAATR \ No newline at end of file diff --git a/tests/__snapshots__/angular/issue-3103/default/default.service.ts b/tests/__snapshots__/angular/issue-3103/default/default.service.ts new file mode 100644 index 0000000000..e70e7df995 --- /dev/null +++ b/tests/__snapshots__/angular/issue-3103/default/default.service.ts @@ -0,0 +1,159 @@ +/** + * Generated by orval v8.10.0 🍺 + * Do not edit manually. + * Issue 3103 - Angular tags-split default service query params + * 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 { GetApiProductParams, Product } 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, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet | undefined, + preserveRequiredNullables: true, +): Record; +function filterParams( + params: Record, + requiredNullableKeys: ReadonlySet = new Set(), + preserveRequiredNullables = false, +): Record { + const filteredParams: Record = {}; + for (const [key, value] of Object.entries(params)) { + 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 ( + preserveRequiredNullables && + value === null && + requiredNullableKeys.has(key) + ) { + filteredParams[key] = null; + } else if ( + value != null && + (typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean') + ) { + filteredParams[key] = value; + } + } + return filteredParams; +} + +@Injectable({ providedIn: 'root' }) +export class DefaultService { + private readonly http = inject(HttpClient); + getApiProduct( + params?: GetApiProductParams, + options?: HttpClientBodyOptions, + ): Observable; + getApiProduct( + params?: GetApiProductParams, + options?: HttpClientEventOptions, + ): Observable>; + getApiProduct( + params?: GetApiProductParams, + options?: HttpClientResponseOptions, + ): Observable>; + getApiProduct( + params?: GetApiProductParams, + options?: HttpClientObserveOptions, + ): Observable | AngularHttpResponse> { + const filteredParams = filterParams( + { ...params, ...options?.params }, + new Set([]), + ); + + if (options?.observe === 'events') { + return this.http.get(`/api/Product`, { + ...(options as Omit, 'observe'>), + observe: 'events', + params: filteredParams, + }); + } + + if (options?.observe === 'response') { + return this.http.get(`/api/Product`, { + ...(options as Omit, 'observe'>), + observe: 'response', + params: filteredParams, + }); + } + + return this.http.get(`/api/Product`, { + ...(options as Omit, 'observe'>), + observe: 'body', + params: filteredParams, + }); + } +} diff --git a/tests/__snapshots__/angular/issue-3103/model/getApiProductParams.ts b/tests/__snapshots__/angular/issue-3103/model/getApiProductParams.ts new file mode 100644 index 0000000000..69d0338127 --- /dev/null +++ b/tests/__snapshots__/angular/issue-3103/model/getApiProductParams.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v8.10.0 🍺 + * Do not edit manually. + * Issue 3103 - Angular tags-split default service query params + * OpenAPI spec version: 1.0.0 + */ + +export type GetApiProductParams = { + q?: string; +}; diff --git a/tests/__snapshots__/angular/issue-3103/model/index.ts b/tests/__snapshots__/angular/issue-3103/model/index.ts new file mode 100644 index 0000000000..c6f4d2e4e3 --- /dev/null +++ b/tests/__snapshots__/angular/issue-3103/model/index.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v8.10.0 🍺 + * Do not edit manually. + * Issue 3103 - Angular tags-split default service query params + * OpenAPI spec version: 1.0.0 + */ + +export * from './getApiProductParams'; +export * from './product'; diff --git a/tests/__snapshots__/angular/issue-3103/model/product.ts b/tests/__snapshots__/angular/issue-3103/model/product.ts new file mode 100644 index 0000000000..ba795fff22 --- /dev/null +++ b/tests/__snapshots__/angular/issue-3103/model/product.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v8.10.0 🍺 + * Do not edit manually. + * Issue 3103 - Angular tags-split default service query params + * OpenAPI spec version: 1.0.0 + */ + +export interface Product { + id?: number; +} diff --git a/tests/api-generation.spec.ts b/tests/api-generation.spec.ts index 776f70f7d9..88dd309b98 100644 --- a/tests/api-generation.spec.ts +++ b/tests/api-generation.spec.ts @@ -1,5 +1,8 @@ +import { readFile } from 'node:fs/promises'; import path from 'node:path'; +import { expect, test } from 'vitest'; + import { describeApiGenerationSnapshots } from '../test-utils/snapshot-testing'; const generated = (...segments: string[]) => @@ -26,3 +29,19 @@ await describeApiGenerationSnapshots({ snapshotsDir: path.resolve(import.meta.dirname, '__snapshots__'), rootDir: path.resolve(import.meta.dirname, '..'), }); + +test('angular issue-3103 emits filterParams in tags-split default service', async () => { + // Keep this focused assertion alongside the snapshot so #3103 fails with a + // targeted message instead of a full-file snapshot diff. + const defaultServiceFile = generated( + 'angular', + 'issue-3103', + 'default', + 'default.service.ts', + ); + const content = await readFile(defaultServiceFile, 'utf8'); + + expect(content).toContain('export class DefaultService'); + expect(content).toContain('function filterParams('); + expect(content).toContain('const filteredParams = filterParams('); +}); diff --git a/tests/configs/angular.config.ts b/tests/configs/angular.config.ts index 16c54cccb1..a719439dc0 100644 --- a/tests/configs/angular.config.ts +++ b/tests/configs/angular.config.ts @@ -210,4 +210,17 @@ export default defineConfig({ target: '../specifications/angular-multi-content-query-params.yaml', }, }, + issue3103: { + output: { + target: '../generated/angular/issue-3103/endpoints.ts', + schemas: '../generated/angular/issue-3103/model', + client: 'angular', + mode: 'tags-split', + clean: true, + formatter: 'prettier', + }, + input: { + target: '../specifications/issue-3103.yaml', + }, + }, }); diff --git a/tests/specifications/issue-3103.yaml b/tests/specifications/issue-3103.yaml new file mode 100644 index 0000000000..4f19fa5606 --- /dev/null +++ b/tests/specifications/issue-3103.yaml @@ -0,0 +1,29 @@ +openapi: 3.0.4 +info: + title: Issue 3103 - Angular tags-split default service query params + version: 1.0.0 +paths: + /api/Product: + get: + operationId: getApiProduct + parameters: + - name: q + in: query + required: false + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Product' +components: + schemas: + Product: + type: object + properties: + id: + type: integer + format: int32