express-rested

Manage resources with REST through Express

Usage no npm install needed!

<script type="module">
  import expressRested from 'https://cdn.skypack.dev/express-rested';
</script>

README

express-rested

Build Status Coverage Status

Installation

npm install --save express-rested

Why express-rested?

REST in practice

REST is a great style to create an HTTP API to manage resources. HTTP methods make it easy to access and manipulate resources based on their URL. There are many solutions out there that allow you to define REST interfaces and play with them. They're great and definitely have their place, but express-rested comes with some different design goals.

I believe the flexibility that many of these systems provide are actually distracting. The reason why some people prefer this flexibility is because REST in terms of HTTP is quite loosely defined (if at all). Therefore everyone and their uncle has an opinion on what it means for an API to be truly RESTful and love spending days having religious debates on this very topic. I find this a waste of my time, and I hope you agree with me at least on that.

Therefore I reasoned that it would be much more rewarding to just decide on one way to implement a REST interface (more on this below) and stick with it, end-of-debate. This way we can focus on writing the logic for the resources we want to manage, instead of spending all our time debating POST vs. PUT, what a URL should look like and which HTTP status codes should really be returned when. With express-rested, these decisions are simply made for you, and you can move on with your life. That means this library is absolutely, unapologetically biased. Also, it doesn't care if you don't like it. It does, however, care about keeping a very strict separation between what it is and isn't responsible for.

What else will it do for me?

Automatic routing

With one line of code, you can expose a collection of resources to your Express app, and thus to the web. It will automatically register routes for the HEAD, GET, POST, PUT, DELETE HTTP methods for collections, and the HEAD, GET, PUT, PATCH, DELETE methods for resources. That's a lot of bang for your buck.

Rights management

Whenever you expose data to the outside world, rights management immediately becomes an issue. While supporting virtually every useful HTTP method, I believe CRUD methods (Create, Read, Update, Delete) are the best methods to base rights management on. It doesn't have to be more granular than that, nor any less granular. Express-rested easily helps you define who is allowed to do what to which resource.

Search

Search through HTTP query strings is built right in. So resources a user has the right to access can be filtered any way you see fit, based on the user's input.

Data Persistence

Well it doesn't really persist data at all. But it will tell you exactly when what changed, so that you're able to persist your resources to your favorite datastore.

File formats

By default, resources are serialized into JSON, but you can expose a resource using any file type you want and provide your own serialization methods.

A rich API for in-app access to your data

You can access your resources through collections that you instantiate. These collections have easy to use, powerful APIs to manipulate the collection, as well as index (for performance) and search for resources.

Should I trust you?

You don't have to trust me. But you can trust the tests that ship with this library. We make sure to keep 100% code coverage, and don't have any external dependencies.

Design Philosophy

  • You implement your resources as classes
  • You own these resource classes and should give them any API that works well for you
  • Resources are collected in "collections"
  • The entire collection is always in-memory, but may be persisted to a datastore
  • The application can access a collection's resources through the collection's API
  • The collection and its resources can be exposed REST-style in Express through a single function call

Usage

Given a resource "Beer"

class Beer {
  constructor(id, info) {
    this.id = id;
    this.edit(info);
  }

  createId() {
    this.id = this.name;
    return this.id;
  }

  edit(info) {
    this.name = info.name;
    this.rating = info.rating;
  }
}

module.exports = Beer;

Search

class Beer {
  /* ... */

  matches(obj) {
    // obj is the parsed query string of the HTTP request
    return this.name.indexOf(obj.name) !== -1;
  }
}

Cleanup

class Beer {
  /* ... */

  deleted() {
    // stop a running process
    banner.stopAdvertising(this.name);
  }
}

Custom file extensions

class Beer {
  /* ... */

  getJpeg(req, res) {
    res.sendFile('/beer-images/' + this.id + '.jpg');
  }

