Skip to content

sinclairzx81/servicebox

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

36 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ServiceBox

Typed Web Services for NodeJS

npm version GitHub CI

import { Service, Method, Type } from '@sinclair/servicebox'

// -------------------------------------
// Service
// -------------------------------------

const service = new Service({ 
    'add': new Method([], {
        request: Type.Tuple([
            Type.Number(), 
            Type.Number()
        ]),
        response: Type.Number()
    }, (context, [a, b]) => {
        return a + b
    })
})

// -------------------------------------
// Test
// -------------------------------------

const result = await service.execute('add', {}, [1, 2])

assert(result, 3)

// -------------------------------------
// Host
// -------------------------------------

const app = express()

app.post('/api', (req, res) => service.request(req, res))

app.listen(5000)

// -------------------------------------
// Call
// -------------------------------------

const results = await post('http://localhost:5000/api', [
    { jsonrpc: '2.0', method: 'add', params: [10, 20] },
    { jsonrpc: '2.0', method: 'add', params: [20, 30] },
    { jsonrpc: '2.0', method: 'add', params: [30, 40] }
])

// results = [
//   { jsonrpc: '2.0', id: null, result: 30 },
//   { jsonrpc: '2.0', id: null, result: 50 },
//   { jsonrpc: '2.0', id: null, result: 70 },
// ]

Overview

ServiceBox is a library for building type safe Web Services in NodeJS. It offers a set of web service types that are used to compose methods whose requests are runtime checked with JSON Schema and statically checked with TypeScript. ServiceBox is designed to allow for documentation and validation logic to be derived from a runtime type system based on JSON schema. This library can be used independently or integrated into existing applications via middleware.

Built with TypeScript 4.2 and Node 14 LTS.

License MIT

Install

$ npm install @sinclair/servicebox

Contents

Methods

ServiceBox methods are created using the following parameters. See sections below for more details.

const method = new Method([...middleware], contract, body)

Middleware

ServiceBox middleware are implementations of Middleware<Context> that map IncomingMessage requests to context objects that are passed to the methods context argument. Middleware functions can be used for reading header information from an incoming request and preparing a valid context for the method to execute on. Middleware can also be used to reject a request (for example failed Authorization checks). For more information on rejecting requests see the Exceptions section.

Methods can apply multiple middleware which will be merged into the methods context argument. If a middleware should not return a context, the middleware should return null.

import { IncomingMessage } from 'http'

export class Foo {
    map(request: IncomingMessage) { 
        return { foo: 'foo' }
    }
}

export class Bar {
    map(request: IncomingMessage) { 
        return { bar: 'bar' }
    }
}
export class Baz {
    map(request: IncomingMessage) { 
        return null // no context
    }
}

const method = new Method([
    new Foo(), 
    new Bar(), 
    new Baz()
] {
    request: Type.Any(),
    response: Type.Any()    
}, ({ foo, bar }, request) => {
    //
    // ^ from middleware
    //
})

Contracts

Contracts describe the Request and Response signature for a method and are represented internally as JSON Schema. ServiceBox is able to resolve the appropriate TypeScript static types for the request and response using type inference. For more information on the Type object refer to the TypeBox project.

import { Method, Type } from '@sinclair/servicebox'
 
// type Add = (request: [number, number]) => number

const add = new Method([], {
    request:  Type.Tuple([ 
        Type.Number(), 
        Type.Number() 
    ]),
    response: Type.Number()
}, (context, [a, b]) => {
    //
    // [a, b] = [number, number]
    //
    return a + b // number
})

Exceptions

By default, errors that are thrown inside a method or middleware will cause the method to respond with a non-descriptive error message. It is possible to override this and return application specific error codes and messages by throwing instances of type Exception. The example below creates a NotImplementedException by extending the type Exception.

import { Method, Type, Exception } from '@sinclair/servicebox'


export class NotImplementedException extends Exception {
    constructor() {
        super(4000, "Method not implemented")
    }
}

const add = new Method([], {
    request: Type.Tuple([ 
        Type.Number(), 
        Type.Number() 
    ]),
    response: Type.Number()
}, (context, request) => {
    throw new NotImplementedException()
})

// Which results in the following error.
//
// { 
//    error: { 
//      code: 4000, 
//      message: 'Method not implemented', 
//      data: {}
//    } 
// }

Services

Services are containers for methods. Services handle method routing logic as well as invoking calls on the requested method. The following example creates a service on the /api route using express.

import { Service, Method, Type } from '@sinclair/servicebox'

const service = new Service({
    'add': new Method([], {
        request: Type.Tuple([
            Type.Number(), 
            Type.Number()
        ]),
        response: Type.Number()
    }, (context, [a, b]) => {
        return a + b
    })
})


// ------------------------------------------
// Bind the service to the /api route.
// ------------------------------------------

const app = express()

app.use('/api', (req, res) => {
    service.request(req, res))
})

app.listen(5000)

Testing

You can run any method created on the service using the services execute() function. Calls to execute() will bypass HTTP and invoke the method directly. As such the context object should match that of any middleware used by the method. As follows.

export class Foo {
    map() {
        return { foo: 1 }
    }
}

const service = new Service({
    'add': new Method([new Foo()], {
        request: Type.Tuple([
            Type.Number(), 
            Type.Number()
        ]),
        response: Type.Number()
    }, (context, [a, b]) => {
        return a + b
    })
})

// -----------------------------------------
// Tests
// -----------------------------------------

const response = await service.execute('add', { 
    foo: 1 
}, [1, 2])

assert(response, 3)

Protocol

ServiceBox implements the JSON-RPC 2.0 specification. Requests to invoke a method must be sent via HTTP POST passing the { 'Content-Type': 'application/json' } header and request payload. ServiceBox accepts requests as batched JSON-RPC requests.

See the example/client.ts class that provides a basic implementation for a client.

// -------------------------------------
// Service
// -------------------------------------

const service = new Service({
    "add": new Method([], {
        request: Type.Tuple([
            Type.Number(),
            Type.Number()
        ]),
        response: Type.Number()
    }, (context, [a, b] => a + b))
})

// ------------------------------------
// Request
// ------------------------------------

const result = await post(endpoint, [
    { jsonrpc: '2.0', method: 'add', params: [10, 20] },
    { jsonrpc: '2.0', method: 'add', params: [20, 30] },
    { jsonrpc: '2.0', method: 'add', params: [30, 40] }
])

// result = [
//   { jsonrpc: '2.0', id: null, result: 30 },
//   { jsonrpc: '2.0', id: null, result: 50 },
//   { jsonrpc: '2.0', id: null, result: 70 },
// ]

Metadata

Metadata for a service can be obtained in two ways. The first is inspecting the service.metadata property. The other is making a HTTP GET request to the services HTTP endpoint. The following inspects the metadata using service.metadata.

Note: ServiceBox will respond with metadata for the service if it receives a HTTP GET request. To disable this, ensure that the service.request(req, res) is called only for HTTP POST requests.

import { Service, Method, Type } from '@sinclair/servicebox'

const service = new Service({
    'add': new Method([], {
        request: Type.Tuple([
            Type.Number(),
            Type.Number()
        ]),
        response: Type.Number()
    }, (context, [a, b]) => {
        return a + b
    })
})

console.log(service.metadata)

// service.metadata = {
//     "add": {
//         "request": {
//             "type": "array",
//             "items": [
//                 { "type": "number" },
//                 { "type": "number" }
//             ],
//             "additionalItems": false,
//             "minItems": 2,
//             "maxItems": 2
//         },
//         "response": {
//             "type": "number"
//         }
//     }
// }

Releases

No releases published

Packages

No packages published