From b7648f6ec6116cedb87e0228ae21b20811240092 Mon Sep 17 00:00:00 2001 From: Drew Powers Date: Thu, 15 Jun 2023 21:53:05 -0600 Subject: [PATCH] Multiple improvements --- .changeset/beige-ligers-hunt.md | 5 + .changeset/khaki-bobcats-shave.md | 5 + .changeset/khaki-cycles-cough.md | 5 + .changeset/long-llamas-attend.md | 5 + .changeset/mighty-dots-hammer.md | 5 + .changeset/rude-bikes-happen.md | 5 + docs/src/content/docs/openapi-fetch/api.md | 17 +- packages/openapi-fetch/src/index.test.ts | 829 +++++++++++---------- packages/openapi-fetch/src/index.ts | 58 +- 9 files changed, 532 insertions(+), 402 deletions(-) create mode 100644 .changeset/beige-ligers-hunt.md create mode 100644 .changeset/khaki-bobcats-shave.md create mode 100644 .changeset/khaki-cycles-cough.md create mode 100644 .changeset/long-llamas-attend.md create mode 100644 .changeset/mighty-dots-hammer.md create mode 100644 .changeset/rude-bikes-happen.md diff --git a/.changeset/beige-ligers-hunt.md b/.changeset/beige-ligers-hunt.md new file mode 100644 index 000000000..4ad4c9246 --- /dev/null +++ b/.changeset/beige-ligers-hunt.md @@ -0,0 +1,5 @@ +--- +"openapi-fetch": patch +--- + +Clone response internally diff --git a/.changeset/khaki-bobcats-shave.md b/.changeset/khaki-bobcats-shave.md new file mode 100644 index 000000000..9bbf7bf95 --- /dev/null +++ b/.changeset/khaki-bobcats-shave.md @@ -0,0 +1,5 @@ +--- +"openapi-fetch": minor +--- + +Expose createFinalURL() logic for testing diff --git a/.changeset/khaki-cycles-cough.md b/.changeset/khaki-cycles-cough.md new file mode 100644 index 000000000..8ece43edd --- /dev/null +++ b/.changeset/khaki-cycles-cough.md @@ -0,0 +1,5 @@ +--- +"openapi-fetch": minor +--- + +Automatically remove `undefined` and `null` query params without requiring querySerializer diff --git a/.changeset/long-llamas-attend.md b/.changeset/long-llamas-attend.md new file mode 100644 index 000000000..33f3d8fa4 --- /dev/null +++ b/.changeset/long-llamas-attend.md @@ -0,0 +1,5 @@ +--- +"openapi-fetch": patch +--- + +Strip trailing slashes from baseUrl diff --git a/.changeset/mighty-dots-hammer.md b/.changeset/mighty-dots-hammer.md new file mode 100644 index 000000000..3859efb3e --- /dev/null +++ b/.changeset/mighty-dots-hammer.md @@ -0,0 +1,5 @@ +--- +"openapi-fetch": patch +--- + +Fix querySerializer typing diff --git a/.changeset/rude-bikes-happen.md b/.changeset/rude-bikes-happen.md new file mode 100644 index 000000000..1338c03e8 --- /dev/null +++ b/.changeset/rude-bikes-happen.md @@ -0,0 +1,5 @@ +--- +"openapi-fetch": minor +--- + +Allow overriding of JSON body parsing diff --git a/docs/src/content/docs/openapi-fetch/api.md b/docs/src/content/docs/openapi-fetch/api.md index 8e81c0895..602d56026 100644 --- a/docs/src/content/docs/openapi-fetch/api.md +++ b/docs/src/content/docs/openapi-fetch/api.md @@ -27,14 +27,15 @@ const { get, put, post, del, options, head, patch, trace } = createClient const { data, error, response } = await get("/my-url", options); ``` -| Name | Type | Description | -| :---------------- | :-----------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `params` | ParamsObject | Provide `path` and `query` params from the OpenAPI schema | -| `params.path` | `{ [name]: value }` | Provide all `path` params (params that are part of the URL) | -| `params.query` | `{ [name]: value }` | Provide all `query params (params that are part of the searchParams | -| `body` | `{ [name]:value }` | The requestBody data, if needed (PUT/POST/PATCH/DEL only) | -| `querySerializer` | QuerySerializer | (optional) Override default param serialization (see [Parameter Serialization](#parameter-serialization)) | -| (Fetch options) | | Any valid fetch option (`headers`, `mode`, `cache`, `signal` …) (docs) | +| Name | Type | Description | +| :---------------- | :-------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `params` | ParamsObject | Provide `path` and `query` params from the OpenAPI schema | +| `params.path` | `{ [name]: value }` | Provide all `path` params (params that are part of the URL) | +| `params.query` | `{ [name]: value }` | Provide all `query params (params that are part of the searchParams | +| `body` | `{ [name]:value }` | The requestBody data, if needed (PUT/POST/PATCH/DEL only) | +| `querySerializer` | QuerySerializer | (optional) Override default param serialization (see [Parameter Serialization](#parameter-serialization)) | +| `parseAs` | `"json" \| "arrayBuffer" \| "blob" \| "text"` | Decide how to parse the response body (default: `"json"`) | +| (Fetch options) | | Any valid fetch option (`headers`, `mode`, `cache`, `signal` …) (docs) | ### Parameter Serialization diff --git a/packages/openapi-fetch/src/index.test.ts b/packages/openapi-fetch/src/index.test.ts index d13fc4f0b..9b7e9d5d7 100644 --- a/packages/openapi-fetch/src/index.test.ts +++ b/packages/openapi-fetch/src/index.test.ts @@ -2,11 +2,9 @@ import { atom, computed } from "nanostores"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; // @ts-expect-error import createFetchMock from "vitest-fetch-mock"; -import createClient, { FilterKeys, JSONLike, RequestBody, RequestBodyContent, RequestBodyJSON } from "./index.js"; +import createClient from "./index.js"; import type { paths } from "../test/v1.js"; -type CreateTag = paths["/tag/{name}"]["put"]; - const fetchMocker = createFetchMock(vi); beforeAll(() => { @@ -16,6 +14,20 @@ afterEach(() => { fetchMocker.resetMocks(); }); +interface MockResponse { + headers?: Record; + status: number; + body: string; +} + +function mockFetch(res: MockResponse) { + fetchMocker.mockResponse(() => res); +} + +function mockFetchOnce(res: MockResponse) { + fetchMocker.mockResponseOnce(() => res); +} + describe("client", () => { it("generates all proper functions", () => { const client = createClient(); @@ -30,465 +42,520 @@ describe("client", () => { expect(client).toHaveProperty("trace"); }); - it("marks data as undefined, but never both", async () => { - const client = createClient(); - - // data - fetchMocker.mockResponseOnce(JSON.stringify(["one", "two", "three"])); - const dataRes = await client.get("/string-array", {}); + describe("TypeScript checks", () => { + it("marks data or error as undefined, but never both", async () => { + const client = createClient(); - // … is initially possibly undefined - // @ts-expect-error - expect(dataRes.data[0]).toBe("one"); + // data + mockFetchOnce({ status: 200, body: JSON.stringify(["one", "two", "three"]) }); + const dataRes = await client.get("/string-array", {}); - // … is present if error is undefined - if (!dataRes.error) { + // … is initially possibly undefined + // @ts-expect-error expect(dataRes.data[0]).toBe("one"); - } - // … means data is undefined - if (dataRes.data) { - // @ts-expect-error - expect(() => dataRes.error.message).toThrow(); - } - - // error - fetchMocker.mockResponseOnce(() => ({ - status: 500, - body: JSON.stringify({ status: "500", message: "Something went wrong" }), - })); - const errorRes = await client.get("/string-array", {}); - - // … is initially possibly undefined - // @ts-expect-error - expect(errorRes.error.message).toBe("Something went wrong"); - - // … is present if error is undefined - if (!errorRes.data) { - expect(errorRes.error.message).toBe("Something went wrong"); - } + // … is present if error is undefined + if (!dataRes.error) { + expect(dataRes.data[0]).toBe("one"); + } - // … means data is undefined - if (errorRes.error) { - // @ts-expect-error - expect(() => errorRes.data[0]).toThrow(); - } - }); + // … means data is undefined + if (dataRes.data) { + // @ts-expect-error + expect(() => dataRes.error.message).toThrow(); + } - it("requires path params", async () => { - const client = createClient({ baseUrl: "https://myapi.com/v1" }); - fetchMocker.mockResponse(JSON.stringify({ message: "OK" })); + // error + mockFetchOnce({ status: 500, body: JSON.stringify({ code: 500, message: "Something went wrong" }) }); + const errorRes = await client.get("/string-array", {}); - // expect error on missing 'params' - // @ts-expect-error - await client.get("/post/{post_id}", {}); + // … is initially possibly undefined + // @ts-expect-error + expect(errorRes.error.message).toBe("Something went wrong"); - // expect error on empty params - // @ts-expect-error - await client.get("/post/{post_id}", { params: {} }); + // … is present if error is undefined + if (!errorRes.data) { + expect(errorRes.error.message).toBe("Something went wrong"); + } - // expect error on empty params.path - // @ts-expect-error - await client.get("/post/{post_id}", { params: { path: {} } }); + // … means data is undefined + if (errorRes.error) { + // @ts-expect-error + expect(() => errorRes.data[0]).toThrow(); + } + }); - // expect error on mismatched type (number v string) - // @ts-expect-error - await client.get("/post/{post_id}", { params: { path: { post_id: 1234 } }, query: {} }); + it("requires path params", async () => { + const client = createClient({ baseUrl: "https://myapi.com/v1" }); + mockFetch({ status: 200, body: JSON.stringify({ message: "OK" }) }); - // (no error) - await client.get("/post/{post_id}", { params: { path: { post_id: "1234" }, query: {} } }); - }); + // expect error on missing 'params' + // @ts-expect-error + await client.get("/post/{post_id}", {}); - it("requires necessary requestBodies", async () => { - const client = createClient({ baseUrl: "https://myapi.com/v1" }); - fetchMocker.mockResponse(JSON.stringify({ message: "OK" })); + // expect error on empty params + // @ts-expect-error + await client.get("/post/{post_id}", { params: {} }); - // expect error on missing `body` - // @ts-expect-error - await client.get("/post", {}); + // expect error on empty params.path + // @ts-expect-error + await client.get("/post/{post_id}", { params: { path: {} } }); - // expect error on missing fields - // @ts-expect-error - await client.put("/post", { body: { title: "Foo" } }); + // expect error on mismatched type (number v string) + // @ts-expect-error + await client.get("/post/{post_id}", { params: { path: { post_id: 1234 } }, query: {} }); - // expect present body to be good enough (all fields optional) - // (no error) - await client.put("/post", { - body: { title: "Foo", body: "Bar", publish_date: new Date("2023-04-01T12:00:00Z").getTime() }, + // (no error) + await client.get("/post/{post_id}", { params: { path: { post_id: "1234" }, query: {} } }); }); - }); - it("skips optional requestBody", async () => { - const mockData = { status: "success" }; - const client = createClient(); - fetchMocker.mockResponse(() => ({ status: 201, body: JSON.stringify(mockData) })); - - // assert omitting `body` doesn’t raise a TS error (testing the response isn’t necessary) - await client.put("/tag/{name}", { - params: { path: { name: "New Tag" } }, - }); + it("requires necessary requestBodies", async () => { + const client = createClient({ baseUrl: "https://myapi.com/v1" }); + mockFetch({ status: 200, body: JSON.stringify({ message: "OK" }) }); - // assert providing `body` with correct schema doesn’t raise a TS error - await client.put("/tag/{name}", { - params: { path: { name: "New Tag" } }, - body: { description: "This is a new tag" }, - }); + // expect error on missing `body` + // @ts-expect-error + await client.get("/post", {}); - // assert providing `body` with bad schema WILL raise a TS error - await client.put("/tag/{name}", { - params: { path: { name: "New Tag" } }, + // expect error on missing fields // @ts-expect-error - body: { foo: "Bar" }, + await client.put("/post", { body: { title: "Foo" } }); + + // expect present body to be good enough (all fields optional) + // (no error) + await client.put("/post", { + body: { title: "Foo", body: "Bar", publish_date: new Date("2023-04-01T12:00:00Z").getTime() }, + }); }); - }); - it("respects baseUrl", async () => { - const client = createClient({ baseUrl: "https://myapi.com/v1" }); - fetchMocker.mockResponse(JSON.stringify({ message: "OK" })); - await client.get("/self", {}); - expect(fetchMocker.mock.calls[0][0]).toBe("https://myapi.com/v1/self"); - }); + it("allows optional requestBody", async () => { + const mockData = { status: "success" }; + const client = createClient(); + mockFetch({ status: 201, body: JSON.stringify(mockData) }); + + // assert omitting `body` doesn’t raise a TS error (testing the response isn’t necessary) + await client.put("/tag/{name}", { + params: { path: { name: "New Tag" } }, + }); + + // assert providing `body` with correct schema doesn’t raise a TS error + await client.put("/tag/{name}", { + params: { path: { name: "New Tag" } }, + body: { description: "This is a new tag" }, + }); + + // assert providing `body` with bad schema WILL raise a TS error + await client.put("/tag/{name}", { + params: { path: { name: "New Tag" } }, + // @ts-expect-error + body: { foo: "Bar" }, + }); + }); - it("preserves default headers", async () => { - const headers: HeadersInit = { Authorization: "Bearer secrettoken" }; + it("request body type when optional", async () => { + mockFetch({ status: 201, body: "{}" }); + const client = createClient(); - const client = createClient({ headers }); - fetchMocker.mockResponseOnce(JSON.stringify({ email: "user@user.com" })); - await client.get("/self", {}); + // expect error on wrong body type + // @ts-expect-error + await client.post("/post/optional", { body: { error: true } }); + + // (no error) + await client.post("/post/optional", { + body: { + title: "", + publish_date: 3, + body: "", + }, + }); + }); - // assert default headers were passed - const options = fetchMocker.mock.calls[0][1]; - expect(options?.headers).toEqual( - new Headers({ - ...headers, // assert new header got passed - "Content-Type": "application/json", // probably doesn’t need to get tested, but this was simpler than writing lots of code to ignore these - }) - ); - }); + it("request body type when optional inline", async () => { + mockFetch({ status: 201, body: "{}" }); + const client = createClient(); - it("allows override headers", async () => { - const client = createClient({ headers: { "Cache-Control": "max-age=10000000" } }); - fetchMocker.mockResponseOnce(JSON.stringify({ email: "user@user.com" })); - await client.get("/self", { params: {}, headers: { "Cache-Control": "no-cache" } }); - - // assert default headers were passed - const options = fetchMocker.mock.calls[0][1]; - expect(options?.headers).toEqual( - new Headers({ - "Cache-Control": "no-cache", - "Content-Type": "application/json", - }) - ); + // expect error on wrong body type + // @ts-expect-error + await client.post("/post/optional/inline", { body: { error: true } }); + + // (no error) + await client.post("/post/optional/inline", { + body: { + title: "", + publish_date: 3, + body: "", + }, + }); + }); }); - it("accepts a custom fetch function", async () => { - const data = { works: true }; - const client = createClient({ - fetch: async () => - Promise.resolve({ - headers: new Headers(), - json: async () => data, - status: 200, - ok: true, - } as Response), + describe("options", () => { + it("respects baseUrl", async () => { + const client = createClient({ baseUrl: "https://myapi.com/v1" }); + mockFetch({ status: 200, body: JSON.stringify({ message: "OK" }) }); + await client.get("/self", {}); + expect(fetchMocker.mock.calls[0][0]).toBe("https://myapi.com/v1/self"); }); - expect((await client.get("/self", {})).data).toBe(data); - }); - it("treats `default` as an error", async () => { - const client = createClient({ headers: { "Cache-Control": "max-age=10000000" } }); - fetchMocker.mockResponseOnce(() => ({ status: 500, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code: 500, message: "An unexpected error occurred" }) })); - const { error } = await client.get("/default-as-error", {}); + it("preserves default headers", async () => { + const headers: HeadersInit = { Authorization: "Bearer secrettoken" }; + + const client = createClient({ headers }); + mockFetchOnce({ status: 200, body: JSON.stringify({ email: "user@user.com" }) }); + await client.get("/self", {}); + + // assert default headers were passed + const options = fetchMocker.mock.calls[0][1]; + expect(options?.headers).toEqual( + new Headers({ + ...headers, // assert new header got passed + "Content-Type": "application/json", // probably doesn’t need to get tested, but this was simpler than writing lots of code to ignore these + }) + ); + }); - // discard `data` object - if (!error) throw new Error("treats `default` as an error: error response should be present"); + it("allows override headers", async () => { + const client = createClient({ headers: { "Cache-Control": "max-age=10000000" } }); + mockFetchOnce({ status: 200, body: JSON.stringify({ email: "user@user.com" }) }); + await client.get("/self", { params: {}, headers: { "Cache-Control": "no-cache" } }); + + // assert default headers were passed + const options = fetchMocker.mock.calls[0][1]; + expect(options?.headers).toEqual( + new Headers({ + "Cache-Control": "no-cache", + "Content-Type": "application/json", + }) + ); + }); - // assert `error.message` doesn’t throw TS error - expect(error.message).toBe("An unexpected error occurred"); + it("accepts a custom fetch function", async () => { + const data = { works: true }; + const customFetch = { + clone: () => ({ ...customFetch }), + headers: new Headers(), + json: async () => data, + status: 200, + ok: true, + }; + const client = createClient({ + fetch: async () => Promise.resolve(customFetch as Response), + }); + expect((await client.get("/self", {})).data).toBe(data); + }); }); -}); -describe("get()", () => { - it("sends the correct method", async () => { - const client = createClient(); - fetchMocker.mockResponseOnce(() => ({ status: 200, body: "{}" })); - await client.get("/anyMethod", {}); - expect(fetchMocker.mock.calls[0][1]?.method).toBe("GET"); - }); + describe("requests", () => { + it("escapes URLs properly", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + await client.get("/post/{post_id}", { + params: { path: { post_id: "post?id = 🥴" }, query: {} }, + }); - it("sends correct options, returns success", async () => { - const mockData = { title: "My Post", body: "

This is a very good post

", publish_date: new Date("2023-03-01T12:00:00Z").getTime() }; - const client = createClient(); - fetchMocker.mockResponseOnce(() => ({ status: 200, body: JSON.stringify(mockData) })); - const { data, error, response } = await client.get("/post/{post_id}", { - params: { path: { post_id: "my-post" }, query: {} }, + // expect post_id to be encoded properly + expect(fetchMocker.mock.calls[0][0]).toBe("/post/post%3Fid%20%3D%20%F0%9F%A5%B4"); }); - - // assert correct URL was called - expect(fetchMocker.mock.calls[0][0]).toBe("/post/my-post"); - - // assert correct data was returned - expect(data).toEqual(mockData); - expect(response.status).toBe(200); - - // assert error is empty - expect(error).toBe(undefined); }); - it("sends correct options, returns error", async () => { - const mockError = { code: 404, message: "Post not found" }; - const client = createClient(); - fetchMocker.mockResponseOnce(() => ({ status: 404, body: JSON.stringify(mockError) })); - const { data, error, response } = await client.get("/post/{post_id}", { - params: { - path: { post_id: "my-post" }, - query: {}, - }, - }); - - // assert correct URL was called - expect(fetchMocker.mock.calls[0][0]).toBe("/post/my-post"); - - // assert correct method was called - expect(fetchMocker.mock.calls[0][1]?.method).toBe("GET"); + describe("responses", () => { + it("returns empty object on 204", async () => { + const client = createClient(); + mockFetchOnce({ status: 204, body: "" }); + const { data, error, response } = await client.put("/tag/{name}", { + params: { path: { name: "New Tag" } }, + body: { description: "This is a new tag" }, + }); - // assert correct error was returned - expect(error).toEqual(mockError); - expect(response.status).toBe(404); + // assert correct data was returned + expect(data).toEqual({}); + expect(response.status).toBe(204); - // assert data is empty - expect(data).toBe(undefined); - }); + // assert error is empty + expect(error).toBe(undefined); + }); - // note: this was a previous bug in the type inference - it("handles array-type responses", async () => { - const client = createClient(); - fetchMocker.mockResponseOnce(() => ({ status: 200, body: "[]" })); - const { data } = await client.get("/posts", { params: {} }); - if (!data) throw new Error("data empty"); + it("treats `default` as an error", async () => { + const client = createClient({ headers: { "Cache-Control": "max-age=10000000" } }); + mockFetchOnce({ status: 500, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code: 500, message: "An unexpected error occurred" }) }); + const { error } = await client.get("/default-as-error", {}); - // assert array type (and only array type) was inferred - expect(data.length).toBe(0); - }); + // discard `data` object + if (!error) throw new Error("treats `default` as an error: error response should be present"); - it("escapes URLs properly", async () => { - const client = createClient(); - fetchMocker.mockResponseOnce(() => ({ status: 200, body: "{}" })); - await client.get("/post/{post_id}", { - params: { path: { post_id: "post?id = 🥴" }, query: {} }, + // assert `error.message` doesn’t throw TS error + expect(error.message).toBe("An unexpected error occurred"); }); - // expect post_id to be encoded properly - expect(fetchMocker.mock.calls[0][0]).toBe("/post/post%3Fid%20%3D%20%F0%9F%A5%B4"); - }); + it("falls back to text() on invalid JSON", async () => { + const client = createClient(); + const bodyResponse = "My Post"; + mockFetchOnce({ status: 200, body: bodyResponse }); + const { data, error } = await client.get("/post/{post_id}", { params: { path: { post_id: "my-post" } } }); + if (error) throw new Error("falls back to text(): error shouldn’t be present"); - it("serializes params properly", async () => { - const client = createClient(); - fetchMocker.mockResponseOnce(() => ({ status: 200, body: "{}" })); - await client.get("/post/{post_id}", { - params: { - path: { post_id: "my-post" }, - query: { version: 2, format: "json" }, - }, + // assert `data` is a string + expect(data).toBe(bodyResponse); }); - expect(fetchMocker.mock.calls[0][0]).toBe("/post/my-post?version=2&format=json"); - }); - - it("serializes params properly with querySerializer", async () => { - const client = createClient(); - fetchMocker.mockResponseOnce(() => ({ status: 200, body: "{}" })); - await client.get("/post/{post_id}", { - params: { - path: { post_id: "my-post" }, - query: { version: 2, format: "json" }, - }, - querySerializer: (q) => `alpha=${q.version}&beta=${q.format}`, + describe("parseAs", () => { + it("text", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + const { data } = await client.get("/anyMethod", { parseAs: "text" }); + expect(data).toBe("{}"); + }); + + it("arrayBuffer", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + const { data } = await client.get("/anyMethod", { parseAs: "arrayBuffer" }); + expect(data instanceof ArrayBuffer).toBe(true); + }); + + it("blob", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + const { data } = await client.get("/anyMethod", { parseAs: "blob" }); + expect((data as any).constructor.name).toBe("Blob"); + }); }); - expect(fetchMocker.mock.calls[0][0]).toBe("/post/my-post?alpha=2&beta=json"); - }); -}); - -describe("post()", () => { - it("sends the correct method", async () => { - const client = createClient(); - fetchMocker.mockResponseOnce(() => ({ status: 200, body: "{}" })); - await client.post("/anyMethod", {}); - expect(fetchMocker.mock.calls[0][1]?.method).toBe("POST"); + describe("querySerializer", () => { + it("default", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + await client.get("/post/{post_id}", { + params: { + path: { post_id: "my-post" }, + query: { version: 2, format: "json" }, + }, + }); + + expect(fetchMocker.mock.calls[0][0]).toBe("/post/my-post?version=2&format=json"); + }); + + it("default (with empty params)", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + await client.get("/post/{post_id}", { + params: { + path: { post_id: "my-post" }, + query: { version: undefined, format: null as any }, + }, + }); + + expect(fetchMocker.mock.calls[0][0]).toBe("/post/my-post"); + }); + + it("custom", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + await client.get("/post/{post_id}", { + params: { + path: { post_id: "my-post" }, + query: { version: 2, format: "json" }, + }, + querySerializer: (q) => `alpha=${q.version}&beta=${q.format}`, + }); + + expect(fetchMocker.mock.calls[0][0]).toBe("/post/my-post?alpha=2&beta=json"); + }); + }); }); - it("sends correct options, returns success", async () => { - const mockData = { status: "success" }; - const client = createClient(); - fetchMocker.mockResponseOnce(() => ({ status: 201, body: JSON.stringify(mockData) })); - const { data, error, response } = await client.put("/post", { - params: {}, - body: { - title: "New Post", - body: "

Best post yet

", - publish_date: new Date("2023-03-31T12:00:00Z").getTime(), - }, + describe("get()", () => { + it("sends the correct method", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + await client.get("/anyMethod", {}); + expect(fetchMocker.mock.calls[0][1]?.method).toBe("GET"); }); - // assert correct URL was called - expect(fetchMocker.mock.calls[0][0]).toBe("/post"); + it("sends correct options, returns success", async () => { + const mockData = { title: "My Post", body: "

This is a very good post

", publish_date: new Date("2023-03-01T12:00:00Z").getTime() }; + const client = createClient(); + mockFetchOnce({ status: 200, body: JSON.stringify(mockData) }); + const { data, error, response } = await client.get("/post/{post_id}", { + params: { path: { post_id: "my-post" }, query: {} }, + }); - // assert correct data was returned - expect(data).toEqual(mockData); - expect(response.status).toBe(201); + // assert correct URL was called + expect(fetchMocker.mock.calls[0][0]).toBe("/post/my-post"); - // assert error is empty - expect(error).toBe(undefined); - }); + // assert correct data was returned + expect(data).toEqual(mockData); + expect(response.status).toBe(200); - it("supports sepecifying utf-8 encoding", async () => { - const mockData = { message: "My reply" }; - const client = createClient(); - fetchMocker.mockResponseOnce(() => ({ status: 201, body: JSON.stringify(mockData) })); - const { data, error, response } = await client.put("/comment", { - params: {}, - body: { - message: "My reply", - replied_at: new Date("2023-03-31T12:00:00Z").getTime(), - }, + // assert error is empty + expect(error).toBe(undefined); }); - // assert correct data was returned - expect(data).toEqual(mockData); - expect(response.status).toBe(201); - - // assert error is empty - expect(error).toBe(undefined); - }); - - it("returns empty object on 204", async () => { - const client = createClient(); - fetchMocker.mockResponseOnce(() => ({ status: 204, body: "" })); - const { data, error, response } = await client.put("/tag/{name}", { - params: { path: { name: "New Tag" } }, - body: { description: "This is a new tag" }, + it("sends correct options, returns error", async () => { + const mockError = { code: 404, message: "Post not found" }; + const client = createClient(); + mockFetchOnce({ status: 404, body: JSON.stringify(mockError) }); + const { data, error, response } = await client.get("/post/{post_id}", { + params: { + path: { post_id: "my-post" }, + query: {}, + }, + }); + + // assert correct URL was called + expect(fetchMocker.mock.calls[0][0]).toBe("/post/my-post"); + + // assert correct method was called + expect(fetchMocker.mock.calls[0][1]?.method).toBe("GET"); + + // assert correct error was returned + expect(error).toEqual(mockError); + expect(response.status).toBe(404); + + // assert data is empty + expect(data).toBe(undefined); }); - // assert correct data was returned - expect(data).toEqual({}); - expect(response.status).toBe(204); - - // assert error is empty - expect(error).toBe(undefined); - }); - - it("request body type when optional", async () => { - fetchMocker.mockResponse(() => ({ status: 201, body: "{}" })); - const client = createClient(); - - // expect error on wrong body type - // @ts-expect-error - await client.post("/post/optional", { body: { error: true } }); + // note: this was a previous bug in the type inference + it("handles array-type responses", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "[]" }); + const { data } = await client.get("/posts", { params: {} }); + if (!data) throw new Error("data empty"); - // (no error) - await client.post("/post/optional", { - body: { - title: "", - publish_date: 3, - body: "", - }, + // assert array type (and only array type) was inferred + expect(data.length).toBe(0); }); }); - it("request body type when optional inline", async () => { - fetchMocker.mockResponse(() => ({ status: 201, body: "{}" })); - const client = createClient(); - - // expect error on wrong body type - // @ts-expect-error - await client.post("/post/optional/inline", { body: { error: true } }); + describe("post()", () => { + it("sends the correct method", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + await client.post("/anyMethod", {}); + expect(fetchMocker.mock.calls[0][1]?.method).toBe("POST"); + }); - // (no error) - await client.post("/post/optional/inline", { - body: { - title: "", - publish_date: 3, - body: "", - }, + it("sends correct options, returns success", async () => { + const mockData = { status: "success" }; + const client = createClient(); + mockFetchOnce({ status: 201, body: JSON.stringify(mockData) }); + const { data, error, response } = await client.put("/post", { + params: {}, + body: { + title: "New Post", + body: "

Best post yet

", + publish_date: new Date("2023-03-31T12:00:00Z").getTime(), + }, + }); + + // assert correct URL was called + expect(fetchMocker.mock.calls[0][0]).toBe("/post"); + + // assert correct data was returned + expect(data).toEqual(mockData); + expect(response.status).toBe(201); + + // assert error is empty + expect(error).toBe(undefined); }); - }); -}); -describe("delete()", () => { - it("sends the correct method", async () => { - const client = createClient(); - fetchMocker.mockResponseOnce(() => ({ status: 200, body: "{}" })); - await client.del("/anyMethod", {}); - expect(fetchMocker.mock.calls[0][1]?.method).toBe("DELETE"); + it("supports sepecifying utf-8 encoding", async () => { + const mockData = { message: "My reply" }; + const client = createClient(); + mockFetchOnce({ status: 201, body: JSON.stringify(mockData) }); + const { data, error, response } = await client.put("/comment", { + params: {}, + body: { + message: "My reply", + replied_at: new Date("2023-03-31T12:00:00Z").getTime(), + }, + }); + + // assert correct data was returned + expect(data).toEqual(mockData); + expect(response.status).toBe(201); + + // assert error is empty + expect(error).toBe(undefined); + }); }); - it("returns empty object on 204", async () => { - const client = createClient(); - fetchMocker.mockResponseOnce(() => ({ status: 204, body: "" })); - const { data, error } = await client.del("/post/{post_id}", { - params: { - path: { post_id: "123" }, - }, + describe("delete()", () => { + it("sends the correct method", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + await client.del("/anyMethod", {}); + expect(fetchMocker.mock.calls[0][1]?.method).toBe("DELETE"); }); - // assert correct data was returned - expect(data).toEqual({}); + it("returns empty object on 204", async () => { + const client = createClient(); + mockFetchOnce({ status: 204, body: "" }); + const { data, error } = await client.del("/post/{post_id}", { + params: { + path: { post_id: "123" }, + }, + }); - // assert error is empty - expect(error).toBe(undefined); - }); + // assert correct data was returned + expect(data).toEqual({}); - it("returns empty object on Content-Length: 0", async () => { - const client = createClient(); - fetchMocker.mockResponseOnce(() => ({ headers: { "Content-Length": 0 }, status: 200, body: "" })); - const { data, error } = await client.del("/post/{post_id}", { - params: { - path: { post_id: "123" }, - }, + // assert error is empty + expect(error).toBe(undefined); }); - // assert correct data was returned - expect(data).toEqual({}); + it("returns empty object on Content-Length: 0", async () => { + const client = createClient(); + mockFetchOnce({ headers: { "Content-Length": "0" }, status: 200, body: "" }); + const { data, error } = await client.del("/post/{post_id}", { + params: { + path: { post_id: "123" }, + }, + }); - // assert error is empty - expect(error).toBe(undefined); + // assert correct data was returned + expect(data).toEqual({}); + + // assert error is empty + expect(error).toBe(undefined); + }); }); -}); -describe("options()", () => { - it("sends the correct method", async () => { - const client = createClient(); - fetchMocker.mockResponseOnce(() => ({ status: 200, body: "{}" })); - await client.options("/anyMethod", {}); - expect(fetchMocker.mock.calls[0][1]?.method).toBe("OPTIONS"); + describe("options()", () => { + it("sends the correct method", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + await client.options("/anyMethod", {}); + expect(fetchMocker.mock.calls[0][1]?.method).toBe("OPTIONS"); + }); }); -}); -describe("head()", () => { - it("sends the correct method", async () => { - const client = createClient(); - fetchMocker.mockResponseOnce(() => ({ status: 200, body: "{}" })); - await client.head("/anyMethod", {}); - expect(fetchMocker.mock.calls[0][1]?.method).toBe("HEAD"); + describe("head()", () => { + it("sends the correct method", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + await client.head("/anyMethod", {}); + expect(fetchMocker.mock.calls[0][1]?.method).toBe("HEAD"); + }); }); -}); -describe("patch()", () => { - it("sends the correct method", async () => { - const client = createClient(); - fetchMocker.mockResponseOnce(() => ({ status: 200, body: "{}" })); - await client.patch("/anyMethod", {}); - expect(fetchMocker.mock.calls[0][1]?.method).toBe("PATCH"); + describe("patch()", () => { + it("sends the correct method", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + await client.patch("/anyMethod", {}); + expect(fetchMocker.mock.calls[0][1]?.method).toBe("PATCH"); + }); }); -}); -describe("trace()", () => { - it("sends the correct method", async () => { - const client = createClient(); - fetchMocker.mockResponseOnce(() => ({ status: 200, body: "{}" })); - await client.trace("/anyMethod", {}); - expect(fetchMocker.mock.calls[0][1]?.method).toBe("TRACE"); + describe("trace()", () => { + it("sends the correct method", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + await client.trace("/anyMethod", {}); + expect(fetchMocker.mock.calls[0][1]?.method).toBe("TRACE"); + }); }); }); @@ -499,13 +566,13 @@ describe("examples", () => { const client = computed([token], (currentToken) => createClient({ headers: currentToken ? { Authorization: `Bearer ${currentToken}` } : {} })); // assert initial call is unauthenticated - fetchMocker.mockResponseOnce(() => ({ status: 200, body: "{}" })); + mockFetchOnce({ status: 200, body: "{}" }); await client.get().get("/post/{post_id}", { params: { path: { post_id: "1234" }, query: {} } }); expect(fetchMocker.mock.calls[0][1].headers.get("authorization")).toBeNull(); // assert after setting token, client is authenticated const tokenVal = "abcd"; - fetchMocker.mockResponseOnce(() => ({ status: 200, body: "{}" })); + mockFetchOnce({ status: 200, body: "{}" }); await new Promise((resolve) => setTimeout(() => { token.set(tokenVal); // simulate promise-like token setting @@ -528,13 +595,13 @@ describe("examples", () => { }); // assert initial call is unauthenticated - fetchMocker.mockResponseOnce(() => ({ status: 200, body: "{}" })); + mockFetchOnce({ status: 200, body: "{}" }); await client.get("/post/{post_id}", { params: { path: { post_id: "1234" }, query: {} } }); expect(fetchMocker.mock.calls[0][1].headers.get("authorization")).toBeNull(); // assert after setting token, client is authenticated const tokenVal = "abcd"; - fetchMocker.mockResponseOnce(() => ({ status: 200, body: "{}" })); + mockFetchOnce({ status: 200, body: "{}" }); await new Promise((resolve) => setTimeout(() => { token = tokenVal; // simulate promise-like token setting diff --git a/packages/openapi-fetch/src/index.ts b/packages/openapi-fetch/src/index.ts index bcd780b23..2ed16c4d8 100644 --- a/packages/openapi-fetch/src/index.ts +++ b/packages/openapi-fetch/src/index.ts @@ -1,7 +1,8 @@ -// settings +// settings & const const DEFAULT_HEADERS = { "Content-Type": "application/json", }; +const TRAILING_SLASH_RE = /\/*$/; /** options for each client instance */ interface ClientOptions extends RequestInit { @@ -17,6 +18,7 @@ export interface BaseParams { // const export type PathItemObject = { [M in HttpMethod]: OperationObject } & { parameters?: any }; +export type ParseAs = "json" | "blob" | "arrayBuffer" | "text"; export interface OperationObject { parameters: any; requestBody: any; // note: "any" will get overridden in inference @@ -43,8 +45,8 @@ export type RequestBodyObj = O extends { requestBody?: any } ? O["requestBody export type RequestBodyContent = undefined extends RequestBodyObj ? FilterKeys>, "content"> | undefined : FilterKeys, "content">; export type RequestBodyJSON = FilterKeys, JSONLike> extends never ? FilterKeys>, JSONLike> | undefined : FilterKeys, JSONLike>; export type RequestBody = undefined extends RequestBodyJSON ? { body?: RequestBodyJSON } : { body: RequestBodyJSON }; -export type QuerySerializer = (query: O extends { parameters: { query: any } } ? O["parameters"]["query"] : Record) => string; -export type RequestOptions = Params & RequestBody & { querySerializer?: QuerySerializer }; +export type QuerySerializer = (query: O extends { parameters: any } ? NonNullable : Record) => string; +export type RequestOptions = Params & RequestBody & { querySerializer?: QuerySerializer; parseAs?: ParseAs }; export type Success = FilterKeys, "content">; export type Error = FilterKeys, "content">; @@ -54,6 +56,31 @@ export type FetchResponse = | { data: T extends { responses: any } ? NonNullable, JSONLike>> : unknown; error?: never; response: Response } | { data?: never; error: T extends { responses: any } ? NonNullable, JSONLike>> : unknown; response: Response }; +/** Call URLSearchParams() on the object, but remove `undefined` and `null` params */ +export function defaultSerializer(q: unknown): string { + const search = new URLSearchParams(); + if (q && typeof q === "object") { + for (const [k, v] of Object.entries(q)) { + if (v === undefined || v === null) continue; + search.set(k, String(v)); + } + } + return search.toString(); +} + +/** Construct URL string from baseUrl and handle path and query params */ +export function createFinalURL(url: string, options: { baseUrl?: string; params: { query?: Record; path?: Record }; querySerializer: QuerySerializer }): string { + let finalURL = `${options.baseUrl ? options.baseUrl.replace(TRAILING_SLASH_RE, "") : ""}${url as string}`; + if (options.params.path) { + for (const [k, v] of Object.entries(options.params.path)) finalURL = finalURL.replace(`{${k}}`, encodeURIComponent(String(v))); + } + if (options.params.query) { + const search = options.querySerializer(options.params.query as any); + if (search) finalURL += `?${search}`; + } + return finalURL; +} + export default function createClient(clientOptions: ClientOptions = {}) { const { fetch = globalThis.fetch, ...options } = clientOptions; @@ -63,16 +90,10 @@ export default function createClient(clientOptions: ClientOpti }); async function coreFetch

(url: P, fetchOptions: FetchOptions): Promise> { - const { headers, body: requestBody, params = {}, querySerializer = (q: QuerySerializer) => new URLSearchParams(q as any).toString(), ...init } = fetchOptions || {}; + const { headers, body: requestBody, params = {}, parseAs = "json", querySerializer = defaultSerializer, ...init } = fetchOptions || {}; // URL - let finalURL = `${options.baseUrl ?? ""}${url as string}`; - if ((params as any).path) { - for (const [k, v] of Object.entries((params as any).path)) finalURL = finalURL.replace(`{${k}}`, encodeURIComponent(String(v))); - } - if ((params as any).query && Object.keys((params as any).query).length) { - finalURL += `?${querySerializer((params as any).query)}`; - } + const finalURL = createFinalURL(url as string, { baseUrl: options.baseUrl, params, querySerializer }); // headers const baseHeaders = new Headers(defaultHeaders); // clone defaults (don’t overwrite!) @@ -91,8 +112,19 @@ export default function createClient(clientOptions: ClientOpti body: typeof requestBody === "string" ? requestBody : JSON.stringify(requestBody), }); - // don’t parse JSON if status is 204, or Content-Length is '0' - const body = response.status === 204 || response.headers.get("Content-Length") === "0" ? {} : await response.json(); + // handle empty content + // note: we return `{}` because we want user truthy checks for `.data` or `.error` to succeed + if (response.status === 204 || response.headers.get("Content-Length") === "0") { + return response.ok ? { data: {} as any, response } : { error: {} as any, response }; + } + + // parse response (falling back to .text() when necessary) + let body: any = {}; + try { + body = await response.clone()[parseAs](); // + } catch { + body = await response.clone().text(); + } return response.ok ? { data: body, response } : { error: body, response: response }; }