  putJpeg(req, res) {
    const buffs = [];
    req.on('data', function (buff) { buffs.push(buff); });
    req.on('end', () => {
      fs.writeFileSync('/beer-images/' + this.id + '.jpg', Buffer.concat(buffs));
      res.sendStatus(200);
    });
  }

  deleteJpeg(req, res) {
    fs.unlinkSync('/beer-images/' + this.id + '.jpg');
    res.sendStatus(200);
  }

  static getJson(req, res, beersArray) {
    beersArray.sort((a, b) => {
      return a.name.localeCompare(b.name);
    });

    res.status(200).send(beersArray);
  }
}

Creating a collection

const rested = require('express-rested');
const beers = rested.createCollection(require('./resources/Beer'));

Persisting changes

beers.loadMap(require('./db/beers.json'));

beers.persist(function (ids, cb) {
  fs.writeFile('./db/beers.json', JSON.stringify(beers), cb);
});

Collection exposure through Express

const app = require('express')();
const route = rested.route(app);

route(beers, '/rest/beers', { rights: true });

Logging warnings and errors

rested.on('error', console.error);
rested.on('warning', console.error);

Rights management

route(beers, '/rest/beers', {
  rights: {
    read: true,     // anybody can read
    delete: false,  // nobody can delete
    create: function (req, res, resource) {
      return res.locals.isAdmin;  // admins can create
    },
    update: function (req, res, resource) {
      return res.locals.isAdmin;  // admins can update
    }
  }
});

Using an Express sub-router

const express = require('express');
const rested = require('express-rested');

const app = express();
const router = new express.Router();
const route = rested.route(router);

app.use('/rest', router);

route(beers, '/beers', { rights: true });

HTTP in practice

Supported HTTP methods

GET POST PUT PATCH DELETE
/beers Returns all beers Creates a new beer Sets the entire beer collection Not supported Deletes all beers
/beers/123 Returns a beer Not supported Creates or updates a beer Updates a beer Deletes a beer

HTTP status codes

HTTP status codes returned by express-rested:

  • Success: 200 (OK), 201 (Created), 204 (No Content)
  • User error: 400 (Bad Request), 404 (Not Found), 405 (Method Not Allowed), 415 (Unsupported Media Type)
  • Server error: 500 (Internal Server Error)

All 4xx errors are generated by express-rested. All 5xx errors result from user-land code throwing an error or returning an error to an asynchronous function (like persist).

Errors

Whenever your code throws an error or returns it to a callback, this error is returned to the client as a text/plain human readable response body. If your error object also has a "code" property, it will be returned as an HTTP response header called x-error-code.

Errors that are thrown by your resources are also emitted as ("warning", error) on the rested object. Other errors are emitted as ("error", error) on the rested object. Keep in mind that Node will consider an error event an uncaught exception if you are not listening for them, so register at least an "error" listener.

URI Locations

When you know the name of a collection and the ID of a resource, you can reference both. But when you use POST to create a resource, you don't know the ID of the resource. The HTTP response will contain a Location header that will contain the full path to the newly created resource.

API

Resource types can be declared as an ES6 class or as a constructor function. There are a few APIs however that you must or may implement for things to work.

Your Resource API

Your resource class may expose the following APIs:

constructor(string|null id, Object info)

This allows you to load objects into the collection. During a POST, the id will be null, as it will be assigned at a later time using createId() (see below). If the data in info is not what it's supposed to be, you may throw an error to bail out.

Required for HTTP methods: POST, PUT.

edit(Object info) (optional)

This enables updating of the resource value. The info argument is like the one in the constructor. If the data in info is not what it's supposed to be, you may throw an error to bail out. To support partial updates (PATCH), please allow edit() to accept a partial object. If you don't want to accept partial objects, please throw an error when you detect this to be the case. The edit method should never write an id, as that is the job of the constructor and the optional createId method (see below).

Required for HTTP method: PUT, PATCH

createId() -> string (optional)

