RPC Like router with automatic Validation and Serialization.
@mikrokit/router
🚀 Lightweight router based in plain javascript objects.
Thanks to it's RPC style there is no need to parse parameters or regular expressions when finding a route. Just a simple Map in memory containing all the routes.
MikroKit Router uses a Remote Procedure Call style routing, unlike traditional routers it does not use GET
, PUT
, POST
and DELETE
methods, everything should be transmitted using HTTP POST
method and all data is sent/received in the request/response body
and headers
.
HTTP
method is ignored by this router.- Data is sent and received only in the
body
and headers
. (no request params) - Data is sent and received only in
JSON
format.
Rpc VS Rest
RPC Like Request | REST Request | Description |
---|
POST /users/get BODY {"/users/get":[{"id":1}]} | GET /users/1 BODY NONE | Get user by id |
POST /users/create BODY {"/users/create":[{"name":"John"}]} | POST /users BODY {"name":"John"} | Create new user |
POST /users/delete BODY {"/users/delete":[{"id":1}]} | DELETE /users/1 BODY NONE | Delete user |
POST /users/getAll BODY {"/users/getAll":[]} | GET /users BODY NONE | Get All users |
Please have a look to this great Presentation for more info about each different type of API and the pros and cons of each one:
Nate Barbettini – API Throwdown: RPC vs REST vs GraphQL, Iterate 2018
Routes
A route is just a function, the first parameter is always the call context
, the rest of parameters are extracted from the request body or headers. Route names are defined using a plain javascript object, where every property of the object is the route's name. Adding types is recommended when defining a route so typescript can statically check parameters.
MikroKit only cares about the path
, and completely ignores the http method, so in theory request could be made using POST
or PUT
, or the router could be used in any event driven environment where the concept of method does not exist.
import {Route, Handler, Routes, MkRouter} from '@mikrokit/router';
const sayHello: Handler = (context, name: string): string => {
return `Hello ${name}.`;
};
const sayHello2: Route = {
route(context, name1: string, name2: string): string {
return `Hello ${name1} and ${name2}.`;
},
};
const routes: Routes = {
sayHello,
sayHello2,
};
MkRouter.setRouterOptions({prefix: 'api/'});
MkRouter.addRoutes(routes);
Using javascript names helps keeping route names simple, it is not recommended to use the array notation to define route names. no url decoding is done when finding the route
import {Routes, MkRouter, Route} from '@mikrokit/router';
const sayHello: Route = (context, name: string): string => {
return `Hello ${name}.`;
};
const routes: Routes = {
'say-Hello': sayHello,
'say Hello': sayHello,
};
MkRouter.addRoutes(routes);
Request & Response
Route parameters
are passed as an Array in the request body, in a field with the same name as the route. Elements in the array must have the same order as the function parameters.
Route response
is send back in the body in a field with the same name as the route.
The reason for this weird naming is to future proof the router to be able to accept multiple routes on a single request. However this can be changed setting the routeFieldName
in the router options.
POST REQUEST | Request Body | Response Body |
---|
/api/sayHello | {"/api/sayHello": ["John"] } | {"/api/sayHello": "Hello John."} |
/api/sayHello2 | {"/api/sayHello2": ["Adan", "Eve"] } | {"/api/sayHello2": "Hello Adan and Eve."} |
Hooks
A route might require some extra data like authorization, preconditions, logging, etc... Hooks are auxiliary functions executed in order before or after the route.
Hooks can use context.shared
to share data with other routes and hooks. The return value will be ignored unless canReturnData
is set to true, in that case the returned value will be serialized in and send back in response body.
import {Route, Routes, MkRouter, Hook} from '@mikrokit/router';
import {getAuthUser, isAuthorized} from 'MyAuth';
const authorizationHook: Hook = {
fieldName: 'Authorization',
inHeader: true,
async hook(context, token: string): Promise<void> {
const me = await getAuthUser(token);
if (!isAuthorized(me)) throw {code: 401, message: 'user is not authorized'};
context.auth = {me};
},
};
const getPet: Route = async (context, petId: number): Promise<Pet> => {
const pet = context.app.deb.getPet(petId);
return pet;
};
const logs: Hook = {
async hook(context): Promise<void> {
const me = context.errors;
if (context.errors) await context.cloudLogs.error(context.errors);
else context.cloudLogs.log(context.request.path, context.auth.me, context.mkkOutput);
},
};
const routes: Routes = {
authorizationHook,
users: {
getPet,
},
logs,
};
MkRouter.addRoutes(routes);
Execution Order
The order in which routes
and hooks
are added to the router is important as they will be executed in the same order they are defined (Top Down order). An execution path is generated for every route.
const routes: Routes = {
authorizationHook,
users: {
userOnlyHook,
getUser,
},
pets: {
getPet,
},
errorHandlerHook,
loggingHook,
};
MkRouter.addRoutes(routes);
Execution path for: users/getUser
graph LR;
A(authorizationHook) --> B(userOnlyHook) --> C{{getUser}} --> E(errorHandlerHook) --> D(loggingHook)
Execution path for: pets/getPets
graph LR;
A(authorizationHook) --> B{{getPet}} --> E(errorHandlerHook) --> C(loggingHook)
To guarantee the correct execution order of hooks and routes, the properties of the router CAN NOT BE numeric or digits only.
An error will thrown when adding routes with MkRouter.addRoutes
. More info about order of properties in javascript objects here and here.
const invalidRoutes = {
authorizationHook,
1: {
getFoo,
},
'2': {
getBar,
},
};
MkRouter.addRoutes(invalidRoutes);
Throwing errors within Routes & Hooks
All errors thrown within Routes/Hooks will be automatically catch and handled, as there is no concept of logger within the router, to errors are generated, One public error to be returned in the context.response.body
& one private error stored in the context.internalErrors
. The public errors only contains generic message an an status code, the private errors contains also stack trace and the rest of properties of any js Error.
For proper standardization of errors it is recommended to always throw a RouteError
, that contains an statusCode
and both normal error.message
as well as a error.publicMessage
.
import {Route, RouteError, StatusCodes} from '@mikrokit/router';
const getSomeData: Route = {
route: (context): void => {
try {
const data = context.app.db.getSomeData();
} catch (dbError) {
const statusCode = StatusCodes.INTERNAL_SERVER_ERROR;
const publicMessage = `Cant fetch data.`;
throw new RouteError(statusCode, publicMessage, dbError);
}
},
};
Routes & Hooks Config
Hooks config | Routes config |
---|
export type Handler = (context: Context<any, any, any, any>, ...args: any) => any | Promise<any>;
export type RouteObject = {
path?: string;
description?: string;
enableValidation?: boolean;
enableSerialization?: boolean;
route: Handler;
};
export type Route = RouteObject | Handler;
|
export type Hook = {
forceRunOnError?: boolean;
canReturnData?: boolean;
inHeader?: boolean;
fieldName?: string;
description?: string;
enableValidation?: boolean;
enableSerialization?: boolean;
hook: Handler;
};
|
Extending Route and Hook Types
Your application might need to add some extra metadata to every route or hook, to keep types working you can extend the Route
and Hook
types as follows:
import {Route, Hook} from '@mikrokit/router';
type MyRoute = Route & {doNotFail: boolean};
type MyHook = Hook & {shouldLog: boolean};
const someRoute: MyRoute = {
doNotFail: true,
route: (): void => {
if (someRoute.doNotFail) {
} else {
throw {statusCode: 400, message: 'operation failed'};
}
},
};
const someHook: MyHook = {
shouldLog: false,
hook: (context): void => {
if (someHook.shouldLog) {
context.app.cloudLogs.log('hello');
} else {
}
},
};
Call Context
The Context
or Call Context
contains all data related to the route being called and is always passed in the first parameter to routes/hooks handler.
Most of the data within the Context
is marked as read only, this is because it is not recommended modifying the context manually. Instead Hooks/Routes should just return data, modify the shared
object or throw a RouteError
and the router would take care of correctly assign values to the Call Context
. It is still possible to modify them (the context is not a real Immutable js object).
Context Type
export type MkResponse = {
statusCode: Readonly<number>;
errors: Readonly<PublicError[]>;
headers: MkHeaders;
body: Readonly<MapObj>;
json: Readonly<string>;
};
export type Context<
App,
SharedData,
ServerReq extends MkRequest,
AnyServerCall extends ServerCall<ServerReq> = ServerCall<ServerReq>,
> = Readonly<{
app: Readonly<App>;
serverCall: Readonly<AnyServerCall>;
path: Readonly<string>;
internalErrors: Readonly<RouteError[]>;
request: Readonly<{
headers: MapObj;
body: MapObj;
}>;
response: Readonly<MkResponse>;
shared: Readonly<SharedData>;
}>;
export type ServerCall<ServerReq extends MkRequest> = {
req: ServerReq;
};
Using context
import {MkRouter, Context} from '@mikrokit/router';
import {APIGatewayProxyResult, APIGatewayEvent} from 'aws-lambda';
import {someDbDriver} from 'someDbDriver';
import {cloudLogs} from 'MyCloudLogLs';
const app = {cloudLogs, db: someDbDriver};
const shared = {auth: {me: null}};
const getSharedData = (): typeof shared => shared;
type App = typeof app;
type SharedData = ReturnType<typeof getSharedData>;
type CallContext = Context<App, SharedData, APIGatewayEvent>;
const getMyPet = async (context: CallContext): Promise<Pet> => {
const user = context.shared.auth.me;
const pet = context.app.db.getPetFromUser(user);
context.app.cloudLogs.log('pet from user retrieved');
return pet;
};
const routes = {getMyPet};
MkRouter.initRouter(app, getSharedData);
MkRouter.addRoutes(routes);
Automatic Serialization and Validation
Mikrokit uses Deepkit's runtime types to automatically validate request params and serialize/deserialize request/response data.
Thanks to Deepkit's magic the type information is available at runtime and the data can be auto-magically Validated and Serialized. For more information please read deepkit's documentation:
Request Validation examples
Code | POST Request /users/getUser |
---|
import {Route, Routes, MkRouter} from '@mikrokit/router';
const getUser: Route = async (context: any, entity: {id: number}): Promise<User> => {
const user = await context.db.getUserById(entity.id);
return user;
};
const routes: Routes = {
users: {
getUser,
},
};
MkRouter.addRoutes(routes);
|
{ "/users/getUser": [ {"id" : 1} ]}
{"/users/getUser": [ {"id" : "1"} ]}
{"/users/getUser": [ {"ID" : 1} ]}
{"/users/getUser": []}
|
!!! IMPORTANT !!!
Deepkit does not support Type Inference, parameter types
and more importantly return types
must be explicitly defined, so they are correctly validated/serialized.
🚫 Invalid route definitions!
const myRoute1: Route = () {};
const myRoute2: Route = () => null;
const sayHello: Route = (context, name) => `Hello ${name}`;
const getYser: Route = async (context, userId) => context.db.getUserById(userId);
✅ Valid route definitions!
const myRoute1: Route = (): void {};
const myRoute2: Route = (): null => null;
const sayHello: Route = (context: Context, name:string): string => `Hello ${name}`;
const getYser: Route = async (context: Context, userId:number): Promise<User> => context.db.getUserById(userId);
Configuring Eslint to enforce explicit types in router files:
Declaring explicit types everywhere can be a bit annoying, so you could suffix your route filles with .routes.ts
and add bellow eslint config to your project, (the important part here is the overrides
config).
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
parserOptions: {
project: ['./tsconfig.json', './packages/*/tsconfig.json'],
},
overrides: [
{
files: ['**/*.routes.ts'],
rules: {
'@typescript-eslint/explicit-function-return-type': 'error',
'@typescript-eslint/no-explicit-any': 'error',
},
},
],
};
Router Options
export const DEFAULT_ROUTE_OPTIONS: Readonly<RouterOptions> = {
prefix: '',
suffix: '',
pathTransform: undefined,
routeFieldName: undefined,
enableValidation: true,
enableSerialization: true,
serializationOptions: {
loosely: false,
},
customSerializer: undefined,
serializerNamingStrategy: undefined,
bodyParser: JSON,
responseContentType: 'application/json; charset=utf-8',
};
Full Working Example
import {MkRouter, Context, Route, Routes, Hook, MkError, StatusCodes} from '@mikrokit/router';
import {APIGatewayEvent} from 'aws-lambda';
interface User {
id: number;
name: string;
surname: string;
}
type NewUser = Omit<User, 'id'>;
const myDBService = {
usersStore: new Map<number, User>(),
createUser: (user: NewUser): User => {
const id = myDBService.usersStore.size + 1;
const newUser: User = {id, ...user};
myDBService.usersStore.set(id, newUser);
return newUser;
},
getUser: (id: number): User | undefined => myDBService.usersStore.get(id),
updateUser: (user: User): User | null => {
if (!myDBService.usersStore.has(user.id)) return null;
myDBService.usersStore.set(user.id, user);
return user;
},
deleteUser: (id: number): User | null => {
const user = myDBService.usersStore.get(id);
if (!user) return null;
myDBService.usersStore.delete(id);
return user;
},
};
const myAuthService = {
isAuthorized: (token: string): boolean => token === 'ABCD',
getIdentity: (token: string): User | null => (token === 'ABCD' ? ({id: 0, name: 'admin', surname: 'admin'} as User) : null),
};
const app = {
db: myDBService,
auth: myAuthService,
};
const shared = {
me: null as any as User,
};
const getSharedData = (): typeof shared => shared;
type App = typeof app;
type SharedData = ReturnType<typeof getSharedData>;
type CallContext = Context<App, SharedData, APIGatewayEvent>;
const getUser: Route = (ctx: CallContext, id: number): User => {
const user = ctx.app.db.getUser(id);
if (!user) throw {statusCode: 200, message: 'user not found'};
return user;
};
const createUser: Route = (ctx: CallContext, newUser: NewUser): User => ctx.app.db.createUser(newUser);
const updateUser: Route = (ctx: CallContext, user: User): User => {
const updated = ctx.app.db.updateUser(user);
if (!updated) throw {statusCode: 200, message: 'user not found, can not be updated'};
return updated;
};
const deleteUser: Route = (ctx: CallContext, id: number): User => {
const deleted = ctx.app.db.deleteUser(id);
if (!deleted) throw {statusCode: 200, message: 'user not found, can not be deleted'};
return deleted;
};
const auth: Hook = {
inHeader: true,
fieldName: 'Authorization',
hook: (ctx: CallContext, token: string): void => {
const {auth} = ctx.app;
if (!auth.isAuthorized(token)) throw {statusCode: StatusCodes.FORBIDDEN, message: 'Not Authorized'} as MkError;
ctx.shared.me = auth.getIdentity(token) as User;
},
};
const routes: Routes = {
auth,
users: {
get: getUser,
create: createUser,
update: updateUser,
delete: deleteUser,
},
};
MkRouter.initRouter(app, getSharedData, {prefix: 'api/v1'});
MkRouter.addRoutes(routes);
MIT LICENSE