import { HttpError } from "../HttpError"; import { IConnection } from "../IConnection"; import { IFetchEvent } from "../IFetchEvent"; import { IFetchRoute } from "../IFetchRoute"; import { IPropagation } from "../IPropagation"; import { is_binary_response_content_type } from "./is_binary_response_content_type"; import { join_host_and_path, normalize_route_path } from "./join_host_and_path"; /** @internal */ export namespace FetcherBase { export interface IProps { className: string; encode: ( input: any, headers: Record, ) => string; decode: ( input: string, headers: Record, ) => any; } export const request = (props: IProps) => async ( connection: IConnection, route: IFetchRoute<"DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT">, input?: Input, stringify?: (input: Input) => string, ): Promise => { const result = await _Propagate("fetch")(props)( connection, route, input, stringify, ); if ((result as any).success === false) throw new HttpError( route.method, route.path, result.status as any as number, result.headers, result.data as string, ); return result.data as Output; }; export const propagate = (props: IProps) => async ( connection: IConnection, route: IFetchRoute<"DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT">, input?: Input, stringify?: (input: Input) => string, ): Promise> => _Propagate("propagate")(props)(connection, route, input, stringify); /** @internal */ const _Propagate = (method: string) => (props: IProps) => async ( connection: IConnection, route: IFetchRoute<"DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT">, input?: Input, stringify?: (input: Input) => string, ): Promise> => { //---- // REQUEST MESSAGE //---- // METHOD & HEADERS const headers: Record = { ...(connection.headers ?? {}), }; if (input !== undefined) { if (route.request?.type === undefined) throw new Error( `Error on ${props.className}.fetch(): no content-type being configured.`, ); else if (route.request.type !== "multipart/form-data") { deleteHeader(headers, "content-type"); headers["Content-Type"] = route.request.type; } } else if (input === undefined) deleteHeader(headers, "content-type"); // INIT REQUEST DATA const init: RequestInit = { ...(connection.options ?? {}), method: route.method, headers: (() => { const output: [string, string][] = []; for (const [key, value] of Object.entries(headers)) if (value === undefined) continue; else if (Array.isArray(value)) for (const v of value) output.push([key, String(v)]); else output.push([key, String(value)]); return output; })(), }; // CONSTRUCT BODY DATA if (input !== undefined) init.body = props.encode( // BODY TRANSFORM route.request?.type === "application/x-www-form-urlencoded" ? request_query_body(input) : route.request?.type === "multipart/form-data" ? request_form_data_body(input as any) : route.request?.type !== "text/plain" ? (stringify ?? JSON.stringify)(input) : input, headers, ); //---- // RESPONSE MESSAGE //---- // URL SPECIFICATION const path: string = normalize_route_path(route.path); const url: URL = new URL(join_host_and_path(connection.host, route.path)); // DO FETCH const event: IFetchEvent = { route, path, status: null, input, output: undefined, started_at: new Date(), respond_at: null, completed_at: null!, }; try { // TRY FETCH const response: Response = await (connection.fetch ?? fetch)( url.href, init, ); event.respond_at = new Date(); event.status = response.status; // CONSTRUCT RESULT DATA const result: IPropagation = { success: response.status === 200 || response.status === 201 || response.status === route.status, status: response.status, headers: response_headers_to_object(response.headers), data: undefined!, } as any; if ((result as any).success === false) { // WHEN FAILED result.data = await response.text(); const type = response.headers.get("content-type"); if ( method !== "fetch" && type && type.indexOf("application/json") !== -1 ) try { result.data = JSON.parse(result.data); } catch {} } else { // WHEN SUCCESS if (route.method === "HEAD") result.data = undefined!; else if (route.response?.type === "application/json") { const text: string = await response.text(); result.data = text.length ? JSON.parse(text) : undefined; } else if ( route.response?.type === "application/x-www-form-urlencoded" ) { const query: URLSearchParams = new URLSearchParams( await response.text(), ); result.data = route.parseQuery ? route.parseQuery(query) : query; } else if (is_binary_response_content_type(route.response?.type)) result.data = response.body ?? new ReadableStream(); else result.data = props.decode(await response.text(), result.headers); } event.output = result.data; return result; } catch (exp) { throw exp; } finally { event.completed_at = new Date(); if (connection.logger) try { await connection.logger(event); } catch {} } }; } /** @internal */ const request_query_body = (input: any): URLSearchParams => { const q: URLSearchParams = new URLSearchParams(); for (const [key, value] of Object.entries(input)) if (value === undefined) continue; else if (Array.isArray(value)) value.forEach((elem) => q.append(key, String(elem))); else q.set(key, String(value)); return q; }; /** @internal */ const request_form_data_body = (input: Record): FormData => { const encoded: FormData = new FormData(); const append = (key: string) => (value: any) => { if (value === undefined) return; else if (typeof File === "function" && value instanceof File) encoded.append(key, value, value.name); else encoded.append(key, value); }; for (const [key, value] of Object.entries(input)) if (Array.isArray(value)) value.map(append(key)); else append(key)(value); return encoded; }; /** @internal */ const response_headers_to_object = ( headers: Headers, ): Record => { const output: Record = {}; const cookieHeaders: string[] | undefined = typeof (headers as any).getSetCookie === "function" ? (headers as any).getSetCookie() : undefined; if (cookieHeaders?.length) output["set-cookie"] = cookieHeaders; headers.forEach((value, key) => { if (key.toLowerCase() === "set-cookie") { if (cookieHeaders?.length) return; output[key] ??= []; (output[key] as string[]).push(value); } else output[key] = value; }); return output; }; const deleteHeader = ( headers: Record, name: string, ): void => { const normalized: string = name.toLowerCase(); for (const key of Object.keys(headers)) if (key.toLowerCase() === normalized) delete headers[key]; };