Using higher-order functions to generate route handlers

Use some vanilla JS techniques to programmatically generate powerful Mirage route handlers

Transcript

Mirage provides some out-of-the-box route handlers called "shorthands" that should be used whenever possible, but eventually you'll need to write custom route handlers for your app. In this video I want to show you how to extract your route handlers into separate functions, and in particular how higher-order functions can be used to clean up your Mirage config.

I've started building out the Media page of this application. Here we see all of our albums, we can click on an album to see all of its pictures, and we can click on a picture for a bigger version. I can go back, go to the other album and browse it as well.

Now, all this data is coming from Mirage, and it seems to be working well. But if I come to the "Four-legged creatures" album and hit refresh – well now it's showing the wrong data. What's going on here?

Well, if we look at the route, there's some existing logic here that's querying albums and filtering by slug, and it expects the first result in the response to be the correct album. But if we look at this request in our app, but the response is 2 albums. So Mirage isn't honoring this filter, it's just returning all the data.

If we open Mirage's config, we can see that right now we're just using the get shorthand for albums, and this shorthand doesn't know anything about filtering. So let's instead drop down and write our own function that respects the filter query param.

Route handlers have two args: the schema ORM object, and the request that triggered this route handler.

this.get('albums', (schema, request) => {
});

Now normally, the get handler should just respond with with the full collection

return schema.albums.all();

Let's save this and see it working just like before. Yep, everything seems the same as before. So our destructured version is the same as the get shorthand.

Ok, let's go back to our code and keep going. We get get the slug from request.queryParams, and let's define a new variable. And if we have a slug we'll use where to find albums with a matching slug (which should just return a collection with a single model). Otherwise, we'll set albums to all of our data. And then we'll return it.

this.get('albums', (schema, request) => {
  let albums;
  let slug = request.queryParams['filter[slug]'];

  if (slug) {
    albums = schema.albums.where({ slug });
  } else {
    albums = schema.albums.all();
  }

  return albums;
});

Let's save this and check it out. Awesome! Now when we start out on four-legged creatures we see the response only returns 1 album. And if we go to City living, we also see it working. And the original request still works, if we refresh on this albums route.

Now we can actually refactor this handler a bit. .where on a collection will return the entire thing if there's no keys in this object, so let's make a filters object, and then if we have a slug we can add it to our filters, and then we can just return albums that match our filters object.

this.get('albums', (schema, request) => {
  let filters = {};
  let slug = request.queryParams['filter[slug]'];

  if (slug) {
    filters.slug = slug;
  }

  return schema.albums.where(filters);
});

Let's save and try this out. Great! Everything still works.

Alright, now we need to do the same thing for images. If we click on this cat and refresh, we'll see the same bug happen as before - the wrong picture shows up, and if we look at the console here, the response is trying to filter by slug but Mirage is just responding with all the images.

So let's just come back to our config file, copy this, change albums to images, and try it out!

this.get('images', (schema, request) => {
  let filters = {};
  let slug = request.queryParams['filter[slug]'];

  if (slug) {
    filters.slug = slug;
  }

  return schema.images.where(filters);
});

Now we see the right picture, and if we look at the console we see only one record being returned from Mirage. Great!

Ok, let's come back to our config. Now, what we have here works - but this is a lot of code for two route handlers! In a real app you'll probably have way more routes, and writing handlers this verbose could lead to a config file that's hard to maintain and understand. So, let's work to refactor this code a little bit.

First, let's come up here to the albums route handler and extract this into an external named function. We'll grab this function, cut it, paste it up here, and make this a named function called handler. And then we'll pass this in where we had it inlined before.

function handler(schema, request) {
  let filters = {};
  let slug = request.queryParams['filter[slug]'];

  if (slug) {
    filters.slug = slug;
  }

  return schema.albums.where(filters);
}

this.get('albums', sluggable);

Let's save this and try it out. Now if we go to an album, refresh, we see one, and if we go to the other, we see the right one. So this is working well.

So to explain this, as long as this second argument is function that takes schema and request, Mirage will invoke it and generate a response. You're probably used to seeing these inline like this, but what we've done here is just pass in this external function and it all works just the same. So this is how we can extract route handlers into external functions. And let's go ahead and give this a better name. We'll call it sluggable, since that's what it's doing - filtering by slug.

