Extract jsonrpc functionality into a separate file
This commit is contained in:
parent
be7b38b39e
commit
c273c1b914
56
packages/jsonrpc/src/error.ts
Normal file
56
packages/jsonrpc/src/error.ts
Normal 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
|
||||
}
|
||||
@ -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'))
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
export * from './error'
|
||||
export * from './express'
|
||||
export * from './jsonrpc'
|
||||
export * from './local'
|
||||
export * from './redux'
|
||||
export * from './remote'
|
||||
|
||||
5
packages/jsonrpc/src/isPromise.ts
Normal file
5
packages/jsonrpc/src/isPromise.ts
Normal 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'
|
||||
}
|
||||
139
packages/jsonrpc/src/jsonrpc.ts
Normal file
139
packages/jsonrpc/src/jsonrpc.ts
Normal 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)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user