Migrating an Express.js App to a Functional Web App

avatar
Simon MacDonald
March 10, 2022

migrating Photo by Chris Briggs on Unsplash

Functional Web App (FWA) is an architectural pattern for building web applications and APIs. An FWA is composed of cloud functions, talking to a managed database, that are deterministically deployed. However, many monolithic Node.js applications run popular web application frameworks like Express.js.

This blog post will deconstruct an Express.js app into a functional web app. We’ll focus on the cloud function pillar of FWA for brevity.

You can clone the source code and run the application locally using Architect if you want to follow along.

git clone git@github.com:macdonst/express-to-fwa.git
cd express-to-fwa
npm install

Our Express.js App

Has the following routes:

GET /api/repos
GET /api/users
GET /api/user/:name/repos

And it uses an express middleware function to check for the existence of an api-key.

You can start the app using npm run express and test the routes in your browser. For example, http://localhost:3000/api/user/brian/repos/?api-key=foo will produce the output:

[
 {
   "name":"architect",
   "url":"https://github.com/architect/architect"
 },
 {
   "name":"functions",
   "url":"https://github.com/architect/functions"
 }
]
Full source code of Express.js app
// server/index.js
const express = require('express');
const app = express();

function error(status, msg) {
 const err = new Error(msg);
 err.status = status;
 return err;
}

const apiKeys = ['foo', 'bar', 'baz'];

app.use('/api', function(req, res, next){
 const key = req.query['api-key'];
 if (!key) return next(error(400, 'api key required'));
 if (apiKeys.indexOf(key) === -1) return next(error(401, 'invalid api key'))
 req.key = key;
 next();
});

const repos = [
 { name: 'architect', url: 'https://github.com/architect/architect' },
 { name: 'functions', url: 'https://github.com/architect/functions' },
 { name: 'sandbox', url: 'https://github.com/architect/sandbox' }
];

const users = [
 { name: 'brian' },
 { name: 'kj' },
 { name: 'ryan' }
];

const userRepos = {
 brian: [repos[0], repos[1]],
 kj: [repos[1]],
 ryan: [repos[2]]
};

// example: http://localhost:3000/api/users/?api-key=foo
app.get('/api/users', function(req, res, next){
 res.send(users);
});

// example: http://localhost:3000/api/repos/?api-key=foo
app.get('/api/repos', function(req, res, next){
 res.send(repos);
});

// example: http://localhost:3000/api/user/brian/repos/?api-key=foo
app.get('/api/user/:name/repos', function(req, res, next){
 const name = req.params.name;
 const user = userRepos[name];

 if (user) res.send(user);
 else next();
});

app.use(function(req, res){
 res.status(404);
 res.send({ error: "Sorry, can't find that" })
});

app.listen(3000);
console.log('Express started on port 3000');

Wrapping an Express.js App

One of the main benefits of moving to an FWA architecture is the ability to iterate rapidly on converting your application over to cloud functions. Our first step will be wrapping the existing Express.js app in some middleware and deploying it as a cloud function.

First, let’s add a catch-all route to our app.arc file. Then we’ll create a cloud function that will proxy incoming requests to our wrapped Express.js app.

@app
express-to-fwa

