UNPKG

shelving

Version:

Toolkit for using data in JavaScript.

158 lines (157 loc) 8.17 kB
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 { omitProps } from "./object.js"; import { getPlaceholders, renderTemplate } from "./template.js"; import { withURIParams } from "./uri.js"; export async function _getMessageJSON(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 }); } } export async function _getMessageFormData(message, MessageError, caller) { try { return await message.formData(); } catch (cause) { throw new MessageError(`Body must be valid form multipart data`, { cause, caller }); } } export function _getMessageContent(message, MessageError, caller) { const type = message.headers.get("Content-Type"); if (!type || type?.startsWith("text/")) return message.text(); if (type?.startsWith("application/json")) return _getMessageJSON(message, MessageError, caller); if (type?.startsWith("multipart/form-data")) return _getMessageFormData(message, MessageError, caller); return Promise.resolve(); } /** * Get 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 getRequestContent(request, caller = getRequestContent) { const { method } = request; // The HTTP/1.1 RFC 7231 does not forbid sending a body in GET or HEAD requests, but it is uncommon and not recommended because many servers, proxies, and caches may ignore or mishandle it. if (method === "GET" || method === "HEAD") return Promise.resolve(undefined); return _getMessageContent(request, RequestError, caller); } /** * Get the body content of an HTTP `Response` based on its content type, or throw `ResponseError` if the content could not be parsed. * * @returns undefined If the request status is `204 No Content` (this response has 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 getResponseContent(response, caller = getResponseContent) { const { status } = response; // RFC 7230 Section 3.3.3: A server MUST NOT send a Content-Length header field in any response with a status code of 1xx (Informational), 204 (No Content), or 304 (Not Modified). if ((status >= 100 && status < 200) || status === 204 || status === 304) return Promise.resolve(undefined); return _getMessageContent(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 }); } export function getRequest(method, url, payload, options = {}, caller = getRequest) { // Render any `{placeholders}` in the URL string. const placeholders = getPlaceholders(url); if (placeholders.length) { if (!isData(payload)) throw new RequiredError("Payload for request with URL {placeholders} must be data object", { received: payload, caller }); url = renderTemplate(url, payload, caller); payload = omitProps(payload, ...placeholders); } // This is a body-less request, so ensure the payload is a data object and set the `?query=params` in the URL. if (method === "GET" || method === "HEAD") { if (!isData(payload)) throw new RequiredError(`Payload for ${method} request must be data object`, { received: payload, caller }); url = withURIParams(url, payload).href; payload = undefined; } // `null` or `undefined` payloads send no body. if (isNullish(payload)) return new Request(url, { ...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 new Request(url, { ...options, method, body: payload }); // Strings are sent as plain text. if (typeof payload === "string") return new Request(url, { ...options, headers: { ...options.headers, "Content-Type": "text/plain" }, method, body: payload, }); // JSON is the default. return new Request(url, { ...options, headers: { ...options.headers, "Content-Type": "application/json" }, method, body: JSON.stringify(payload), }); }