Search code examples
node.jsloopbackobservers

Defining the order of loopback observers


I'm currently writing a REST API in node.js using IBM's loopback. I'm running into an issue where the order of observers is relevant, and they are called in the wrong order.

My Ticket model has an internal status field. This status is stored in the database as a UUID. Somewhere in a large "metadata" file, the human name for each status is also defined. The same goes for the external status field.

Here are the relevant parts of Ticket.json:

{
  "name": "Ticket",
  "properties": {
    "internalStatusId": {
      "type": "String"
    },
    "externalStatusId": {
      "type": "String"
    }
  }
}

I have written a generic mixin that can convert the human name of such a UUID field to the correct UUID. This mixin is used to allow clients to set a UUID field by its name (via a differently named field).

Here is a simplified version of the mixin:

// This observer is actually a simplification of the actual observer,
// which is more generic. It handles "name to id" mapping for any number
// of fields you can configure in the <model>.json file.
Model.observe('before save', function (ctx, next) {
  let data = ctx.isNewInstance ? ctx.instance : ctx.data;
  if (data.internalStatusName) {
    data.internalStatusId = internalStatusNameToIdMap[data.internalStatusName];
    delete data.internalStatusName;
  }
  next();
});

So if you send a PUT request to /Tickets/1 with { "internalStatusName": "Closed" } as body, this code will convert that to the correct UUID, put that in the internalStatusId field, and remove the internalStatusName field from the arguments.

Now, there is a business rule in the system: if the internal status is set to Closed, the external status also needs to be set to Closed. The code for that is located in Ticket.js, because it's not generic but only relevant for the Ticket model:

// This observer is located in Ticket.json.
// It makes sure that, when a ticket's internal status is set to Closed,
// the external status is also set to Closed.
Ticket.observe('before save', function (ctx, next) {
  let data = ctx.isNewInstance ? ctx.instance : ctx.data;
  if (data.internalStatusId === INTERNAL_STATUS_CLOSED_UUID) {
    data.externalStatusId = EXTERNAL_STATUS_CLOSED_UUID;
  }
  next();
});

The problem that I'm running into is that these two observers don't work well together, because they are invoked in the wrong order.

If I send { "internalStatusName": "Closed" }:

  • First the observer from Ticket.js is called. This checks if the internalStatusId field is set, to know if the external status needs to be updated. The internalStatusId is not present in the arguments, so the external status is not set.
  • Second, the mixin observer is called. The internal status name is converted to the id.

This order is probably caused by loopback first loading the observers in Ticket.js and after that the mixin observers.

I could, of course, modify the second observer to look for both the internalStatusId and the internalStatusName fields. However, this leads to a significant duplication of code, especially because I have many of such business logic observers and also many UUID fields like this.

I've been looking for a way to tell loopback which order to run these observers in. Even a function as simple as Model.observeFirst() (this does not actually exist!) to put one or more observers at the front of the chain would solve this problem. I have not been able to find if this is possible, and if so, how.

How would you solve this problem?


Solution

  • Loopback loads first the implementation of the model and only after this implementation of the mixins, and therefore the wrong order. Loopback uses a standard system of subscriptions to the event. You can therefore use the prependListener to add the listener to the beginning.

    // Puts first
    Model.prependListener('before save', function (ctx, next) {
      let data = ctx.isNewInstance ? ctx.instance : ctx.data;
      if (data.internalStatusName) {
        data.internalStatusId = internalStatusNameToIdMap[data.internalStatusName];
        delete data.internalStatusName;
      }
      next();
    });