@pawsteam/ts-jsonrpc-server
v0.4.1
Published
A framework for easily building JSONRPC simple servers in TS
Downloads
1
Readme
Typescript JSONRPC server
This is a very simple & straight forward way of creating JSONRPC 2.0 services. It can also emit events that other services can subscribe to, in order to create an event-based API 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
npm install --save @pawsteam/ts-jsonrpc-server
.
Hello World
Since we have to start somewhere, let's start small. Let's just build a very simple API that just says hi back to you, if you give it your name.
Create the start of our app, let's say src/index.ts
:
import {AppConfigDecorator} from "@pawsteam/ts-jsonrpc-server";
import {WorldApi} from "./World.api";
@AppConfigDecorator({
port: 2000,
services: [WorldApi],
})
class AppMain {
};
Notice how we import another file, called World.api, which should be in the same src
folder. This is where we'll describe our service. Let's create that one now:
import {RequestContextType, ServiceDecorator} from "@pawsteam/ts-jsonrpc-server";
@ServiceDecorator({path: '/'})
export class WorldApi {
sayHello(context: RequestContextType): Promise<any> {
return Promise.resolve(`Hello, ${context.parsedRpcObject.params.name}`)
}
};
Now, if you run some transpiler on index.ts
, say ts-node
, you should have a server listening on your local port 2000.
So let's get at it.
Start with a basic tsconfig.json file, which you could find here.
Then run the following in a terminal:
node ./node_modules/.bin/ts-node src/index.ts
You should be presented with a log letting you know the server is running and listening:
Server listening on port: 2000
Now open your preferred HTTP client, and make a simple post request. Don't forget to state your name
in the JSONRPC request params.
If you're like me, and you like Postman, just create a POST request to localhost:2000/
, with a raw body of JSON type, and paste this json in the body of the request:
{
"method": "sayHello",
"id": 3,
"jsonrpc": "2.0",
"params": {
"name": "John"
}
}
Once you made this request, your server will respond with
{
"result": "Hello, John",
"jsonrpc": "2.0",
"id": 3
}
The above example can be found in the samples folder
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(context: RequestContextType): Promise<any> {
return new Promise((resolve, reject) => {
// fetch data.then((data) => {
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(context: RequestContextType) {
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 {
/**
* The ParsedRpcObjectType is an object created from the body of the request, parsed,
* and put into the form of a JSONRPC object:
* {
* id: <id>,
* method: <methodname>,
* jsonrpc: '2.0',
* params: {...}
* }
*/
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(context: RequestContextType, car: CarType, engine: EngineType) {
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(context: RequestContextType, bucket: BucketType): 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).
// example of serializer - removes all keys that contain the string 'test' from the response.
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(context: RequestContextType) {
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({
// step 1 - define the connection & transport
// for now only Rabbit transporters are allowed
messaging: {
serviceName: 'NewService',
host: 'localhost'
},
port: 2000,
services: [NewService]
})
class app {
}
@ServiceDecorator({
path: '/newpath',
serializer: new RemoveTestsSerializer()
})
export class NewService implements ServiceInterface {
// step 2 - in the service method, mention the decorator
@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: '', // an empty string means the key's original name will be used in the event keys
prod: 'product'
})
method3(context: RequestContextType) {
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
.