Typescript JSONRPC server
This is a very simple & straight forward way of creating JSONRPC serices.
It can also emit events that other services can subscribe to, in order to create an API event-based infrastructure.
Features
- Input validators
- Input transformers
- Response serializer
- Ideal for event-driven architectures
Important Note
The library is currently in development phase. Please contact the maintainers for feature requests, negative/constructive feedbacks, or just to hola at us, if you like it.
Installation
For MacOS Catalina, please follow this link
All the rest, npm install @pawsteam/ts-jsonrpc-server
.
Basic usage
@AppConfigDecorator({
port: 2000,
services: [HotelsService],
genericValidators: [
new HeaderExistsValidator('content-type'),
new HeaderValueContainsValidator('user-agent', 'Mozilla/5.0')
]
})
class AppMain {
}
@ServiceDecorator({path: '/hotels'})
class HotelsService {
listHotels(request: IncomingMessage, response: ServerResponse): Promise<any> {
return new Promise((resolve, reject) => {
resolve(data);
})
}
}
Observe how we have an array, services: [HotelsService]
which uses the class with the same name and listens on the path /hotels
.
By default, the procedure that gets called receives 2 parameters: a request object of type IncomingMessage
, default for nodejs HTTP servers, and a response object of type ServerResponse
, also default for nodejs HTTP servers.
The method you want to export must return a promise for any type of data.
Later, when running this app, you can make a JSONRPC request to the methods exposed by this endpoint:
request({
method: 'post',
url: 'http://localhost:2000/hotels'
json: {
id: Math.round(Math.random() * 1000),
jsonrpc: '2.0',
method: 'listHotels',
params: {}
}
}, (error, response, body) => {
expect(body).toMatchObject({
id: ...,
jsonrpc: '2.0',
result: {data}
})
})
The Framework
The framework is aimed at making life easier when writing JSON-RPC servers.
It is Promise-based, so everything returns a promise, with very few exceptions (validators).
It is based on stages/hooks in the life of a request:
The life stages of a request can be intercepted with special classes that must implement specific interfaces.
Input validators
They check the request for required data, without which you cannot perform your business logic. It's a place to define what parameters are needed for a method to be called.
Any number of Input Validators can be assigned for one method. Generic ones as well;
An input validator must implement the ValidatorInterface
, which means it must implement the validate
method, which will receive 2 parameters: params
which is a JSON object with the params from the JSONRPC object. The second parameter is the whole request object, of type IncomingMessage
This validate
method returns true of false. A truthy value will make the validation pass, a falsy value will make the validation fail.
Passing a validation means the exported method will be called.
Failing a validation means a 400 Bad Request
will be returned.
Generic validators
Generic validators are set on the app level, and they will be triggered on all requests for this app.
@AppConfigDecorator({
port: 2000,
services: [SomeService],
genericValidators: [
new HeaderExistsValidator('content-type'),
new HeaderValueContainsValidator('user-agent', 'Mozilla/5.0')
]
})
export class AppMain {
}
export class HeaderExistsValidator implements ValidatorInterface {
headerName: string;
constructor(headerName: string) {
this.headerName = headerName;
}
validate(params: any, request: IncomingMessage): boolean {
return !!request.headers[this.headerName];
}
}
Method specific validators
@ValidatorDecorator(new HeaderExistsValidator('x-content-type'))
private method1() {
return Promise.resolve(true);
}
Similarily to the example above, here we define the validator for this particular method.
Input transformers
They transform the request data into application-defined objects that are passed to the methods. Any number of input transformers can exist per method.
They must implement the TransformerInterface
, which means implementing the method transform
which receives 2 parameters.
First one is the default request, of type IncomingMessage
and the second one is the parsed JSON from the JSONRPC call, of type ParsedRpcObjectType
The transformers return a Promise of object type required.
Examples below:
export class BucketTransformer implements TransformerInterface {
transform(request: IncomingMessage, jsonrpcObj: ParsedRpcObjectType): Promise<BucketType> {
const newBucket = new BucketType(jsonrpcObj.params.path);
return Promise.resolve(newBucket);
}
}
The reason this method needs to return a promise is because we might need to fetch additional data in order to create the object.
For instance, we might need to query a DB.
Validation can also be done in the transformer. If the transformer rejects the promise or throws, the transformation is interpreted as failed, so validation failed, returning 400 Bad Request
.
To use a transformer, you simply instantiate a new instance of your transformer:
class CarType {
wheels: number;
engine: string;
constructor(_wheels: number, _engine: string) {
this.wheels = _wheels;
this.engine = _engine;
}
}
class EngineType {
power: number
constructor(_power: number) {
this.power = _power;
}
}
class CarTransformer implements TransformerInterface {
transform(request: IncomingMessage, jsonrpcObj: ParsedRpcObjectType): Promise<any> {
return Promise.resolve(new CarType(4, 'v6'));
}
}
class EngineTransformer implements TransformerInterface {
transform(request: IncomingMessage, jsonrpcObj: ParsedRpcObjectType): Promise<any> {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(new EngineType(260));
}, 500);
})
}
}
@TransformerDecorator(new CarTransformer())
@TransformerDecorator(new EngineTransformer())
method3(car: CarType, engine: EngineType, request: IncomingMessage, response: ServerResponse) {
return Promise.resolve(car.wheels * engine.power);
}
Note how we instantiate the 2 classes for transforming, CarTransformer and EngineTransformer, and how the order of the two decorators is kept in the parameters passed to the method.
The first transformer will pass the first parameter, and the second transformer passes the second parameter.
Last 2 parameters remain the request and response.
The method itself
This is where the app logic lives
It must also return a promise of any type of object, this object will be serialized/processed if needed, then replied to the user
@ValidatorDecorator(new BucketValidator())
@TransformerDecorator(new BucketTransformer())
createBucket(bucket: BucketType, context: RequestContextType): Promise<any> {
return Promise.resolve({created: bucket.path});
}
As a best practice, the validators should be as light as possible, and the validation should be avoided in the transformers, unless absolutely necessary.
Response serializer
This is where you can decide what exactly ends up in the response. Everything in the response can be changed at this step.
The serializers must implement SerializerInterface
, which means implement the method serialize
. This method receives 1 parameter of type SerializerParameterType
which is a JSON containing a lot of information.
The properties of this object are:
public objectToBeReturned: JsonRpcResponseType;
public methodOutput: HttpResponse;
public requestJsonRpcBody: ParsedRpcObjectType;
public originalRequest: IncomingMessage;
public service: ServiceType<any>;
Basically all of the information that are available in this request are passed through this object.
They must return an object of type JsonRpcResponseType (Which is also passed as a parameter).
class RemoveTestsSerializer implements SerializerInterface {
serialize(serializeParams: SerializerParameterType): JsonRpcResponseType {
for (const key in serializeParams.objectToBeReturned) {
if (key.indexOf('test') !== -1) {
delete serializeParams.objectToBeReturned[key];
}
}
return serializeParams.objectToBeReturned;
}
}
@SerializerDecorator(new RemoveTestsSerializer())
method4(req, resp) {
console.log('METHOD 4 called');
return Promise.resolve({test2: 'test', ihaveatesttorun: 'Ihaveatesttorun', someKey: 16});
}
Expected output:
{
"id": ...,
"jsonrpc": "2.0",
"result": {
"someKey": 16
}
}
Events
Sending events
One of the core functionalities of the framework is to make sure that methods can asynchronously emit events, on API calls.
The messaging paradigm used is PUB/SUB. This means that the events are published and lost if no subscribers are present.
On the other hand, if multiple subscribers subscribe to the same event, they will all receive the same events.
Easiest way to include the events functionality is through a simple decorator.
@AppConfigDecorator({
messaging: {
serviceName: 'NewService',
host: 'localhost'
},
port: 2000,
services: [NewService]
})
class app {
}
@ServiceDecorator({
path: '/newpath',
serializer: new RemoveTestsSerializer()
})
export class NewService implements ServiceInterface {
@EventEmitterDecorator()
method2() {
return Promise.resolve({sum: 8, product: 15});
}
}
By default, this will produce an event with the full response object as eventParams
{
eventType: 'method2',
eventParams: {
sum: 8,
product: 15
}
}
Alternatively, you can also filter the keys that will be published in the event, and even rename them, by passing a json parameter to the EventEmitterDecorator()
@EventEmitterDecorator({
sum: '',
prod: 'product'
})
method3() {
return Promise.resolve({sum: 5+3, prod: 5*3, excluded: 5-3});
}
The above example would send a message on the queue which looks like this:
{
sum: 8,
product: 15
}
Notice how the returned keys are sum
, prod
and excluded
, and the event only has sum
and product
, where the product
is actually the returned prod
.