defensive
is a TypeScript library for creating contracts (aka services) with a proper validation and logging.
It depends on veni (validator).
Only node v10 and v12 are supported.
The motivation is to provide a library for contract programming that works well with TypeScript.
There are many existing libraries for data validation that rely heavily on decorator annotations. Unfortunately, decorators have many flaws:
- it's an experimental feature, and its syntax is going to change,
- redundant syntax because we must create special classes instead of using plain objects,
- it's a runtime feature, and there are some bugs related to reflection,
- no type inference, any typos or mistakes cause a runtime error instead of a compilation error.
Since Typescript 2.8, it's possible to use conditional types, that allow us to map one type to another. It's a powerful feature that can extract a Typescript interface from javascript objects (implemented by veni).
See the example below. There are no TypeScript annotations. It's pure JavaScript code, but we have type checking inferred from Veni.
- Full type inference for input parameters.
- Input validation and normalization (example: string type
"2"
to number type2
). - Input logging (input parameters):
ENTER myService#methodName: {param1: 'foo', param2: 'bar'}
- Output logging:
EXIT myService#methodName: {result: 'foobar', anotherProp: 'bar'}
- Error logging with input parameters (see example below).
- Bindings to 3rd party frameworks (see example below).
- Context (aka continuation local storage) - passing data between function calls without using function arguments (see example below).
npm install defensive
yarn add defensive
// contract.ts
export const { createContract } = initialize();
// services/CalcService.ts
import { V } from 'veni';
import { createContract } from './contract';
import util from 'util';
export const add = createContract('CalcService#add')
.params('a', 'b')
.schema({
a: V.number(),
b: V.number(),
})
.fn(async (a, b) => a + b);
(async function main() {
try {
await add(1, 3); // returns 4
await add('5' as any, '6' as any); // returns 11, input parameters are converted to number types
await add('1' as any, { foo: 'bar' } as any); // throws an error
// NOTE: you shouldn't use casting `as any` in your code. It's used only for a demonstration purpose.
// The service is expected to be called with unknown input (for example: req.body).
} catch (e) {
console.error(util.inspect(e, { depth: null }));
}
})();
$ ts-node -T examples/example1.ts
ENTER CalcService#add: { a: 1, b: 3 }
EXIT CalcService#add: 4
ENTER CalcService#add: { a: '5', b: '6' }
EXIT CalcService#add: 11
ENTER CalcService#add: { a: '1', b: { foo: 'bar' } }
{ Error: ContractError: Validation error: 'b' must be a number.
at wrappedFunction (/defensive/src/_createContract.ts:81:17)
at process._tickCallback (internal/process/next_tick.js:68:7)
at Function.Module.runMain (internal/modules/cjs/loader.js:744:11)
at Object.<anonymous> (/.nvm/versions/node/v10.12.0/lib/node_modules/ts-node/src/bin.ts:158:12)
at Module._compile (internal/modules/cjs/loader.js:688:30)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:699:10)
at Module.load (internal/modules/cjs/loader.js:598:32)
at tryModuleLoad (internal/modules/cjs/loader.js:537:12)
at Function.Module._load (internal/modules/cjs/loader.js:529:3)
at Function.Module.runMain (internal/modules/cjs/loader.js:741:12)
original:
{ Error: Validation error: 'b' must be a number.
at new ValidationError (/defensive/node_modules/veni/ValidationError.js:19:28)
at Object.exports.validate (/defensive/node_modules/veni/validate.js:37:21)
at /defensive/src/wrapValidate.ts:17:24
at logDecorator (/defensive/src/wrapLog.ts:40:26)
at hook.runInNewScope (/defensive/src/_createContract.ts:67:52)
at AsyncResource.runInAsyncScope (async_hooks.js:188:21)
at ContractHook.runInNewScope (/defensive/src/ContractHook.ts:45:26)
at wrappedFunction (/defensive/src/_createContract.ts:67:32)
at main (/defensive/examples/example1.ts:24:11)
at process._tickCallback (internal/process/next_tick.js:68:7)
errors:
[ { type: 'number.base',
message: 'must be a number',
path: [ 'b' ],
value: { foo: 'bar' } } ] },
entries:
[ { signature: 'CalcService#add',
input: '{ a: \'1\', b: { foo: \'bar\' } }' } ] }
See example under examples/example1.ts
. Run it using npm run example1
.
By default properties password
, token
, accessToken
are removed from logging.
Additionally you can set options to {removeOutput: true}
to remove the method result.
Example:
file services/SecurityService.ts
// services/SecurityService.ts
import { createContract } from 'defensive';
import { V } from 'veni';
const hashPassword = createContract('SecurityService#hashPassword')
.params('password')
.schema({
password: V.string(),
})
.fn(async password => 'ba817ef716');
hashPassword('secret-password');
$ ts-node -T examples/example2.ts
ENTER SecurityService#hashPassword: { password: '<removed>' }
EXIT SecurityService#hashPassword: 'ba817ef716'
See example under examples/example2.ts
. Run it using npm run example2
.
- The wrapped function must have 0-4 arguments.
- You can always override the inferred type. For example, if you to skip strict validation of properties.
createContract('CalcService#add')
.params('foo')
.schema({
foo: V.object(),
})
.fn((foo: SomeExistingObject) => {
});
It's possible to extend the contract prototype and add custom metadata that can be used to mount the contract in 3rd party frameworks or library.
For example: you can create your own binding for an express app, graphql app, kafka events or cron jobs.
Example binding for Express
import { initialize, ContractBinding } from 'defensive';
import { V } from 'veni';
import { Request, Response, default as express, Handler } from 'express';
const { createContract } = initialize();
// Creating binding definition
// bindings.ts
ContractBinding.prototype.express = function(options) {
if (!this.fn.expressOptions) {
this.fn.expressOptions = [];
}
this.fn.expressOptions.push(options);
return this.fn as any;
};
interface ExpressOptions {
auth?: boolean;
method: 'get' | 'post' | 'put' | 'delete' | 'patch';
path: string;
handler(req: Request, res: Response): Promise<void>;
}
declare module '../src' {
interface ContractBinding<T> {
expressOptions: ExpressOptions[];
express(options: ExpressOptions): T & ContractBinding<T>;
}
}
// Create service
// UserService.ts
export const getUser = createContract('User#getUser')
.params('id')
.schema({
id: V.number(),
})
.fn(async id => {
return {
id,
username: 'name',
};
})
.express({
auth: true,
method: 'get',
path: '/users/me',
async handler(req, res) {
res.json(await getUser((req as any).user.id));
},
})
.express({
method: 'get',
path: '/users/:id',
async handler(req, res) {
res.json(await getUser(req.params.id));
},
});
// Main entry point
// app.ts
const app = express();
const authMiddleware = (req: Request, res: Response) => {
// check if user is logged in
};
getUser.expressOptions.forEach(options => {
const middleware: Handler[] = [
(req, res, next) => {
options.handler(req, res).catch(next);
},
];
if (options.auth) {
middleware.unshift(authMiddleware);
}
app[options.method](options.path, ...middleware);
});
import { initialize } from 'defensive';
interface Context {
foo: string;
}
const { createContract, getContext, runWithContext } = initialize<Context>();
const fn = createContract('myService#fn2')
.params()
.fn(async () => {
return new Promise(resolve =>
// here will be created a new scope in the event loop
setTimeout(() => {
const context = getContext();
resolve(context.foo);
}, 0)
);
});
runWithContext(
{
foo: 'bar',
},
async () => {
console.log(await fn()); // returns 'bar'
}
);
$ ts-node -T examples/example4.ts
ENTER myService#fn2: { }
EXIT myService#fn2: 'bar'
bar
See example under examples/example4.ts
. Run it using npm run example4
.
initialize
- initialize the library.
const { createContract, runWithContext, getContext, disable } = initialize({
// an array of fields to be removed when formatting input or output
removeFields: ['password', 'token', 'accessToken'],
// true if enable debugEnter and debugExit, it can be disabled in production
debug: true,
// the object depth when serializing nested object
depth: 4,
// the max array length to be serialized
maxArrayLength: 30,
// the function for handling ENTER event
// formattedInput is a serialized contract input
debugEnter: (signature, formattedInput) => {
console.log(`ENTER ${signature}:`, formattedInput);
},
// the function for handling EXI event
// formattedOutput is a serialized contract output
debugExit: (signature, formattedOutput) => {
console.log(`EXIT ${signature}:`, formattedOutput);
},
});
createContract
- create a new contract.
const add = createContract('CalcService#add') // the function signature
.params('a', 'b') // input parameter names
.schema({
a: V.number(), // validation schema for each defined param
b: V.number(), // names must match
})
.fn(async (a, b) => a + b) // the implementation
runWithContext
- run the given function with a given context.
const context = { user: { id: 1 } };
await runWithContext(context, async () => {
await add(1, 2);
})
getContext
- get current context or throw an error if not set. The parent function must callrunWithContext
.
const { createContract, getContext, runWithContext } = initialize<Context>();
const fn = createContract('myService#fn2')
.params()
.fn(async () => {
return new Promise(resolve =>
setTimeout(() => {
const context = getContext();
resolve(context.foo);
}, 0)
);
});
await runWithContext(
{
foo: 'bar',
},
async () => {
await fn(); // returns 'bar'
}
)
ContractError
if an error occurs, aContractError
will be thrown.
It contains the following properties:
original: Error
- the original error.entries: MethodEntry[]
- the call stack of all contracts entries. Each entry contains:signature: string
- the contract signature.input: string
- the serialized input.
- Why can't I just use express validator and write code directly in controllers?
Such an approach can work for small apps, but it can complicate things if the application is growing. It's a common scenario when you write the code in one place, and then you must reuse it in another place.
For example:
You create an endpoint /POST register
for user registration.
After some time, you must create a command line script that will register a default user.
You can't call the express router from the command line (you can try but it's a hacky solution), and you must either extract logic to common file (util or helper) or duplicate code. The application is much easier to understand if the business operations are organized in contracts/services instead of chaotic helper methods.
- Why do you recommend to keep bindings and services in a single file?
Most of the services are usually small, and there is 1:1 mapping between them and REST endpoints. It can be overwhelming for the developer when adding a new simple endpoint requires editing multiple files (controllers/services/route config).
- Why bindings are not provided by this library?
It's difficult to create a generic binding that will work well for all users. It's recommended to create a minimal binding that all only needed in your app.
- Is context stable?
Yes, it's based on a native nodejs feature.
MIT