UNPKG

@bytekit/autofetch

Version:

A TypeScript-first, decorator-based HTTP client for building elegant and modular API clients with fetch under the hood. Inspired by Spring's `@RestClient` and OpenFeign.

274 lines (273 loc) 11.4 kB
import { ClientConstants } from "./constants.js"; import { HttpMethod } from "./HttpMethod.js"; const Mapping = ({ baseUrl, method, value, blob, stream, response, produces, consumes, throws = true, cache, fromCache, cacheQueryOptions, cacheMissBehavior = "fetch", interceptors, before, after, adaptor: adaptorArg, adaptorFactory }) => (t, propertyKey, descriptor) => { descriptor.value = async function (...args) { // @ts-expect-error this is bound to the decorated class instance when called const self = this; const target = Object.getPrototypeOf(self); const clientOptions = Reflect.getMetadata(ClientConstants.ClientOptions, target); if (!clientOptions) { throw new Error(`Client options not defined for ${target.constructor.name}`); } let adaptor = typeof adaptorFactory === "function" ? await adaptorFactory(self) : adaptorArg; if (!adaptor) { adaptor = typeof clientOptions.adaptorFactory === "function" ? await clientOptions.adaptorFactory(self) : clientOptions.adaptor; } if (!adaptor) { adaptor = globalThis.fetch; } const base = await (baseUrl ?? clientOptions.baseUrl)(self); const { body, headers, inits, url } = processArgs(target, propertyKey, args, base, value, produces, consumes); const init = await buildRequestInit(self, interceptors, clientOptions, inits, method, headers, body); const id = await executeBefore(self, before, url, init, clientOptions, propertyKey, args); const cacheName = cache ?? clientOptions.cache; const cacheStore = cacheName ? await globalThis.caches.open(cacheName) : undefined; const request = new Request(url, init); let resp; try { if (fromCache) { const cachedResponse = await cacheStore.match(request, cacheQueryOptions); if (!cachedResponse) { switch (cacheMissBehavior) { case "return": return; case "fetch": fromCache = false; resp = await adaptor(request); } } else { resp = cachedResponse; } } else { resp = await adaptor(request); } } catch (error) { await executeAfter(self, after, error, id, clientOptions, propertyKey, args); throw error; } await executeAfter(self, after, resp, id, clientOptions, propertyKey, args); if (resp.ok || resp.redirected) { const contentType = resp.headers.get("content-type"); const isJson = contentType === "application/json" || contentType?.startsWith("application/json;"); if (!fromCache && cacheStore) { await cacheStore.put(resp.url, resp.clone()); } if (response) { return resp; } else if (blob) { return resp.blob(); } else if (stream) { return resp.body; } else if (isJson) { return resp.json(); } else { return resp.text(); } } else if (throws) { throw resp; } else { return resp; } }; }; function processArgs(target, propertyKey, args, baseUrl, path, produces, consumes) { const pathParams = Reflect.getMetadata(ClientConstants.PathParams, target, propertyKey); const queryParams = Reflect.getMetadata(ClientConstants.QueryParams, target, propertyKey); const headerParams = Reflect.getMetadata(ClientConstants.HeaderParams, target, propertyKey); const formParams = Reflect.getMetadata(ClientConstants.FormParams, target, propertyKey); const urlEncodedFormParams = Reflect.getMetadata(ClientConstants.URLEncodedFormParams, target, propertyKey); const bodyParams = Reflect.getMetadata(ClientConstants.BodyParams, target, propertyKey); const ignoreParams = Reflect.getMetadata(ClientConstants.IgnoreParams, target, propertyKey); const initOptions = Reflect.getMetadata(ClientConstants.InitOptions, target, propertyKey); if (bodyParams?.size > 1) { throw new Error("Only a single body param may be used."); } const exclusivity = Number((formParams?.size ?? 0) > 0) + Number((urlEncodedFormParams?.size ?? 0) > 0) + Number((bodyParams?.size ?? 0) > 0); if (exclusivity > 1) { throw new Error("Request may include either form parameters, URL-encoded parameters, or a body parameter—never a combination."); } const inits = []; const headers = new Headers(); const query = {}; let body; for (let i = 0; i < args.length; i++) { let processed = false; const current = args[i]; if (pathParams?.has(i)) { const name = pathParams.get(i); const replacementPath = path.replaceAll(`{${name}}`, encodeURIComponent(current)); if (replacementPath === path) { throw new Error(`Path param '${name}' not found in path spec`); } path = replacementPath; processed = true; } if (queryParams?.has(i)) { const options = queryParams.get(i); if (typeof options === "string") { query[options] = current; } else if ((options.required ?? true) || ((current !== null && current !== undefined) || options.defaultValue)) { query[options.name] = current ?? options.defaultValue; } processed = true; } if (headerParams?.has(i)) { const options = headerParams.get(i); const name = typeof options === "string" ? options : options.name; const required = (typeof options === "string" || options.required) ?? true; const defaultValue = typeof options === "string" ? undefined : options.defaultValue; if (required || ((current !== null && current !== undefined) || defaultValue)) { if (name.toLowerCase() === "content-type" || name.toLowerCase() === "authorization") { headers.set(name, current ?? defaultValue); } else { headers.append(name, current ?? defaultValue); } } processed = true; } if (formParams?.has(i)) { if (!body || !(body instanceof FormData)) { body = new FormData(); } const name = formParams.get(i); body.append(name, current); if (!headers.has("content-type")) { headers.set("content-type", produces ?? "multipart/form-data"); } processed = true; } if (urlEncodedFormParams?.has(i)) { if (!body || !(body instanceof URLSearchParams)) { body = new URLSearchParams(); } const name = urlEncodedFormParams.get(i); body.append(name, current); if (!headers.has("content-type")) { headers.set("content-type", produces ?? "application/x-www-form-urlencoded"); } processed = true; } if (bodyParams?.has(i)) { let contentType; if (current instanceof Blob) { body = current; contentType = current.type; } else if (current instanceof FormData) { body = current; contentType = "multipart/form-data"; } else if (current instanceof URLSearchParams) { body = current; contentType = "application/x-www-form-urlencoded"; } else if (typeof current === "object") { body = JSON.stringify(current); contentType = "application/json"; } else { body = String(current); contentType = "text/plain"; } if (!headers.has("content-type")) { headers.set("content-type", produces ?? contentType); } processed = true; } if (initOptions?.has(i) && current) { inits.push(current); processed = true; } if (ignoreParams?.has(i)) { processed = true; } if (!processed) { throw new Error(`Unknown parameter at index ${i}`); } } if (headers.get("content-type") === "multipart/form-data" && body instanceof FormData) { // delete form-data content type header -- fetch will set this for us with the proper boundary value headers.delete("content-type"); } if (consumes) { headers.set("accept", consumes); } const url = new URL(path, baseUrl); for (const [name, value] of Object.entries(query)) { url.searchParams.append(name, value); } return { body, headers, inits, url }; } async function executeBefore(self, before, url, init, clientOptions, methodName, args) { const id = crypto.randomUUID(); await before?.({ self, url, init, id, methodName, args }); await clientOptions.before?.({ self, url, init, id, methodName, args }); return id; } async function executeAfter(self, after, response, id, clientOptions, methodName, args) { await after?.({ self, response, id, methodName, args }); await clientOptions.after?.({ self, response, id, methodName, args }); } function mergeHeaders(a, b) { const x = new Headers(a); const y = new Headers(b); for (const [key, value] of y) { const lower = key.toLowerCase(); if (lower === "content-type" || lower === "authorization") { // don't overwrite existing content-type or auth headers--processArgs takes precedence if (!x.has(lower)) { x.set(key, value); } } else { x.append(key, value); } } return x; } async function buildRequestInit(self, interceptors, clientOptions, inits, method, headers, body) { return (await Promise.all([...(interceptors ?? []), ...(clientOptions.interceptors ?? [])].map((item) => item(self)))) .concat(...inits) .reduce((acc, cur) => ({ ...acc, ...cur, headers: mergeHeaders(acc.headers, cur.headers) }), { method, headers, body }); } const GetMapping = (options) => Mapping({ ...options, method: HttpMethod.GET }); const PostMapping = (options) => Mapping({ ...options, method: HttpMethod.POST }); const PutMapping = (options) => Mapping({ ...options, method: HttpMethod.PUT }); const PatchMapping = (options) => Mapping({ ...options, method: HttpMethod.PATCH }); const DeleteMapping = (options) => Mapping({ ...options, method: HttpMethod.DELETE }); export default Mapping; export { GetMapping, PostMapping, PutMapping, PatchMapping, DeleteMapping };