diff --git a/packages/jsonrpc/jest.config.js b/packages/jsonrpc/jest.config.js new file mode 100644 index 0000000..3a4457a --- /dev/null +++ b/packages/jsonrpc/jest.config.js @@ -0,0 +1,18 @@ +module.exports = { + roots: [ + '/src' + ], + transform: { + '^.+\\.tsx?$': 'ts-jest' + }, + testRegex: '(/__tests__/.*|\\.(test|spec))\\.tsx?$', + moduleFileExtensions: [ + 'ts', + 'tsx', + 'js', + 'jsx' + ], + setupFiles: ['/jest.setup.js'], + maxConcurrency: 1, + verbose: false +} diff --git a/packages/jsonrpc/jest.setup.js b/packages/jsonrpc/jest.setup.js new file mode 100644 index 0000000..e69de29 diff --git a/packages/jsonrpc/src/index.ts b/packages/jsonrpc/src/index.ts new file mode 100644 index 0000000..3718365 --- /dev/null +++ b/packages/jsonrpc/src/index.ts @@ -0,0 +1 @@ +export * from './server' diff --git a/packages/jsonrpc/src/server.test.ts b/packages/jsonrpc/src/server.test.ts new file mode 100644 index 0000000..f5e429e --- /dev/null +++ b/packages/jsonrpc/src/server.test.ts @@ -0,0 +1,108 @@ +import {jsonrpc} from './server' +import request from 'supertest' +import express from 'express' +import bodyParser from 'body-parser' + +describe('jsonrpc', () => { + + class Service { + constructor(readonly time: number) {} + add(a: number, b: number) { + return a + b + } + multiply(...numbers: number[]) { + return numbers.reduce((a, b) => a * b, 1) + } + delay() { + return new Promise(resolve => { + setTimeout(resolve, this.time) + }) + } + } + + function createApp() { + const app = express() + app.use(bodyParser.json()) + app.use('/myService', jsonrpc(new Service(5), ['add', 'delay'])) + app.use((err: any, req: any, res: any, next: any) => { + console.log(err) + next(err) + }) + return app + } + + describe('errors', () => { + + }) + + describe('success', () => { + it('can call method and receive results', async () => { + const response = await request(createApp()) + .post('/myService') + .send({ + jsonrpc: '2.0', + id: 1, + method: 'add', + params: [1, 2], + }) + .expect(200) + + expect(response.body).toEqual({ + jsonrpc: '2.0', + id: 1, + result: 3, + error: null, + }) + }) + it('handles promises', async () => { + const response = await request(createApp()) + .post('/myService') + .send({ + jsonrpc: '2.0', + id: 1, + method: 'delay', + params: [], + }) + .expect(200) + expect(response.body).toEqual({ + jsonrpc: '2.0', + id: 1, + // result: null, + error: null, + }) + }) + it('handles synchronous notifications', async () => { + await request(createApp()) + .post('/myService') + .send({ + jsonrpc: '2.0', + method: 'add', + params: [1, 2], + }) + .expect(200) + .expect('') + + await request(createApp()) + .post('/myService') + .send({ + jsonrpc: '2.0', + id: null, + method: 'add', + params: [1, 2], + }) + .expect(200) + .expect('') + }) + }) + + describe('security', () => { + it('cannot call toString method', () => { + + }) + + it('cannot call any other methods in objects prototype', () => { + + }) + }) + +}) diff --git a/packages/jsonrpc/src/server.ts b/packages/jsonrpc/src/server.ts new file mode 100644 index 0000000..c1a6b9d --- /dev/null +++ b/packages/jsonrpc/src/server.ts @@ -0,0 +1,174 @@ +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 type FunctionPropertyNames = { + // tslint:disable-next-line + [K in keyof T]: T[K] extends Function ? K : never +}[keyof T] + +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' +} + +/** + * Adds middleware for handling JSON RPC requests. Expects JSON middleware to + * already be configured. + */ +export function jsonrpc>( + service: T, + functions: F[], +) { + const rpcService = pick(service, functions) + + 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) + ) { + res.status(400) + return res.json(createErrorResponse(null, ERROR_INVALID_REQUEST)) + } + + if ( + !rpcService.hasOwnProperty(method) || + typeof (rpcService as any)[method] !== 'function' + ) { + res.status(404) + return res.json(createErrorResponse(null, ERROR_METHOD_NOT_FOUND)) + } + + // if (rpcService[method].arguments.length !== params.length) { + // return res.json(createErrorResponse(null, ERROR_INVALID_PARAMS)) + // } + + const retValue = (rpcService as any)[method](...params) + + if (!isPromise(retValue)) { + if (isNotification) { + return res.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 +} + +function handleError( + err: any, + req: Request, + res: Response, + next: NextFunction, +) { + const statusCode: number = 'statusCode' in err && + typeof err.statusCode === 'number' + ? err.statusCode + : 500 + + const message = statusCode >= 400 && statusCode < 500 + ? err.message + : ERROR_SERVER.message + + res.status(statusCode) + res.json(createErrorResponse(req.body.id, { + code: ERROR_SERVER.code, + message, + data: err.errors, + })) +} diff --git a/packages/jsonrpc/tsconfig.esm.json b/packages/jsonrpc/tsconfig.esm.json new file mode 100644 index 0000000..915284d --- /dev/null +++ b/packages/jsonrpc/tsconfig.esm.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "esm" + }, + "references": [] +} diff --git a/packages/jsonrpc/tsconfig.json b/packages/jsonrpc/tsconfig.json new file mode 100644 index 0000000..94e864b --- /dev/null +++ b/packages/jsonrpc/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.common.json", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src" + }, + "references": [ + ] +}