From d1096ffe49f5a20b7d4acfbb5d5638cd95c6a72c Mon Sep 17 00:00:00 2001 From: Leander Peter Date: Fri, 3 Apr 2026 12:36:26 +0200 Subject: [PATCH] fix: implement style:deepObject query parameter serialization Orval did not implement the OpenAPI `style` field for query parameters. When a parameter declared `style: deepObject` with an object schema, the generated code called `.toString()` on the entire object, producing `key=[object+Object]` instead of the specified bracket notation `key[field]=value`. This adds a deepObject branch to the URL-building template in both `@orval/fetch` and `@orval/solid-start`, following the same pattern used for exploded array parameters. Bracket-encoded output from URLSearchParams is decoded so servers receive raw `[]` notation. Closes #2589 Made-with: Cursor --- packages/fetch/src/index.test.ts | 470 +++++++++++++++++++++++++ packages/fetch/src/index.ts | 74 +++- packages/solid-start/src/index.test.ts | 237 +++++++++++++ packages/solid-start/src/index.ts | 60 +++- 4 files changed, 833 insertions(+), 8 deletions(-) create mode 100644 packages/fetch/src/index.test.ts diff --git a/packages/fetch/src/index.test.ts b/packages/fetch/src/index.test.ts new file mode 100644 index 0000000000..e65446d76e --- /dev/null +++ b/packages/fetch/src/index.test.ts @@ -0,0 +1,470 @@ +import type { + ContextSpec, + GeneratorOptions, + GeneratorVerbOptions, + OpenApiParameterObject, + OpenApiReferenceObject, +} from '@orval/core'; +import { + EnumGeneration, + FormDataArrayHandling, + NamingConvention, + OutputClient, + OutputHttpClient, + OutputMode, + PropertySortOrder, + Verbs, +} from '@orval/core'; +import { describe, expect, it } from 'vitest'; + +import { generateRequestFunction } from './index'; + +type OpenApiParameterLike = OpenApiParameterObject | OpenApiReferenceObject; + +function makeOutput(useDates = false): ContextSpec['output'] { + return { + target: '', + namingConvention: NamingConvention.CAMEL_CASE, + fileExtension: '.ts', + mode: OutputMode.SINGLE, + client: OutputClient.FETCH, + httpClient: OutputHttpClient.FETCH, + clean: false, + docs: false, + formatter: undefined, + headers: false, + indexFiles: false, + allParamsOptional: false, + urlEncodeParameters: false, + unionAddMissingProperties: false, + optionsParamRequired: false, + propertySortOrder: PropertySortOrder.ALPHABETICAL, + override: { + title: undefined, + transformer: undefined, + mutator: undefined, + operations: {}, + tags: {}, + mock: undefined, + contentType: undefined, + header: false, + formData: { + disabled: false, + arrayHandling: FormDataArrayHandling.SERIALIZE, + }, + formUrlEncoded: false, + paramsSerializer: undefined, + paramsSerializerOptions: undefined, + namingConvention: {}, + components: { + schemas: { suffix: '', itemSuffix: '' }, + responses: { suffix: '' }, + parameters: { suffix: '' }, + requestBodies: { suffix: '' }, + }, + hono: { compositeRoute: '', validator: false, validatorOutputPath: '' }, + query: { + useQuery: false, + useSuspenseQuery: false, + useMutation: false, + useInfinite: false, + useSuspenseInfiniteQuery: false, + useInfiniteQueryParam: '', + usePrefetch: false, + useInvalidate: false, + useSetQueryData: false, + useGetQueryData: false, + shouldExportMutatorHooks: false, + shouldExportHttpClient: false, + shouldExportQueryKey: false, + shouldSplitQueryKey: false, + useOperationIdAsQueryKey: false, + signal: false, + version: 5, + }, + angular: { + provideIn: 'root', + client: 'httpClient', + runtimeValidation: false, + }, + swr: {}, + zod: { + strict: { + param: false, + query: false, + header: false, + body: false, + response: false, + }, + generate: { + param: false, + query: false, + header: false, + body: false, + response: false, + }, + coerce: { + param: false, + query: false, + header: false, + body: false, + response: false, + }, + generateEachHttpStatus: false, + useBrandedTypes: false, + dateTimeOptions: {}, + timeOptions: { precision: 3 }, + }, + fetch: { + includeHttpResponseReturnType: false, + forceSuccessResponse: false, + runtimeValidation: false, + }, + useDates, + enumGenerationType: EnumGeneration.UNION, + jsDoc: {}, + requestOptions: true, + aliasCombinedTypes: false, + }, + }; +} + +function makeContext( + parameters: OpenApiParameterLike[] = [], + useDates = false, +): ContextSpec { + return { + target: '', + workspace: '', + spec: { + openapi: '3.1.0', + info: { title: 'Test' }, + paths: { + '/pets': { + get: { parameters }, + }, + }, + }, + output: makeOutput(useDates), + }; +} + +function makeVerbOptions( + overrides: Partial = {}, +): GeneratorVerbOptions { + return { + verb: Verbs.GET, + route: '/pets', + pathRoute: '/pets', + operationId: 'listPets', + operationName: 'listPets', + doc: '', + tags: [], + response: { + definition: { success: 'Pet[]', errors: '' }, + imports: [], + types: { success: [], errors: [] }, + contentTypes: ['application/json'], + schemas: [], + isBlob: false, + } as GeneratorVerbOptions['response'], + body: { + definition: '', + implementation: '', + imports: [], + schemas: [], + formData: undefined, + formUrlEncoded: undefined, + contentType: '', + isOptional: true, + originalSchema: {}, + } as GeneratorVerbOptions['body'], + params: [], + props: [], + override: { + formData: { + disabled: false, + arrayHandling: FormDataArrayHandling.SERIALIZE, + }, + formUrlEncoded: false, + requestOptions: false, + fetch: { + includeHttpResponseReturnType: false, + forceSuccessResponse: false, + runtimeValidation: false, + }, + } as GeneratorVerbOptions['override'], + originalOperation: {} as GeneratorVerbOptions['originalOperation'], + ...overrides, + } as GeneratorVerbOptions; +} + +function makeOptions( + context: ContextSpec, + overrides: Partial = {}, +): GeneratorOptions { + return { + route: '/pets', + pathRoute: '/pets', + override: {} as GeneratorOptions['override'], + output: '', + context, + ...overrides, + } as GeneratorOptions; +} + +const STUB_QUERY_PARAMS: GeneratorVerbOptions['queryParams'] = { + schema: { + name: 'ListPetsParams', + model: 'export type ListPetsParams = { limit?: string }', + imports: [], + }, + deps: [], + isOptional: true, +} as GeneratorVerbOptions['queryParams']; + +function generateImplementation( + verbOptions: GeneratorVerbOptions, + options: GeneratorOptions, +): string { + return generateRequestFunction(verbOptions, options); +} + +describe('generateRequestFunction — deepObject query parameters', () => { + it('generates bracket notation for a style:deepObject query param', () => { + const parameters = [ + { + name: 'scope', + in: 'query', + style: 'deepObject', + explode: true, + schema: { + type: 'object', + properties: { + call_id: { type: 'string' }, + }, + }, + }, + ]; + const verbOptions = makeVerbOptions({ queryParams: STUB_QUERY_PARAMS }); + const options = makeOptions(makeContext(parameters)); + + const implementation = generateImplementation(verbOptions, options); + + expect(implementation).toContain('const deepObjectParameters = ["scope"]'); + expect(implementation).toContain( + "typeof value === 'object' && value !== null && !Array.isArray(value) && deepObjectParameters.includes(key)", + ); + expect(implementation).toContain( + 'Object.entries(value).forEach(([subKey, subValue])', + ); + expect(implementation).toContain('deepObjectEntries.push('); + expect(implementation).toContain('encodeURIComponent(key)'); + expect(implementation).toContain('encodeURIComponent(subKey)'); + expect(implementation).toContain( + "[normalizedParams.toString(), deepObjectEntries.join('&')].filter(Boolean).join('&')", + ); + }); + + it('does NOT decode brackets when there are no deepObject params', () => { + const parameters = [ + { name: 'limit', in: 'query', schema: { type: 'string' } }, + ]; + const verbOptions = makeVerbOptions({ queryParams: STUB_QUERY_PARAMS }); + const options = makeOptions(makeContext(parameters)); + + const implementation = generateImplementation(verbOptions, options); + + expect(implementation).not.toContain('deepObjectEntries'); + }); + + it('does NOT generate deepObject logic for a plain object param without style:deepObject', () => { + const parameters = [ + { + name: 'filter', + in: 'query', + schema: { + type: 'object', + properties: { + status: { type: 'string' }, + }, + }, + }, + ]; + const verbOptions = makeVerbOptions({ queryParams: STUB_QUERY_PARAMS }); + const options = makeOptions(makeContext(parameters)); + + const implementation = generateImplementation(verbOptions, options); + + expect(implementation).not.toContain('deepObjectParameters'); + expect(implementation).toContain('normalizedParams.append(key'); + }); + + it('handles mixed deepObject and scalar params', () => { + const parameters = [ + { + name: 'scope', + in: 'query', + style: 'deepObject', + explode: true, + schema: { + type: 'object', + properties: { call_id: { type: 'string' } }, + }, + }, + { name: 'limit', in: 'query', schema: { type: 'integer' } }, + ]; + const verbOptions = makeVerbOptions({ queryParams: STUB_QUERY_PARAMS }); + const options = makeOptions(makeContext(parameters)); + + const implementation = generateImplementation(verbOptions, options); + + expect(implementation).toContain('const deepObjectParameters = ["scope"]'); + expect(implementation).toContain('deepObjectEntries.push('); + // scalar fallback still present for `limit` + expect(implementation).toContain( + "value === null ? 'null' : value.toString()", + ); + }); + + it('handles mixed deepObject and exploded array params', () => { + const parameters = [ + { + name: 'scope', + in: 'query', + style: 'deepObject', + explode: true, + schema: { + type: 'object', + properties: { call_id: { type: 'string' } }, + }, + }, + { + name: 'tags', + in: 'query', + explode: true, + schema: { type: 'array', items: { type: 'string' } }, + }, + ]; + const verbOptions = makeVerbOptions({ queryParams: STUB_QUERY_PARAMS }); + const options = makeOptions(makeContext(parameters)); + + const implementation = generateImplementation(verbOptions, options); + + expect(implementation).toContain('const explodeParameters = ["tags"]'); + expect(implementation).toContain('const deepObjectParameters = ["scope"]'); + // both explode and deepObject are the only params, so no scalar fallback + expect(implementation).not.toContain( + "value === null ? 'null' : value.toString()", + ); + }); + + it('omits scalar fallback when all params are deepObject', () => { + const parameters = [ + { + name: 'scope', + in: 'query', + style: 'deepObject', + explode: true, + schema: { + type: 'object', + properties: { call_id: { type: 'string' } }, + }, + }, + ]; + const verbOptions = makeVerbOptions({ queryParams: STUB_QUERY_PARAMS }); + const options = makeOptions(makeContext(parameters)); + + const implementation = generateImplementation(verbOptions, options); + + expect(implementation).toContain('const deepObjectParameters = ["scope"]'); + expect(implementation).not.toContain( + "value === null ? 'null' : value.toString()", + ); + }); + + it('generates toISOString() for deepObject properties with date-time format when useDates is true', () => { + const parameters = [ + { + name: 'scope', + in: 'query', + style: 'deepObject', + explode: true, + schema: { + type: 'object', + properties: { + created_at: { type: 'string', format: 'date-time' }, + }, + }, + }, + ]; + const verbOptions = makeVerbOptions({ queryParams: STUB_QUERY_PARAMS }); + const options = makeOptions(makeContext(parameters, true)); + + const implementation = generateImplementation(verbOptions, options); + + expect(implementation).toContain('const deepObjectParameters = ["scope"]'); + expect(implementation).toContain( + 'subValue instanceof Date ? subValue.toISOString()', + ); + }); + + it('does NOT generate toISOString() for deepObject when useDates is false', () => { + const parameters = [ + { + name: 'scope', + in: 'query', + style: 'deepObject', + explode: true, + schema: { + type: 'object', + properties: { + created_at: { type: 'string', format: 'date-time' }, + }, + }, + }, + ]; + const verbOptions = makeVerbOptions({ queryParams: STUB_QUERY_PARAMS }); + const options = makeOptions(makeContext(parameters, false)); + + const implementation = generateImplementation(verbOptions, options); + + expect(implementation).toContain('const deepObjectParameters = ["scope"]'); + expect(implementation).not.toContain( + 'subValue instanceof Date ? subValue.toISOString()', + ); + }); + + it('handles multiple deepObject params', () => { + const parameters = [ + { + name: 'scope', + in: 'query', + style: 'deepObject', + explode: true, + schema: { + type: 'object', + properties: { call_id: { type: 'string' } }, + }, + }, + { + name: 'filter', + in: 'query', + style: 'deepObject', + explode: true, + schema: { + type: 'object', + properties: { status: { type: 'string' } }, + }, + }, + ]; + const verbOptions = makeVerbOptions({ queryParams: STUB_QUERY_PARAMS }); + const options = makeOptions(makeContext(parameters)); + + const implementation = generateImplementation(verbOptions, options); + + expect(implementation).toContain('"scope"'); + expect(implementation).toContain('"filter"'); + expect(implementation).toContain('deepObjectEntries.push('); + }); +}); diff --git a/packages/fetch/src/index.ts b/packages/fetch/src/index.ts index 0563d02580..574ae62de0 100644 --- a/packages/fetch/src/index.ts +++ b/packages/fetch/src/index.ts @@ -84,8 +84,22 @@ export const generateRequestFunction = ( 'implementation', ); - const spec = context.spec.paths?.[pathRoute]; - const parameters = spec?.[verb]?.parameters ?? []; + const pathItem = context.spec.paths?.[pathRoute]; + const operation = pathItem?.[verb]; + const mergedParameters = [ + ...(pathItem?.parameters ?? []), + ...(operation?.parameters ?? []), + ] as (OpenApiParameterObject | OpenApiReferenceObject)[]; + const byKey = new Map< + string, + OpenApiParameterObject | OpenApiReferenceObject + >(); + for (const parameter of mergedParameters) { + const { schema } = resolveRef(parameter, context); + const parameterObject = schema as OpenApiParameterObject; + byKey.set(`${parameterObject.in}:${parameterObject.name}`, parameter); + } + const parameters = [...byKey.values()]; const parameterObjects = parameters.map((parameter) => { const { schema } = resolveRef(parameter, context); return schema as OpenApiParameterObject; @@ -151,8 +165,57 @@ export const generateRequestFunction = ( ` : ''; + const deepObjectParameters = parameterObjects.filter( + (parameterObject) => + parameterObject.in === 'query' && parameterObject.style === 'deepObject', + ); + + const deepObjectParameterNames = deepObjectParameters.map( + (parameter) => parameter.name, + ); + + const hasDeepObjectDateParams = + context.output.override.useDates && + deepObjectParameters.some((parameter) => { + if (!parameter.schema) { + return false; + } + + const { schema } = resolveSchemaRef(parameter.schema, context); + + if (!schema.properties) { + return false; + } + + return Object.values( + schema.properties as Record< + string, + OpenApiSchemaObject | OpenApiReferenceObject + >, + ).some((prop) => { + const { schema: propSchema } = resolveSchemaRef(prop, context); + return propSchema.format === 'date-time'; + }); + }); + + const deepObjectImplementation = + deepObjectParameters.length > 0 + ? `const deepObjectParameters = ${JSON.stringify(deepObjectParameterNames)}; + + if (typeof value === 'object' && value !== null && !Array.isArray(value) && deepObjectParameters.includes(key)) { + Object.entries(value).forEach(([subKey, subValue]) => { + if (subValue !== undefined) { + deepObjectEntries.push(encodeURIComponent(key) + '[' + encodeURIComponent(subKey) + ']=' + (subValue === null ? 'null' : encodeURIComponent(${hasDeepObjectDateParams ? 'subValue instanceof Date ? subValue.toISOString() : ' : ''}subValue.toString()))); + } + }); + return; + } + ` + : ''; + const isExplodeParametersOnly = - explodeParameters.length === parameters.length; + explodeParameters.length + deepObjectParameters.length === + parameters.length; const hasDateParams = context.output.override.useDates && @@ -173,15 +236,16 @@ export const generateRequestFunction = ( ${ queryParams ? ` const normalizedParams = new URLSearchParams(); - +${deepObjectParameters.length > 0 ? ' const deepObjectEntries = [];\n' : ''} Object.entries(params || {}).forEach(([key, value]) => { ${explodeArrayImplementation} + ${deepObjectImplementation} ${isExplodeParametersOnly ? '' : normalParamsImplementation} });` : '' } - ${queryParams ? `const stringifiedParams = normalizedParams.toString();` : ``} + ${queryParams ? (deepObjectParameters.length > 0 ? `const stringifiedParams = [normalizedParams.toString(), deepObjectEntries.join('&')].filter(Boolean).join('&');` : `const stringifiedParams = normalizedParams.toString();`) : ``} ${ queryParams diff --git a/packages/solid-start/src/index.test.ts b/packages/solid-start/src/index.test.ts index 438dda41f3..60aefb8bd7 100644 --- a/packages/solid-start/src/index.test.ts +++ b/packages/solid-start/src/index.test.ts @@ -494,6 +494,243 @@ describe('generateSolidStart — path-level parameter merging', () => { }); }); +describe('generateSolidStart — deepObject query parameters', () => { + it('generates bracket notation for a style:deepObject query param', async () => { + const parameters = [ + { + name: 'scope', + in: 'query', + style: 'deepObject', + explode: true, + schema: { + type: 'object', + properties: { + call_id: { type: 'string' }, + }, + }, + }, + ]; + const verbOptions = makeVerbOptions({ queryParams: STUB_QUERY_PARAMS }); + const options = makeOptions(makeContext(parameters)); + + const implementation = await generateImplementation(verbOptions, options); + + expect(implementation).toContain('const deepObjectParameters = ["scope"]'); + expect(implementation).toContain( + "typeof value === 'object' && value !== null && !Array.isArray(value) && deepObjectParameters.includes(key)", + ); + expect(implementation).toContain( + 'Object.entries(value).forEach(([subKey, subValue])', + ); + expect(implementation).toContain('deepObjectEntries.push('); + expect(implementation).toContain('encodeURIComponent(key)'); + expect(implementation).toContain('encodeURIComponent(subKey)'); + expect(implementation).toContain( + "[normalizedParams.toString(), deepObjectEntries.join('&')].filter(Boolean).join('&')", + ); + }); + + it('does NOT generate deepObjectEntries when there are no deepObject params', async () => { + const parameters = [ + { name: 'limit', in: 'query', schema: { type: 'string' } }, + ]; + const verbOptions = makeVerbOptions({ queryParams: STUB_QUERY_PARAMS }); + const options = makeOptions(makeContext(parameters)); + + const implementation = await generateImplementation(verbOptions, options); + + expect(implementation).not.toContain('deepObjectEntries'); + }); + + it('does NOT generate deepObject logic for a plain object param without style:deepObject', async () => { + const parameters = [ + { + name: 'filter', + in: 'query', + schema: { + type: 'object', + properties: { + status: { type: 'string' }, + }, + }, + }, + ]; + const verbOptions = makeVerbOptions({ queryParams: STUB_QUERY_PARAMS }); + const options = makeOptions(makeContext(parameters)); + + const implementation = await generateImplementation(verbOptions, options); + + expect(implementation).not.toContain('deepObjectParameters'); + expect(implementation).toContain('normalizedParams.append(key'); + }); + + it('handles mixed deepObject and scalar params', async () => { + const parameters = [ + { + name: 'scope', + in: 'query', + style: 'deepObject', + explode: true, + schema: { + type: 'object', + properties: { call_id: { type: 'string' } }, + }, + }, + { name: 'limit', in: 'query', schema: { type: 'integer' } }, + ]; + const verbOptions = makeVerbOptions({ queryParams: STUB_QUERY_PARAMS }); + const options = makeOptions(makeContext(parameters)); + + const implementation = await generateImplementation(verbOptions, options); + + expect(implementation).toContain('const deepObjectParameters = ["scope"]'); + expect(implementation).toContain('deepObjectEntries.push('); + // scalar fallback still present for `limit` + expect(implementation).toContain( + "value === null ? 'null' : value.toString()", + ); + }); + + it('handles mixed deepObject and exploded array params', async () => { + const parameters = [ + { + name: 'scope', + in: 'query', + style: 'deepObject', + explode: true, + schema: { + type: 'object', + properties: { call_id: { type: 'string' } }, + }, + }, + { + name: 'tags', + in: 'query', + explode: true, + schema: { type: 'array', items: { type: 'string' } }, + }, + ]; + const verbOptions = makeVerbOptions({ queryParams: STUB_QUERY_PARAMS }); + const options = makeOptions(makeContext(parameters)); + + const implementation = await generateImplementation(verbOptions, options); + + expect(implementation).toContain('const explodeParameters = ["tags"]'); + expect(implementation).toContain('const deepObjectParameters = ["scope"]'); + // both branches handle all params, so no scalar fallback + expect(implementation).not.toContain( + "value === null ? 'null' : value.toString()", + ); + }); + + it('picks up a deepObject param defined at the path-item level', async () => { + const context = makeContextWithPathParams([ + { + name: 'scope', + in: 'query', + style: 'deepObject', + explode: true, + schema: { + type: 'object', + properties: { call_id: { type: 'string' } }, + }, + }, + ]); + const verbOptions = makeVerbOptions({ queryParams: STUB_QUERY_PARAMS }); + const options = makeOptions(context); + + const implementation = await generateImplementation(verbOptions, options); + + expect(implementation).toContain('const deepObjectParameters = ["scope"]'); + expect(implementation).toContain('deepObjectEntries.push('); + }); + + it('handles multiple deepObject params', async () => { + const parameters = [ + { + name: 'scope', + in: 'query', + style: 'deepObject', + explode: true, + schema: { + type: 'object', + properties: { call_id: { type: 'string' } }, + }, + }, + { + name: 'filter', + in: 'query', + style: 'deepObject', + explode: true, + schema: { + type: 'object', + properties: { status: { type: 'string' } }, + }, + }, + ]; + const verbOptions = makeVerbOptions({ queryParams: STUB_QUERY_PARAMS }); + const options = makeOptions(makeContext(parameters)); + + const implementation = await generateImplementation(verbOptions, options); + + expect(implementation).toContain('"scope"'); + expect(implementation).toContain('"filter"'); + expect(implementation).toContain('deepObjectEntries.push('); + }); + + it('generates toISOString() for deepObject properties with date-time format when useDates is true', async () => { + const parameters = [ + { + name: 'scope', + in: 'query', + style: 'deepObject', + explode: true, + schema: { + type: 'object', + properties: { + created_at: { type: 'string', format: 'date-time' }, + }, + }, + }, + ]; + const verbOptions = makeVerbOptions({ queryParams: STUB_QUERY_PARAMS }); + const options = makeOptions(makeContext(parameters, true)); + + const implementation = await generateImplementation(verbOptions, options); + + expect(implementation).toContain('const deepObjectParameters = ["scope"]'); + expect(implementation).toContain( + 'subValue instanceof Date ? subValue.toISOString()', + ); + }); + + it('does NOT generate toISOString() for deepObject when useDates is false', async () => { + const parameters = [ + { + name: 'scope', + in: 'query', + style: 'deepObject', + explode: true, + schema: { + type: 'object', + properties: { + created_at: { type: 'string', format: 'date-time' }, + }, + }, + }, + ]; + const verbOptions = makeVerbOptions({ queryParams: STUB_QUERY_PARAMS }); + const options = makeOptions(makeContext(parameters, false)); + + const implementation = await generateImplementation(verbOptions, options); + + expect(implementation).toContain('const deepObjectParameters = ["scope"]'); + expect(implementation).not.toContain( + 'subValue instanceof Date ? subValue.toISOString()', + ); + }); +}); + describe('generateSolidStart — date-time format on array items (useDates)', () => { it('generates toISOString() for an exploded array param', async () => { const parameters = [ diff --git a/packages/solid-start/src/index.ts b/packages/solid-start/src/index.ts index 9da38bf4f0..1b7e36adff 100644 --- a/packages/solid-start/src/index.ts +++ b/packages/solid-start/src/index.ts @@ -261,8 +261,46 @@ const generateImplementation = ( return schemaObject.format === 'date-time' || itemsFormat === 'date-time'; }); + const deepObjectParameters = parameterObjects.filter((parameterObject) => { + return ( + parameterObject.in === 'query' && parameterObject.style === 'deepObject' + ); + }); + + const deepObjectParameterNames = deepObjectParameters.map( + (parameter) => parameter.name, + ); + + const hasDeepObjectDateParams = + context.output.override.useDates && + deepObjectParameters.some((parameter) => { + if (!parameter.schema) { + return false; + } + + const { schema: schemaObject } = resolveSchemaRef( + parameter.schema, + context, + ); + + if (!schemaObject.properties) { + return false; + } + + return Object.values( + schemaObject.properties as Record< + string, + OpenApiSchemaObject | OpenApiReferenceObject + >, + ).some((prop) => { + const { schema: propSchema } = resolveSchemaRef(prop, context); + return propSchema.format === 'date-time'; + }); + }); + const isExplodeParametersOnly = - explodeParameters.length === parameters.length; + explodeParameters.length + deepObjectParameters.length === + parameters.length; const hasDateParams = context.output.override.useDates && @@ -296,6 +334,21 @@ const generateImplementation = ( ` : ''; + const deepObjectImplementation = + deepObjectParameters.length > 0 + ? `const deepObjectParameters = ${JSON.stringify(deepObjectParameterNames)}; + + if (typeof value === 'object' && value !== null && !Array.isArray(value) && deepObjectParameters.includes(key)) { + Object.entries(value).forEach(([subKey, subValue]) => { + if (subValue !== undefined) { + deepObjectEntries.push(encodeURIComponent(key) + '[' + encodeURIComponent(subKey) + ']=' + (subValue === null ? 'null' : encodeURIComponent(${hasDeepObjectDateParams ? 'subValue instanceof Date ? subValue.toISOString() : ' : ''}subValue.toString()))); + } + }); + return; + } + ` + : ''; + const normalParamsImplementation = `if (value !== undefined) { normalizedParams.append(key, Array.isArray(value) ? value.map(v => v === null ? 'null' : ${hasDateParams ? 'v instanceof Date ? v.toISOString() : ' : ''}String(v)).join(',') : value === null ? 'null' : ${hasDateParams ? 'value instanceof Date ? value.toISOString() : ' : ''}value.toString()) }`; @@ -303,9 +356,10 @@ const generateImplementation = ( // Build query params string const queryParamsCode = queryParams ? `const normalizedParams = new URLSearchParams(); - +${deepObjectParameters.length > 0 ? ' const deepObjectEntries = [];\n' : ''} Object.entries(params || {}).forEach(([key, value]) => { ${explodeArrayImplementation} + ${deepObjectImplementation} ${ // When every parameter is declared as an exploded array, scalar values // are a type error at the call site (orval generates array-only types), @@ -314,7 +368,7 @@ const generateImplementation = ( } }); - const queryString = normalizedParams.toString(); + const queryString = ${deepObjectParameters.length > 0 ? `[normalizedParams.toString(), deepObjectEntries.join('&')].filter(Boolean).join('&')` : `normalizedParams.toString()`}; const url = queryString ? \`${route}?\${queryString}\` : \`${route}\`;` : `const url = \`${route}\`;`;