bleupen / halacious

a better HAL processor for Hapi
MIT License
108 stars 22 forks source link

halacious

a better HAL processor for Hapi

Overview

Halacious is a plugin for the HapiJS web application server that makes HATEOASIFYING your app ridiculously easy. When paired with a well-aged HAL client-side library, you will feel the warmth of loose API coupling and the feeling of moral superiorty as you rid your SPA of hard-coded api links.

Halacious removes the boilerplate standing between you and a Restful application, allowing you to focus on your app's secret sauce. Halacious embraces Hapi's configuration-centric approach to application scaffolding. Most common tasks can be accomplished without writing any code at all.

Features

Getting Started

Start by npm installing the halacious library into your hapi project folder:

npm install halacious --save

Register the plugin with the app server

var hapi = require('hapi');
var halacious = require('halacious');

var server = new hapi.Server();
server.connection({ port: 8080 });
server.register(halacious, function(err){
    if (err) console.log(err);
});

server.route({
    method: 'get',
    path: '/hello/{name}',
    handler: function(req, reply) {
        reply({ message: 'Hello, '+req.params.name });
    }
});

server.start(function(err){
    if (err) return console.log(err);
    console.log('Server started at %s', server.info.uri);
});

Launch the server:

node ./examples/hello-world

Make a request

curl -H 'Accept: application/hal+json' http://localhost:8080/hello/world

See the response

{
    "_links": {
        "self": {
            "href": "/hello/world"
        }
    },
    "message": "Hello, world"
}

Linking

Links may be declared directly within the route config.

server.route({
    method: 'get',
    path: '/users/{userId}',
    config: {
        handler: function (req, reply) {
            // look up user
            reply({ id: req.params.userId, name: 'User ' + req.params.userId, googlePlusId: '107835557095464780852' });
        },
        plugins: {
            hal: {
                links: {
                    'home': 'http://plus.google.com/{googlePlusId}'
                },
                ignore: 'googlePlusId' // remove the id property from the response
            }
        }
    }
});
curl -H 'Accept: application/hal+json' http://localhost:8080/users/100

will produce:

{
    "_links": {
        "self": {
            "href": "/users/1234"
        },
        "home": {
            "href": "http://plus.google.com/107835557095464780852"
        }
    },
    "id": "100",
    "name": "User 1234"
}

Embedding

HAL allows you to conserve bandwidth by optionally embedding link payloads in the original request. Halacious will automatically convert nested objects into embedded HAL representations (if you ask nicely).

server.route({
    method: 'get',
    path: '/users/{userId}',
    config: {
        handler: function (req, reply) {
            // look up user
            reply({
                id: req.params.userId,
                name: 'User ' + req.params.userId,
                boss: {
                    id: 1234,
                    name: 'Boss Man'
                }
            });
        },
        plugins: {
            hal: {
                embedded: {
                    'boss': {
                        path: 'boss', // the property name of the object to embed
                        href: '../{item.id}'
                    }
                }
            }
        }
    }
});
curl -H 'Accept: application/hal+json' http://localhost:8080/users/100

{
    "_links": {
        "self": {
            "href": "/users/100"
        }
    },
    "id": "100",
    "name": "User 100",
    "_embedded": {
        "boss": {
            "_links": {
                "self": {
                    "href": "/users/1234"
                }
            },
            "id": 1234,
            "name": "Boss Man"
        }
    }
}

Programmatic configuration of HAL representations

You may find the need to take the wheel on occasion and directly configure outbound representions. For example, some links may be conditional on potentially asynchronous criteria. Fortunately, Halacious provides two ways to do this:

  1. By providing a prepare() function on the route's hal descriptor (or by assigning the function directly to the hal property)
  2. By implementing a toHal() method directly on a wrapped entity.

In either case, the method signature is the same: fn(rep, callback) where

Example 1: A prepare() function declared in the route descriptor.

server.route({
    method: 'get',
    path: '/users',
    config: {
        handler: function (req, reply) {
            // look up user
            reply({
                start: 0,
                count: 2,
                limit: 2,
                items: [
                    { id: 100, firstName: 'Brad', lastName: 'Leupen', googlePlusId: '107835557095464780852'},
                    { id: 101, firstName: 'Mark', lastName: 'Zuckerberg'}
                ]
            });
        },
        plugins: {
            hal: {
                // you can also assign this function directly to the hal property above as a shortcut
                prepare: function (rep, next) {
                    rep.entity.items.forEach(function (item) {
                        var embed = rep.embed('item', './' + item.id, item);
                        if (item.googlePlusId) {
                            embed.link('home', 'http://plus.google.com/' + item.googlePlusId);
                            embed.ignore('googlePlusId');
                        }
                    });
                    rep.ignore('items');
                    // dont forget to call next!
                    next();
                }
            }
        }
    }
});
curl -H 'Accept: application/hal+json' http://localhost:8080/users

{
    "_links": {
        "self": {
            "href": "/users"
        }
    },
    "start": 0,
    "count": 2,
    "limit": 2,
    "_embedded": {
        "item": [
            {
                "_links": {
                    "self": {
                        "href": "/users/100"
                    },
                    "home": {
                        "href": "http://plus.google.com/107835557095464780852"
                    }
                },
                "id": 100,
                "firstName": "Brad",
                "lastName": "Leupen"
            },
            {
                "_links": {
                    "self": {
                        "href": "/users/101"
                    }
                },
                "id": 101,
                "firstName": "Mark",
                "lastName": "Zuckerberg"
            }
        ]
    }
}

