Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: add buildUrl and querySerializer to Axios client #1420

Merged
merged 1 commit into from
Dec 12, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
@@ -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.
11 changes: 5 additions & 6 deletions packages/client-axios/src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
30 changes: 29 additions & 1 deletion packages/client-axios/src/types.ts
Original file line number Diff line number Diff line change
@@ -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>>;

@@ -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,
@@ -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;
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>;
@@ -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> {
@@ -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,
@@ -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'>,
@@ -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,
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
@@ -292,10 +292,35 @@
}
}

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)

Check warning on line 300 in packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts

Codecov / codecov/patch

packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts#L300

Added line #L300 was not covered by tests
) {
// override the default settings for `querySerializer`
requestOptions.push({
key: 'querySerializer',
value: [
{
key: 'array',
value: [
{
key: 'explode',
value: false,
},
{
key: 'style',
value: 'form',
},
],
},
],
});
break;
}

Check warning on line 322 in packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts

Codecov / codecov/patch

packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts#L302-L322

Added lines #L302 - L322 were not covered by tests
}

const fileTransformers = context.file({ id: 'transformers' });
if (fileTransformers) {
@@ -315,37 +340,10 @@
}
}

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({
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
@@ -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',
Loading