Now, what we want is to be able to reuse our sluggable handler for our images route. But our images route handler has one difference - at the end it accesses the images collection instead of the albums collection.

Wouldn't it be nice if we could customize sluggable? What we want is to be able to pass in the resource name here, so this one would return albums, and this one would return images.

this.get('albums', sluggable('albums'));
this.get('images', sluggable('images'));

Let's come up here and do that. What we need to do is makesluggable an outer function that takes in this resource name, and then returns the inner function, which is the route handler. And the route handler will do exactly what it did before, but down here we'll make it use resourceName instead of this hard-coded albums.

function sluggable(resourceName) {
  return function(schema, request) {
    let filters = {};
    let slug = request.queryParams['filter[slug]'];

    if (slug) {
      filters.slug = slug;
    }

    return schema[resourceName].where(filters);
  }
}

And now we should be able to use this and this, and delete this old handler, save this, and let's give it a try! Great, everything seems to be working!

So coming back to the code, what we've done is make sluggable a higher-order function, which is a special name for a function that returns another function. You can think of it as a way to generate customized versions of your functions.

So when we invoke sluggable right here, it uses the albums param to return the inner function, which is a route handler that references the albums resource. And that route handler gets registered with Mirage, and is executed at request time with the correct schema and request variables. So, hopefully you can see how helpful this pattern is for pragmatically generating these custom route handlers.

Now, let's push this idea a little farther. I have another part of the app that's hidden right now, so I'll come here and uncomment this. And now we can see that we can view albums, or we can view styles. And on this styles page, we should be able to view light photos or dark photos. But right now, both styles are returning all the photos - there's a mix of light and dark photos on both styles, so it's not working. And if we look in the console, the request is trying to filter by style but in both cases, all 14 images are being returned. So it's the same problem as before - Mirage is not respecting this style filter.

If we come back to our Mirage config, the easiest way to update this might be to come here, and add style to our filters object. But that would make our albums route filterable by style too. And after 15 of these that might get ugly.

What we really want to be able to do is at a resource level, say which resources are filterable by which attributes. So we could come down here and say, this is a filterable albums resource, and it is filterable by slug; and this is a filterable images resource, and it's filterable by slug and style.

this.get('albums', filterable('albums', [ 'slug' ]));
this.get('images', filterable('images', [ 'slug', 'style' ]));

So this would be a nice minimal, declarative API, that makes it easy to understand what's going on. Let's implement this.

W'ell come up here and rename sluggable to filterable, and now filterable takes two params, the resourceName and also an array of attrs. And here we can write attrs, and then we'll write a reducing function to turn this into our filters. Reduce will build up a hash with each attr, we'll start it out as an empty object. And then for each attr, we'll get the query param, and if it exists, add it to the hash and then return the hash.

function filterable(resourceName, attrs) {
  return (schema, request) => {
    let filters = attrs.reduce((hash, attr) => {
      let val = request.queryParams[`filter[${attr}]`];
      if (val) {
        hash[attr] = val;
      }

      return hash;
    }, {});

    return schema[resourceName].where(filters);
  }
}

And now we have a generic, declarative filterable handler! Let's save this and see if it works.

So now only 7 light images are showing up when we filter by style, and if we go back to albums and refresh, find by slug works as well. And finding an image by slug works too.

And looking at our Mirage config, our route handlers are much simpler and easy to understand. And you can imagine moving this filterable higher-order function into another directory and importing it, and even writing tests for it! This is all just JavaScript, and as long as you end up giving Mirage a function that takes schema and request and returns a model or collection from the ORM, you can break these down however you want. You could even imagine making more HOFs like sortable and paginated that can compose together, to make your route handlers even more powerful while keeping them maintainable.

But also, don't jump the gun here and try to abstract everything right away. This kind of code can get complex pretty fast, so instead just approach this like we did in this video. Start off by writing very basic route handlers that do just what you need, and don't worry about duplicating them three or four times before generalizing. Then, abstract one piece at a time, and only for the specific needs that you have - just like how we started with sluggable before abstracting to a generic filterable function.

Higher-order functions are a super powerful feature of Javascript, and their usefulness goes beyond Mirage. The more you use them the more comfortable you'll become with how they work, and you'll even start to see new opportunities for applying them throughout your applications.

Questions?

Send us a tweet:

Or ask us in Inside EmberMap, our private Slack workspace for subscribers.