Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Hapi nested routing

Suppose I want to have REST endpoints which look roughly like this:

/projects/
/projects/project_id 

/projects/project_id/items/
/projects/project_id/items/item_id

CRUD on each if makes sense. For example, /projects POST creates a new project, GET fetches all projects. /projects/project_id GET fetches just that one project.

Items are project specific so I put them under project_id, which is a particular project.

Are there any way of creating this kind of nested routes?

Right now I have something like this:

  server.route({
    method: 'GET',
    path: '/projects',
    handler: getAllProjects
  });

  server.route({
    method: 'GET',
    path: '/projects/{project_id}',
    handler: getOneProject
  });

  server.route({
    method: 'GET',
    path: '/projects/{project_id}/items/{item_id}',
    handler: getOneItemForProject
  });

  server.route({
    method: 'GET',
    path: '/projects/{project_id}/items',
    handler: getAllItemsForProject
  })

But I am looking for a way to nest items routes into projects routes and for ability to pass project further.

Any recommendations?

like image 624
PoMaHTuK Avatar asked Mar 19 '15 11:03

PoMaHTuK


3 Answers

While there is no concept of "subrouting" (that I know of) in hapi itself, the basics are easy enough to implement.

First off, hapi offers wildcard variables in paths, using these you basically create a catch-all route for a given path. For example:

server.route({
  method: 'GET',
  path: '/projects/{project*}',
  handler: (request, reply) => {
    reply('in /projects, re-dispatch ' + request.params.project);
  }
});

There are some rules to these wildcard paths, the most important one being it can only be in the last segment, which makes sense if you think of it as a "catch-all".

In the example above, the {project*} parameter will be available as request.params.project and will contain the remainder of the called path, e.g. GET /projects/some/awesome/thing will set request.params.project to some/awesome/project.

The next step is to handle this "sub-path" (your actual question), which is mostly a matter of taste and how you would like to work. Your question seems to imply you don't want to create an endless repeating list of pretty much similar things but at the same time to be able to have very specific project routes.

One way would be to split up the request.params.project parameter into chunks and look for folders with matching names, which could contain logic to handle the request further.

Let's explore this concept by assuming a folder structure (relative to the file containing the route, e.g. index.js) which can be easily be used to include the handlers for the specific routes.

const fs = require('fs'); // require the built-in fs (filesystem) module

server.route({
    method: 'GET',
    path: '/projects/{project*}',
    handler: (request, reply) => {
        const segment = 'project' in request.params ? request.params.project.split('/') : [];
        const name = segment.length ? segment.shift() : null;

        if (!name) {
            //  given the samples in the question, this should provide a list of all projects,
            //  which would be easily be done with fs.readdir or glob.
            return reply('getAllProjects');
        }

        let projectHandler = [__dirname, 'projects', name, 'index.js'].join('/');

        fs.stat(projectHandler, (error, stat) => {
            if (error) {
                return reply('Not found').code(404);
            }

            if (!stat.isFile()) {
                return reply(projectHandler + ' is not a file..').code(500);
            }

            const module = require(projectHandler);

             module(segment, request, reply);
        });
    }
});

A mechanism like this would allow you have each project as a node module in your application and have your code figure out the appropriate module to use to handle the path at runtime.

You don't even have to specify this for every request method, as you can simply have routes handle multiple methods by using method: ['GET', 'POST', 'PUT', 'DELETE'] instead of method: 'GET'.

It does not, however, entirely deal with the repetitive declaration of how to handle routes, as you will need a rather similar module setup for every project.

In the way the example above includes and invokes "sub-route-handlers", a sample implementation would be:

//  <app>/projects/<projectname>/index.js
module.exports = (segments, request, reply) => {
    //  segments contains the remainder of the called project path
    //  e.g. /projects/some/awesome/project
    //       would become ['some', 'awesome', 'project'] inside the hapi route itself
    //       which in turn removes the first part (the project: 'some'), which is were we are now
    //       <app>/projects/some/index.js
    //       leaving the remainder to be ['awesome', 'project']
    //  request and reply are the very same ones the hapi route has received

    const action = segments.length ? segments.shift() : null;
    const item   = segments.length ? segments.shift() : null;

    //  if an action was specified, handle it.
    if (action) {
        //  if an item was specified, handle it.
        if (item) {
            return reply('getOneItemForProject:' + item);
        }

        //  if action is 'items', the reply will become: getAllItemsForProject
        //  given the example, the reply becomes: getAllAwesomeForProject
        return reply('getAll' + action[0].toUpperCase() + action.substring(1) + 'ForProject');
    }

    //  no specific action, so reply with the entire project
    reply('getOneProject');
};

I think this illustrates how individual projects can be handled within you application at runtime, though it does raise a couple of concerns you will want to deal with when building your application architecture:

  • if the project handling module really are very similar, you should create a library which you use to prevent copying the same module over and over again, as that makes maintenance easier (which, I recon, was the ultimate goal of having sub-routing)
  • if you can figure out which modules to use at runtime, you should also be able to figure this out when the server process starts.

