A Promise-handling REST API
Phrapi is intended to be simple, composable, and deal exclusively with JSON-based REST.
The goal is not to build a Hapi-level framework with authentication and templating baked in. If you need those capabilities, use Hapi. If, instead, you just need something light, fast, and simple, maybe give Phrapi a try.
I've spent a lot of time working with frameworks like Hapi and Express and I really like both. I tend to prefer Hapi, but there are occasions where Express is the right answer.
I found that the way that I was building APIs began to follow a basic pattern that required me to reproduce the same code structures with predictable regularity. That pattern is:
- Routes define paths and invoke handlers
- Handlers contain the business logic of the API and invoke controllers to access data
- Controllers CRUD data
Within this pattern, I always made my controllers return promises so that they could be composed into the flows determined by the routes and executed by the handlers and I really liked it. It was easy to make big changes and still have a codebase that was easy to read for someone that is unfamiliar with the code.
What I felt like I was missing was the ability to have the handlers also deal in Promises. The structure of the handler function is typically determined by the route()
method of whatever framework you're using and generally follows the (request, response[, next]) => {}
signature. To compose a handler, you typically have to find a way to orchestrate the assignment of data to the request or response context. Once you've done that, to make it work with Promises, you've got to start all function calls by returning a new Promise()
.
// handlers.js
'use strict';
const handlers = {
ping(ctx, resolve, reject) {
resolve({ pong: new Date() });
},
foo(ctx, resolve, reject) {
let { params } = ctx;
resolve({ foo: 'URL {bar} param = ' + params.bar });
},
bar(ctx, resolve, reject) {
let { resolved } = ctx;
resolve({ bar: 'foo resolved = ' + resolved.foo });
}
};
export default handlers;
// routes.js
'use strict';
import { ping, foo, bar } from './handlers';
const routes = [{
method: 'get',
path: '/ping',
flow: [ ping ]
}, {
method: 'get',
path: '/foo/{bar}',
flow: [ foo, bar ]
}];
export default routes;
// server.js
'use strict';
import Phrapi from 'phrapi';
import routes from './routes';
const router = new Phrapi.Router({ routes });
const api = new Phrapi.Server({ router });
api.start(3000, (err) => {
if (err)
throw err;
console.log('listening:', api.info);
});
$ curl localhost:3000/ping
{"pong":"2016-02-25T17:12:46.504Z"}
$ curl localhost:3000/foo/baz
{"bar":"foo resolved = URL {bar} param = baz"}
The Phrapi.Server()
call will return an object with five properties:
start(port, callback)
stop()
route({ route })
test({ method, path[, payload] })
info
Server()
accepts an optional { router: new Phrapi.Router() }
argument making it possible to compose your routing externally to the server code.
Server.route()
is a convenience call to Router.route()
.
In an effort to make testing as easy as possible, the test()
method was added to the Server
. This will programmatically call server.start(0, () => {})
and send an actual http.request()
call to trigger the route.
'use strict';
import Phrapi from '../';
import routes from './routes';
const router = new Phrapi.Router({ routes });
const server = new Phrapi.Server({ router });
server.test({
method: 'get',
path: '/ping'
}).then(json => {
// assert on json
console.log('you got back:', json);
}).catch(err => {
console.log('There was an error!');
console.log(err.message);
process.exit(1);
});
server.test({
method: 'post',
path: '/foo',
payload: { blargh: 'honk' }
}).then(json => {
// assert on json
console.log('you got back:', json);
}).catch(err => {
console.log('There was an error!');
console.log(err.message);
process.exit(1);
});
The Phrapi.Router()
call returns a ... wait for it ... router object with three properties:
routes[]
route({Route})
find(method, path)
Router()
accepts an optional { routes: [{Route}], pathPrefix: '/v1' }
argument so that you can also compose routes externally and mount them on a specific prefix
const router = new Phrapi.Router({ pathPrefix: '/v1' });
router.route({
method: 'get',
path: '/foo',
flow: [(ctx, resolve, reject) => resolve({ foo: true }) ]
});
const server = new Phrapi.Server({ router });
server.start(3000, (err) => {
if (err)
throw err;
console.log('server started:', server.info);
});
$ curl localhost:3000/v1/foo
{"foo":true}
The flow
property on a Route is where you compose your handlers to build a response. The array is processed serially unless you group handler functions within an array.
const routes = [{
method: 'get',
path: '/foo',
// when foo resolves, bar is called
flow: [ foo, bar ]
}, {
method: 'get',
path: '/baz',
// when foo resolves, bar and baz are called with Promise.all
flow: [ foo, [ bar, baz ] ]
}];
A flow handler expects the signature (ctx, resolve, reject) => {}
.
resolve()
and reject()
are the standard ES6 Promise calls.
The ctx
argument is the context of the request/response that has been decorated with a few properties to make processing simple:
params
- An object with key/value pairs parsed from the URL params defined in the routequery
- An object with key/value pairs parsed from the query stringpayload
- The contents of any JSON sent with the requestresolved
- When a flow has multiple handlers, the results of any previously resolved handlers are made available in thectx.resolved
object. This makes it possible to quickly compose responses that require multiple database calls to construct the response JSON. Think of this as being very similar to thepre
option in Hapi route congifuration, except that it's built in by default.reply
- CAUTION Thereply
property gives you access to tinker with the response HTTP status code, headers, and payload. You should not mutate payload through this interface! To mutate the response payload, use theresolve({})
interface. If you need to change thecode
or addheaders
, however, this is the place to do that.
Whatever the final handler resolves is what becomes the response JSON.
request
- The raw HTTP requestresponse
- The raw HTTP response. While you 'can' makeresponse.end()
calls here, you 'should' rely on theresolve()
andreject()
process to ensure that your composed routes function as expected.
The Phrapi.Errors
object has methods for easily composing standard HTTP errors. It's very much inspired by Boom
.
const router = new Phrapi.Router({ pathPrefix: '/v1' });
router.route({
method: 'get',
path: '/error',
flow: [(ctx, resolve, reject) => reject(Phrapi.Errors.invalidRequest('go away!')) ]
});
const server = new Phrapi.Server({ router });
server.start(3000, (err) => {
if (err)
throw err;
console.log('server started:', server.info);
});
$ curl localhost:3000/v1/error
{"code":400,"message":"go away!"}
- 1.0.0 - Breaking change - Converted handler signature to
(ctx, resolve, reject)
wherectx = { request, response, reply, params, query, payload, resolved }
- 0.1.1 - Readme fix
- 0.1.0 - Added
Server.stop()
andServer.test()
- 0.0.2 - Added
reply
torequest
in Handler signature - 0.0.1 - Initial release
- Allow for custom 404