@nestia/fetcher
Version:
Fetcher library of Nestia SDK
236 lines (223 loc) • 7.52 kB
text/typescript
import { HttpError } from "../HttpError";
import { IConnection } from "../IConnection";
import { IFetchEvent } from "../IFetchEvent";
import { IFetchRoute } from "../IFetchRoute";
import { IPropagation } from "../IPropagation";
/** @internal */
export namespace FetcherBase {
export interface IProps {
className: string;
encode: (
input: any,
headers: Record<string, IConnection.HeaderValue | undefined>,
) => string;
decode: (
input: string,
headers: Record<string, IConnection.HeaderValue | undefined>,
) => any;
}
export const request =
(props: IProps) =>
async <Input, Output>(
connection: IConnection,
route: IFetchRoute<"DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT">,
input?: Input,
stringify?: (input: Input) => string,
): Promise<Output> => {
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 <Input>(
connection: IConnection,
route: IFetchRoute<"DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT">,
input?: Input,
stringify?: (input: Input) => string,
): Promise<IPropagation<any, any>> =>
_Propagate("propagate")(props)(connection, route, input, stringify);
/** @internal */
const _Propagate =
(method: string) =>
(props: IProps) =>
async <Input>(
connection: IConnection,
route: IFetchRoute<"DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT">,
input?: Input,
stringify?: (input: Input) => string,
): Promise<IPropagation<any, any>> => {
//----
// REQUEST MESSAGE
//----
// METHOD & HEADERS
const headers: Record<string, IConnection.HeaderValue | undefined> = {
...(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")
headers["Content-Type"] = route.request.type;
} else if (input === undefined && headers["Content-Type"] !== undefined)
delete 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 =
connection.host[connection.host.length - 1] !== "/" &&
route.path[0] !== "/"
? `/${route.path}`
: route.path;
const url: URL = new URL(`${connection.host}${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<any, any> = {
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
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<string, any>): 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<string, string | string[]> => {
const output: Record<string, string | string[]> = {};
headers.forEach((value, key) => {
if (key === "set-cookie") {
output[key] ??= [];
(output[key] as string[]).push(
...value.split(";").map((str) => str.trim()),
);
} else output[key] = value;
});
return output;
};