import { Logger } from '@rondo.dev/logger' import express, { ErrorRequestHandler, Request, Response, NextFunction, RequestHandler } from 'express' import { createError, ErrorResponse, isRPCError } from './error' import { IDEMPOTENT_METHOD_REGEX } from './idempotent' import { createRpcService, ERROR_METHOD_NOT_FOUND, ERROR_SERVER, Request as RPCRequest, SuccessResponse } from './jsonrpc' import { FunctionPropertyNames } from './types' export type GetContext = (req: Request) => Promise | Context export interface RPCReturnType { addService>( path: string, service: T, methods?: F[], ): RPCReturnType router(): Array } export interface InvocationDetails { context: Context path: string request: A } async function defaultHook( details: InvocationDetails, invoke: () => Promise, ): Promise { const result = await invoke() return result } export function jsonrpc( getContext: GetContext, logger: Logger, hook: ( details: InvocationDetails, invoke: (request?: A) => Promise) => Promise = defaultHook, idempotentMethodRegex = IDEMPOTENT_METHOD_REGEX, ): RPCReturnType { const router: Array = [] const self = { /** * Adds middleware for handling JSON RPC requests. Expects JSON middleware to * already be configured. */ addService>( path: string, service: T, methods?: F[], ) { const rpcService = createRpcService(service, methods) const handleError: ErrorRequestHandler = (err, req, res, next) => { if (req.path !== path) { next(err) return } logger.error('JSON-RPC Error: %s', err.stack) if (isRPCError(err)) { res.status(err.statusCode) res.json(err.response) return } const id = getRequestId(req) const statusCode: number = err.statusCode || 500 const errorResponse: ErrorResponse = { jsonrpc: '2.0', id, result: null, error: { code: ERROR_SERVER.code, message: statusCode >= 500 ? ERROR_SERVER.message : err.message, data: 'errors' in err ? err.errors : null, }, } res.status(statusCode) res.json(errorResponse) } function handleResponse( response: SuccessResponse | null, res: Response, ) { if (response === null) { // notification res.status(204).send() } else { res.json(response) } } function handle(req: Request, res: Response, next: NextFunction) { if (req.path !== path) { next() return } switch(req.method) { case 'GET': handleGet(req, res, next) break case 'POST': handlePost(req, res, next) break default: next() } } const handleGet: RequestHandler = (req, res, next) => { if (!idempotentMethodRegex.test(req.query.method)) { // TODO fix status code and error type const err = createError(ERROR_METHOD_NOT_FOUND, { id: req.query.id, data: null, statusCode: 405, }) throw err } const request = { id: req.query.id, jsonrpc: req.query.jsonrpc, method: req.query.method, params: JSON.parse(req.query.params), } Promise.resolve(getContext(req)) .then(context => hook( {path, request, context}, (body = request) => rpcService.invoke(body, context))) .then(response => handleResponse(response, res)) .catch(next) } const handlePost: RequestHandler = (req, res, next) => { Promise.resolve(getContext(req)) .then(context => hook( {path, request: req.body, context}, (body = req.body) => rpcService.invoke(body, context))) .then(response => handleResponse(response, res)) .catch(next) } router.push(handle) router.push(handleError) return self }, router() { return router }, } return self } function getRequestId(req: express.Request) { const id = req.method === 'POST' ? req.body.id : req.query.id return id !== undefined ? id : null }