Example 2: Implementing toHal() on a domain entity:

function User(id, firstName, lastName, googlePlusId) {
    this.id = id;
    this.firstName = firstName;
    this.lastName = lastName;
    this.googlePlusId = googlePlusId;
}

User.prototype.toHal = function(rep, next) {
    if (this.googlePlusId) {
        rep.link('home', 'http://plus.google.com/' + this.googlePlusId);
        rep.ignore('googlePlusId');
    }
    next();
};

server.route({
    method: 'get',
    path: '/users',
    config: {
        handler: function (req, reply) {
            // look up user
            reply({
                start: 0,
                count: 2,
                limit: 2,
                items: [
                    new User(100, 'Brad', 'Leupen', '107835557095464780852'),
                    new User(101, 'Mark', 'Zuckerberg')
                ]
            });
        },
        plugins: {
            hal: {
                embedded: {
                    item: {
                        path: 'items',
                        href: './{item.id}'
                    }
                }
            }
        }
    }
});

The HAL route configuration object

The config.plugins.hal route configuration object takes the following format:

Namespaces and Rels

So far, we have not done a real good job in our examples defining our link relations. Unless registered with the IANA, link relations should really be unique URLs that resolve to documentation regarding their semantics. Halacious will happily let you be lazy but its much better if we do things the Right Way.

Manually creating a namespace

Halacious exposes its api to your Hapi server so that you may configure it at runtime like so:

 var server = new hapi.Server();
 server.connection({ port: 8080 });
 var halacious = require('halacious');
 server.register(halacious, function(err){
     if (err) return console.log(err);
     var ns = server.plugins.halacious.namespaces.add({ name: 'mycompany', description: 'My Companys namespace', prefix: 'mco'});
     ns.rel({ name: 'users', description: 'a collection of users' });
     ns.rel({ name: 'user', description: 'a single user' });
     ns.rel({ name: 'boss', description: 'a users boss' });
 });

 server.route({
     method: 'get',
     path: '/users/{userId}',
     config: {
         handler: function (req, reply) {
             // look up user
             reply({ id: req.params.userId, name: 'User ' + req.params.userId, bossId: 200 });
         },
         plugins: {
             hal: {
                 links: {
                     'mco:boss': '../{bossId}'
                 },
                 ignore: 'bossId'
             }
         }
     }
 });

Now, when we access the server we see a new type of link in the _links collection, curies. The curies link provides a mechanism to use shorthand rel names while preserving their uniqueness. Without the curie, the 'mco:boss' rel key would be expanded to read /rels/mycompany/boss

 curl -H 'Accept: application/hal+json' http://localhost:8080/users/100

 {
     "_links": {
         "self": {
             "href": "/users/100"
         },
         "curies": [
             {
                 "name": "mco",
                 "href": "/rels/mycompany/{rel}",
                 "templated": true
             }
         ],
         "mco:boss": {
             "href": "/users/200"
         }
     },
     "id": "100",
     "name": "User 100"
 }

Creating a namespace from a folder of documentated rels

In our examples folder, we have created a folder rels/mycompany containing markdown documents for all of the rels in our company's namespace. We can suck all these into the system in one fell swoop:

var server = new hapi.Server();
server.connection({ port: 8080 });
var halacious = require('halacious');
server.register(halacious, function(err){
    if (err) return console.log(err);
    server.plugins.halacious.namespaces.add({ dir: __dirname + '/rels/mycompany', prefix: 'mco' });
});

Ideally these documents should provide your api consumer enough semantic information to navigate your api.

Rels documentation

Halacious includes an (extremely) barebones namespace / rel navigator for users to browse your documentation. The server binds to the /rels path on your server by default.

_Note: Hapi 9 / 10 users must install and configure the vision views plugin to enable this feature.

Automatic /api root

Discoverability is a key tenant of any hypermedia system. HAL requires that only the root API url be known to clients of your application, from which all other urls may be derived via rel names. If you want, Halacious will create this root api route for you automatically. All you need to do is to identify which resources to include by using the api route configuration option. For example:

server.register(halacious, function(err){
    if (err) return console.log(err);
    var ns = server.plugins.halacious.namespaces.add({ name: 'mycompany', description: 'My Companys namespace', prefix: 'mco'});
    ns.rel({ name: 'users', description: 'a collection of users' });
    ns.rel({ name: 'user', description: 'a single user' });
});

server.route({
    method: 'get',
    path: '/users',
    config: {
        handler: function (req, reply) {
            // look up user
            reply({});
        },
        plugins: {
            hal: {
                api: 'mco:users'
            }
        }
    }
});

server.route({
    method: 'get',
    path: '/users/{userId}',
    config: {
        handler: function (req, reply) {
            // look up user
            reply({});
        },
        plugins: {
            hal: {
                api: 'mco:user'
            }
        }
    }
});

will auto-create the following api root:

curl -H 'Accept: application/hal+json' http://localhost:8080/api/

{
    "_links": {
        "self": {
            "href": "/api/"
        },
        "curies": [
            {
                "name": "mco",
                "href": "/rels/mycompany/{rel}",
                "templated": true
            }
        ],
        "mco:users": {
            "href": "/users"
        },
        "mco:user": {
            "href": "/users/{userId}",
            "templated": true
        }
    }
}

Plugin Options