Skip to content

feat(openapi-fetch): add transform options for response data handling #2253

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

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
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/strong-wombats-rhyme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"openapi-fetch": patch
---

openapi-fetch - add `transform` option (createClient) for response data handling
19 changes: 19 additions & 0 deletions docs/openapi-fetch/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ createClient<paths>(options);
| `fetch` | `fetch` | Fetch instance used for requests (default: `globalThis.fetch`) |
| `querySerializer` | QuerySerializer | (optional) Provide a [querySerializer](#queryserializer) |
| `bodySerializer` | BodySerializer | (optional) Provide a [bodySerializer](#bodyserializer) |
| `transform` | TransformOptions| (optional) Provide [transform functions](#transform) for response data |
| (Fetch options) | | Any valid fetch option (`headers`, `mode`, `cache`, `signal` …) ([docs](https://developer.mozilla.org/en-US/docs/Web/API/fetch#options) |

## Fetch options
Expand Down Expand Up @@ -192,6 +193,24 @@ or when instantiating the client.

:::

## transform

The transform option lets you modify request and response data before it's sent or after it's received. This is useful for tasks like deserialization.

```ts
const client = createClient<paths>({
transform: {
response: (method, path, data) => {
// Convert date strings to Date objects
if (data?.created_at) {
data.created_at = new Date(data.created_at);
}
return data;
}
}
});
```

## Path serialization

openapi-fetch supports path serialization as [outlined in the 3.1 spec](https://swagger.io/docs/specification/serialization/#path). This happens automatically, based on the specific format in your OpenAPI schema:
Expand Down
15 changes: 15 additions & 0 deletions packages/openapi-fetch/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export interface ClientOptions extends Omit<RequestInit, "headers"> {
querySerializer?: QuerySerializer<unknown> | QuerySerializerOptions;
/** global bodySerializer */
bodySerializer?: BodySerializer<unknown>;
/** transform functions for request/response data */
transform?: TransformOptions<unknown, unknown>;
headers?: HeadersOptions;
/** RequestInit extension object to pass as 2nd argument to fetch when supported (defaults to undefined) */
requestInitExt?: Record<string, unknown>;
Expand Down Expand Up @@ -64,6 +66,18 @@ export type QuerySerializerOptions = {

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

export type TransformOptions<T = any, R = any> = {
response?: (method: string, path: string, data: T) => R;
};

export type TransformFunction<T = any, R = any> = (
method: string,
path: string,
options: {
data: T;
},
) => R;

type BodyType<T = unknown> = {
json: T;
text: Awaited<ReturnType<Response["text"]>>;
Expand Down Expand Up @@ -127,6 +141,7 @@ export type MergedOptions<T = unknown> = {
parseAs: ParseAs;
querySerializer: QuerySerializer<T>;
bodySerializer: BodySerializer<T>;
transform?: TransformOptions<T, T>;
fetch: typeof globalThis.fetch;
};

Expand Down
11 changes: 10 additions & 1 deletion packages/openapi-fetch/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export default function createClient(clientOptions) {
fetch: baseFetch = globalThis.fetch,
querySerializer: globalQuerySerializer,
bodySerializer: globalBodySerializer,
transform: globalTransform,
headers: baseHeaders,
requestInitExt = undefined,
...baseOptions
Expand Down Expand Up @@ -114,6 +115,7 @@ export default function createClient(clientOptions) {
parseAs,
querySerializer,
bodySerializer,
transform: globalTransform,
});
for (const m of middlewares) {
if (m && typeof m === "object" && typeof m.onRequest === "function") {
Expand Down Expand Up @@ -219,7 +221,14 @@ export default function createClient(clientOptions) {
if (parseAs === "stream") {
return { data: response.body, response };
}
return { data: await response[parseAs](), response };

let responseData = await response[parseAs]();

if (globalTransform?.response && responseData !== undefined) {
responseData = globalTransform.response(request.method, schemaPath, responseData);
}

return { data: responseData, response };
}

// handle errors
Expand Down
4 changes: 4 additions & 0 deletions packages/openapi-fetch/test/redocly.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ apis:
root: ./path-based-client/schemas/path-based-client.yaml
x-openapi-ts:
output: ./path-based-client/schemas/path-based-client.d.ts
transform:
root: ./transform/schemas/transform.yaml
x-openapi-ts:
output: ./transform/schemas/transform.d.ts
github:
root: ../../openapi-typescript/examples/github-api.yaml
x-openapi-ts:
Expand Down
68 changes: 68 additions & 0 deletions packages/openapi-fetch/test/transform/schemas/transform.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* This file was manually created based on transform.yaml
*/

import type { PathsWithMethod } from "openapi-typescript-helpers";

export interface paths {
"/posts": {
get: {
responses: {
200: {
content: {
"application/json": {
items: any[];
meta: {
total: number;
};
};
};
};
};
};
post: {
requestBody: {
content: {
"application/json": {
title: string;
content: string;
};
};
};
responses: {
200: {
content: {
"application/json": {
id: number;
name: string;
created_at: string;
updated_at: string;
};
};
};
};
};
};
"/posts/{id}": {
get: {
parameters: {
path: {
id: number;
};
};
responses: {
200: {
content: {
"application/json": {
id: number;
title: string;
content: string;
created_at: string;
updated_at: string;
};
};
};
};
};
};
}
96 changes: 96 additions & 0 deletions packages/openapi-fetch/test/transform/schemas/transform.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
openapi: 3.0.0
info:
title: Transform Test API
version: 1.0.0
paths:
/posts:
get:
summary: Get all posts
responses:
'200':
description: A list of posts
content:
application/json:
schema:
type: object
properties:
items:
type: array
items:
type: object
properties:
id:
type: integer
name:
type: string
description:
type: string
sensitive:
type: string
created_at:
type: string
format: date-time
meta:
type: object
properties:
total:
type: integer
post:
summary: Create a new post
requestBody:
content:
application/json:
schema:
type: object
properties:
title:
type: string
content:
type: string
responses:
'200':
description: The created post
content:
application/json:
schema:
type: object
properties:
id:
type: integer
name:
type: string
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
/posts/{id}:
get:
summary: Get a post by ID
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: A post
content:
application/json:
schema:
type: object
properties:
id:
type: integer
title:
type: string
content:
type: string
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
48 changes: 48 additions & 0 deletions packages/openapi-fetch/test/transform/transform.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { assert, expect, test } from "vitest";
import { createObservedClient } from "../helpers.js";
import type { paths } from "./schemas/transform.js";

interface PostResponse {
id: number;
title: string;
created_at: string | Date;
}

test("transforms date strings to Date objects", async () => {
const client = createObservedClient<paths>(
{
transform: {
response: (method, path, data) => {
if (!data || typeof data !== "object") {
return data;
}

const result = { ...data } as PostResponse;

if (typeof result.created_at === "string") {
result.created_at = new Date(result.created_at);
}

return result;
},
},
},
async () =>
Response.json({
id: 1,
title: "Test Post",
created_at: "2023-01-01T00:00:00Z",
}),
);

const { data } = await client.GET("/posts/{id}", {
params: { path: { id: 1 } },
});

const post = data as PostResponse;

assert(post.created_at instanceof Date, "created_at should be a Date");
expect(post.created_at.getFullYear()).toBe(2023);
expect(post.created_at.getMonth()).toBe(0); // January
expect(post.created_at.getDate()).toBe(1);
});