@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.
281 lines (280 loc) • 11.9 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.DeleteMapping = exports.PatchMapping = exports.PutMapping = exports.PostMapping = exports.GetMapping = void 0;
const constants_ts_1 = require("./constants.js");
const HttpMethod_ts_1 = require("./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(constants_ts_1.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(constants_ts_1.ClientConstants.PathParams, target, propertyKey);
const queryParams = Reflect.getMetadata(constants_ts_1.ClientConstants.QueryParams, target, propertyKey);
const headerParams = Reflect.getMetadata(constants_ts_1.ClientConstants.HeaderParams, target, propertyKey);
const formParams = Reflect.getMetadata(constants_ts_1.ClientConstants.FormParams, target, propertyKey);
const urlEncodedFormParams = Reflect.getMetadata(constants_ts_1.ClientConstants.URLEncodedFormParams, target, propertyKey);
const bodyParams = Reflect.getMetadata(constants_ts_1.ClientConstants.BodyParams, target, propertyKey);
const ignoreParams = Reflect.getMetadata(constants_ts_1.ClientConstants.IgnoreParams, target, propertyKey);
const initOptions = Reflect.getMetadata(constants_ts_1.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_ts_1.HttpMethod.GET });
exports.GetMapping = GetMapping;
const PostMapping = (options) => Mapping({ ...options, method: HttpMethod_ts_1.HttpMethod.POST });
exports.PostMapping = PostMapping;
const PutMapping = (options) => Mapping({ ...options, method: HttpMethod_ts_1.HttpMethod.PUT });
exports.PutMapping = PutMapping;
const PatchMapping = (options) => Mapping({
...options,
method: HttpMethod_ts_1.HttpMethod.PATCH
});
exports.PatchMapping = PatchMapping;
const DeleteMapping = (options) => Mapping({
...options,
method: HttpMethod_ts_1.HttpMethod.DELETE
});
exports.DeleteMapping = DeleteMapping;
exports.default = Mapping;