20

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?

laser
  • 1,388
  • 13
  • 14
PoMaHTuK
  • 223
  • 2
  • 7
  • I don't really understand what your question is. Your wanted nested routes, and you've supplied the working code above. So where are you stuck? – Matt Harrison Mar 19 '15 at 12:45
  • 1
    I am wondering if there any other, more convenient way, because in this approach routing for third level will become a huge mess. `server.route({ method: 'GET', path: '/projects/{project_id}/items/{item_id}/results/{result_id}', handler: getAllItemsForProject })` So a im looking for solution, similar to this one: [link](http://stackoverflow.com/questions/25260818/rest-with-express-js-nested-router) – PoMaHTuK Mar 19 '15 at 14:44
  • I think the problem with nesting is that Hapi doesn't really has a way of having multiple handlers per route definition. You still have to explicitly declare each route, and as of right now, Hapi doesn't really has a way of declaring child routes. – Osukaa Mar 23 '15 at 23:13
  • @PoMaHTuK can you post an example of how you would solve this in another framework or some code for how you would see the API working for this if it did exist in hapi? – Matt Harrison Mar 27 '15 at 06:17
  • @PoMaHTuK I know this isn't the answer your looking for, but I would highly advice you to flatten the API. True each Project has its Items but as the API becomes more complex this will be very messy, lets say that each item has Watchers. Now your API endpoints will be '/projects/{project_id}/items/{item_id}/watchers' But each watcher has his Projects... When will this stop ? What endpoint will you use to Fetch all Items being watched by a specific User ? How will you share the Item fetching logic between different endpoints ? Flat API using GET params to filter data will be more useful. – Yoni Jah Mar 31 '15 at 06:40
  • if your preoccupation is about rewrite the code for get the project into all handlers you can isolate this piece of code into [pre route options](http://hapijs.com/api#route-options). – Bruno Peres Dec 06 '16 at 19:49

3 Answers3

7

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)

Rogier Spieker
  • 4,087
  • 2
  • 22
  • 25
  • this gets unwieldy very quickly. highly recommend not using this approach, as you lose all the features of hapi's payloads/params/etc. – Roi Jul 19 '19 at 16:25
4

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.

thom_nic
  • 7,809
  • 6
  • 42
  • 43
  • 4
    Yes, the question requests a solution similar to Express nested routes... but for **Hapi** framework! It isn't constructive to suggest the answer for framework B if the question applies to framework A - unless it also explains how A and B may be used together. – Estus Flask Dec 04 '16 at 03:15
  • Fair criticism. I posted this in response to @Matt Harrison's comment of "how would you do this in another framework." I guess this clearly explains how it is done in Express for comparison. Express and Hapi can certainly be used side-by-side (or proxy Hapi routes to an Express server) but I'm not aware of a way to directly use Express routes in a Hapi server. – thom_nic Dec 05 '16 at 18:33
  • 1
    I see. I don't think that it is possible to integrate Hapi and Express without a lot of extra work, it seems there were some initiatives to provide adapters for Hapi but they stopped long time ago. Thanks for on-topic `hapi-route-builder` reference, it is quite helpful. – Estus Flask Dec 09 '16 at 11:06
3

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.

Roi
  • 1,597
  • 16
  • 19
  • Didn't know such option `sandbox: 'plugin'` exists, been looking for something like this. Thanks for sharing. – Rico Chen Jul 21 '19 at 17:26