diff --git a/.changeset/strong-wombats-rhyme.md b/.changeset/strong-wombats-rhyme.md new file mode 100644 index 000000000..c09f3bfc9 --- /dev/null +++ b/.changeset/strong-wombats-rhyme.md @@ -0,0 +1,5 @@ +--- +"openapi-fetch": patch +--- + +openapi-fetch - add `transform` option (createClient) for response data handling diff --git a/docs/openapi-fetch/api.md b/docs/openapi-fetch/api.md index 1a504c5d1..d60b9d8ef 100644 --- a/docs/openapi-fetch/api.md +++ b/docs/openapi-fetch/api.md @@ -19,6 +19,7 @@ createClient(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 @@ -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({ + 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: diff --git a/packages/openapi-fetch/src/index.d.ts b/packages/openapi-fetch/src/index.d.ts index 79dec3d77..f69a3c32c 100644 --- a/packages/openapi-fetch/src/index.d.ts +++ b/packages/openapi-fetch/src/index.d.ts @@ -23,6 +23,8 @@ export interface ClientOptions extends Omit { querySerializer?: QuerySerializer | QuerySerializerOptions; /** global bodySerializer */ bodySerializer?: BodySerializer; + /** transform functions for request/response data */ + transform?: TransformOptions; headers?: HeadersOptions; /** RequestInit extension object to pass as 2nd argument to fetch when supported (defaults to undefined) */ requestInitExt?: Record; @@ -64,6 +66,18 @@ export type QuerySerializerOptions = { export type BodySerializer = (body: OperationRequestBodyContent) => any; +export type TransformOptions = { + response?: (method: string, path: string, data: T) => R; +}; + +export type TransformFunction = ( + method: string, + path: string, + options: { + data: T; + }, +) => R; + type BodyType = { json: T; text: Awaited>; @@ -127,6 +141,7 @@ export type MergedOptions = { parseAs: ParseAs; querySerializer: QuerySerializer; bodySerializer: BodySerializer; + transform?: TransformOptions; fetch: typeof globalThis.fetch; }; diff --git a/packages/openapi-fetch/src/index.js b/packages/openapi-fetch/src/index.js index ad83112e0..d7a048ef6 100644 --- a/packages/openapi-fetch/src/index.js +++ b/packages/openapi-fetch/src/index.js @@ -28,6 +28,7 @@ export default function createClient(clientOptions) { fetch: baseFetch = globalThis.fetch, querySerializer: globalQuerySerializer, bodySerializer: globalBodySerializer, + transform: globalTransform, headers: baseHeaders, requestInitExt = undefined, ...baseOptions @@ -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") { @@ -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 diff --git a/packages/openapi-fetch/test/redocly.yaml b/packages/openapi-fetch/test/redocly.yaml index 6030cfa41..efdf918bc 100644 --- a/packages/openapi-fetch/test/redocly.yaml +++ b/packages/openapi-fetch/test/redocly.yaml @@ -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: diff --git a/packages/openapi-fetch/test/transform/schemas/transform.d.ts b/packages/openapi-fetch/test/transform/schemas/transform.d.ts new file mode 100644 index 000000000..1f1413658 --- /dev/null +++ b/packages/openapi-fetch/test/transform/schemas/transform.d.ts @@ -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; + }; + }; + }; + }; + }; + }; +} \ No newline at end of file diff --git a/packages/openapi-fetch/test/transform/schemas/transform.yaml b/packages/openapi-fetch/test/transform/schemas/transform.yaml new file mode 100644 index 000000000..8e333411f --- /dev/null +++ b/packages/openapi-fetch/test/transform/schemas/transform.yaml @@ -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 \ No newline at end of file diff --git a/packages/openapi-fetch/test/transform/transform.test.ts b/packages/openapi-fetch/test/transform/transform.test.ts new file mode 100644 index 000000000..183a632fe --- /dev/null +++ b/packages/openapi-fetch/test/transform/transform.test.ts @@ -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( + { + 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); +});