Skip to content

Commit

Permalink
Merge pull request #1420 from hey-api/fix/client-axios-query-styles
Browse files Browse the repository at this point in the history
fix: add buildUrl and querySerializer to Axios client
  • Loading branch information
mrlubos authored Dec 12, 2024
2 parents 9ad666d + 8010dbb commit f8274b4
Show file tree
Hide file tree
Showing 26 changed files with 596 additions and 70 deletions.
5 changes: 5 additions & 0 deletions .changeset/fast-laws-divide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hey-api/openapi-ts': patch
---

fix: generate querySerializer options for Axios client
5 changes: 5 additions & 0 deletions .changeset/great-ears-fly.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hey-api/client-axios': patch
---

fix: add buildUrl method to Axios client API
5 changes: 5 additions & 0 deletions .changeset/smart-eyes-promise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hey-api/client-axios': minor
---

feat: handle parameter styles the same way fetch client does if paramsSerializer is undefined
5 changes: 5 additions & 0 deletions .changeset/stale-swans-attend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hey-api/docs': patch
---

docs: add buildUrl() method to Axios client page
31 changes: 31 additions & 0 deletions docs/openapi-ts/clients/axios.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<FooData>({
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.
Expand Down
11 changes: 5 additions & 6 deletions packages/client-axios/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import axios from 'axios';

import type { Client, Config } from './types';
import {
buildUrl,
createConfig,
getUrl,
mergeConfigs,
mergeHeaders,
setAuthParams,
Expand Down Expand Up @@ -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,
});

Expand All @@ -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,
Expand Down
30 changes: 29 additions & 1 deletion packages/client-axios/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import type {
CreateAxiosDefaults,
} from 'axios';

import type { BodySerializer } from './utils';
import type {
BodySerializer,
QuerySerializer,
QuerySerializerOptions,
} from './utils';

type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;

Expand Down Expand Up @@ -67,6 +71,17 @@ export interface Config<ThrowOnError extends boolean = boolean>
| '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,
Expand Down Expand Up @@ -141,6 +156,19 @@ type RequestFn = <
) => RequestResult<Data, TError, ThrowOnError>;

export interface Client {
/**
* Returns the final request URL. This method works only with experimental parser.
*/
buildUrl: <
Data extends {
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
url: string;
},
>(
options: Pick<Data, 'url'> & Omit<Options<Data>, 'axios'>,
) => string;
delete: MethodFn;
get: MethodFn;
getConfig: () => Config;
Expand Down
106 changes: 103 additions & 3 deletions packages/client-axios/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Config, RequestOptions, Security } from './types';
import type { Client, Config, RequestOptions, Security } from './types';

interface PathSerializer {
path: Record<string, unknown>;
Expand All @@ -13,6 +13,8 @@ type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
type ObjectStyle = 'form' | 'deepObject';
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;

export type QuerySerializer = (query: Record<string, unknown>) => string;

export type BodySerializer = (body: any) => any;

interface SerializerOptions<T> {
Expand All @@ -34,6 +36,12 @@ interface SerializePrimitiveParam extends SerializePrimitiveOptions {
value: string;
}

export interface QuerySerializerOptions {
allowReserved?: boolean;
array?: SerializerOptions<ArrayStyle>;
object?: SerializerOptions<ObjectStyle>;
}

const serializePrimitiveParam = ({
allowReserved,
name,
Expand Down Expand Up @@ -250,6 +258,66 @@ const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
return url;
};

export const createQuerySerializer = <T = unknown>({
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<string, unknown>,
...object,
}),
];
continue;
}

search = [
...search,
serializePrimitiveParam({
allowReserved,
name,
value: value as string,
}),
];
}
}
return search.join('&');
};
return querySerializer;
};

export const getAuthToken = async (
security: Security,
options: Pick<RequestOptions, 'accessToken' | 'apiKey'>,
Expand Down Expand Up @@ -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<string, unknown>;
query?: Record<string, unknown>;
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,
Expand Down
68 changes: 33 additions & 35 deletions packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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({
Expand Down
9 changes: 9 additions & 0 deletions packages/openapi-ts/test/3.0.x.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading

0 comments on commit f8274b4

Please sign in to comment.