Creating a library to prevent repetitive code is something you should do (learn to do) early on, as this make maintenance easier and your future self will be thankful.

Figuring out which modules will be available to handle the various projects you have at the start of the application will save every request from having to apply the same logic over and over again. Hapi may be able to cache this for you, in which case it doesn't really matter, but if caching is not an option you might be better off using less dynamic paths (which - I believe - is the primary reason this is not offered by hapi by default).

You can traverse the projects folder looking for all <project>/index.js at the start of the application and register a more specific route using glob like this:

const glob = require('glob');

glob('projects/*', (error, projects) => {
    projects.forEach((project) => {
        const name = project.replace('projects/', '');
        const module = require(project);

        server.route({
            method: 'GET',
            path: '/projects/' + name + '/{remainder*}',
            handler: (request, reply) => {
                const segment = 'remainder' in request.params ? request.params.remainder.split('/') : [];

                module(segment, request, reply);
            }
        });
    });
});

This effectively replaces the above logic of looking up module on every request and switch to a (slightly) more efficient routing as you are talling hapi exactly which projects you'll be serving while still leaving the actual handling to every project-module you provide. (Don't forget to implement the /projects route, as this now needs to be done explicitly)

like image 199
Rogier Spieker Avatar answered Oct 31 '22 15:10

Rogier Spieker


What you're looking for is something similar to Express's Router. In fact, Express does a good job of burying the usefulness of this feature so I'll re-post an example here:

// routes/users.js:
// Note we are not specifying the '/users' portion of the path here...

const router = express.Router();

// index route
router.get('/', (req, res) => {... });

// item route
router.get('/:id', (req, res) => { ... });

// create route
router.post('/', (req,res) => { ... });

// update route
router.put('/:id', (req,res) => { ... });

// Note also you should be using router.param to consolidate lookup logic:
router.param('id', (req, res, next) => {
  const id = req.params.id;
  User.findById(id).then( user => {
    if ( ! user ) return next(Boom.notFound(`User [${id}] does not exist`));
    req.user = user;
    next();
  }).catch(next);
});

module.exports = router;

Then in your app.js or main routes/index.js where you assemble your routes:

const userRoutes = require('./routes/users')

// now we say to mount those routes at /users!  Yay DRY!
server.use('/users', userRoutes)

I'm actually disappointed to find this SO post with no other responses so I'll assume there's nothing out of the box (or even a third-party module!) to achieve this. I imagine it might not be too difficult to create a simple module that uses functional composition to remove duplication. Since each of those hapi route defs is just an object it seems like you could make a similar wrapper like the following (untested):

function mountRoutes(pathPrefix, server, routes) {
  // for the sake of argument assume routes is an array and each item is 
  // what you'd normally pass to hapi's `server.route
  routes.forEach( route => {
    const path = `${pathPrefix}{route.path}`;
    server.route(Object.assign(routes, {path}));
  });
}

EDIT In your case since you have multiple layers of nesting, a feature similar to Express's router.param would also be extremely helpful. I am not terribly familiar with hapi so I don't know if it already has this capability.

EDIT #2 To more directly answer the original question, here is a hapi-route-builder has a setRootPath() method that lets you achieve something very similar by letting you specify the base portion of the path once.

like image 22
thom_nic Avatar answered Oct 31 '22 14:10

thom_nic


There isn't great information about such a basic requirement. Currently, I am doing the following & it works well.

Step 1: Contain the routes in a plugin, as so:

// server.js
const server = Hapi.server({ ... })
await server.register(require('./routes/projects'), { routes: { prefix: '/projects' } })

Step 2: Register an ext within the scope of that plugin.

// routes/projects/index.js
module.exports = {

    name: 'projects',

    async register(server) {

        server.route({
            method: 'get',
            path: '/', // note: you don't need to prefix with `projects`
            async handler(request, h) {
                return [ ... ]
            }
        })

        server.route({
            method: 'get',
            path: '/{projectId}', // note: you don't need to prefix with `projects`
            async handler(request, h) {
                return { ... }
            }
        })

        server.ext({
            // https://hapijs.com/api#request-lifecycle
            type: 'onPostAuth',
            options: {
                // `sandbox: plugin` will scope this ext to this plugin
                sandbox: 'plugin'
            },
            async method (request, h) {
                // here you can do things as `pre` steps to all routes, for example:
                // verify that the project `id` exists
                if(request.params.projectId) {
                    const project = await getProjectById(request.params.projectId)
                    if(!project) {
                        throw Boom.notFound()
                    }
                    // Now request.params.project can be available to all sub routes
                    request.params.project = project
                }
                return h.continue
            }
        })

    }

}

This has been as close to I have been able to get to recreating Express Router functionality.

like image 38
Roi Avatar answered Oct 31 '22 14:10

Roi