Should always return an ID that is fairly unique. It could be a UUID, but a username on a User resource would also be perfectly fine. It's not the resource's job to ensure uniqueness. ID collisions will be handled gracefully by express-rested. The createId() method must store the ID it generates and returns.

Required for HTTP method: POST

matches(Object obj) -> boolean (optional)

Implement this function to allow filtering to happen on your resource collection. When the query string in a URL (eg: ?name=bob) is passed, this function will be called and the entire parsed query object will be passed. If it does not return true, the resource will not end up in the final collection that is being retrieved.

Required for HTTP method: GET with query string

deleted() (optional)

This function will be called after the resource is deleted from the collection. You may use this to clean up or disable things tied to your resource instance. Thrown exceptions will be logged (see Debugging).

static getExt(express.Request req, express.Response res, Object[] resources)

You may replace "Ext" in this method name by any file extension you wish to expose a GET endpoint for (eg: getTxt). You will receive the req and res objects and will have full control over how to parse the request and respond to it. The third argument you receive is an array containing all the resources in the collection. If a search query was passed, the matches() method will have run on each resource, and non-matching resources will not be in this array. Implementing this method can be used not just for alternative extensions, but also if you want to change the output of how a collection is returned in JSON, for example by simply responding directly with the array (by default a collection is returned as a key/value lookup object).

getExt(express.Request req, express.Response res) (optional)

You may replace "Ext" in this method name by any file extension you wish to expose a GET endpoint for (eg: getTxt). You will receive the req and res objects and will have full control over how to parse the request and respond to it.

putExt(express.Request req, express.Response res) (optional)

You may replace "Ext" in this method name by any file extension you wish to expose a PUT endpoint for (eg: putTxt). You will receive the req and res objects and will have full control over how to parse the request and respond to it.

patchExt(express.Request req, express.Response res) (optional)

You may replace "Ext" in this method name by any file extension you wish to expose a PATCH endpoint for (eg: patchTxt). You will receive the req and res objects and will have full control over how to parse the request and respond to it.

deleteExt(express.Request req, express.Response res) (optional)

You may replace "Ext" in this method name by any file extension you wish to expose a DELETE endpoint for (eg: deleteTxt). You will receive the req and res objects and will have full control over how to parse the request and respond to it.

Notes

No other requirements exist on your resource. That also means that the ID used does not necessarily have to be stored in an id property. It may be called anything. Express-rested will never interact with your resource instances beyond:

  • reading the Class/constructor name (when auto-generating URL paths)
  • Calling the constructor and methods mentioned above

Express-Rested API

const rested = require('express-rested');

This imports the library.

Managing collections

rested.createCollection(constructor Class[, Object options]) -> Collection

Creates and returns a collection for objects of type Class.

Options:

  • persist: Function A function that will be called after each modification of the collection. See the documentation on the collection.persist method below for more information on the function signature and usage.

rested.getCollection(string name) -> Collection|undefined

Returns the collection with the given class name (case insensitive), or undefined if it doesn't exist.

rested.delCollection(string name)

Deletes the collection with the given class name (case insensitive) from memory. It does destroy resources, nor will it call the persist function for removal. The method is simply there to let express-rested forget about a collection. Please do note that if HTTP routes have already been assigned for this collection, calling this function will have no effect, other than that getCollection(name) will no longer return the collection.

Registering routes for collections

rested.route(express.Router restRouter) -> Function

Instantiates a route function through which you can expose collections on your HTTP server.

You must pass an Express router (an Express app, or sub-router) so that routes to the collections you add will automatically be registered on it. If the router already uses the body-parser middleware to parse JSON, express-rested will use it. Otherwise it will take care of JSON parsing by itself.

It may make sense to use a sub-router that listens for incoming requests on a URL such as /rest. The URLs to our collections will sit on top of this.

The function you get back has the following signature:

route(Collection collection[, string path, Object options])

