openapi-fetch
Advanced tools
Comparing version 0.2.1 to 0.3.0
# openapi-fetch | ||
## 0.1.2 | ||
## 0.3.0 | ||
### Minor Changes | ||
- [#1169](https://github.com/drwpow/openapi-typescript/pull/1169) [`74bfc0d`](https://github.com/drwpow/openapi-typescript/commit/74bfc0d879747790aed7942e11f4b277b9b0428d) Thanks [@drwpow](https://github.com/drwpow)! - Expose createFinalURL() logic for testing | ||
- [#1169](https://github.com/drwpow/openapi-typescript/pull/1169) [`74bfc0d`](https://github.com/drwpow/openapi-typescript/commit/74bfc0d879747790aed7942e11f4b277b9b0428d) Thanks [@drwpow](https://github.com/drwpow)! - Automatically remove `undefined` and `null` query params without requiring querySerializer | ||
- [#1169](https://github.com/drwpow/openapi-typescript/pull/1169) [`74bfc0d`](https://github.com/drwpow/openapi-typescript/commit/74bfc0d879747790aed7942e11f4b277b9b0428d) Thanks [@drwpow](https://github.com/drwpow)! - Allow overriding of JSON body parsing | ||
### Patch Changes | ||
- [#1169](https://github.com/drwpow/openapi-typescript/pull/1169) [`74bfc0d`](https://github.com/drwpow/openapi-typescript/commit/74bfc0d879747790aed7942e11f4b277b9b0428d) Thanks [@drwpow](https://github.com/drwpow)! - Clone response internally | ||
- [#1169](https://github.com/drwpow/openapi-typescript/pull/1169) [`74bfc0d`](https://github.com/drwpow/openapi-typescript/commit/74bfc0d879747790aed7942e11f4b277b9b0428d) Thanks [@drwpow](https://github.com/drwpow)! - Strip trailing slashes from baseUrl | ||
- [#1169](https://github.com/drwpow/openapi-typescript/pull/1169) [`74bfc0d`](https://github.com/drwpow/openapi-typescript/commit/74bfc0d879747790aed7942e11f4b277b9b0428d) Thanks [@drwpow](https://github.com/drwpow)! - Fix querySerializer typing | ||
## 0.2.1 | ||
### Patch Changes | ||
- [#1139](https://github.com/drwpow/openapi-typescript/pull/1139) [`30c01fa`](https://github.com/drwpow/openapi-typescript/commit/30c01fa3727a9696166a9bf44dd01693cc354a09) Thanks [@drwpow](https://github.com/drwpow)! - Treat `default` response as error | ||
@@ -8,0 +26,0 @@ |
@@ -18,2 +18,3 @@ /** options for each client instance */ | ||
}; | ||
export type ParseAs = "json" | "text" | "blob" | "arrayBuffer" | "stream"; | ||
export interface OperationObject { | ||
@@ -55,8 +56,7 @@ parameters: any; | ||
export type QuerySerializer<O> = (query: O extends { | ||
parameters: { | ||
query: any; | ||
}; | ||
} ? O["parameters"]["query"] : Record<string, unknown>) => string; | ||
parameters: any; | ||
} ? NonNullable<O["parameters"]["query"]> : Record<string, unknown>) => string; | ||
export type RequestOptions<T> = Params<T> & RequestBody<T> & { | ||
querySerializer?: QuerySerializer<T>; | ||
parseAs?: ParseAs; | ||
}; | ||
@@ -79,2 +79,13 @@ export type Success<O> = FilterKeys<FilterKeys<O, OkStatus>, "content">; | ||
}; | ||
/** Call URLSearchParams() on the object, but remove `undefined` and `null` params */ | ||
export declare function defaultSerializer(q: unknown): string; | ||
/** Construct URL string from baseUrl and handle path and query params */ | ||
export declare function createFinalURL<O>(url: string, options: { | ||
baseUrl?: string; | ||
params: { | ||
query?: Record<string, unknown>; | ||
path?: Record<string, unknown>; | ||
}; | ||
querySerializer: QuerySerializer<O>; | ||
}): string; | ||
export default function createClient<Paths extends {}>(clientOptions?: ClientOptions): { | ||
@@ -81,0 +92,0 @@ /** Call a GET endpoint */ |
@@ -1,5 +0,32 @@ | ||
// settings | ||
// settings & const | ||
const DEFAULT_HEADERS = { | ||
"Content-Type": "application/json", | ||
}; | ||
const TRAILING_SLASH_RE = /\/*$/; | ||
/** Call URLSearchParams() on the object, but remove `undefined` and `null` params */ | ||
export function defaultSerializer(q) { | ||
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, options) { | ||
let finalURL = `${options.baseUrl ? options.baseUrl.replace(TRAILING_SLASH_RE, "") : ""}${url}`; | ||
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); | ||
if (search) | ||
finalURL += `?${search}`; | ||
} | ||
return finalURL; | ||
} | ||
export default function createClient(clientOptions = {}) { | ||
@@ -12,12 +39,5 @@ const { fetch = globalThis.fetch, ...options } = clientOptions; | ||
async function coreFetch(url, fetchOptions) { | ||
const { headers, body: requestBody, params = {}, querySerializer = (q) => new URLSearchParams(q).toString(), ...init } = fetchOptions || {}; | ||
const { headers, body: requestBody, params = {}, parseAs = "json", querySerializer = defaultSerializer, ...init } = fetchOptions || {}; | ||
// URL | ||
let finalURL = `${options.baseUrl ?? ""}${url}`; | ||
if (params.path) { | ||
for (const [k, v] of Object.entries(params.path)) | ||
finalURL = finalURL.replace(`{${k}}`, encodeURIComponent(String(v))); | ||
} | ||
if (params.query && Object.keys(params.query).length) { | ||
finalURL += `?${querySerializer(params.query)}`; | ||
} | ||
const finalURL = createFinalURL(url, { baseUrl: options.baseUrl, params, querySerializer }); | ||
// headers | ||
@@ -40,5 +60,29 @@ const baseHeaders = new Headers(defaultHeaders); // clone defaults (don’t overwrite!) | ||
}); | ||
// 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(); | ||
return response.ok ? { data: body, response } : { error: body, response: response }; | ||
// 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: {}, response } : { error: {}, response }; | ||
} | ||
// parse response (falling back to .text() when necessary) | ||
if (response.ok) { | ||
let data = response.body; | ||
if (parseAs !== "stream") { | ||
try { | ||
data = await response.clone()[parseAs](); | ||
} | ||
catch { | ||
data = await response.clone().text(); | ||
} | ||
} | ||
return { data, response }; | ||
} | ||
// handle errors (always parse as .json() or .text()) | ||
let error = {}; | ||
try { | ||
error = await response.clone().json(); | ||
} | ||
catch { | ||
error = await response.clone().text(); | ||
} | ||
return { error, response }; | ||
} | ||
@@ -45,0 +89,0 @@ return { |
@@ -18,2 +18,3 @@ /** options for each client instance */ | ||
}; | ||
export type ParseAs = "json" | "text" | "blob" | "arrayBuffer" | "stream"; | ||
export interface OperationObject { | ||
@@ -55,8 +56,7 @@ parameters: any; | ||
export type QuerySerializer<O> = (query: O extends { | ||
parameters: { | ||
query: any; | ||
}; | ||
} ? O["parameters"]["query"] : Record<string, unknown>) => string; | ||
parameters: any; | ||
} ? NonNullable<O["parameters"]["query"]> : Record<string, unknown>) => string; | ||
export type RequestOptions<T> = Params<T> & RequestBody<T> & { | ||
querySerializer?: QuerySerializer<T>; | ||
parseAs?: ParseAs; | ||
}; | ||
@@ -79,2 +79,13 @@ export type Success<O> = FilterKeys<FilterKeys<O, OkStatus>, "content">; | ||
}; | ||
/** Call URLSearchParams() on the object, but remove `undefined` and `null` params */ | ||
export declare function defaultSerializer(q: unknown): string; | ||
/** Construct URL string from baseUrl and handle path and query params */ | ||
export declare function createFinalURL<O>(url: string, options: { | ||
baseUrl?: string; | ||
params: { | ||
query?: Record<string, unknown>; | ||
path?: Record<string, unknown>; | ||
}; | ||
querySerializer: QuerySerializer<O>; | ||
}): string; | ||
export default function createClient<Paths extends {}>(clientOptions?: ClientOptions): { | ||
@@ -81,0 +92,0 @@ /** Call a GET endpoint */ |
@@ -1,1 +0,1 @@ | ||
var F={"Content-Type":"application/json"};function m(P={}){let{fetch:c=globalThis.fetch,...i}=P,u=new Headers({...F,...i.headers??{}});async function s(e,t){let{headers:O,body:p,params:r={},querySerializer:l=a=>new URLSearchParams(a).toString(),...x}=t||{},y=`${i.baseUrl??""}${e}`;if(r.path)for(let[a,o]of Object.entries(r.path))y=y.replace(`{${a}}`,encodeURIComponent(String(o)));r.query&&Object.keys(r.query).length&&(y+=`?${l(r.query)}`);let d=new Headers(u),f=new Headers(O);for(let[a,o]of f.entries())o==null?d.delete(a):d.set(a,o);let n=await c(y,{redirect:"follow",...i,...x,headers:d,body:typeof p=="string"?p:JSON.stringify(p)}),h=n.status===204||n.headers.get("Content-Length")==="0"?{}:await n.json();return n.ok?{data:h,response:n}:{error:h,response:n}}return{async get(e,t){return s(e,{...t,method:"GET"})},async put(e,t){return s(e,{...t,method:"PUT"})},async post(e,t){return s(e,{...t,method:"POST"})},async del(e,t){return s(e,{...t,method:"DELETE"})},async options(e,t){return s(e,{...t,method:"OPTIONS"})},async head(e,t){return s(e,{...t,method:"HEAD"})},async patch(e,t){return s(e,{...t,method:"PATCH"})},async trace(e,t){return s(e,{...t,method:"TRACE"})}}}export{m as default}; | ||
var b={"Content-Type":"application/json"},R=/\/*$/;function F(i){let n=new URLSearchParams;if(i&&typeof i=="object")for(let[r,a]of Object.entries(i))a!=null&&n.set(r,String(a));return n.toString()}function S(i,n){let r=`${n.baseUrl?n.baseUrl.replace(R,""):""}${i}`;if(n.params.path)for(let[a,o]of Object.entries(n.params.path))r=r.replace(`{${a}}`,encodeURIComponent(String(o)));if(n.params.query){let a=n.querySerializer(n.params.query);a&&(r+=`?${a}`)}return r}function q(i={}){let{fetch:n=globalThis.fetch,...r}=i,a=new Headers({...b,...r.headers??{}});async function o(e,t){let{headers:P,body:p,params:l={},parseAs:u="json",querySerializer:O=F,...x}=t||{},f=S(e,{baseUrl:r.baseUrl,params:l,querySerializer:O}),d=new Headers(a),m=new Headers(P);for(let[y,c]of m.entries())c==null?d.delete(y):d.set(y,c);let s=await n(f,{redirect:"follow",...r,...x,headers:d,body:typeof p=="string"?p:JSON.stringify(p)});if(s.status===204||s.headers.get("Content-Length")==="0")return s.ok?{data:{},response:s}:{error:{},response:s};if(s.ok){let y=s.body;if(u!=="stream")try{y=await s.clone()[u]()}catch{y=await s.clone().text()}return{data:y,response:s}}let h={};try{h=await s.clone().json()}catch{h=await s.clone().text()}return{error:h,response:s}}return{async get(e,t){return o(e,{...t,method:"GET"})},async put(e,t){return o(e,{...t,method:"PUT"})},async post(e,t){return o(e,{...t,method:"POST"})},async del(e,t){return o(e,{...t,method:"DELETE"})},async options(e,t){return o(e,{...t,method:"OPTIONS"})},async head(e,t){return o(e,{...t,method:"HEAD"})},async patch(e,t){return o(e,{...t,method:"PATCH"})},async trace(e,t){return o(e,{...t,method:"TRACE"})}}}export{S as createFinalURL,q as default,F as defaultSerializer}; |
{ | ||
"name": "openapi-fetch", | ||
"description": "Ultra-fast fetching for TypeScript generated automatically from your OpenAPI schema. Weighs in at 1 kb and has virtually zero runtime. Works with React, Vue, Svelte, or vanilla JS.", | ||
"version": "0.2.1", | ||
"version": "0.3.0", | ||
"author": { | ||
@@ -50,10 +50,10 @@ "name": "Drew Powers", | ||
"del-cli": "^5.0.0", | ||
"esbuild": "^0.17.19", | ||
"esbuild": "^0.18.3", | ||
"nanostores": "^0.8.1", | ||
"openapi-typescript": "^6.2.4", | ||
"openapi-typescript": "*", | ||
"prettier": "^2.8.8", | ||
"typescript": "^5.0.4", | ||
"vitest": "^0.31.1", | ||
"typescript": "^5.1.3", | ||
"vitest": "^0.32.0", | ||
"vitest-fetch-mock": "^0.2.2" | ||
} | ||
} |
@@ -5,7 +5,5 @@ import { atom, computed } from "nanostores"; | ||
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); | ||
@@ -20,2 +18,16 @@ | ||
interface MockResponse { | ||
headers?: Record<string, string>; | ||
status: number; | ||
body: any; | ||
} | ||
function mockFetch(res: MockResponse) { | ||
fetchMocker.mockResponse(() => res); | ||
} | ||
function mockFetchOnce(res: MockResponse) { | ||
fetchMocker.mockResponseOnce(() => res); | ||
} | ||
describe("client", () => { | ||
@@ -35,461 +47,530 @@ it("generates all proper functions", () => { | ||
it("marks data as undefined, but never both", async () => { | ||
const client = createClient<paths>(); | ||
describe("TypeScript checks", () => { | ||
it("marks data or error as undefined, but never both", async () => { | ||
const client = createClient<paths>(); | ||
// data | ||
fetchMocker.mockResponseOnce(JSON.stringify(["one", "two", "three"])); | ||
const dataRes = await client.get("/string-array", {}); | ||
// data | ||
mockFetchOnce({ status: 200, body: JSON.stringify(["one", "two", "three"]) }); | ||
const dataRes = await client.get("/string-array", {}); | ||
// … is initially possibly undefined | ||
// @ts-expect-error | ||
expect(dataRes.data[0]).toBe("one"); | ||
// … 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(); | ||
} | ||
// … is present if error is undefined | ||
if (!dataRes.error) { | ||
expect(dataRes.data[0]).toBe("one"); | ||
} | ||
// error | ||
fetchMocker.mockResponseOnce(() => ({ | ||
status: 500, | ||
body: JSON.stringify({ status: "500", message: "Something went wrong" }), | ||
})); | ||
const errorRes = await client.get("/string-array", {}); | ||
// … means data is undefined | ||
if (dataRes.data) { | ||
// @ts-expect-error | ||
expect(() => dataRes.error.message).toThrow(); | ||
} | ||
// … is initially possibly undefined | ||
// @ts-expect-error | ||
expect(errorRes.error.message).toBe("Something went wrong"); | ||
// error | ||
mockFetchOnce({ status: 500, body: JSON.stringify({ code: 500, message: "Something went wrong" }) }); | ||
const errorRes = await client.get("/string-array", {}); | ||
// … is present if error is undefined | ||
if (!errorRes.data) { | ||
// … is initially possibly undefined | ||
// @ts-expect-error | ||
expect(errorRes.error.message).toBe("Something went wrong"); | ||
} | ||
// … means data is undefined | ||
if (errorRes.error) { | ||
// @ts-expect-error | ||
expect(() => errorRes.data[0]).toThrow(); | ||
} | ||
}); | ||
// … is present if error is undefined | ||
if (!errorRes.data) { | ||
expect(errorRes.error.message).toBe("Something went wrong"); | ||
} | ||
it("requires path params", async () => { | ||
const client = createClient<paths>({ baseUrl: "https://myapi.com/v1" }); | ||
fetchMocker.mockResponse(JSON.stringify({ message: "OK" })); | ||
// … means data is undefined | ||
if (errorRes.error) { | ||
// @ts-expect-error | ||
expect(() => errorRes.data[0]).toThrow(); | ||
} | ||
}); | ||
// expect error on missing 'params' | ||
// @ts-expect-error | ||
await client.get("/post/{post_id}", {}); | ||
it("requires path params", async () => { | ||
const client = createClient<paths>({ baseUrl: "https://myapi.com/v1" }); | ||
mockFetch({ status: 200, body: JSON.stringify({ message: "OK" }) }); | ||
// expect error on empty params | ||
// @ts-expect-error | ||
await client.get("/post/{post_id}", { params: {} }); | ||
// expect error on missing 'params' | ||
// @ts-expect-error | ||
await client.get("/post/{post_id}", {}); | ||
// expect error on empty params.path | ||
// @ts-expect-error | ||
await client.get("/post/{post_id}", { params: { path: {} } }); | ||
// expect error on empty params | ||
// @ts-expect-error | ||
await client.get("/post/{post_id}", { params: {} }); | ||
// expect error on mismatched type (number v string) | ||
// @ts-expect-error | ||
await client.get("/post/{post_id}", { params: { path: { post_id: 1234 } }, query: {} }); | ||
// expect error on empty params.path | ||
// @ts-expect-error | ||
await client.get("/post/{post_id}", { params: { path: {} } }); | ||
// (no error) | ||
await client.get("/post/{post_id}", { params: { path: { post_id: "1234" }, query: {} } }); | ||
}); | ||
// 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 necessary requestBodies", async () => { | ||
const client = createClient<paths>({ baseUrl: "https://myapi.com/v1" }); | ||
fetchMocker.mockResponse(JSON.stringify({ message: "OK" })); | ||
// (no error) | ||
await client.get("/post/{post_id}", { params: { path: { post_id: "1234" }, query: {} } }); | ||
}); | ||
// expect error on missing `body` | ||
// @ts-expect-error | ||
await client.get("/post", {}); | ||
it("requires necessary requestBodies", async () => { | ||
const client = createClient<paths>({ baseUrl: "https://myapi.com/v1" }); | ||
mockFetch({ status: 200, body: JSON.stringify({ message: "OK" }) }); | ||
// expect error on missing fields | ||
// @ts-expect-error | ||
await client.put("/post", { body: { title: "Foo" } }); | ||
// expect error on missing `body` | ||
// @ts-expect-error | ||
await client.get("/post", {}); | ||
// 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() }, | ||
// expect error on missing fields | ||
// @ts-expect-error | ||
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("skips optional requestBody", async () => { | ||
const mockData = { status: "success" }; | ||
const client = createClient<paths>(); | ||
fetchMocker.mockResponse(() => ({ status: 201, body: JSON.stringify(mockData) })); | ||
it("allows optional requestBody", async () => { | ||
const mockData = { status: "success" }; | ||
const client = createClient<paths>(); | ||
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 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" }, | ||
}); | ||
}); | ||
// 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" }, | ||
it("request body type when optional", async () => { | ||
mockFetch({ status: 201, body: "{}" }); | ||
const client = createClient<paths>(); | ||
// 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 providing `body` with bad schema WILL raise a TS error | ||
await client.put("/tag/{name}", { | ||
params: { path: { name: "New Tag" } }, | ||
it("request body type when optional inline", async () => { | ||
mockFetch({ status: 201, body: "{}" }); | ||
const client = createClient<paths>(); | ||
// expect error on wrong body type | ||
// @ts-expect-error | ||
body: { foo: "Bar" }, | ||
await client.post("/post/optional/inline", { body: { error: true } }); | ||
// (no error) | ||
await client.post("/post/optional/inline", { | ||
body: { | ||
title: "", | ||
publish_date: 3, | ||
body: "", | ||
}, | ||
}); | ||
}); | ||
}); | ||
it("respects baseUrl", async () => { | ||
const client = createClient<paths>({ 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"); | ||
}); | ||
describe("options", () => { | ||
it("respects baseUrl", async () => { | ||
let client = createClient<paths>({ baseUrl: "https://myapi.com/v1" }); | ||
mockFetch({ status: 200, body: JSON.stringify({ message: "OK" }) }); | ||
await client.get("/self", {}); | ||
it("preserves default headers", async () => { | ||
const headers: HeadersInit = { Authorization: "Bearer secrettoken" }; | ||
// assert baseUrl and path mesh as expected | ||
expect(fetchMocker.mock.calls[0][0]).toBe("https://myapi.com/v1/self"); | ||
const client = createClient<paths>({ headers }); | ||
fetchMocker.mockResponseOnce(JSON.stringify({ email: "user@user.com" })); | ||
await client.get("/self", {}); | ||
client = createClient<paths>({ baseUrl: "https://myapi.com/v1/" }); | ||
await client.get("/self", {}); | ||
// assert trailing '/' was removed | ||
expect(fetchMocker.mock.calls[1][0]).toBe("https://myapi.com/v1/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 | ||
}) | ||
); | ||
}); | ||
it("preserves default headers", async () => { | ||
const headers: HeadersInit = { Authorization: "Bearer secrettoken" }; | ||
it("allows override headers", async () => { | ||
const client = createClient<paths>({ headers: { "Cache-Control": "max-age=10000000" } }); | ||
fetchMocker.mockResponseOnce(JSON.stringify({ email: "user@user.com" })); | ||
await client.get("/self", { params: {}, headers: { "Cache-Control": "no-cache" } }); | ||
const client = createClient<paths>({ 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({ | ||
"Cache-Control": "no-cache", | ||
"Content-Type": "application/json", | ||
}) | ||
); | ||
}); | ||
it("accepts a custom fetch function", async () => { | ||
const data = { works: true }; | ||
const client = createClient<paths>({ | ||
fetch: async () => | ||
Promise.resolve({ | ||
headers: new Headers(), | ||
json: async () => data, | ||
status: 200, | ||
ok: true, | ||
} as Response), | ||
// 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 | ||
}) | ||
); | ||
}); | ||
expect((await client.get("/self", {})).data).toBe(data); | ||
}); | ||
it("treats `default` as an error", async () => { | ||
const client = createClient<paths>({ 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("allows override headers", async () => { | ||
const client = createClient<paths>({ 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" } }); | ||
// discard `data` object | ||
if (!error) throw new Error("treats `default` as an error: error response should be present"); | ||
// 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<paths>({ | ||
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<paths>(); | ||
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<paths>(); | ||
mockFetchOnce({ status: 200, body: "{}" }); | ||
await client.get("/post/{post_id}", { | ||
params: { path: { post_id: "post?id = 🥴" }, 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"); | ||
}); | ||
}); | ||
it("sends correct options, returns success", async () => { | ||
const mockData = { title: "My Post", body: "<p>This is a very good post</p>", publish_date: new Date("2023-03-01T12:00:00Z").getTime() }; | ||
const client = createClient<paths>(); | ||
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: {} }, | ||
describe("responses", () => { | ||
it("returns empty object on 204", async () => { | ||
const client = createClient<paths>(); | ||
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 data was returned | ||
expect(data).toEqual({}); | ||
expect(response.status).toBe(204); | ||
// assert error is empty | ||
expect(error).toBe(undefined); | ||
}); | ||
// assert correct URL was called | ||
expect(fetchMocker.mock.calls[0][0]).toBe("/post/my-post"); | ||
it("treats `default` as an error", async () => { | ||
const client = createClient<paths>({ 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 correct data was returned | ||
expect(data).toEqual(mockData); | ||
expect(response.status).toBe(200); | ||
// discard `data` object | ||
if (!error) throw new Error("treats `default` as an error: error response should be present"); | ||
// assert error is empty | ||
expect(error).toBe(undefined); | ||
}); | ||
// assert `error.message` doesn’t throw TS error | ||
expect(error.message).toBe("An unexpected error occurred"); | ||
}); | ||
it("sends correct options, returns error", async () => { | ||
const mockError = { code: 404, message: "Post not found" }; | ||
const client = createClient<paths>(); | ||
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: {}, | ||
}, | ||
it("falls back to text() on invalid JSON", async () => { | ||
const client = createClient<paths>(); | ||
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"); | ||
// assert `data` is a string | ||
expect(data).toBe(bodyResponse); | ||
}); | ||
// assert correct URL was called | ||
expect(fetchMocker.mock.calls[0][0]).toBe("/post/my-post"); | ||
describe("parseAs", () => { | ||
it("text", async () => { | ||
const client = createClient<paths>(); | ||
mockFetchOnce({ status: 200, body: "{}" }); | ||
const { data } = await client.get("/anyMethod", { parseAs: "text" }); | ||
expect(data).toBe("{}"); | ||
}); | ||
// assert correct method was called | ||
expect(fetchMocker.mock.calls[0][1]?.method).toBe("GET"); | ||
it("arrayBuffer", async () => { | ||
const client = createClient<paths>(); | ||
mockFetchOnce({ status: 200, body: "{}" }); | ||
const { data } = await client.get("/anyMethod", { parseAs: "arrayBuffer" }); | ||
expect(data instanceof ArrayBuffer).toBe(true); | ||
}); | ||
// assert correct error was returned | ||
expect(error).toEqual(mockError); | ||
expect(response.status).toBe(404); | ||
it("blob", async () => { | ||
const client = createClient<paths>(); | ||
mockFetchOnce({ status: 200, body: "{}" }); | ||
const { data } = await client.get("/anyMethod", { parseAs: "blob" }); | ||
expect((data as any).constructor.name).toBe("Blob"); | ||
}); | ||
// assert data is empty | ||
expect(data).toBe(undefined); | ||
}); | ||
it("stream", async () => { | ||
const client = createClient<paths>(); | ||
mockFetchOnce({ status: 200, body: "{}" }); | ||
const { data } = await client.get("/anyMethod", { parseAs: "stream" }); | ||
expect(data instanceof Buffer).toBe(true); | ||
}); | ||
}); | ||
// note: this was a previous bug in the type inference | ||
it("handles array-type responses", async () => { | ||
const client = createClient<paths>(); | ||
fetchMocker.mockResponseOnce(() => ({ status: 200, body: "[]" })); | ||
const { data } = await client.get("/posts", { params: {} }); | ||
if (!data) throw new Error("data empty"); | ||
describe("querySerializer", () => { | ||
it("default", async () => { | ||
const client = createClient<paths>(); | ||
mockFetchOnce({ status: 200, body: "{}" }); | ||
await client.get("/post/{post_id}", { | ||
params: { | ||
path: { post_id: "my-post" }, | ||
query: { version: 2, format: "json" }, | ||
}, | ||
}); | ||
// assert array type (and only array type) was inferred | ||
expect(data.length).toBe(0); | ||
}); | ||
expect(fetchMocker.mock.calls[0][0]).toBe("/post/my-post?version=2&format=json"); | ||
}); | ||
it("escapes URLs properly", async () => { | ||
const client = createClient<paths>(); | ||
fetchMocker.mockResponseOnce(() => ({ status: 200, body: "{}" })); | ||
await client.get("/post/{post_id}", { | ||
params: { path: { post_id: "post?id = 🥴" }, query: {} }, | ||
}); | ||
it("default (with empty params)", async () => { | ||
const client = createClient<paths>(); | ||
mockFetchOnce({ status: 200, body: "{}" }); | ||
await client.get("/post/{post_id}", { | ||
params: { | ||
path: { post_id: "my-post" }, | ||
query: { version: undefined, format: null as any }, | ||
}, | ||
}); | ||
// expect post_id to be encoded properly | ||
expect(fetchMocker.mock.calls[0][0]).toBe("/post/post%3Fid%20%3D%20%F0%9F%A5%B4"); | ||
}); | ||
expect(fetchMocker.mock.calls[0][0]).toBe("/post/my-post"); | ||
}); | ||
it("serializes params properly", async () => { | ||
const client = createClient<paths>(); | ||
fetchMocker.mockResponseOnce(() => ({ status: 200, body: "{}" })); | ||
await client.get("/post/{post_id}", { | ||
params: { | ||
path: { post_id: "my-post" }, | ||
query: { version: 2, format: "json" }, | ||
}, | ||
it("custom", async () => { | ||
const client = createClient<paths>(); | ||
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"); | ||
}); | ||
}); | ||
expect(fetchMocker.mock.calls[0][0]).toBe("/post/my-post?version=2&format=json"); | ||
}); | ||
it("serializes params properly with querySerializer", async () => { | ||
const client = createClient<paths>(); | ||
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("get()", () => { | ||
it("sends the correct method", async () => { | ||
const client = createClient<paths>(); | ||
mockFetchOnce({ status: 200, body: "{}" }); | ||
await client.get("/anyMethod", {}); | ||
expect(fetchMocker.mock.calls[0][1]?.method).toBe("GET"); | ||
}); | ||
expect(fetchMocker.mock.calls[0][0]).toBe("/post/my-post?alpha=2&beta=json"); | ||
}); | ||
}); | ||
it("sends correct options, returns success", async () => { | ||
const mockData = { title: "My Post", body: "<p>This is a very good post</p>", publish_date: new Date("2023-03-01T12:00:00Z").getTime() }; | ||
const client = createClient<paths>(); | ||
mockFetchOnce({ status: 200, body: JSON.stringify(mockData) }); | ||
const { data, error, response } = await client.get("/post/{post_id}", { | ||
params: { path: { post_id: "my-post" }, query: {} }, | ||
}); | ||
describe("post()", () => { | ||
it("sends the correct method", async () => { | ||
const client = createClient<paths>(); | ||
fetchMocker.mockResponseOnce(() => ({ status: 200, body: "{}" })); | ||
await client.post("/anyMethod", {}); | ||
expect(fetchMocker.mock.calls[0][1]?.method).toBe("POST"); | ||
}); | ||
// assert correct URL was called | ||
expect(fetchMocker.mock.calls[0][0]).toBe("/post/my-post"); | ||
it("sends correct options, returns success", async () => { | ||
const mockData = { status: "success" }; | ||
const client = createClient<paths>(); | ||
fetchMocker.mockResponseOnce(() => ({ status: 201, body: JSON.stringify(mockData) })); | ||
const { data, error, response } = await client.put("/post", { | ||
params: {}, | ||
body: { | ||
title: "New Post", | ||
body: "<p>Best post yet</p>", | ||
publish_date: new Date("2023-03-31T12:00:00Z").getTime(), | ||
}, | ||
// assert correct data was returned | ||
expect(data).toEqual(mockData); | ||
expect(response.status).toBe(200); | ||
// assert error is empty | ||
expect(error).toBe(undefined); | ||
}); | ||
// assert correct URL was called | ||
expect(fetchMocker.mock.calls[0][0]).toBe("/post"); | ||
it("sends correct options, returns error", async () => { | ||
const mockError = { code: 404, message: "Post not found" }; | ||
const client = createClient<paths>(); | ||
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 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 method was called | ||
expect(fetchMocker.mock.calls[0][1]?.method).toBe("GET"); | ||
it("supports sepecifying utf-8 encoding", async () => { | ||
const mockData = { message: "My reply" }; | ||
const client = createClient<paths>(); | ||
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 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(mockData); | ||
expect(response.status).toBe(201); | ||
// note: this was a previous bug in the type inference | ||
it("handles array-type responses", async () => { | ||
const client = createClient<paths>(); | ||
mockFetchOnce({ status: 200, body: "[]" }); | ||
const { data } = await client.get("/posts", { params: {} }); | ||
if (!data) throw new Error("data empty"); | ||
// assert error is empty | ||
expect(error).toBe(undefined); | ||
// assert array type (and only array type) was inferred | ||
expect(data.length).toBe(0); | ||
}); | ||
}); | ||
it("returns empty object on 204", async () => { | ||
const client = createClient<paths>(); | ||
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" }, | ||
describe("post()", () => { | ||
it("sends the correct method", async () => { | ||
const client = createClient<paths>(); | ||
mockFetchOnce({ status: 200, body: "{}" }); | ||
await client.post("/anyMethod", {}); | ||
expect(fetchMocker.mock.calls[0][1]?.method).toBe("POST"); | ||
}); | ||
// assert correct data was returned | ||
expect(data).toEqual({}); | ||
expect(response.status).toBe(204); | ||
it("sends correct options, returns success", async () => { | ||
const mockData = { status: "success" }; | ||
const client = createClient<paths>(); | ||
mockFetchOnce({ status: 201, body: JSON.stringify(mockData) }); | ||
const { data, error, response } = await client.put("/post", { | ||
params: {}, | ||
body: { | ||
title: "New Post", | ||
body: "<p>Best post yet</p>", | ||
publish_date: new Date("2023-03-31T12:00:00Z").getTime(), | ||
}, | ||
}); | ||
// assert error is empty | ||
expect(error).toBe(undefined); | ||
}); | ||
// assert correct URL was called | ||
expect(fetchMocker.mock.calls[0][0]).toBe("/post"); | ||
it("request body type when optional", async () => { | ||
fetchMocker.mockResponse(() => ({ status: 201, body: "{}" })); | ||
const client = createClient<paths>(); | ||
// assert correct data was returned | ||
expect(data).toEqual(mockData); | ||
expect(response.status).toBe(201); | ||
// 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 error is empty | ||
expect(error).toBe(undefined); | ||
}); | ||
}); | ||
it("request body type when optional inline", async () => { | ||
fetchMocker.mockResponse(() => ({ status: 201, body: "{}" })); | ||
const client = createClient<paths>(); | ||
it("supports sepecifying utf-8 encoding", async () => { | ||
const mockData = { message: "My reply" }; | ||
const client = createClient<paths>(); | ||
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(), | ||
}, | ||
}); | ||
// expect error on wrong body type | ||
// @ts-expect-error | ||
await client.post("/post/optional/inline", { body: { error: true } }); | ||
// assert correct data was returned | ||
expect(data).toEqual(mockData); | ||
expect(response.status).toBe(201); | ||
// (no error) | ||
await client.post("/post/optional/inline", { | ||
body: { | ||
title: "", | ||
publish_date: 3, | ||
body: "", | ||
}, | ||
// assert error is empty | ||
expect(error).toBe(undefined); | ||
}); | ||
}); | ||
}); | ||
describe("delete()", () => { | ||
it("sends the correct method", async () => { | ||
const client = createClient<paths>(); | ||
fetchMocker.mockResponseOnce(() => ({ status: 200, body: "{}" })); | ||
await client.del("/anyMethod", {}); | ||
expect(fetchMocker.mock.calls[0][1]?.method).toBe("DELETE"); | ||
}); | ||
it("returns empty object on 204", async () => { | ||
const client = createClient<paths>(); | ||
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<paths>(); | ||
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<paths>(); | ||
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<paths>(); | ||
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<paths>(); | ||
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<paths>(); | ||
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<paths>(); | ||
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<paths>(); | ||
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<paths>(); | ||
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<paths>(); | ||
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<paths>(); | ||
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<paths>(); | ||
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<paths>(); | ||
mockFetchOnce({ status: 200, body: "{}" }); | ||
await client.trace("/anyMethod", {}); | ||
expect(fetchMocker.mock.calls[0][1]?.method).toBe("TRACE"); | ||
}); | ||
}); | ||
@@ -505,3 +586,3 @@ }); | ||
// 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: {} } }); | ||
@@ -512,3 +593,3 @@ expect(fetchMocker.mock.calls[0][1].headers.get("authorization")).toBeNull(); | ||
const tokenVal = "abcd"; | ||
fetchMocker.mockResponseOnce(() => ({ status: 200, body: "{}" })); | ||
mockFetchOnce({ status: 200, body: "{}" }); | ||
await new Promise<void>((resolve) => | ||
@@ -536,3 +617,3 @@ setTimeout(() => { | ||
// 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: {} } }); | ||
@@ -543,3 +624,3 @@ expect(fetchMocker.mock.calls[0][1].headers.get("authorization")).toBeNull(); | ||
const tokenVal = "abcd"; | ||
fetchMocker.mockResponseOnce(() => ({ status: 200, body: "{}" })); | ||
mockFetchOnce({ status: 200, body: "{}" }); | ||
await new Promise<void>((resolve) => | ||
@@ -546,0 +627,0 @@ setTimeout(() => { |
@@ -1,5 +0,6 @@ | ||
// settings | ||
// settings & const | ||
const DEFAULT_HEADERS = { | ||
"Content-Type": "application/json", | ||
}; | ||
const TRAILING_SLASH_RE = /\/*$/; | ||
@@ -20,2 +21,3 @@ /** options for each client instance */ | ||
export type PathItemObject = { [M in HttpMethod]: OperationObject } & { parameters?: any }; | ||
export type ParseAs = "json" | "text" | "blob" | "arrayBuffer" | "stream"; | ||
export interface OperationObject { | ||
@@ -47,4 +49,4 @@ parameters: any; | ||
export type RequestBody<O> = undefined extends RequestBodyJSON<O> ? { body?: RequestBodyJSON<O> } : { body: RequestBodyJSON<O> }; | ||
export type QuerySerializer<O> = (query: O extends { parameters: { query: any } } ? O["parameters"]["query"] : Record<string, unknown>) => string; | ||
export type RequestOptions<T> = Params<T> & RequestBody<T> & { querySerializer?: QuerySerializer<T> }; | ||
export type QuerySerializer<O> = (query: O extends { parameters: any } ? NonNullable<O["parameters"]["query"]> : Record<string, unknown>) => string; | ||
export type RequestOptions<T> = Params<T> & RequestBody<T> & { querySerializer?: QuerySerializer<T>; parseAs?: ParseAs }; | ||
export type Success<O> = FilterKeys<FilterKeys<O, OkStatus>, "content">; | ||
@@ -59,2 +61,27 @@ export type Error<O> = FilterKeys<FilterKeys<O, ErrorStatus>, "content">; | ||
/** 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<O>(url: string, options: { baseUrl?: string; params: { query?: Record<string, unknown>; path?: Record<string, unknown> }; querySerializer: QuerySerializer<O> }): 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<Paths extends {}>(clientOptions: ClientOptions = {}) { | ||
@@ -69,12 +96,6 @@ const { fetch = globalThis.fetch, ...options } = clientOptions; | ||
async function coreFetch<P extends keyof Paths, M extends HttpMethod>(url: P, fetchOptions: FetchOptions<M extends keyof Paths[P] ? Paths[P][M] : never>): Promise<FetchResponse<M extends keyof Paths[P] ? Paths[P][M] : unknown>> { | ||
const { headers, body: requestBody, params = {}, querySerializer = (q: QuerySerializer<M extends keyof Paths[P] ? Paths[P][M] : never>) => 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 }); | ||
@@ -98,5 +119,29 @@ // headers | ||
// 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(); | ||
return response.ok ? { data: body, response } : { error: body, response: response }; | ||
// 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) | ||
if (response.ok) { | ||
let data: any = response.body; | ||
if (parseAs !== "stream") { | ||
try { | ||
data = await response.clone()[parseAs](); | ||
} catch { | ||
data = await response.clone().text(); | ||
} | ||
} | ||
return { data, response }; | ||
} | ||
// handle errors (always parse as .json() or .text()) | ||
let error: any = {}; | ||
try { | ||
error = await response.clone().json(); | ||
} catch { | ||
error = await response.clone().text(); | ||
} | ||
return { error, response }; | ||
} | ||
@@ -103,0 +148,0 @@ |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
71110
15
1052