From 8010dbb1ab8b91d1d49d5cf16276183764a63ff3 Mon Sep 17 00:00:00 2001 From: Lubos Date: Thu, 12 Dec 2024 22:13:17 +0800 Subject: [PATCH] fix: add buildUrl and querySerializer to Axios client --- .changeset/fast-laws-divide.md | 5 + .changeset/great-ears-fly.md | 5 + .changeset/smart-eyes-promise.md | 5 + .changeset/stale-swans-attend.md | 5 + docs/openapi-ts/clients/axios.md | 31 +++++ packages/client-axios/src/index.ts | 11 +- packages/client-axios/src/types.ts | 30 ++++- packages/client-axios/src/utils.ts | 106 +++++++++++++++++- .../src/plugins/@hey-api/sdk/plugin.ts | 68 ++++++----- packages/openapi-ts/test/3.0.x.test.ts | 9 ++ packages/openapi-ts/test/3.1.x.test.ts | 9 ++ .../parameter-explode-false-axios/index.ts | 3 + .../parameter-explode-false-axios/sdk.gen.ts | 19 ++++ .../types.gen.ts | 17 +++ .../3.0.x/parameter-explode-false/sdk.gen.ts | 4 +- .../parameter-explode-false-axios/index.ts | 3 + .../parameter-explode-false-axios/sdk.gen.ts | 19 ++++ .../types.gen.ts | 17 +++ .../3.1.x/parameter-explode-false/sdk.gen.ts | 4 +- .../client/index.ts.snap | 11 +- .../client/types.ts.snap | 30 ++++- .../client/utils.ts.snap | 106 +++++++++++++++++- .../client/index.ts.snap | 11 +- .../client/types.ts.snap | 30 ++++- .../client/utils.ts.snap | 106 +++++++++++++++++- packages/openapi-ts/test/sample.cjs | 2 +- 26 files changed, 596 insertions(+), 70 deletions(-) create mode 100644 .changeset/fast-laws-divide.md create mode 100644 .changeset/great-ears-fly.md create mode 100644 .changeset/smart-eyes-promise.md create mode 100644 .changeset/stale-swans-attend.md create mode 100644 packages/openapi-ts/test/__snapshots__/3.0.x/parameter-explode-false-axios/index.ts create mode 100644 packages/openapi-ts/test/__snapshots__/3.0.x/parameter-explode-false-axios/sdk.gen.ts create mode 100644 packages/openapi-ts/test/__snapshots__/3.0.x/parameter-explode-false-axios/types.gen.ts create mode 100644 packages/openapi-ts/test/__snapshots__/3.1.x/parameter-explode-false-axios/index.ts create mode 100644 packages/openapi-ts/test/__snapshots__/3.1.x/parameter-explode-false-axios/sdk.gen.ts create mode 100644 packages/openapi-ts/test/__snapshots__/3.1.x/parameter-explode-false-axios/types.gen.ts diff --git a/.changeset/fast-laws-divide.md b/.changeset/fast-laws-divide.md new file mode 100644 index 000000000..20bbdeba4 --- /dev/null +++ b/.changeset/fast-laws-divide.md @@ -0,0 +1,5 @@ +--- +'@hey-api/openapi-ts': patch +--- + +fix: generate querySerializer options for Axios client diff --git a/.changeset/great-ears-fly.md b/.changeset/great-ears-fly.md new file mode 100644 index 000000000..b5e9dd0c3 --- /dev/null +++ b/.changeset/great-ears-fly.md @@ -0,0 +1,5 @@ +--- +'@hey-api/client-axios': patch +--- + +fix: add buildUrl method to Axios client API diff --git a/.changeset/smart-eyes-promise.md b/.changeset/smart-eyes-promise.md new file mode 100644 index 000000000..9631c9f39 --- /dev/null +++ b/.changeset/smart-eyes-promise.md @@ -0,0 +1,5 @@ +--- +'@hey-api/client-axios': minor +--- + +feat: handle parameter styles the same way fetch client does if paramsSerializer is undefined diff --git a/.changeset/stale-swans-attend.md b/.changeset/stale-swans-attend.md new file mode 100644 index 000000000..4def5ba66 --- /dev/null +++ b/.changeset/stale-swans-attend.md @@ -0,0 +1,5 @@ +--- +'@hey-api/docs': patch +--- + +docs: add buildUrl() method to Axios client page diff --git a/docs/openapi-ts/clients/axios.md b/docs/openapi-ts/clients/axios.md index 473c74fdd..f0e304475 100644 --- a/docs/openapi-ts/clients/axios.md +++ b/docs/openapi-ts/clients/axios.md @@ -140,6 +140,37 @@ const response = await getFoo({ }); ``` +## Build URL + +::: warning +To use this feature, you must opt in to the [experimental parser](/openapi-ts/configuration#parser). +::: + +If you need to access the compiled URL, you can use the `buildUrl()` method. It's loosely typed by default to accept almost any value; in practice, you will want to pass a type hint. + +```ts +type FooData = { + path: { + fooId: number; + }; + query?: { + bar?: string; + }; + url: '/foo/{fooId}'; +}; + +const url = client.buildUrl({ + path: { + fooId: 1, + }, + query: { + bar: 'baz', + }, + url: '/foo/{fooId}', +}); +console.log(url); // prints '/foo/1?bar=baz' +``` + ## Bundling Sometimes, you may not want to declare client packages as a dependency. This scenario is common if you're using Hey API to generate output that is repackaged and published for other consumers under your own brand. For such cases, our clients support bundling through the `client.bundle` configuration option. diff --git a/packages/client-axios/src/index.ts b/packages/client-axios/src/index.ts index ad10cf1b8..be3c360e4 100644 --- a/packages/client-axios/src/index.ts +++ b/packages/client-axios/src/index.ts @@ -3,8 +3,8 @@ import axios from 'axios'; import type { Client, Config } from './types'; import { + buildUrl, createConfig, - getUrl, mergeConfigs, mergeHeaders, setAuthParams, @@ -48,17 +48,15 @@ export const createClient = (config: Config): Client => { opts.body = opts.bodySerializer(opts.body); } - const url = getUrl({ - path: opts.path, - url: opts.url, - }); + const url = buildUrl(opts); try { const response = await opts.axios({ ...opts, data: opts.body, headers: opts.headers as RawAxiosRequestHeaders, - params: opts.query, + // let `paramsSerializer()` handle query params if it exists + params: opts.paramsSerializer ? opts.query : undefined, url, }); @@ -84,6 +82,7 @@ export const createClient = (config: Config): Client => { }; return { + buildUrl, delete: (options) => request({ ...options, method: 'delete' }), get: (options) => request({ ...options, method: 'get' }), getConfig, diff --git a/packages/client-axios/src/types.ts b/packages/client-axios/src/types.ts index 7fff7be39..56cacb235 100644 --- a/packages/client-axios/src/types.ts +++ b/packages/client-axios/src/types.ts @@ -6,7 +6,11 @@ import type { CreateAxiosDefaults, } from 'axios'; -import type { BodySerializer } from './utils'; +import type { + BodySerializer, + QuerySerializer, + QuerySerializerOptions, +} from './utils'; type OmitKeys = Pick>; @@ -67,6 +71,17 @@ export interface Config | 'post' | 'put' | 'trace'; + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions; /** * A function for transforming response data before it's returned to the * caller function. This is an ideal place to post-process server data, @@ -141,6 +156,19 @@ type RequestFn = < ) => RequestResult; export interface Client { + /** + * Returns the final request URL. This method works only with experimental parser. + */ + buildUrl: < + Data extends { + body?: unknown; + path?: Record; + query?: Record; + url: string; + }, + >( + options: Pick & Omit, 'axios'>, + ) => string; delete: MethodFn; get: MethodFn; getConfig: () => Config; diff --git a/packages/client-axios/src/utils.ts b/packages/client-axios/src/utils.ts index f171ade00..196928cf7 100644 --- a/packages/client-axios/src/utils.ts +++ b/packages/client-axios/src/utils.ts @@ -1,4 +1,4 @@ -import type { Config, RequestOptions, Security } from './types'; +import type { Client, Config, RequestOptions, Security } from './types'; interface PathSerializer { path: Record; @@ -13,6 +13,8 @@ type ArraySeparatorStyle = ArrayStyle | MatrixStyle; type ObjectStyle = 'form' | 'deepObject'; type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; +export type QuerySerializer = (query: Record) => string; + export type BodySerializer = (body: any) => any; interface SerializerOptions { @@ -34,6 +36,12 @@ interface SerializePrimitiveParam extends SerializePrimitiveOptions { value: string; } +export interface QuerySerializerOptions { + allowReserved?: boolean; + array?: SerializerOptions; + object?: SerializerOptions; +} + const serializePrimitiveParam = ({ allowReserved, name, @@ -250,6 +258,66 @@ const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { return url; }; +export const createQuerySerializer = ({ + allowReserved, + array, + object, +}: QuerySerializerOptions = {}) => { + const querySerializer = (queryParams: T) => { + let search: string[] = []; + if (queryParams && typeof queryParams === 'object') { + for (const name in queryParams) { + const value = queryParams[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + search = [ + ...search, + serializeArrayParam({ + allowReserved, + explode: true, + name, + style: 'form', + value, + ...array, + }), + ]; + continue; + } + + if (typeof value === 'object') { + search = [ + ...search, + serializeObjectParam({ + allowReserved, + explode: true, + name, + style: 'deepObject', + value: value as Record, + ...object, + }), + ]; + continue; + } + + search = [ + ...search, + serializePrimitiveParam({ + allowReserved, + name, + value: value as string, + }), + ]; + } + } + return search.join('&'); + }; + return querySerializer; +}; + export const getAuthToken = async ( security: Security, options: Pick, @@ -297,13 +365,45 @@ export const setAuthParams = async ({ } }; +export const buildUrl: Client['buildUrl'] = (options) => { + const url = getUrl({ + path: options.path, + // let `paramsSerializer()` handle query params if it exists + query: !options.paramsSerializer ? options.query : undefined, + querySerializer: + typeof options.querySerializer === 'function' + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + return url; +}; + export const getUrl = ({ path, - url, + query, + querySerializer, + url: _url, }: { path?: Record; + query?: Record; + querySerializer: QuerySerializer; url: string; -}) => (path ? defaultPathSerializer({ path, url }) : url); +}) => { + const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; + let url = pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ''; + if (search.startsWith('?')) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; +}; const serializeFormDataPair = ( formData: FormData, diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts index 632dbd96c..d59474702 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts @@ -292,10 +292,35 @@ const operationStatements = ({ } } - requestOptions.push({ - key: 'url', - value: operation.path, - }); + for (const name in operation.parameters?.query) { + const parameter = operation.parameters.query[name]; + if ( + (parameter.schema.type === 'array' || + parameter.schema.type === 'tuple') && + (parameter.style !== 'form' || !parameter.explode) + ) { + // override the default settings for `querySerializer` + requestOptions.push({ + key: 'querySerializer', + value: [ + { + key: 'array', + value: [ + { + key: 'explode', + value: false, + }, + { + key: 'style', + value: 'form', + }, + ], + }, + ], + }); + break; + } + } const fileTransformers = context.file({ id: 'transformers' }); if (fileTransformers) { @@ -315,37 +340,10 @@ const operationStatements = ({ } } - for (const name in operation.parameters?.query) { - const parameter = operation.parameters.query[name]; - if ( - (parameter.schema.type === 'array' || - parameter.schema.type === 'tuple') && - (parameter.style !== 'form' || !parameter.explode) - ) { - // override the default settings for `querySerializer` - if (context.config.client.name === '@hey-api/client-fetch') { - requestOptions.push({ - key: 'querySerializer', - value: [ - { - key: 'array', - value: [ - { - key: 'explode', - value: false, - }, - { - key: 'style', - value: 'form', - }, - ], - }, - ], - }); - } - break; - } - } + requestOptions.push({ + key: 'url', + value: operation.path, + }); return [ compiler.returnFunctionCall({ diff --git a/packages/openapi-ts/test/3.0.x.test.ts b/packages/openapi-ts/test/3.0.x.test.ts index 7cea50db1..96d65aa0b 100644 --- a/packages/openapi-ts/test/3.0.x.test.ts +++ b/packages/openapi-ts/test/3.0.x.test.ts @@ -400,6 +400,15 @@ describe(`OpenAPI ${VERSION}`, () => { }), description: 'handles non-exploded array query parameters', }, + { + config: createConfig({ + client: '@hey-api/client-axios', + input: 'parameter-explode-false.json', + output: 'parameter-explode-false-axios', + plugins: ['@hey-api/sdk'], + }), + description: 'handles non-exploded array query parameters (Axios)', + }, { config: createConfig({ input: 'security-api-key.json', diff --git a/packages/openapi-ts/test/3.1.x.test.ts b/packages/openapi-ts/test/3.1.x.test.ts index 90839db42..6673d812c 100644 --- a/packages/openapi-ts/test/3.1.x.test.ts +++ b/packages/openapi-ts/test/3.1.x.test.ts @@ -438,6 +438,15 @@ describe(`OpenAPI ${VERSION}`, () => { }), description: 'handles non-exploded array query parameters', }, + { + config: createConfig({ + client: '@hey-api/client-axios', + input: 'parameter-explode-false.json', + output: 'parameter-explode-false-axios', + plugins: ['@hey-api/sdk'], + }), + description: 'handles non-exploded array query parameters (Axios)', + }, { config: createConfig({ input: 'required-all-of-ref.json', diff --git a/packages/openapi-ts/test/__snapshots__/3.0.x/parameter-explode-false-axios/index.ts b/packages/openapi-ts/test/__snapshots__/3.0.x/parameter-explode-false-axios/index.ts new file mode 100644 index 000000000..e64537d21 --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.0.x/parameter-explode-false-axios/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts +export * from './types.gen'; +export * from './sdk.gen'; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.0.x/parameter-explode-false-axios/sdk.gen.ts b/packages/openapi-ts/test/__snapshots__/3.0.x/parameter-explode-false-axios/sdk.gen.ts new file mode 100644 index 000000000..81b67634c --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.0.x/parameter-explode-false-axios/sdk.gen.ts @@ -0,0 +1,19 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createClient, createConfig, type Options } from '@hey-api/client-axios'; +import type { PostFooData } from './types.gen'; + +export const client = createClient(createConfig()); + +export const postFoo = (options?: Options) => { + return (options?.client ?? client).post({ + ...options, + querySerializer: { + array: { + explode: false, + style: 'form' + } + }, + url: '/foo' + }); +}; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.0.x/parameter-explode-false-axios/types.gen.ts b/packages/openapi-ts/test/__snapshots__/3.0.x/parameter-explode-false-axios/types.gen.ts new file mode 100644 index 000000000..5abc09372 --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.0.x/parameter-explode-false-axios/types.gen.ts @@ -0,0 +1,17 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type PostFooData = { + body?: never; + path?: never; + query?: { + foo?: Array; + }; + url: '/foo'; +}; + +export type PostFooResponses = { + /** + * OK + */ + default: unknown; +}; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.0.x/parameter-explode-false/sdk.gen.ts b/packages/openapi-ts/test/__snapshots__/3.0.x/parameter-explode-false/sdk.gen.ts index 6fb044134..7a0d238e4 100644 --- a/packages/openapi-ts/test/__snapshots__/3.0.x/parameter-explode-false/sdk.gen.ts +++ b/packages/openapi-ts/test/__snapshots__/3.0.x/parameter-explode-false/sdk.gen.ts @@ -8,12 +8,12 @@ export const client = createClient(createConfig()); export const postFoo = (options?: Options) => { return (options?.client ?? client).post({ ...options, - url: '/foo', querySerializer: { array: { explode: false, style: 'form' } - } + }, + url: '/foo' }); }; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.1.x/parameter-explode-false-axios/index.ts b/packages/openapi-ts/test/__snapshots__/3.1.x/parameter-explode-false-axios/index.ts new file mode 100644 index 000000000..e64537d21 --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.1.x/parameter-explode-false-axios/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts +export * from './types.gen'; +export * from './sdk.gen'; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.1.x/parameter-explode-false-axios/sdk.gen.ts b/packages/openapi-ts/test/__snapshots__/3.1.x/parameter-explode-false-axios/sdk.gen.ts new file mode 100644 index 000000000..81b67634c --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.1.x/parameter-explode-false-axios/sdk.gen.ts @@ -0,0 +1,19 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createClient, createConfig, type Options } from '@hey-api/client-axios'; +import type { PostFooData } from './types.gen'; + +export const client = createClient(createConfig()); + +export const postFoo = (options?: Options) => { + return (options?.client ?? client).post({ + ...options, + querySerializer: { + array: { + explode: false, + style: 'form' + } + }, + url: '/foo' + }); +}; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.1.x/parameter-explode-false-axios/types.gen.ts b/packages/openapi-ts/test/__snapshots__/3.1.x/parameter-explode-false-axios/types.gen.ts new file mode 100644 index 000000000..5abc09372 --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.1.x/parameter-explode-false-axios/types.gen.ts @@ -0,0 +1,17 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type PostFooData = { + body?: never; + path?: never; + query?: { + foo?: Array; + }; + url: '/foo'; +}; + +export type PostFooResponses = { + /** + * OK + */ + default: unknown; +}; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.1.x/parameter-explode-false/sdk.gen.ts b/packages/openapi-ts/test/__snapshots__/3.1.x/parameter-explode-false/sdk.gen.ts index 6fb044134..7a0d238e4 100644 --- a/packages/openapi-ts/test/__snapshots__/3.1.x/parameter-explode-false/sdk.gen.ts +++ b/packages/openapi-ts/test/__snapshots__/3.1.x/parameter-explode-false/sdk.gen.ts @@ -8,12 +8,12 @@ export const client = createClient(createConfig()); export const postFoo = (options?: Options) => { return (options?.client ?? client).post({ ...options, - url: '/foo', querySerializer: { array: { explode: false, style: 'form' } - } + }, + url: '/foo' }); }; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle/client/index.ts.snap b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle/client/index.ts.snap index ad10cf1b8..be3c360e4 100644 --- a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle/client/index.ts.snap +++ b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle/client/index.ts.snap @@ -3,8 +3,8 @@ import axios from 'axios'; import type { Client, Config } from './types'; import { + buildUrl, createConfig, - getUrl, mergeConfigs, mergeHeaders, setAuthParams, @@ -48,17 +48,15 @@ export const createClient = (config: Config): Client => { opts.body = opts.bodySerializer(opts.body); } - const url = getUrl({ - path: opts.path, - url: opts.url, - }); + const url = buildUrl(opts); try { const response = await opts.axios({ ...opts, data: opts.body, headers: opts.headers as RawAxiosRequestHeaders, - params: opts.query, + // let `paramsSerializer()` handle query params if it exists + params: opts.paramsSerializer ? opts.query : undefined, url, }); @@ -84,6 +82,7 @@ export const createClient = (config: Config): Client => { }; return { + buildUrl, delete: (options) => request({ ...options, method: 'delete' }), get: (options) => request({ ...options, method: 'get' }), getConfig, diff --git a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle/client/types.ts.snap b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle/client/types.ts.snap index 7fff7be39..56cacb235 100644 --- a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle/client/types.ts.snap +++ b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle/client/types.ts.snap @@ -6,7 +6,11 @@ import type { CreateAxiosDefaults, } from 'axios'; -import type { BodySerializer } from './utils'; +import type { + BodySerializer, + QuerySerializer, + QuerySerializerOptions, +} from './utils'; type OmitKeys = Pick>; @@ -67,6 +71,17 @@ export interface Config | 'post' | 'put' | 'trace'; + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions; /** * A function for transforming response data before it's returned to the * caller function. This is an ideal place to post-process server data, @@ -141,6 +156,19 @@ type RequestFn = < ) => RequestResult; export interface Client { + /** + * Returns the final request URL. This method works only with experimental parser. + */ + buildUrl: < + Data extends { + body?: unknown; + path?: Record; + query?: Record; + url: string; + }, + >( + options: Pick & Omit, 'axios'>, + ) => string; delete: MethodFn; get: MethodFn; getConfig: () => Config; diff --git a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle/client/utils.ts.snap b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle/client/utils.ts.snap index f171ade00..196928cf7 100644 --- a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle/client/utils.ts.snap +++ b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle/client/utils.ts.snap @@ -1,4 +1,4 @@ -import type { Config, RequestOptions, Security } from './types'; +import type { Client, Config, RequestOptions, Security } from './types'; interface PathSerializer { path: Record; @@ -13,6 +13,8 @@ type ArraySeparatorStyle = ArrayStyle | MatrixStyle; type ObjectStyle = 'form' | 'deepObject'; type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; +export type QuerySerializer = (query: Record) => string; + export type BodySerializer = (body: any) => any; interface SerializerOptions { @@ -34,6 +36,12 @@ interface SerializePrimitiveParam extends SerializePrimitiveOptions { value: string; } +export interface QuerySerializerOptions { + allowReserved?: boolean; + array?: SerializerOptions; + object?: SerializerOptions; +} + const serializePrimitiveParam = ({ allowReserved, name, @@ -250,6 +258,66 @@ const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { return url; }; +export const createQuerySerializer = ({ + allowReserved, + array, + object, +}: QuerySerializerOptions = {}) => { + const querySerializer = (queryParams: T) => { + let search: string[] = []; + if (queryParams && typeof queryParams === 'object') { + for (const name in queryParams) { + const value = queryParams[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + search = [ + ...search, + serializeArrayParam({ + allowReserved, + explode: true, + name, + style: 'form', + value, + ...array, + }), + ]; + continue; + } + + if (typeof value === 'object') { + search = [ + ...search, + serializeObjectParam({ + allowReserved, + explode: true, + name, + style: 'deepObject', + value: value as Record, + ...object, + }), + ]; + continue; + } + + search = [ + ...search, + serializePrimitiveParam({ + allowReserved, + name, + value: value as string, + }), + ]; + } + } + return search.join('&'); + }; + return querySerializer; +}; + export const getAuthToken = async ( security: Security, options: Pick, @@ -297,13 +365,45 @@ export const setAuthParams = async ({ } }; +export const buildUrl: Client['buildUrl'] = (options) => { + const url = getUrl({ + path: options.path, + // let `paramsSerializer()` handle query params if it exists + query: !options.paramsSerializer ? options.query : undefined, + querySerializer: + typeof options.querySerializer === 'function' + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + return url; +}; + export const getUrl = ({ path, - url, + query, + querySerializer, + url: _url, }: { path?: Record; + query?: Record; + querySerializer: QuerySerializer; url: string; -}) => (path ? defaultPathSerializer({ path, url }) : url); +}) => { + const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; + let url = pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ''; + if (search.startsWith('?')) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; +}; const serializeFormDataPair = ( formData: FormData, diff --git a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle_transform/client/index.ts.snap b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle_transform/client/index.ts.snap index ad10cf1b8..be3c360e4 100644 --- a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle_transform/client/index.ts.snap +++ b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle_transform/client/index.ts.snap @@ -3,8 +3,8 @@ import axios from 'axios'; import type { Client, Config } from './types'; import { + buildUrl, createConfig, - getUrl, mergeConfigs, mergeHeaders, setAuthParams, @@ -48,17 +48,15 @@ export const createClient = (config: Config): Client => { opts.body = opts.bodySerializer(opts.body); } - const url = getUrl({ - path: opts.path, - url: opts.url, - }); + const url = buildUrl(opts); try { const response = await opts.axios({ ...opts, data: opts.body, headers: opts.headers as RawAxiosRequestHeaders, - params: opts.query, + // let `paramsSerializer()` handle query params if it exists + params: opts.paramsSerializer ? opts.query : undefined, url, }); @@ -84,6 +82,7 @@ export const createClient = (config: Config): Client => { }; return { + buildUrl, delete: (options) => request({ ...options, method: 'delete' }), get: (options) => request({ ...options, method: 'get' }), getConfig, diff --git a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle_transform/client/types.ts.snap b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle_transform/client/types.ts.snap index 7fff7be39..56cacb235 100644 --- a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle_transform/client/types.ts.snap +++ b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle_transform/client/types.ts.snap @@ -6,7 +6,11 @@ import type { CreateAxiosDefaults, } from 'axios'; -import type { BodySerializer } from './utils'; +import type { + BodySerializer, + QuerySerializer, + QuerySerializerOptions, +} from './utils'; type OmitKeys = Pick>; @@ -67,6 +71,17 @@ export interface Config | 'post' | 'put' | 'trace'; + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions; /** * A function for transforming response data before it's returned to the * caller function. This is an ideal place to post-process server data, @@ -141,6 +156,19 @@ type RequestFn = < ) => RequestResult; export interface Client { + /** + * Returns the final request URL. This method works only with experimental parser. + */ + buildUrl: < + Data extends { + body?: unknown; + path?: Record; + query?: Record; + url: string; + }, + >( + options: Pick & Omit, 'axios'>, + ) => string; delete: MethodFn; get: MethodFn; getConfig: () => Config; diff --git a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle_transform/client/utils.ts.snap b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle_transform/client/utils.ts.snap index f171ade00..196928cf7 100644 --- a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle_transform/client/utils.ts.snap +++ b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle_transform/client/utils.ts.snap @@ -1,4 +1,4 @@ -import type { Config, RequestOptions, Security } from './types'; +import type { Client, Config, RequestOptions, Security } from './types'; interface PathSerializer { path: Record; @@ -13,6 +13,8 @@ type ArraySeparatorStyle = ArrayStyle | MatrixStyle; type ObjectStyle = 'form' | 'deepObject'; type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; +export type QuerySerializer = (query: Record) => string; + export type BodySerializer = (body: any) => any; interface SerializerOptions { @@ -34,6 +36,12 @@ interface SerializePrimitiveParam extends SerializePrimitiveOptions { value: string; } +export interface QuerySerializerOptions { + allowReserved?: boolean; + array?: SerializerOptions; + object?: SerializerOptions; +} + const serializePrimitiveParam = ({ allowReserved, name, @@ -250,6 +258,66 @@ const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { return url; }; +export const createQuerySerializer = ({ + allowReserved, + array, + object, +}: QuerySerializerOptions = {}) => { + const querySerializer = (queryParams: T) => { + let search: string[] = []; + if (queryParams && typeof queryParams === 'object') { + for (const name in queryParams) { + const value = queryParams[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + search = [ + ...search, + serializeArrayParam({ + allowReserved, + explode: true, + name, + style: 'form', + value, + ...array, + }), + ]; + continue; + } + + if (typeof value === 'object') { + search = [ + ...search, + serializeObjectParam({ + allowReserved, + explode: true, + name, + style: 'deepObject', + value: value as Record, + ...object, + }), + ]; + continue; + } + + search = [ + ...search, + serializePrimitiveParam({ + allowReserved, + name, + value: value as string, + }), + ]; + } + } + return search.join('&'); + }; + return querySerializer; +}; + export const getAuthToken = async ( security: Security, options: Pick, @@ -297,13 +365,45 @@ export const setAuthParams = async ({ } }; +export const buildUrl: Client['buildUrl'] = (options) => { + const url = getUrl({ + path: options.path, + // let `paramsSerializer()` handle query params if it exists + query: !options.paramsSerializer ? options.query : undefined, + querySerializer: + typeof options.querySerializer === 'function' + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + return url; +}; + export const getUrl = ({ path, - url, + query, + querySerializer, + url: _url, }: { path?: Record; + query?: Record; + querySerializer: QuerySerializer; url: string; -}) => (path ? defaultPathSerializer({ path, url }) : url); +}) => { + const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; + let url = pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ''; + if (search.startsWith('?')) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; +}; const serializeFormDataPair = ( formData: FormData, diff --git a/packages/openapi-ts/test/sample.cjs b/packages/openapi-ts/test/sample.cjs index 6ca3b983e..e2b9743ef 100644 --- a/packages/openapi-ts/test/sample.cjs +++ b/packages/openapi-ts/test/sample.cjs @@ -13,7 +13,7 @@ const main = async () => { exclude: '^#/components/schemas/ModelWithCircularReference$', // include: // '^(#/components/schemas/import|#/paths/api/v{api-version}/simple/options)$', - path: './test/spec/3.1.x/schema-recursive.json', + path: './test/spec/3.1.x/parameter-explode-false.json', // path: 'https://mongodb-mms-prod-build-server.s3.amazonaws.com/openapi/2caffd88277a4e27c95dcefc7e3b6a63a3b03297-v2-2023-11-15.json', // path: 'https://raw.githubusercontent.com/swagger-api/swagger-petstore/master/src/main/resources/openapi.yaml', },