UNPKG

shelving

Version:

Toolkit for using data in JavaScript.

148 lines (147 loc) 6.54 kB
import { ResponseError } from "../error/ResponseError.js"; import { ValueError } from "../error/ValueError.js"; import { UNDEFINED } from "../schema/Schema.js"; import { isData } from "../util/data.js"; import { getMessage } from "../util/error.js"; import { getRequest, getResponse, getResponseContent } from "../util/http.js"; import { renderTemplate } from "../util/template.js"; /** * An abstract API resource definition, used to specify types for e.g. serverless functions. * * @param method The method of the endpoint, e.g. `GET` * @param url Endpoint URL, possibly including placeholders e.g. `https://api.mysite.com/users/{id}` * @param payload A `Schema` for the payload of the endpoint. * @param result A `Schema` for the result of the endpoint. */ export class Endpoint { /** Endpoint method. */ method; /** Endpoint URL, possibly including placeholders e.g. `https://api.mysite.com/users/{id}` */ url; /** Payload schema. */ payload; /** Result schema. */ result; constructor(method, url, payload, result) { this.method = method; this.url = url; this.payload = payload; this.result = result; } /** * Return an `EndpointHandler` for this endpoint. * * @param callback The callback function that implements the logic for this endpoint by receiving the payload and returning the response. */ handler(callback) { return { endpoint: this, callback }; } /** * Handle a request to this endpoint with a callback implementation, with a given payload and request. * * @param callback The endpoint callback function that implements the logic for this endpoint by receiving the payload and returning the response. * @param unsafePayload The payload to pass into the callback (will be validated against this endpoint's payload schema). * @param request The entire HTTP request that is being handled (payload was possibly extracted from this somehow). * * @throws `string` if the payload is invalid. * @throws `ValueError` if `callback()` returns an invalid result. */ async handle(callback, unsafePayload, request, caller = this.handle) { // Validate the payload against this endpoint's payload type. const payload = this.payload.validate(unsafePayload); // Call the callback with the validated payload to get the result. const unsafeResult = await callback(payload, request); try { // Convert the result to a `Response` object. return getResponse(this.result.validate(unsafeResult)); } catch (thrown) { if (typeof thrown === "string") throw new ValueError(`Invalid result for ${this.toString()}:\n${thrown}`, { endpoint: this, callback, cause: thrown, caller, }); throw thrown; } } /** * Render the URL for this endpoint with the given payload. * - URL might contain `{placeholder}` values that are replaced with values from the payload. * * @returns Rendered URL with `{placeholders}` rendered with values from `payload` * @throws {RequiredError} if `{placeholders}` are set in the URL but `payload` is not a data object. */ renderURL(payload, caller = this.renderURL) { return renderTemplate(this.url, isData(payload) ? payload : {}, caller); // Empty object in `renderTemplate()` will throw intended `RequiredError` for missing `{placeholder}` } /** * Get an HTTP `Request` object for this endpoint. * - Validates a payload against this endpoints payload schema * - Return an HTTP `Request` that will send it the valid payload to this endpoint. * * @throws `string` if the payload is invalid. */ request(payload, options = {}, caller = this.request) { return getRequest(this.method, this.url, this.payload.validate(payload), options, caller); } /** * Validate an HTTP `Response` against this endpoint. * * @throws `ResponseError` if the response status is not ok (200-299) * @throws `ResponseError` if the response content is invalid. */ async response(response, caller = this.response) { // Get the response. const { ok, status } = response; const content = await getResponseContent(response, caller); // Throw `ResponseError` if the API returns status outside the 200-299 range. if (!ok) throw new ResponseError(getMessage(content) ?? `Error ${status}`, { code: status, cause: response, caller }); // Validate the success response. try { return this.result.validate(content); } catch (thrown) { if (typeof thrown === "string") throw new ResponseError(`Invalid result for ${this.toString()}:\n${thrown}`, { endpoint: this, code: 422, caller }); throw thrown; } } /** * Perform a fetch to this endpoint. * - Validate the `payload` against this endpoint's payload schema. * - Validate the returned response against this endpoint's result schema. * * @throws `string` if the payload is invalid. * @throws `ResponseError` if the response status is not ok (200-299) * @throws `ResponseError` if the response content is invalid. */ async fetch(payload, options = {}, caller = this.fetch) { const response = await fetch(this.request(payload, options, caller)); return this.response(response, caller); } /** Convert to string, e.g. `GET https://a.com/user/{id}` */ toString() { return `${this.method} ${this.url}`; } } export function HEAD(url, payload = UNDEFINED, result = UNDEFINED) { return new Endpoint("HEAD", url, payload, result); } export function GET(url, payload = UNDEFINED, result = UNDEFINED) { return new Endpoint("GET", url, payload, result); } export function POST(url, payload = UNDEFINED, result = UNDEFINED) { return new Endpoint("POST", url, payload, result); } export function PUT(url, payload = UNDEFINED, result = UNDEFINED) { return new Endpoint("PUT", url, payload, result); } export function PATCH(url, payload = UNDEFINED, result = UNDEFINED) { return new Endpoint("PATCH", url, payload, result); } export function DELETE(url, payload = UNDEFINED, result = UNDEFINED) { return new Endpoint("DELETE", url, payload, result); }