Skip to content

Commit

Permalink
Improve query + path serialization
Browse files Browse the repository at this point in the history
  • Loading branch information
drwpow committed Feb 15, 2024
1 parent 61f408a commit 2bbeb92
Show file tree
Hide file tree
Showing 14 changed files with 1,336 additions and 358 deletions.
5 changes: 5 additions & 0 deletions .changeset/lovely-needles-try.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"openapi-fetch": patch
---

Add support for automatic label & matrix path serialization.
5 changes: 5 additions & 0 deletions .changeset/shiny-trees-eat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"openapi-fetch": patch
---

Remove leading question marks from querySerializer
5 changes: 5 additions & 0 deletions .changeset/spicy-kings-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"openapi-fetch": minor
---

⚠️ Breaking change: no longer supports deeply-nested objects/arrays for query & path serialization.
2 changes: 1 addition & 1 deletion docs/data/contributors.json

Large diffs are not rendered by default.

68 changes: 59 additions & 9 deletions docs/openapi-fetch/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ createClient<paths>(options);
| `baseUrl` | `string` | Prefix all fetch URLs with this option (e.g. `"https://myapi.dev/v1/"`) |
| `fetch` | `fetch` | Fetch instance used for requests (default: `globalThis.fetch`) |
| `querySerializer` | QuerySerializer | (optional) Provide a [querySerializer](#queryserializer) |
| `pathSerializer` | PathSerializer | (optional) Provide a [pathSerializer](#pathserializer) |
| `bodySerializer` | BodySerializer | (optional) Provide a [bodySerializer](#bodyserializer) |
| (Fetch options) | | Any valid fetch option (`headers`, `mode`, `cache`, `signal` …) ([docs](https://developer.mozilla.org/en-US/docs/Web/API/fetch#options) |

Expand All @@ -34,36 +35,85 @@ client.get("/my-url", options);
| `params` | ParamsObject | [path](https://swagger.io/specification/#parameter-locations) and [query](https://swagger.io/specification/#parameter-locations) params for the endpoint |
| `body` | `{ [name]:value }` | [requestBody](https://spec.openapis.org/oas/latest.html#request-body-object) data for the endpoint |
| `querySerializer` | QuerySerializer | (optional) Provide a [querySerializer](#queryserializer) |
| `pathSerializer` | PathSerializer | (optional) Provide a [pathSerializer](#pathserializer) |
| `bodySerializer` | BodySerializer | (optional) Provide a [bodySerializer](#bodyserializer) |
| `parseAs` | `"json"` \| `"text"` \| `"arrayBuffer"` \| `"blob"` \| `"stream"` | (optional) Parse the response using [a built-in instance method](https://developer.mozilla.org/en-US/docs/Web/API/Response#instance_methods) (default: `"json"`). `"stream"` skips parsing altogether and returns the raw stream. |
| `fetch` | `fetch` | Fetch instance used for requests (default: fetch from `createClient`) |
| (Fetch options) | | Any valid fetch option (`headers`, `mode`, `cache`, `signal`, …) ([docs](https://developer.mozilla.org/en-US/docs/Web/API/fetch#options)) |

### querySerializer

By default, this library serializes query parameters using `style: form` and `explode: true` [according to the OpenAPI specification](https://swagger.io/docs/specification/serialization/#query). To change the default behavior, you can supply your own `querySerializer()` function either on the root `createClient()` as well as optionally on an individual request. This is useful if your backend expects modifications like the addition of `[]` for array params:
String, number, and boolean query params are straightforward when forming a request, but arrays and objects not so much. OpenAPI supports [different ways of handling each](https://swagger.io/docs/specification/serialization/#query). By default, this library serializes arrays using `style: "form", explode: true`, and objects using `style: "deepObject", explode: true`.

To change that behavior, you can supply `querySerializer` options that control how `object` and `arrays` are serialized for query params. This can either be passed on `createClient()` to control every request, or on individual requests for just one:

| Option | Type | Description |
| :-------------- | :---------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `array` | SerializerOptions | Set `style` and `explode` for arrays ([docs](https://swagger.io/docs/specification/serialization/#query)). Default: `{ style: "form", explode: true }`. |
| `object` | SerializerOptions | Set `style` and `explode` for objects ([docs](https://swagger.io/docs/specification/serialization/#query)). Default: `{ style: "deepObject", explode: true }`. |
| `allowReserved` | `boolean` | Set to `true` to skip URL encoding (⚠️ may break the request) ([docs](https://swagger.io/docs/specification/serialization/#query)). Default: `false`. |

```ts
const { data, error } = await GET("/search", {
params: {
query: { tags: ["food", "california", "healthy"] },
const client = createClient({
querySerializer: {
array: {
style: "pipeDelimited", // "form" (default) | "spaceDelimited" | "pipeDelimited"
explode: true,
},
object: {
style: "form", // "form" | "deepObject" (default)
explode: true,
},
},
querySerializer(q) {
});
```

#### Function

Sometimes your backend doesn’t use one of the standard serialization methods, in which case you can pass a function to `querySerializer` to serialize the entire string yourself with no restrictions:

```ts
const client = createClient({
querySerializer(queryParam) {
let s = [];
for (const [k, v] of Object.entries(q)) {
for (const [k, v] of Object.entries(queryParam)) {
if (Array.isArray(v)) {
for (const i of v) {
s.push(`${k}[]=${i}`);
s.push(`${k}[]=${encodeURIComponent(i)}`);
}
} else {
s.push(`${k}=${v}`);
s.push(`${k}=${encodeURLComponent(v)}`);
}
}
return s.join("&"); // ?tags[]=food&tags[]=california&tags[]=healthy
return encodeURI(s.join(",")); // ?tags[]=food,tags[]=california,tags[]=healthy
},
});
```

::: warning

When serializing yourself, the string will be kept exactly as-authored, so you’ll have to call [encodeURI](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI) or [encodeURIComponent](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent) to escape special characters.

:::

### pathSerializer

If your backend doesn’t use the standard `{param_name}` syntax, you can control the behavior of how path params are serialized according to the spec ([docs](https://swagger.io/docs/specification/serialization/#path)):

```ts
const client = createClient({
pathSerializer: {
style: "label", // "simple" (default) | "label" | "matrix"
},
});
```

::: info

The `explode` behavior ([docs](https://swagger.io/docs/specification/serialization/#path)) is determined automatically by the pathname, depending on whether an asterisk suffix is present or not, e.g. `/users/{id}` vs `/users/{id*}`. Globs are **NOT** supported, and the param name must still match exactly; the asterisk is only a suffix.

:::

### bodySerializer

Similar to [querySerializer](#querySerializer), bodySerializer allows you to customize how the requestBody is serialized if you don’t want the default [JSON.stringify()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify) behavior. You probably only need this when using `multipart/form-data`:
Expand Down
2 changes: 2 additions & 0 deletions docs/openapi-fetch/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ openapi-fetch infers types from the URL. Prefer static string values over dynami

:::

This library also supports the **label** and **matrix** serialization styles as well ([docs](https://swagger.io/docs/specification/serialization/#path)) automatically.

### Request

The `GET()` request shown needed the `params` object that groups [parameters by type](https://spec.openapis.org/oas/latest.html#parameter-object) (`path` or `query`). If a required param is missing, or the wrong type, a type error will be thrown.
Expand Down
2 changes: 1 addition & 1 deletion packages/openapi-fetch/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"lint": "pnpm run \"/^lint:/\"",
"lint:js": "eslint \"{src,test}/**/*.{js,ts}\"",
"lint:prettier": "prettier --check \"{src,test}/**/*\"",
"generate-types": "cd ../openapi-typescript && pnpm run build && cd ../openapi-fetch ../openapi-typescript/bin/cli.js ./test/fixtures/api.yaml -o ./test/fixtures/v7-beta.test.ts && npx openapi-typescript ./test/fixtures/api.yaml -o ./test/fixtures/api.d.ts",
"generate-types": "cd ../openapi-typescript && pnpm run build && cd ../openapi-fetch && ../openapi-typescript/bin/cli.js ./test/fixtures/api.yaml -o ./test/fixtures/v7-beta.d.ts && npx openapi-typescript ./test/fixtures/api.yaml -o ./test/fixtures/api.d.ts",
"pretest": "pnpm run generate-types",
"test": "pnpm run \"/^test:/\"",
"test:js": "vitest run",
Expand Down
85 changes: 76 additions & 9 deletions packages/openapi-fetch/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export interface ClientOptions extends Omit<RequestInit, "headers"> {
/** custom fetch (defaults to globalThis.fetch) */
fetch?: typeof fetch;
/** global querySerializer */
querySerializer?: QuerySerializer<unknown>;
querySerializer?: QuerySerializer<unknown> | QuerySerializerOptions;
/** global bodySerializer */
bodySerializer?: BodySerializer<unknown>;
headers?: HeadersOptions;
Expand All @@ -36,6 +36,32 @@ export type QuerySerializer<T> = (
: Record<string, unknown>,
) => string;

/** @see https://swagger.io/docs/specification/serialization/#query */
export type QuerySerializerOptions = {
/** Set serialization for arrays. @see https://swagger.io/docs/specification/serialization/#query */
array?: {
/** default: "form" */
style: "form" | "spaceDelimited" | "pipeDelimited";
/** default: true */
explode: boolean;
};
/** Set serialization for objects. @see https://swagger.io/docs/specification/serialization/#query */
object?: {
/** default: "deepObject" */
style: "form" | "deepObject";
/** default: true */
explode: boolean;
};
/**
* The `allowReserved` keyword specifies whether the reserved characters
* `:/?#[]@!$&'()*+,;=` in parameter values are allowed to be sent as they
* are, or should be percent-encoded. By default, allowReserved is `false`,
* and reserved characters are percent-encoded.
* @see https://swagger.io/docs/specification/serialization/#query
*/
allowReserved?: boolean;
};

export type BodySerializer<T> = (body: OperationRequestBodyContent<T>) => any;

type BodyType<T = unknown> = {
Expand Down Expand Up @@ -98,7 +124,7 @@ export type FetchResponse<T, O extends FetchOptions> =

export type RequestOptions<T> = ParamsOption<T> &
RequestBodyOption<T> & {
querySerializer?: QuerySerializer<T>;
querySerializer?: QuerySerializer<T> | QuerySerializerOptions;
bodySerializer?: BodySerializer<T>;
parseAs?: ParseAs;
fetch?: ClientOptions["fetch"];
Expand Down Expand Up @@ -133,14 +159,55 @@ export default function createClient<Paths extends {}>(
TRACE: ClientMethod<Paths, "trace">;
};

/** Serialize query params to string */
export declare function defaultQuerySerializer<T = unknown>(q: T): string;
/** Serialize primitive params to string */
export declare function serializePrimitiveParam(
name: string,
value: string,
options?: { allowReserved?: boolean },
): string;

/** Serialize query param schema types according to expected default OpenAPI 3.x behavior */
export declare function defaultQueryParamSerializer<T = unknown>(
key: string[],
value: T,
): string | undefined;
/** Serialize object param to string */
export declare function serializeObjectParam(
name: string,
value: Record<string, unknown>,
options: {
style: "simple" | "label" | "matrix" | "form" | "deepObject";
explode: boolean;
allowReserved?: boolean;
},
): string;

/** Serialize array param to string */
export declare function serializeArrayParam(
name: string,
value: unknown[],
options: {
style:
| "simple"
| "label"
| "matrix"
| "form"
| "spaceDelimited"
| "pipeDelimited";
explode: boolean;
allowReserved?: boolean;
},
): string;

/** Serialize query params to string */
export declare function createQuerySerializer<T = unknown>(
options?: QuerySerializerOptions,
): (queryParams: T) => string;

/**
* Handle different OpenAPI 3.x serialization styles
* @type {import("./index.js").defaultPathSerializer}
* @see https://swagger.io/docs/specification/serialization/#path
*/
export declare function defaultPathSerializer(
pathname: string,
pathParams: Record<string, unknown>,
): string;

/** Serialize body object to string */
export declare function defaultBodySerializer<T>(body: T): string;
Expand Down
Loading

0 comments on commit 2bbeb92

Please sign in to comment.