Huge News!Announcing our $40M Series B led by Abstract Ventures.Learn More
Socket
Sign inDemoInstall
Socket

openapi-fetch

Package Overview
Dependencies
Maintainers
1
Versions
70
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

openapi-fetch - npm Package Compare versions

Comparing version 0.2.1 to 0.3.0

20

CHANGELOG.md
# 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 @@

19

dist/index.d.ts

@@ -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 @@

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc