defensive
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.
About
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.
Features
- 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).
Getting Started
npm install defensive
yarn add defensive
Example usage
// contract.ts; // services/CalcService.ts;;; .params'a', 'b' .schema .fna + b; ;
$ 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
.
Removing security information
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;; .params'password' .schema .fn'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
.
Notes
- 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 .fn;
Creating bindings
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
;;; ; // Creating binding definition// bindings.ts ContractBinding.prototype.express = ; declare // Create service// UserService.ts .params'id' .schema .fn .express .express; // Main entry point// app.ts ; ; getUser.expressOptions.forEach;
Using Context
; ; .params .fn;runWithContext , ;
$ 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
.
API
initialize
- initialize the library.
;
createContract
- create a new contract.
.params'a', 'b' // input parameter names .schema .fna + b // the implementation
runWithContext
- run the given function with a given context.
;await runWithContextcontext,
getContext
- get current context or throw an error if not set. The parent function must callrunWithContext
.
; .params .fn;await runWithContext ,
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.
FAQ
- 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.
License
MIT