Migrating an Express.js App to a Functional Web App
by Simon MacDonald
on
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.
- We require
@architect/functions
to make writing handler functions easier. - Pull in the
userRepos
object from our shared code module. - Require the
auth
function once again from our shared code module. - Then we write our
getUserRepos
function to talk to our database and return the requested info if the users exist. - If the user doesn’t exist, the
userNotFound
function will handle it. - Then we export our
handler
function usingarc.http.async
to wrap theauth
,getUserRepos
anduserNotFound
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
- Try implementing the
GET /api/repos
andGET /api/users
routes yourself. If you run into some roadblocks, check out the full source code to the app. - Want to try out this app? Start by deploying it to Begin in 30 seconds (no credit card required).
- Join the Architect Discord.
- Follow the @begin Twitter account.