This will register all routes to this collection. The path you can provide will be the sub-path on which all routes are registered. For example the path /beer will sit on top of the base path (eg: /rest) of the router and will therefore respond to HTTP requests to the full route that is /rest/beer. If you do not provide a path, the name of the class you provide will be used (and prefixed with /, eg.: '/Beer').

Options (all optional):

  • rights: object, boolean or function(req, res, resource) Is applied to all CRUD operations (read on for the logic).
  • rights.create: boolean or function(req, res, resource) Should be or return a boolean indicating whether or not creating this resource is allowed.
  • rights.read: boolean or function(req, res, resource) Should be or return a boolean indicating whether or not reading this resource is allowed.
  • rights.update: boolean or function(req, res, resource) Should be or return a boolean indicating whether or not updating this resource is allowed.
  • rights.delete: boolean or function(req, res, resource) Should be or return a boolean indicating whether or not deleting this resource may occur.

By default, the rights option is false (secure by default). This means that exposing a collection to Express requires you to set up the rights for it using these options.

Resource collection API

If you want to manually influence a collection's resources, you can use the following methods.

collection.loadMap(Object map)

Fills up the collection with all objects in the map. The key in the map will be used as the ID. For each object, new Class(key, object) will be called to instantiate the resource.

collection.loadOne(string id, Object info)

This instantiates a resource object from info and loads it into the collection.

collection.has(string id) -> boolean

Returns true if the collection has a resource for the given id, false otherwise.

collection.get(string id) -> Class|undefined

Returns the resource with the given id if it exists, undefined otherwise.

collection.getIds() -> string[]

Returns all IDs in the collection.

collection.getList() -> Class[]

Returns all resources as an array.

collection.getMap() -> Object

Returns a copy of the complete map of all resources.

collection.getMapRef() -> Object

Returns a reference to the complete map of all resources that are inside a collection. Be careful not to write to this object, as it would have likely result in unintended consequences. The most common use-case for this API is to use this object for read-only serialization purposes.

collection.set(string id, Class resource, Function cb)

Ensures inclusion of the given resource into the collection. Triggers the persist callback.

collection.setAll(Class resources[], Function cb)

Deletes all resources not given, and creates or updates all resources given in the resources array. Triggers the persist callback.

collection.del(string id, Function cb)

Deletes a single resource from the collection. Triggers the persist callback.

collection.delAll(Function cb)

Empties the entire collection. Triggers the persist callback.

collection.addIndex(string propertyName)

Creates an index for all resources, current and future, in the collection. The property name you pass will be inspected on every resource that enters the collection, and indexed on that. The value of of the property may be of any type.

This allows you to do quick indexed searches (or rather, lookups) in a collection. In the beer-example above, you could for example create an index on the "rating" property. Using the findOne and findAll methods documented below, you can then start fetching these resources.

Note 1: Creating an index is a heavy operation, so it's best done before adding/loading resources into a collection

Note 2: Indexes are not used in HTTP operations. They are only useful when directly interfacing with your collection

collection.delIndex(string propertyName)

Removes a previously created index from the collection.

collection.findOne(string propertyName, mixed value)

Will return a single resource that holds the given value for the given property. If no resource matches, undefined is returned.

collection.findAll(string propertyName, mixed value) -> Class[]

Will return all resources that hold the given value for the given property. If no resources match, [] is returned.

collection.persist(function (string ids[], [Function cb]) { })

Registers a function that will be called on any change to the collection, and is passed an array of IDs that were affected. You can use this to write changes to a database. If you pass a callback, you will have a chance to do asynchronous operations and return an error on failure. If you don't pass a callback, you may throw an exception to achieve the same.

Errors find their way to the HTTP client as an Internal Server Error (500). Error also have the automatic effect that changes made in the collection will be automatically rolled back.

Debugging

When you want to see which routes are activated by incoming requests, you can enable a debug logger by running your application with the NODE_DEBUG=rested environment variable set.

License

MIT