diff --git a/packages/jsonrpc/src/error.ts b/packages/jsonrpc/src/error.ts new file mode 100644 index 0000000..98812a6 --- /dev/null +++ b/packages/jsonrpc/src/error.ts @@ -0,0 +1,56 @@ +export interface IError { + code: number + message: string +} + +export interface IErrorWithData extends IError { + data: T +} + +export interface IErrorResponse { + jsonrpc: '2.0' + id: string | number | null + result: null + error: IErrorWithData +} + +export interface IJSONRPCError extends Error { + code: number + statusCode: number + response: IErrorResponse +} + +export function isJSONRPCError(err: any): err is IJSONRPCError { + return err.name === 'IJSONRPCError' && + typeof err.message === 'string' && + err.hasOwnProperty('code') && + err.hasOwnProperty('response') +} + +export function createError( + error: IError, + info: { + id: number | string | null + data: T + statusCode: number + }, +): IJSONRPCError { + + const err = new Error(error.message) as IJSONRPCError + + err.name = 'IJSONRPCError' + err.code = error.code + err.statusCode = info.statusCode + err.response = { + jsonrpc: '2.0', + id: info.id, + error: { + message: error.message, + code: error.code, + data: info.data, + }, + result: null, + } + + return err +} diff --git a/packages/jsonrpc/src/express.test.ts b/packages/jsonrpc/src/express.test.ts index a3753e5..df3e0b5 100644 --- a/packages/jsonrpc/src/express.test.ts +++ b/packages/jsonrpc/src/express.test.ts @@ -55,14 +55,15 @@ describe('jsonrpc', () => { function createApp() { const app = express() app.use(bodyParser.json()) - app.use('/myService', jsonrpc(new Service(5), [ + app.use('/myService', jsonrpc(req => ({userId: 1000})) + .addService(new Service(5), [ 'add', 'delay', 'syncError', 'asyncError', 'httpError', 'addWithContext', - ], req => ({userId: 1000}))) + ])) return app } @@ -97,6 +98,7 @@ describe('jsonrpc', () => { result: null, error: { code: -32000, + data: null, message: 'Server error', }, }) @@ -106,19 +108,19 @@ describe('jsonrpc', () => { expect(err.message).toBe('Server error') expect(err.code).toBe(-32000) }) - it('returns an error when message is not in json format', async () => { + it('returns an error when message is not readable', async () => { const result = await request(createApp()) .post('/myService') .send('a=1') .expect(400) - expect(result.body.error.message).toEqual('Parse error') + expect(result.body.error.message).toEqual('Invalid request') }) it('returns an error when message is not valid', async () => { const result = await request(createApp()) .post('/myService') .send({}) .expect(400) - expect(result.body.error.message).toEqual('Invalid Request') + expect(result.body.error.message).toEqual('Invalid request') }) it('converts http errors into jsonrpc errors', async () => { const err = await getError(client.httpError(403, 'Unauthorized')) diff --git a/packages/jsonrpc/src/express.ts b/packages/jsonrpc/src/express.ts index 5126ac6..6cfb244 100644 --- a/packages/jsonrpc/src/express.ts +++ b/packages/jsonrpc/src/express.ts @@ -1,182 +1,68 @@ import {FunctionPropertyNames} from './types' import {NextFunction, Request, Response, Router} from 'express' - -export const ERROR_PARSE = { - code: -32700, message: 'Parse error', data: null} -export const ERROR_INVALID_REQUEST = { - code: -32600, - message: 'Invalid Request', - data: null} -export const ERROR_METHOD_NOT_FOUND = { - code: -32601, - message: 'Method not found', - data: null, -} -export const ERROR_INVALID_PARAMS = { - code: -32602, - message: 'Invalid params', - data: null, -} -export const ERROR_INTERNAL_ERROR = { - code: -32603, - message: 'Internal error', - data: null, -} -export const ERROR_SERVER = { - code: -32000, - message: 'Server error', - data: null, -} - -export interface ISuccessResponse { - jsonrpc: '2.0' - id: number - result: T - error: null -} - -export interface IJSONRPCError { - code: number - message: string - data: T -} - -export interface IErrorResponse { - jsonrpc: '2.0' - id: number | null - result: null - error: IJSONRPCError -} - -export type IRPCResponse = ISuccessResponse | IErrorResponse - -export function createSuccessResponse(id: number, result: T) - : ISuccessResponse { - return { - jsonrpc: '2.0', - id, - result, - error: null, - } -} - -export function createErrorResponse( - id: number | null, error: IJSONRPCError) - : IErrorResponse { - return { - jsonrpc: '2.0', - id, - result: null, - error, - } -} - -export function pick>(t: T, keys: K[]) - : Pick { - return keys.reduce((obj, key) => { - // tslint:disable-next-line - const fn = t[key] as unknown as Function - obj[key] = fn.bind(t) - return obj - }, {} as Pick) -} - -function isPromise(value: any): value is Promise { - return value !== null && typeof value === 'object' && - 'then' in value && 'catch' in value && - typeof value.then === 'function' && typeof value.catch === 'function' -} +import {createRpcService, ERROR_SERVER} from './jsonrpc' +import {createError, isJSONRPCError, IJSONRPCError} from './error' export type TGetContext = (req: Request) => Context -/** - * Adds middleware for handling JSON RPC requests. Expects JSON middleware to - * already be configured. - */ -export function jsonrpc, Context>( - service: T, - methods: F[], +export function jsonrpc( getContext: TGetContext, + idempotentMethodRegex = /^(find|fetch|get)/, ) { - const rpcService = pick(service, methods) - - const router = Router() - - router.post('/', (req, res, next) => { - if (req.headers['content-type'] !== 'application/json') { - res.status(400) - return res.json(createErrorResponse(null, ERROR_PARSE)) - } - const {id, method, params} = req.body - const isNotification = id === null || id === undefined - if ( - req.body.jsonrpc !== '2.0' || - typeof method !== 'string' || - !Array.isArray(params) + return { + /** + * Adds middleware for handling JSON RPC requests. Expects JSON middleware to + * already be configured. + */ + addService>( + service: T, + methods: F[], ) { - res.status(400) - return res.json(createErrorResponse(id, ERROR_INVALID_REQUEST)) - } + const callRpcService = createRpcService(service, methods) - if ( - !rpcService.hasOwnProperty(method) || - typeof (rpcService as any)[method] !== 'function' - ) { - res.status(404) - return res.json(createErrorResponse(id, ERROR_METHOD_NOT_FOUND)) - } + const router = Router() - // if (rpcService[method].arguments.length !== params.length) { - // return res.json(createErrorResponse(null, ERROR_INVALID_PARAMS)) - // } + router.post('/', (req, res, next) => { + callRpcService(req.body, getContext(req)) + .then(response => { + if (response === null) { + // notification + res.status(204).send() + } else { + res.json(response) + } + }) + .catch(err => handleError(req.body.id, err, req, res, next)) + }) - // TODO handle synchronous errors - let retValue - try { - retValue = (rpcService as any)[method](...params) - if (typeof retValue === 'function') { - retValue = retValue(getContext(req)) - } - } catch (err) { - return handleError(err, req, res, next) - } - - if (!isPromise(retValue)) { - if (isNotification) { - return res.status(204).send() - } - return res.json(createSuccessResponse(id, retValue)) - } - - retValue - .then(result => { - return res.json(createSuccessResponse(id, result)) - }) - .catch(err => handleError(err, req, res, next)) - }) - - return router + return router + }, + } } function handleError( + id: number | string | null, err: any, req: Request, res: Response, next: NextFunction, ) { - const statusCode: number = 'statusCode' in err && - typeof err.statusCode === 'number' - ? err.statusCode - : 500 + // TODO log error + // TODO make this nicer - const message = statusCode >= 400 && statusCode < 500 - ? err.message - : ERROR_SERVER.message + const error: IJSONRPCError = isJSONRPCError(err) + ? err + : createError({ + code: ERROR_SERVER.code, + message: err.statusCode >= 400 && err.statusCode < 500 + ? err.message + : ERROR_SERVER.message, + }, { + id, + data: null, + statusCode: err.statusCode || 500, + }) - res.status(statusCode) - res.json(createErrorResponse(req.body.id, { - code: ERROR_SERVER.code, - message, - data: err.errors, - })) + res.status(error.statusCode) + res.json(error.response) } diff --git a/packages/jsonrpc/src/index.ts b/packages/jsonrpc/src/index.ts index 603f87f..e98ef36 100644 --- a/packages/jsonrpc/src/index.ts +++ b/packages/jsonrpc/src/index.ts @@ -1,4 +1,6 @@ +export * from './error' export * from './express' +export * from './jsonrpc' export * from './local' export * from './redux' export * from './remote' diff --git a/packages/jsonrpc/src/isPromise.ts b/packages/jsonrpc/src/isPromise.ts new file mode 100644 index 0000000..1bfd79a --- /dev/null +++ b/packages/jsonrpc/src/isPromise.ts @@ -0,0 +1,5 @@ +export function isPromise(value: any): value is Promise { + return value !== null && typeof value === 'object' && + 'then' in value && 'catch' in value && + typeof value.then === 'function' && typeof value.catch === 'function' +} diff --git a/packages/jsonrpc/src/jsonrpc.ts b/packages/jsonrpc/src/jsonrpc.ts new file mode 100644 index 0000000..0db5a4a --- /dev/null +++ b/packages/jsonrpc/src/jsonrpc.ts @@ -0,0 +1,139 @@ +export type TId = number | string +import {ArgumentTypes, FunctionPropertyNames, RetType} from './types' +import {isPromise} from './isPromise' +import {createError, IErrorWithData} from './error' + +export const ERROR_PARSE = { + code: -32700, + message: 'Parse error', +} + +export const ERROR_INVALID_REQUEST = { + code: -32600, + message: 'Invalid request', +} + +export const ERROR_METHOD_NOT_FOUND = { + code: -32601, + message: 'Method not found', +} + +export const ERROR_INVALID_PARAMS = { + code: -32602, + message: 'Invalid params', +} + +export const ERROR_INTERNAL_ERROR = { + code: -32603, + message: 'Internal error', +} + +export const ERROR_SERVER = { + code: -32000, + message: 'Server error', +} + +export function pick>(t: T, keys: K[]) + : Pick { + return keys.reduce((obj, key) => { + // tslint:disable-next-line + const fn = t[key] as unknown as Function + obj[key] = fn.bind(t) + return obj + }, {} as Pick) +} + +export interface IRequest { + jsonrpc: '2.0' + id: TId | null + method: M + params: A +} + +export interface ISuccessResponse { + jsonrpc: '2.0' + id: TId + result: T + error: null +} + +export interface IErrorResponse { + jsonrpc: '2.0' + id: TId | null + result: null + error: IErrorWithData +} + +export type IResponse = ISuccessResponse | IErrorResponse + +export function createSuccessResponse(id: number | string, result: T) + : ISuccessResponse { + return { + jsonrpc: '2.0', + id, + result, + error: null, + } +} + +export function createErrorResponse( + id: number | string | null, error: IErrorWithData) + : IErrorResponse { + return { + jsonrpc: '2.0', + id, + result: null, + error, + } +} + +export type TGetContext = (req: Request) => Context + +export const createRpcService = + >( + service: T, + methods: M[], + ) => async (req: IRequest>, context: Context) + : Promise> | null> => { + const {id, method, params} = req + + if ( + req.jsonrpc !== '2.0' || + typeof method !== 'string' || + !Array.isArray(params) + ) { + throw createError(ERROR_INVALID_REQUEST, { + id, + data: null, + statusCode: 400, + }) + } + + const isNotification = req.id === null || req.id === undefined + + const rpcService = pick(service, methods) + + if ( + !rpcService.hasOwnProperty(method) || + typeof rpcService[method] !== 'function' + ) { + throw createError(ERROR_METHOD_NOT_FOUND, { + id, + data: null, + statusCode: 404, + }) + } + + let retValue = (rpcService[method] as any)(...params) + + if (typeof retValue === 'function') { + retValue = retValue(context) + } + + if (!isPromise(retValue) && isNotification) { + return null + } + + retValue = await retValue + return createSuccessResponse(req.id as any, retValue) + } diff --git a/packages/jsonrpc/src/redux.test.ts b/packages/jsonrpc/src/redux.test.ts index acf7124..fa6c761 100644 --- a/packages/jsonrpc/src/redux.test.ts +++ b/packages/jsonrpc/src/redux.test.ts @@ -54,9 +54,10 @@ describe('createActions', () => { const app = express() app.use(bodyParser.json()) - app.use('/service', jsonrpc(new Service(), keys(), () => ({ - userId: 1000, - }))) + app.use('/service', jsonrpc(() => ({userId: 1000})).addService( + new Service(), + keys(), + )) let baseUrl: string let server: Server diff --git a/packages/jsonrpc/src/remote.test.ts b/packages/jsonrpc/src/remote.test.ts index d262254..7e2a56c 100644 --- a/packages/jsonrpc/src/remote.test.ts +++ b/packages/jsonrpc/src/remote.test.ts @@ -28,7 +28,7 @@ describe('remote', () => { function createApp() { const a = express() a.use(bodyParser.json()) - a.use('/myService', jsonrpc(service, IServiceKeys, () => ({}))) + a.use('/myService', jsonrpc(() => ({})).addService(service, IServiceKeys)) return a } diff --git a/packages/jsonrpc/src/types.ts b/packages/jsonrpc/src/types.ts index 95dd5df..539949e 100644 --- a/packages/jsonrpc/src/types.ts +++ b/packages/jsonrpc/src/types.ts @@ -1,8 +1,8 @@ import {IPendingAction, IResolvedAction, IRejectedAction} from '@rondo/client' -type ArgumentTypes = +export type ArgumentTypes = T extends (...args: infer U) => infer R ? U : never -type RetType = T extends (...args: any[]) => infer R ? R : never +export type RetType = T extends (...args: any[]) => infer R ? R : never type UnwrapHOC = T extends (...args: any[]) => infer R ? R : T type UnwrapPromise = T extends Promise ? V : T type RetProm = T extends Promise ? T : Promise