@http
get /*

Then we run arc init to create the catch-all route at src/http/get-catchall/index.js. The source code is not all that different from the original Express.js app. At the top of the file, we add two new dependencies:

const arc = require('@architect/functions')
const serverless = require('serverless-http');

At the end of the file, we remove the two lines to start the app listening on port 3000 and replace them with our serverless wrapper, and then we export the function that will handle incoming requests.

- app.listen(3000);
- console.log('Express started on port 3000');
+ let server = serverless(app)
+ exports.handler = arc.http.async(server)

You can start the Architect sandbox using npm start and test the routes in your browser. For example, http://localhost:3333/api/user/brian/repos/?api-key=foo will produce the identical output as when we were running it as an express app.

[
 {
   "name":"architect",
   "url":"https://github.com/architect/architect"
 },
 {
   "name":"functions",
   "url":"https://github.com/architect/functions"
 }
]
Full source code of the wrapped Express.js app
// src/http/get-catchall/index.js
const arc = require('@architect/functions')
const serverless = require('serverless-http');
const express = require('express')
const app = express();

function error(status, msg) {
 const err = new Error(msg);
 err.status = status;
 return err;
}

const apiKeys = ['foo', 'bar', 'baz'];

app.use('/api', function(req, res, next){
 const key = req.query['api-key'];
 if (!key) return next(error(400, 'api key required'));
 if (apiKeys.indexOf(key) === -1) return next(error(401, 'invalid api key'))
 req.key = key;
 next();
});

const repos = [
 { name: 'architect', url: 'https://github.com/architect/architect' },
 { name: 'functions', url: 'https://github.com/architect/functions' },
 { name: 'sandbox', url: 'https://github.com/architect/sandbox' }
];

const users = [
 { name: 'brian' },
 { name: 'kj' },
 { name: 'ryan' }
];

const userRepos = {
 brian: [repos[0], repos[1]],
 kj: [repos[1]],
 ryan: [repos[2]]
};

// example: http://localhost:3333/api/users/?api-key=foo
app.get('/api/users', function(req, res, next){
 res.send(users);
});

// example: http://localhost:3333/api/repos/?api-key=foo
app.get('/api/repos', function(req, res, next){
 res.send(repos);
});

// example: http://localhost:3333/api/user/tobi/repos/?api-key=foo
app.get('/api/user/:name/repos', function(req, res, next){
 const name = req.params.name;
 const user = userRepos[name];

 if (user) res.send(user);
 else next();
});

app.use(function(req, res){
 res.status(404);
 res.send({ error: "Sorry, can't find that" })
});

let server = serverless(app)
exports.handler = arc.http.async(server)

Functional Web App

Now that we’ve deployed our app as a cloud function, we can start to deconstruct each of the internal Express.js routes into individual cloud functions. We’ll start by defining the GET /api/user/:name/repos route in our app.arc file while leaving the catch-all route in place to continue to handle the GET /api/repos and GET /api/users routes:

@app
express-to-fwa

@http
+ get /api/user/:name/repos
get /*

Then we can run arc init to create the stub for our new cloud function. Your folder structure should look like the following:

.
├── server/
│   └── index.js # source code of our express.js app
└── src/         # source code of our architect app
   └── http/
      ├── get-api-user-000name-repos
      │   └── index.js
      └── get-catchall
          └── index.js

We’ve picked the /api/user/:name/repos route as it is the most complex to convert to a cloud function.

Shared Code

Since all of our new cloud functions will need to authenticate the user and access the “database”, we’ll create a shared code folder to hold these modules. Contents of the shared code folder will be automatically copied into the node_modules folder of each of our cloud functions.

Create a shared folder under src and two files named auth.js and data.js.

.
├── server/
│   └── index.js # source code of our express.js app
└── src/         # source code of our architect app
   └── http/
   │  ├── get-api-user-000name-repos
   │  │   └── index.js
   │  └── get-catchall
   │      └── index.js
+   └── shared/
+     ├── auth.js
+     └── data.js

For our “database” we’ll pull the three objects that represent our data out of server/index.js and add them to data.js then export those objects so they can be required from our cloud functions.

// src/shared/data.js
// these three objects will serve as our faux database
const repos = [
 { name: 'architect', url: 'https://github.com/architect/architect' },
 { name: 'functions', url: 'https://github.com/architect/functions' },
 { name: 'sandbox', url: 'https://github.com/architect/sandbox' }
]

const users = [
 { name: 'brian' },
 { name: 'kj' },
 { name: 'ryan' }
]

const userRepos = {
 brian: [repos[0], repos[1]],
 kj: [repos[1]],
 ryan: [repos[2]]
}

module.exports = { repos, users, userRepos }

Then for auth.js we’ll create a new function which can be called as middleware to authenticate access to our new cloud functions:

// src/shared/auth.js
const apiKeys = ['foo', 'bar', 'baz'];

async function auth(req) {
   const key = req.query['api-key'];

   // key isn't present
   if (!key) return {
       statusCode: 400,
       json: { error: 'api key required' }
   }

   // key is invalid
   if (apiKeys.indexOf(key) === -1) return {
       statusCode: 401,
       json: { error: 'invalid api key' }
   }

   // all good, store req.key for route access
   req.key = key;
}

module.exports = auth

Get a users repos

Now we’ll implement the GET /api/user/:name/repos route using a cloud function.

  1. We require @architect/functions to make writing handler functions easier.
  2. Pull in the userRepos object from our shared code module.
  3. Require the auth function once again from our shared code module.
  4. Then we write our getUserRepos function to talk to our database and return the requested info if the users exist.
  5. If the user doesn’t exist, the userNotFound function will handle it.
  6. Then we export our handler function using arc.http.async to wrap the auth, getUserRepos and userNotFound functions. arc.http.async will continue to call the next function in the list as long as it doesn’t return a valid HTTP response payload.

Here’s the full source code for our cloud function.

// src/http/get-api-user-000name-repos/index.js
const arc = require('@architect/functions')
const { userRepos } = require('@architect/shared/data')
const auth = require('@architect/shared/auth')

async function getUserRepos(req) {
 const name = req.params.name;
 const user = userRepos[name];

 if (user) return {
   statusCode: 200,
   json: user
 }
}

async function userNotFound() {
 return {
   statusCode: 404,
   json: { error: `Sorry, can't find that` }
 }
}

exports.handler= arc.http.async(auth, getUserRepos, userNotFound)

Now, if you call the GET /api/user/:name/repos route, it is handled by our new cloud function while GET /api/repos and GET /api/users are still handled by the wrapped Express.js app. This allows you to migrate a monolithic app one route at a time without having to do a complete rewrite in one pass.

Next Steps

deploy-to-begin