Extract jsonrpc functionality into a separate file

This commit is contained in:
Jerko Steiner 2019-08-04 10:08:06 +07:00
parent be7b38b39e
commit c273c1b914
9 changed files with 262 additions and 171 deletions

View File

@ -0,0 +1,56 @@
export interface IError {
code: number
message: string
}
export interface IErrorWithData<T> extends IError {
data: T
}
export interface IErrorResponse<T> {
jsonrpc: '2.0'
id: string | number | null
result: null
error: IErrorWithData<T>
}
export interface IJSONRPCError<T> extends Error {
code: number
statusCode: number
response: IErrorResponse<T>
}
export function isJSONRPCError(err: any): err is IJSONRPCError<unknown> {
return err.name === 'IJSONRPCError' &&
typeof err.message === 'string' &&
err.hasOwnProperty('code') &&
err.hasOwnProperty('response')
}
export function createError<T = null>(
error: IError,
info: {
id: number | string | null
data: T
statusCode: number
},
): IJSONRPCError<T> {
const err = new Error(error.message) as IJSONRPCError<T>
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
}

View File

@ -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'))

View File

@ -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<T> {
jsonrpc: '2.0'
id: number
result: T
error: null
}
export interface IJSONRPCError<T> {
code: number
message: string
data: T
}
export interface IErrorResponse<T> {
jsonrpc: '2.0'
id: number | null
result: null
error: IJSONRPCError<T>
}
export type IRPCResponse<T> = ISuccessResponse<T> | IErrorResponse<T>
export function createSuccessResponse<T>(id: number, result: T)
: ISuccessResponse<T> {
return {
jsonrpc: '2.0',
id,
result,
error: null,
}
}
export function createErrorResponse<T>(
id: number | null, error: IJSONRPCError<T>)
: IErrorResponse<T> {
return {
jsonrpc: '2.0',
id,
result: null,
error,
}
}
export function pick<T, K extends FunctionPropertyNames<T>>(t: T, keys: K[])
: Pick<T, K> {
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<T, K>)
}
function isPromise(value: any): value is Promise<unknown> {
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<Context> = (req: Request) => Context
/**
* Adds middleware for handling JSON RPC requests. Expects JSON middleware to
* already be configured.
*/
export function jsonrpc<T, F extends FunctionPropertyNames<T>, Context>(
service: T,
methods: F[],
export function jsonrpc<Context>(
getContext: TGetContext<Context>,
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<T, F extends FunctionPropertyNames<T>>(
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<unknown> = 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)
}

View File

@ -1,4 +1,6 @@
export * from './error'
export * from './express'
export * from './jsonrpc'
export * from './local'
export * from './redux'
export * from './remote'

View File

@ -0,0 +1,5 @@
export function isPromise(value: any): value is Promise<unknown> {
return value !== null && typeof value === 'object' &&
'then' in value && 'catch' in value &&
typeof value.then === 'function' && typeof value.catch === 'function'
}

View File

@ -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, K extends FunctionPropertyNames<T>>(t: T, keys: K[])
: Pick<T, K> {
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<T, K>)
}
export interface IRequest<M extends string | symbol | number = any, A = any[]> {
jsonrpc: '2.0'
id: TId | null
method: M
params: A
}
export interface ISuccessResponse<T> {
jsonrpc: '2.0'
id: TId
result: T
error: null
}
export interface IErrorResponse<T> {
jsonrpc: '2.0'
id: TId | null
result: null
error: IErrorWithData<T>
}
export type IResponse<T = any> = ISuccessResponse<T> | IErrorResponse<T>
export function createSuccessResponse<T>(id: number | string, result: T)
: ISuccessResponse<T> {
return {
jsonrpc: '2.0',
id,
result,
error: null,
}
}
export function createErrorResponse<T>(
id: number | string | null, error: IErrorWithData<T>)
: IErrorResponse<T> {
return {
jsonrpc: '2.0',
id,
result: null,
error,
}
}
export type TGetContext<Context> = (req: Request) => Context
export const createRpcService =
<T, M extends FunctionPropertyNames<T>>(
service: T,
methods: M[],
) => async <Context>(req: IRequest<M, ArgumentTypes<T[M]>>, context: Context)
: Promise<ISuccessResponse<RetType<T[M]>> | 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)
}

View File

@ -54,9 +54,10 @@ describe('createActions', () => {
const app = express()
app.use(bodyParser.json())
app.use('/service', jsonrpc(new Service(), keys<IService>(), () => ({
userId: 1000,
})))
app.use('/service', jsonrpc(() => ({userId: 1000})).addService(
new Service(),
keys<IService>(),
))
let baseUrl: string
let server: Server

View File

@ -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
}

View File

@ -1,8 +1,8 @@
import {IPendingAction, IResolvedAction, IRejectedAction} from '@rondo/client'
type ArgumentTypes<T> =
export type ArgumentTypes<T> =
T extends (...args: infer U) => infer R ? U : never
type RetType<T> = T extends (...args: any[]) => infer R ? R : never
export type RetType<T> = T extends (...args: any[]) => infer R ? R : never
type UnwrapHOC<T> = T extends (...args: any[]) => infer R ? R : T
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T
type RetProm<T> = T extends Promise<any> ? T : Promise<T>