Caine Nielsen • How we built a middleware to divide traffic in our migration

I spent most of my year @ Pura developing a new back end system for controlling our iot devices. To migrate our users/devices to this system we decided to implement new handlers in our public APIs that would circumvent the legacy system and take advantage of the newly developed flows. Today I want to show you how we updated our APIs to do this!

It was decided that part of ‘upgrading’ our devices into the new system should include setting a migration flag on our device database records. This flag, a MigrationState would indicate to our APIs that when requests or events are received for the device, the new migration flows should be invoked instead of the legacy flows.

Our ‘mobile API’ is the primary back end interface ours customers interact with to control their devices. Previously this API was built with all of the device control business logic ‘baked’ into it which made it difficult to maintain a standard set of device control procedures across our other clients like our Alexa and Google integrations.

To remedy this, we built a new API to expose client agnostic endpoints for controlling the devices, this allowed us to move faster, remove old legacy code and other technical debt and dramatically simplify the rest of our back end device control system.

Now the remaining problem was handling requests from users in the mobile API, how could we route migrated devices through the new API while still maintaining the existing business logic for our legacy devices?

Ultimately the migration flow is quite simple, we need to fetch the device from our database, determine if it has the MigrationState flag and determine whether that flag indicates the device is migrated to the new system.

1
if (device.migrated === true) {
2
  runMigratedState(ctx)
3
} else {
4
  runLegacyFlow(ctx)
5
}

Since we had a decent number of endpoints related to device control in our mobile API, we needed to determine a pattern to implement the migration check and migration pathing that made it easy bootstrap for each endpoint and made it possible to handle the complexity of our existing request routing and middleware.

To do this we built a new middleware that I am existed to share with you. The goal of this middleware is to be able to place it anywhere in the request stack and hand it both a legacy routing stack and a migrated routing stack. At runtime, this middleware would read the contents of the request and based on a conditional value it would determine which stack is chosen.

1
export const conditionalMiddleware = (req, res, next) => {
2
  if (condition) {
3
	  trueFlow(req, res, next)
4
  } else {
5
	  falseFlow(req, res, next)
6
  }
7
}

The above example is quite bare bones. you might be asking yourself, how do we pass in the condition logic and the correct request value? How might we handle calling one or more middleware for each path? How do we handle errors if we can’t determine the condition?

These are all questions we had too, and ultimately it led us to splitting the middleware into two pieces, one to evaluate the request and return a boolean result and one to route requests based on the condition.

1
export const isMigrated = (req) => {
2
  return req.locals.migrated === true
3
}

To handle this we needed to make a small change to the middleware to allow us to pass the configuration to it. You can see below how we wrapped the middleware in a function that allows us to return a dynamic middleware function that handles requests based on the parameters passed in.

1
export const conditionalMiddleware = (conditionFunction, trueFlow, falseFlow) => {
2
	return (req, res, next) => {
3
		if (conditionFunction()) {
4
		  trueFlow(req, res, next)
5
	  } else {
6
		  falseFlow(req, res, next)
7
	  }
8
	}
9
}

But how might we handle routing requests through a complete request stack including any existing or new middleware? Its actually not too complicated. We first determine if the middleware parameter is an array and if not, we can simply pass the request to the middleware. If it IS an array we simply need to simulate a request handling stack, similar to how express routes requests under the hood.

to do this, we run a reducer on all the array properties and return a request stack wherein each function receives the rest of the stack as the next() function. This ensures that when each middleware in the array calls next, the next middleware in the stack is called.

1
if (Array.isArray(middlewareTrue)) {
2
  return middlewareTrue.reduceRight(
3
    (stack, middleware) => () => middleware(req, res, stack),
4
    next
5
  )()
6
}
7
8
return middlewareTrue(req, res, next)

Now the big reveal, after some additional error handling and refactoring we have our super fancy conditional middleware. Complete with types.

1
// eslint-disable-next-line import/prefer-default-export
2
export const booleanMiddleware = (
3
  conditionFunction: (req: Request) => boolean,
4
  middlewareTrue: RequestHandler | RequestHandler[],
5
  middlewareFalse: RequestHandler | RequestHandler[]
6
): RequestHandler => {
7
  const router: RequestHandler = (req, res, next) => {
8
    const condition: boolean = conditionFunction(req)
9
    if (condition === true) {
10
      if (Array.isArray(middlewareTrue)) {
11
        return middlewareTrue.reduceRight<NextFunction>(
12
          (stack, middleware) => () => middleware(req, res, stack),
13
          next
14
        )()
15
      }
16
      return middlewareTrue(req, res, next)
17
    }
18
    if (condition === false) {
19
      if (Array.isArray(middlewareFalse)) {
20
        return middlewareFalse.reduceRight<NextFunction>(
21
          (stack, middleware) => () => middleware(req, res, stack),
22
          next
23
        )()
24
      }
25
      return middlewareFalse(req, res, next)
26
    }
27
    throw new Error('Invalid condition received')
28
  }
29
  return router
30
}

We can even abstract this further by writing higher level middleware that by default includes the condition function. You might remember the isMigrated method from earlier?

1
const myMigratedMiddleware = (middlewareTrue, middlewareFalse) = > {
2
  return booleanMiddleware(isMigrated, middlewareTrue, middlewareFalse)
3
}

Well that’s all I have for today. I hope this was valuable and interesting. I appreciate the read. Enjoy your day!

As always, thank you for reading. I really appreciate it. 💖