openapi-fetch
Version:
Fast, type-safe fetch client for your OpenAPI schema. Only 6 kb (min). Works with React, Vue, Svelte, or vanilla JS.
670 lines (624 loc) • 19.9 kB
JavaScript
// settings & const
const PATH_PARAM_RE = /\{[^{}]+\}/g;
const supportsRequestInitExt = () => {
return (
typeof process === "object" &&
Number.parseInt(process?.versions?.node?.substring(0, 2)) >= 18 &&
process.versions.undici
);
};
/**
* Returns a cheap, non-cryptographically-secure random ID
* Courtesy of @imranbarbhuiya (https://github.com/imranbarbhuiya)
*/
export function randomID() {
return Math.random().toString(36).slice(2, 11);
}
/**
* Create an openapi-fetch client.
* @type {import("./index.js").default}
*/
export default function createClient(clientOptions) {
let {
baseUrl = "",
Request: CustomRequest = globalThis.Request,
fetch: baseFetch = globalThis.fetch,
querySerializer: globalQuerySerializer,
bodySerializer: globalBodySerializer,
headers: baseHeaders,
requestInitExt = undefined,
...baseOptions
} = { ...clientOptions };
requestInitExt = supportsRequestInitExt() ? requestInitExt : undefined;
baseUrl = removeTrailingSlash(baseUrl);
const globalMiddlewares = [];
/**
* Per-request fetch (keeps settings created in createClient()
* @param {T} url
* @param {import('./index.js').FetchOptions<T>} fetchOptions
*/
async function coreFetch(schemaPath, fetchOptions) {
const {
baseUrl: localBaseUrl,
fetch = baseFetch,
Request = CustomRequest,
headers,
params = {},
parseAs = "json",
querySerializer: requestQuerySerializer,
bodySerializer = globalBodySerializer ?? defaultBodySerializer,
body,
middleware: requestMiddlewares = [],
...init
} = fetchOptions || {};
let finalBaseUrl = baseUrl;
if (localBaseUrl) {
finalBaseUrl = removeTrailingSlash(localBaseUrl) ?? baseUrl;
}
let querySerializer =
typeof globalQuerySerializer === "function"
? globalQuerySerializer
: createQuerySerializer(globalQuerySerializer);
if (requestQuerySerializer) {
querySerializer =
typeof requestQuerySerializer === "function"
? requestQuerySerializer
: createQuerySerializer({
...(typeof globalQuerySerializer === "object" ? globalQuerySerializer : {}),
...requestQuerySerializer,
});
}
const serializedBody =
body === undefined
? undefined
: bodySerializer(
body,
// Note: we declare mergeHeaders() both here and below because it’s a bit of a chicken-or-egg situation:
// bodySerializer() needs all headers so we aren’t dropping ones set by the user, however,
// the result of this ALSO sets the lowest-priority content-type header. So we re-merge below,
// setting the content-type at the very beginning to be overwritten.
// Lastly, based on the way headers work, it’s not a simple “present-or-not” check becauase null intentionally un-sets headers.
mergeHeaders(baseHeaders, headers, params.header),
);
const finalHeaders = mergeHeaders(
// with no body, we should not to set Content-Type
serializedBody === undefined ||
// if serialized body is FormData; browser will correctly set Content-Type & boundary expression
serializedBody instanceof FormData
? {}
: {
"Content-Type": "application/json",
},
baseHeaders,
headers,
params.header,
);
// Client level middleware take priority over request-level middleware
const finalMiddlewares = [...globalMiddlewares, ...requestMiddlewares];
const requestInit = {
redirect: "follow",
...baseOptions,
...init,
body: serializedBody,
headers: finalHeaders,
};
let id;
let options;
let request = new Request(
createFinalURL(schemaPath, { baseUrl: finalBaseUrl, params, querySerializer }),
requestInit,
);
let response;
/** Add custom parameters to Request object */
for (const key in init) {
if (!(key in request)) {
request[key] = init[key];
}
}
if (finalMiddlewares.length) {
id = randomID();
// middleware (request)
options = Object.freeze({
baseUrl: finalBaseUrl,
fetch,
parseAs,
querySerializer,
bodySerializer,
});
for (const m of finalMiddlewares) {
if (m && typeof m === "object" && typeof m.onRequest === "function") {
const result = await m.onRequest({
request,
schemaPath,
params,
options,
id,
});
if (result) {
if (result instanceof Request) {
request = result;
} else if (result instanceof Response) {
response = result;
break;
} else {
throw new Error("onRequest: must return new Request() or Response() when modifying the request");
}
}
}
}
}
if (!response) {
// fetch!
try {
response = await fetch(request, requestInitExt);
} catch (error) {
let errorAfterMiddleware = error;
// middleware (error)
// execute in reverse-array order (first priority gets last transform)
if (finalMiddlewares.length) {
for (let i = finalMiddlewares.length - 1; i >= 0; i--) {
const m = finalMiddlewares[i];
if (m && typeof m === "object" && typeof m.onError === "function") {
const result = await m.onError({
request,
error: errorAfterMiddleware,
schemaPath,
params,
options,
id,
});
if (result) {
// if error is handled by returning a response, skip remaining middleware
if (result instanceof Response) {
errorAfterMiddleware = undefined;
response = result;
break;
}
if (result instanceof Error) {
errorAfterMiddleware = result;
continue;
}
throw new Error("onError: must return new Response() or instance of Error");
}
}
}
}
// rethrow error if not handled by middleware
if (errorAfterMiddleware) {
throw errorAfterMiddleware;
}
}
// middleware (response)
// execute in reverse-array order (first priority gets last transform)
if (finalMiddlewares.length) {
for (let i = finalMiddlewares.length - 1; i >= 0; i--) {
const m = finalMiddlewares[i];
if (m && typeof m === "object" && typeof m.onResponse === "function") {
const result = await m.onResponse({
request,
response,
schemaPath,
params,
options,
id,
});
if (result) {
if (!(result instanceof Response)) {
throw new Error("onResponse: must return new Response() when modifying the response");
}
response = result;
}
}
}
}
}
// handle empty content
if (response.status === 204 || request.method === "HEAD" || response.headers.get("Content-Length") === "0") {
return response.ok ? { data: undefined, response } : { error: undefined, response };
}
// parse response (falling back to .text() when necessary)
if (response.ok) {
// if "stream", skip parsing entirely
if (parseAs === "stream") {
return { data: response.body, response };
}
return { data: await response[parseAs](), response };
}
// handle errors
let error = await response.text();
try {
error = JSON.parse(error); // attempt to parse as JSON
} catch {
// noop
}
return { error, response };
}
return {
request(method, url, init) {
return coreFetch(url, { ...init, method: method.toUpperCase() });
},
/** Call a GET endpoint */
GET(url, init) {
return coreFetch(url, { ...init, method: "GET" });
},
/** Call a PUT endpoint */
PUT(url, init) {
return coreFetch(url, { ...init, method: "PUT" });
},
/** Call a POST endpoint */
POST(url, init) {
return coreFetch(url, { ...init, method: "POST" });
},
/** Call a DELETE endpoint */
DELETE(url, init) {
return coreFetch(url, { ...init, method: "DELETE" });
},
/** Call a OPTIONS endpoint */
OPTIONS(url, init) {
return coreFetch(url, { ...init, method: "OPTIONS" });
},
/** Call a HEAD endpoint */
HEAD(url, init) {
return coreFetch(url, { ...init, method: "HEAD" });
},
/** Call a PATCH endpoint */
PATCH(url, init) {
return coreFetch(url, { ...init, method: "PATCH" });
},
/** Call a TRACE endpoint */
TRACE(url, init) {
return coreFetch(url, { ...init, method: "TRACE" });
},
/** Register middleware */
use(...middleware) {
for (const m of middleware) {
if (!m) {
continue;
}
if (typeof m !== "object" || !("onRequest" in m || "onResponse" in m || "onError" in m)) {
throw new Error("Middleware must be an object with one of `onRequest()`, `onResponse() or `onError()`");
}
globalMiddlewares.push(m);
}
},
/** Unregister middleware */
eject(...middleware) {
for (const m of middleware) {
const i = globalMiddlewares.indexOf(m);
if (i !== -1) {
globalMiddlewares.splice(i, 1);
}
}
},
};
}
class PathCallForwarder {
constructor(client, url) {
this.client = client;
this.url = url;
}
GET = (init) => {
return this.client.GET(this.url, init);
};
PUT = (init) => {
return this.client.PUT(this.url, init);
};
POST = (init) => {
return this.client.POST(this.url, init);
};
DELETE = (init) => {
return this.client.DELETE(this.url, init);
};
OPTIONS = (init) => {
return this.client.OPTIONS(this.url, init);
};
HEAD = (init) => {
return this.client.HEAD(this.url, init);
};
PATCH = (init) => {
return this.client.PATCH(this.url, init);
};
TRACE = (init) => {
return this.client.TRACE(this.url, init);
};
}
class PathClientProxyHandler {
constructor() {
this.client = null;
}
// Assume the property is an URL.
get(coreClient, url) {
const forwarder = new PathCallForwarder(coreClient, url);
this.client[url] = forwarder;
return forwarder;
}
}
/**
* Wrap openapi-fetch client to support a path based API.
* @type {import("./index.js").wrapAsPathBasedClient}
*/
export function wrapAsPathBasedClient(coreClient) {
const handler = new PathClientProxyHandler();
const proxy = new Proxy(coreClient, handler);
// Put the proxy on the prototype chain of the actual client.
// This means if we do not have a memoized PathCallForwarder,
// we fall back to the proxy to synthesize it.
// However, the proxy itself is not on the hot-path (if we fetch the same
// endpoint multiple times, only the first call will hit the proxy).
function Client() {}
Client.prototype = proxy;
const client = new Client();
// Feed the client back to the proxy handler so it can store the generated
// PathCallForwarder.
handler.client = client;
return client;
}
/**
* Convenience method to an openapi-fetch path based client.
* Strictly equivalent to `wrapAsPathBasedClient(createClient(...))`.
* @type {import("./index.js").createPathBasedClient}
*/
export function createPathBasedClient(clientOptions) {
return wrapAsPathBasedClient(createClient(clientOptions));
}
// utils
/**
* Serialize primitive param values
* @type {import("./index.js").serializePrimitiveParam}
*/
export function serializePrimitiveParam(name, value, options) {
if (value === undefined || value === null) {
return "";
}
if (typeof value === "object") {
throw new Error(
"Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.",
);
}
return `${name}=${options?.allowReserved === true ? value : encodeURIComponent(value)}`;
}
/**
* Serialize object param (shallow only)
* @type {import("./index.js").serializeObjectParam}
*/
export function serializeObjectParam(name, value, options) {
if (!value || typeof value !== "object") {
return "";
}
const values = [];
const joiner =
{
simple: ",",
label: ".",
matrix: ";",
}[options.style] || "&";
// explode: false
if (options.style !== "deepObject" && options.explode === false) {
for (const k in value) {
values.push(k, options.allowReserved === true ? value[k] : encodeURIComponent(value[k]));
}
const final = values.join(","); // note: values are always joined by comma in explode: false (but joiner can prefix)
switch (options.style) {
case "form": {
return `${name}=${final}`;
}
case "label": {
return `.${final}`;
}
case "matrix": {
return `;${name}=${final}`;
}
default: {
return final;
}
}
}
// explode: true
for (const k in value) {
const finalName = options.style === "deepObject" ? `${name}[${k}]` : k;
values.push(serializePrimitiveParam(finalName, value[k], options));
}
const final = values.join(joiner);
return options.style === "label" || options.style === "matrix" ? `${joiner}${final}` : final;
}
/**
* Serialize array param (shallow only)
* @type {import("./index.js").serializeArrayParam}
*/
export function serializeArrayParam(name, value, options) {
if (!Array.isArray(value)) {
return "";
}
// explode: false
if (options.explode === false) {
const joiner = { form: ",", spaceDelimited: "%20", pipeDelimited: "|" }[options.style] || ","; // note: for arrays, joiners vary wildly based on style + explode behavior
const final = (options.allowReserved === true ? value : value.map((v) => encodeURIComponent(v))).join(joiner);
switch (options.style) {
case "simple": {
return final;
}
case "label": {
return `.${final}`;
}
case "matrix": {
return `;${name}=${final}`;
}
// case "spaceDelimited":
// case "pipeDelimited":
default: {
return `${name}=${final}`;
}
}
}
// explode: true
const joiner = { simple: ",", label: ".", matrix: ";" }[options.style] || "&";
const values = [];
for (const v of value) {
if (options.style === "simple" || options.style === "label") {
values.push(options.allowReserved === true ? v : encodeURIComponent(v));
} else {
values.push(serializePrimitiveParam(name, v, options));
}
}
return options.style === "label" || options.style === "matrix"
? `${joiner}${values.join(joiner)}`
: values.join(joiner);
}
/**
* Serialize query params to string
* @type {import("./index.js").createQuerySerializer}
*/
export function createQuerySerializer(options) {
return function querySerializer(queryParams) {
const search = [];
if (queryParams && typeof queryParams === "object") {
for (const name in queryParams) {
const value = queryParams[name];
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
if (value.length === 0) {
continue;
}
search.push(
serializeArrayParam(name, value, {
style: "form",
explode: true,
...options?.array,
allowReserved: options?.allowReserved || false,
}),
);
continue;
}
if (typeof value === "object") {
search.push(
serializeObjectParam(name, value, {
style: "deepObject",
explode: true,
...options?.object,
allowReserved: options?.allowReserved || false,
}),
);
continue;
}
search.push(serializePrimitiveParam(name, value, options));
}
}
return search.join("&");
};
}
/**
* Handle different OpenAPI 3.x serialization styles
* @type {import("./index.js").defaultPathSerializer}
* @see https://swagger.io/docs/specification/serialization/#path
*/
export function defaultPathSerializer(pathname, pathParams) {
let nextURL = pathname;
for (const match of pathname.match(PATH_PARAM_RE) ?? []) {
let name = match.substring(1, match.length - 1);
let explode = false;
let style = "simple";
if (name.endsWith("*")) {
explode = true;
name = name.substring(0, name.length - 1);
}
if (name.startsWith(".")) {
style = "label";
name = name.substring(1);
} else if (name.startsWith(";")) {
style = "matrix";
name = name.substring(1);
}
if (!pathParams || pathParams[name] === undefined || pathParams[name] === null) {
continue;
}
const value = pathParams[name];
if (Array.isArray(value)) {
nextURL = nextURL.replace(match, serializeArrayParam(name, value, { style, explode }));
continue;
}
if (typeof value === "object") {
nextURL = nextURL.replace(match, serializeObjectParam(name, value, { style, explode }));
continue;
}
if (style === "matrix") {
nextURL = nextURL.replace(match, `;${serializePrimitiveParam(name, value)}`);
continue;
}
nextURL = nextURL.replace(match, style === "label" ? `.${encodeURIComponent(value)}` : encodeURIComponent(value));
}
return nextURL;
}
/**
* Serialize body object to string
* @type {import("./index.js").defaultBodySerializer}
*/
export function defaultBodySerializer(body, headers) {
if (body instanceof FormData) {
return body;
}
if (headers) {
const contentType =
headers.get instanceof Function
? (headers.get("Content-Type") ?? headers.get("content-type"))
: (headers["Content-Type"] ?? headers["content-type"]);
if (contentType === "application/x-www-form-urlencoded") {
return new URLSearchParams(body).toString();
}
}
return JSON.stringify(body);
}
/**
* Construct URL string from baseUrl and handle path and query params
* @type {import("./index.js").createFinalURL}
*/
export function createFinalURL(pathname, options) {
let finalURL = `${options.baseUrl}${pathname}`;
if (options.params?.path) {
finalURL = defaultPathSerializer(finalURL, options.params.path);
}
let search = options.querySerializer(options.params.query ?? {});
if (search.startsWith("?")) {
search = search.substring(1);
}
if (search) {
finalURL += `?${search}`;
}
return finalURL;
}
/**
* Merge headers a and b, with b taking priority
* @type {import("./index.js").mergeHeaders}
*/
export function mergeHeaders(...allHeaders) {
const finalHeaders = new Headers();
for (const h of allHeaders) {
if (!h || typeof h !== "object") {
continue;
}
const iterator = h instanceof Headers ? h.entries() : Object.entries(h);
for (const [k, v] of iterator) {
if (v === null) {
finalHeaders.delete(k);
} else if (Array.isArray(v)) {
for (const v2 of v) {
finalHeaders.append(k, v2);
}
} else if (v !== undefined) {
finalHeaders.set(k, v);
}
}
}
return finalHeaders;
}
/**
* Remove trailing slash from url
* @type {import("./index.js").removeTrailingSlash}
*/
export function removeTrailingSlash(url) {
if (url.endsWith("/")) {
return url.substring(0, url.length - 1);
}
return url;
}