shelving
Version:
Toolkit for using data in JavaScript.
297 lines (296 loc) • 14 kB
JavaScript
import { RequestError } from "../error/RequestError.js";
import { RequiredError } from "../error/RequiredError.js";
import { ResponseError } from "../error/ResponseError.js";
import { isData } from "./data.js";
import { isError } from "./error.js";
import { isNullish } from "./null.js";
import { withURIParams } from "./uri.js";
import { requireURL } from "./url.js";
import { getXML } from "./xml.js";
/** Get parsed `JSON` from a `Request` or `Response`. */
async function _parseMessageJSON(message, MessageError, caller) {
const trimmed = (await message.text()).trim();
if (!trimmed.length)
return undefined;
try {
return JSON.parse(trimmed);
}
catch (cause) {
throw new MessageError("Body must be valid JSON", { received: trimmed, cause, caller });
}
}
/** Get parsed `FormData` from a `Request` or `Response`. */
async function _parseMessageFormData(message, MessageError, caller) {
try {
return await message.formData();
}
catch (cause) {
throw new MessageError(`Body must be valid form multipart data`, { cause, caller });
}
}
/** Get parsed body from a `Request` or `Response`. */
function _parseMessageBody(message, MessageError, caller) {
const type = message.headers.get("Content-Type");
if (type?.startsWith("text/"))
return message.text();
if (type?.startsWith("application/json"))
return _parseMessageJSON(message, MessageError, caller);
if (type?.startsWith("multipart/form-data"))
return _parseMessageFormData(message, MessageError, caller);
return Promise.resolve(undefined);
}
/**
* Parse the body content of an HTTP `Request` based on its content type, or throw `RequestError` if the content could not be parsed.
*
* @returns undefined If the request method is `GET` or `HEAD` (these request methods have no body).
* @returns unknown If content type is `application/json` and has valid JSON (including `undefined` if the content is empty).
* @returns unknown If content type is `multipart/form-data` then convert it to a simple `Data` object.
* @returns string If content type is `text/plain` or anything else (including `""` empty string if it's empty).
*
* @throws RequestError if the content is not `text/plain`, or `application/json` with valid JSON.
*/
export function parseRequestBody(request, caller = parseRequestBody) {
return _parseMessageBody(request, RequestError, caller);
}
/**
* Parse JSON from an HTTP `Request`, or return `undefined` when the request has no body.
*
* @throws RequestError If the request body is not valid JSON.
*/
export function parseRequestJSON(request, caller = parseRequestJSON) {
return _parseMessageJSON(request, RequestError, caller);
}
/**
* Parse `FormData` from an HTTP `Request`, or return `undefined` when the request has no body.
*
* @throws RequestError If the request body is not valid multipart form-data.
*/
export function parseRequestFormData(request, caller = parseRequestFormData) {
return _parseMessageFormData(request, RequestError, caller);
}
/**
* Parse the body content of an HTTP `Response` based on its content type, or throw `ResponseError` if the content could not be parsed.
*
* @returns unknown If content type is `application/json` and has valid JSON (including `undefined` if the content is empty).
* @returns unknown If content type is `multipart/form-data` then convert it to a simple `Data` object.
* @returns string If content type is `text/plain` or anything else (including `""` empty string if it's empty).
*
* @throws ResponseError if the content is not `text/plain` or `application/json` with valid JSON.
*/
export function parseResponseBody(response, caller = parseResponseBody) {
return _parseMessageBody(response, ResponseError, caller);
}
/**
* Parse JSON from an HTTP `Response`, or return `undefined` when the response has no body.
*
* @throws ResponseError If the response body is not valid JSON.
*/
export function parseResponseJSON(response, caller = parseResponseJSON) {
return _parseMessageJSON(response, ResponseError, caller);
}
/**
* Parse `FormData` from an HTTP `Response`, or return `undefined` when the response has no body.
*
* @throws ResponseError If the response body is not valid multipart form-data.
*/
export function parseResponseFormData(response, caller = parseResponseFormData) {
return _parseMessageFormData(response, ResponseError, caller);
}
/**
* Get an HTTP `Response` for an unknown value.
*
* @param value The value to convert to a `Response`.
* @returns A `Response` with a 2xx status, and response body as JSON (if it was set), or no body if `value` is `undefined`
*/
export function getResponse(value) {
// If it's already a `Response`, return it directly.
if (value instanceof Response)
return value;
// If result is undefined, return 204 No Content response.
if (value === undefined)
return new Response(undefined, { status: 204 });
// Return a new `Response` with a 2xx status and response body as JSON.
return Response.json(value, { status: 200 });
}
/**
* Get an HTTP `Response` for an unknown error value.
*
* Returns the correct `Response` based on the type of error thrown:
* - If `reason` is a `Response` instance, return it directly.
* - If `reason` is a string, return a 422 response with the string message, e.g. `"Invalid input"`
* - If `reason` is an `RequestError` instance, return a response with the error's message and code (but only if `debug` is true so we don't leak error details to the client).
* - If `reason` is an `Error` instance, return a 500 response with the error's message (but only if `debug` is true so we don't leak error details to the client).
* - Anything else returns a 500 response.
*
* @param reason The error value to convert to a `Response`.
* @param debug If `true` include the error message in the response (for debugging), or `false` to return generic error codes (for security).
*/
export function getErrorResponse(reason, debug = false) {
// If it's already a `Response`, return it directly.
if (reason instanceof Response)
return reason;
// Throw validation message strings to return `{ message: "etc" }` to the client.
if (typeof reason === "string")
return new Response(reason, { status: 422 }); // HTTP 422 Unprocessable Entity
// Throw `RequestError` to set a custom status code (e.g. `UnauthorizedError`).
const status = reason instanceof RequestError ? reason.code : 500;
// Throw `Error` to return `{ message: "etc" }` to the client (but only if `debug` is true so we don't leak error details to the client).
if (debug && isError(reason)) {
// Manually destructure because `message` and `cause` on `Error` are not enumerable.
const { message, cause, ...rest } = reason;
return Response.json({ message, cause, ...rest }, { status });
}
// Otherwise return a generic error message with no details.
return new Response(undefined, { status });
}
// Method arrays.
const _REQUEST_HEAD_METHODS = ["HEAD", "GET"];
const _REQUEST_BODY_METHODS = ["POST", "PUT", "PATCH", "DELETE"];
const _REQUEST_METHODS = [..._REQUEST_HEAD_METHODS, ..._REQUEST_BODY_METHODS];
/** Check whether an HTTP Request method string is a supported request methods. */
export function isRequestMethod(method) {
return _REQUEST_METHODS.includes(method);
}
/** Check whether an HTTP Request method string is a supported request method that never sends a body. */
export function isRequestHeadMethod(method) {
return _REQUEST_HEAD_METHODS.includes(method);
}
/**
* Merge provider-level and call-level request options.
* - Scalar options from `b` override `a`.
* - Header dictionaries are merged so call-level headers override default headers by key.
* - Abort signals are merged, so either abort signal will cancel the request.
*/
export function mergeRequestOptions({ headers: aHeaders, signal: aSignal, ...a } = {}, { headers: bHeaders, signal: bSignal, ...b } = {}) {
const headers = { ...aHeaders, ...bHeaders };
const signal = aSignal && bSignal ? AbortSignal.any([aSignal, bSignal]) : aSignal || bSignal || null;
return { ...a, ...b, signal, headers };
}
/**
* Create a body-less `Request`.
* - `HEAD` and `GET` requests never send a body.
*
* @param method The HTTP method.
* @param url The target URL.
* @param params `?query` params to encode into the URL.
* @param options Additional request options.
* @returns A `Request` with no body content.
*
* @example createHeadRequest("POST", "https://api.example.com/items", { name: "abc" })
*/
export function createHeadRequest(method, url, params, options = {}, caller = createHeadRequest) {
return new Request(withURIParams(requireURL(url, undefined, caller), params), { ...options, method, body: null });
}
/**
* Create a plain-text `Request`.
*
* - `HEAD` and `GET` requests never send a body.
*
* @param method The HTTP method.
* @param url The target URL.
* @param body The plain-text request body.
* @param options Additional request options.
* @returns A `Request` with `text/plain` content type.
*
* @example createTextRequest("POST", "https://api.example.com/items", "hello")
*/
export function createTextRequest(method, url, body, options = {}, caller = createTextRequest) {
return new Request(requireURL(url, undefined, caller), { ...mergeRequestOptions(_REQUEST_TEXT_OPTIONS, options), method, body });
}
const _REQUEST_TEXT_OPTIONS = { headers: { "Content-Type": "text/plain" } };
/**
* Create a JSON `Request`.
* - `HEAD` and `GET` requests never send a body.
* - If the JSON body is a data object for `HEAD` or `GET`, it is appended as `?query` params instead.
*
* @param method The HTTP method.
* @param url The target URL.
* @param body The value to JSON-encode.
* @param options Additional request options.
* @returns A `Request` with `application/json` content type.
*
* @example createJSONRequest("POST", "https://api.example.com/items", { name: "abc" })
*/
export function createJSONRequest(method, url, body, options = {}, caller = createJSONRequest) {
return new Request(requireURL(url, undefined, caller), {
...mergeRequestOptions(_REQUEST_JSON_OPTIONS, options),
method,
body: JSON.stringify(body),
});
}
const _REQUEST_JSON_OPTIONS = { headers: { "Content-Type": "application/json" } };
/**
* Create a multipart form-data `Request`.
* - `HEAD` and `GET` requests never send a body.
*
* @param method The HTTP method.
* @param url The target URL.
* @param body The `FormData` payload.
* @param options Additional request options.
* @returns A `Request` with a multipart body.
*
* @example createFormDataRequest("POST", "https://api.example.com/upload", new FormData())
*/
export function createFormDataRequest(method, url, body, options = {}, caller = createFormDataRequest) {
return new Request(requireURL(url, undefined, caller), { ...options, method, body });
}
/**
* Create an XML `Request`.
* - `HEAD` and `GET` requests never send a body.
* - For `HEAD` and `GET`, the data object is appended as `?query` params instead.
*
* @param method The HTTP method.
* @param url The target URL.
* @param data The data object to serialize as XML.
* @param options Additional request options.
* @returns A `Request` with `application/xml` content type.
*
* @throws {RequiredError} If the XML data contains invalid element names or values.
*
* @example createXMLRequest("POST", "https://api.example.com/items", { item: { name: "abc" } })
*/
export function createXMLRequest(method, url, data, options = {}, caller = createXMLRequest) {
return new Request(requireURL(url, undefined, caller), {
...mergeRequestOptions(_REQUEST_XML_OPTIONS, options),
method,
body: `<?xml version="1.0" encoding="UTF-8"?>${getXML(data, caller)}`,
});
}
const _REQUEST_XML_OPTIONS = { headers: { "Content-Type": "application/xml; charset=UTF-8" } };
/**
* Create a `Request` instance with a valid content type based on the body.
* - `undefined` or `null` are sent with no body.
* - `FormData` is sent with `multipart/formdata`
* - `string` is sent with `text/plain` header.
* - Anything else is sent as `application/json`
* - Expects a fully valid URL (any `{placeholders}` in the URL are not considered).
* - As per the HTTP spec, `GET` and `HEAD` requests cannot contain a body
*
* @returns Request object.
*
* @throws {RequiredError} if this is a `HEAD` or `GET` request but `body` is not a data object.
*/
export function createRequest(method, url, payload, options = {}, caller = createRequest) {
url = requireURL(url, undefined, caller);
// `null` or `undefined` payloads send no body.
if (isNullish(payload))
return new Request(url, { ...options, method, body: null });
// HEAD or GET have no body (but payload can only be data object).
if (isRequestHeadMethod(method)) {
assertRequestHeadPayload(payload, method, caller);
return new Request(withURIParams(url, payload), { ...options, method, body: null });
}
// `FormData` instances in body pass through unaltered and will set their own `Content-Type` with complex boundary information
if (payload instanceof FormData)
return createFormDataRequest(method, url, payload, options, caller);
// Strings are sent as plain text.
if (typeof payload === "string")
return createTextRequest(method, url, payload, options, caller);
// JSON is the default.
return createJSONRequest(method, url, payload, options, caller);
}
/** Assert that the payload for a HEAD or GET method is a data object, null, or undefined. */
export function assertRequestHeadPayload(payload, method, caller = assertRequestHeadPayload) {
if (!isData(payload) && !isNullish(payload))
throw new RequiredError(`Payload for ${method} request must be data object, null, or undefined`, { received: payload, caller });
}