import z from "zod"; import { BadRequestError, InternalServerError, NotFoundError, RelayError } from "./errors.ts"; import { Procedure } from "./procedure.ts"; export class RelayApi { /** * Route index in the '${method} ${path}' format allowing for quick access to * a specific route. */ readonly #index = new Map(); /** * Instantiate a new Server instance. * * @param routes - Routes to register with the instance. */ constructor({ procedures }: Config) { for (const procedure of procedures) { this.#index.set(procedure.method, procedure); } } /** * Handle a incoming fetch request. * * @param method - Method name being executed. * @param params - Parameters provided with the method request. * @param id - Request id used for response identification. */ async call(method: string, params: unknown, id: string): Promise { const procedure = this.#index.get(method); if (procedure === undefined) { return toResponse(new NotFoundError(`Method '' does not exist`), id); } // ### Context // Context is passed to every route handler and provides a suite of functionality // and request data. const args: any[] = []; // ### Params // If the route has a body schema we need to validate and parse the body. if (procedure.state.params !== undefined) { if (params === undefined) { return toResponse(new BadRequestError("Procedure expected 'params' but got 'undefined'."), id); } const result = await procedure.state.params.safeParseAsync(params); if (result.success === false) { return toResponse(new BadRequestError("Invalid request body", z.prettifyError(result.error)), id); } args.push(result.data); } // ### Actions // Run through all assigned actions for the route. const data: Record = {}; if (procedure.state.actions !== undefined) { for (const entry of procedure.state.actions) { let action = entry; let input: any; if (Array.isArray(entry)) { action = entry[0]; input = entry[1](args[0]); } const result = (await action.state.input?.safeParseAsync(input)) ?? { success: true, data: {} }; if (result.success === false) { return toResponse(new BadRequestError("Invalid action input", z.prettifyError(result.error)), id); } if (action.state.handle === undefined) { return toResponse(new InternalServerError(`Action '${action.state.name}' is missing handler.`), id); } const output = await action.state.handle(result.data); if (output instanceof RelayError) { return toResponse(output, id); } for (const key in output) { data[key] = output[key]; } } args.push(data); } // ### Handler // Execute the route handler and apply the result. if (procedure.state.handle === undefined) { return toResponse(new InternalServerError(`Path '${procedure.method}' is missing request handler.`), id); } return toResponse(await procedure.state.handle(...args).catch((error) => error), id); } } /* |-------------------------------------------------------------------------------- | Helpers |-------------------------------------------------------------------------------- */ /** * Takes a server side request result and returns a fetch Response. * * @param result - Result to send back as a Response. * @param id - Request id which can be used to identify the response. */ function toResponse(result: object | RelayError | Response | void, id: string): Response { if (result === undefined) { return new Response( JSON.stringify({ result: null, id, }), { status: 200, headers: { "content-type": "application/json", }, }, ); } if (result instanceof Response) { return result; } if (result instanceof RelayError) { return new Response( JSON.stringify({ error: result, id, }), { status: result.status, headers: { "content-type": "application/json", }, }, ); } return new Response( JSON.stringify({ result, id, }), { status: 200, headers: { "content-type": "application/json", }, }, ); } /* |-------------------------------------------------------------------------------- | Types |-------------------------------------------------------------------------------- */ type Config = { procedures: TProcedures; };