shelving
Version:
Toolkit for using data in JavaScript.
148 lines (147 loc) • 6.54 kB
JavaScript
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);
}