Palms are sweaty, knees weak, arms are heavy,
There's vomit on his Patagucci already
These are the types of tech interview questions my friends used to tell me about that would make me freeze up. The way it's phrased just seems like such a lot of work and hidden complexity. And, I'm sure there is - if you really wanted to knock it out of the park -- but today, at approximately 4 hours into a task that I found more annoying than complex, I realized I'd done just this (sorta, at a very low level).
T, why were you creating a custom router?
That's a great question, I'm glad you asked 🕺🏽.
SO
I'm currently working on a project where we're creating a bunch of babby API's to CRUD (Create, Retrieve, Update, Delete) some things from DynamoDB tables. From a bunch of reasons, not least of which including the fact that I am the sole engineer on this project - I'm trying to win sales, earn bonuses and make hella money move quickly and maintain as little "live-infrastructure" as possible.
Because of this, I came to the following conclusion(s)/decision(s) on how I would proceed:
TIRED 😰
- Running a node.js webserver (and associated infra and management) to effectively broker CRUD requests to a DynamoDB?
WIRED ☕️
- Setting up an AWS API Gateway that would trigger a Lambda to CRUD the required things from DynamoDB WIRED
We're $erverle$$ baaaabyyyyy
INSPIRED ✨
Anyways, the TL:DR on this is that there's going to be an API Gateway that gets HTTP requests and then sends them to a Lambda function which decides to how to deal with the request before brokering the interaction with DynamoDB.
I have a single set of resources projects
that exist in DynamoDB (in a single projects
) table, and my Lambda needs to be able to listen to the request and get the things from DynamoDB.
From skimming my original blue-print above, you might think:
This seems easy enough.
And you'd be right, if I only ever had to deal with one entity projects
. As the project went on, now I have a second entity to deal with: status
(es?) and more are soon to come.
Originally I'd thought:
1 lambda per resource.
/resources
will go to theresources-lambda
, and/status
will go to thestatus-lambda
.
However this approach leads to a few issues:
- For every endpoint/lambda, you need to create 3x API gateway references
- For every endpoint/lambda, you need to make more IAM accommodations.
- Deployments would get annoying because I would need to update a specific lambda, or multiple lambdas to implement one feature in the future (i.e. if i needed to add a new field to the
status
which makes use ofprojects
)
I ultimately decided:
No, we're going to have the API gateway send all (proxy) traffic to a single lambda 1 lambda to rule them all (as a proxy resource), and then the lambda can decide how to handle it.
This is why I needed to create a router, so that my Lambda function could figure out what it's being asked to do before doing the appropriate response. For example, it would have to handle:
-
GET /projects
- get me all projects in the database. -
GET /projects:name
- get me details on a single project. -
GET /status
- get me all the status entries in the database. -
GET /status/:name
- get me the status of a single project in the database.
Having worked with Node (and specifically Express) before, I knew there existed a way to specify routes like this:
app.get('/users/:userId/books/:bookId', function (req, res) {
res.send(req.params)
})
And similarly for Lambda, there seemed to exist a specific node module for this case:
import * as router from 'aws-lambda-router'
export const handler = router.handler({
proxyIntegration: {
routes: [
{
// request-path-pattern with a path variable:
path: '/article/:id',
method: 'GET',
// we can use the path param 'id' in the action call:
action: (request, context) => {
return "You called me with: " + request.paths.id;
}
},
{
// request-path-pattern with a path variable in Open API style:
path: '/section/{id}',
method: 'GET',
// we can use the path param 'id' in the action call:
action: (request, context) => {
return "You called me with: " + request.paths.id;
}
}
]
}
})
However, unfortunately - proxy path support is still a WIP :( This would seem to imply that ❌ I wouldn't be able to get at route params like the name in GET /projects/:name
WOMP WOMP ❌
It's also annoying that if you're using custom node-modules, you have to upload it as a zip every single time (as opposed to being able to code / test live if you're using native / vanilla Node).
Well Lambda, I think it's just you (-r event
parameters) and me at this point.
This would just mean that I'd need to create my own router, and thankfully obviously?, the event
payload that's passed into a Lambda function by the API gateway contains all the information we could need.
Specifically, all you really need for a router is three things (to start);
- HTTP Method:
GET
,POST
etc - Resource:
projects
||status
- Params (a.k.a keys):
:name
Once I got these pieces extracted out from lambda by doing the following:
let method = event.httpMethod
let resource = event.path.split("/")[1]
let key = event.path.split("/")[2]
The actual logic of the router wasn't too hard. And I guess, just like in a tech interview -- I came up with 2 "solutions".
V1 - Switch on 1, add more detail inside
let method = event.httpMethod
let resource = event.path.split("/")[1]
let key = event.path.split("/")[2]
switch (resource) {
case "projects":
if (key == undefined) {
body = await dynamo.scan({ TableName: PROJECTS_DB_TABLE }).promise();
} else {
let name = key;
body = await db_get("projects",name)
}
break;
case "status":
break;
default:
body = {
defaultCase: "true",
path: event.path,
resource: event.path.split("/")[1],
};
break;
}
This approach was cool because it allowed me to use the path
as the main selector and then code the logic for the required methods as they came up.
However.it doesn't... look great. On first glance, it looks gross, convoluted, and that's just with a single resource and a single method. Secondly, for any new engineers coming onboard - this doesn't immediately seem like a a router when compared to any previous work they may have done.
Going back to the drawing board, and wanting to get closer to the "gold-standard" I was used to, like in express-router.
I wanted to come up with something that would simply specify:
- Here's the route that we need to handle
- Here's it's associated handler.
With that in mind, I came up with
V2 - Filter on 2 conditions, add more methods as they arise
let method = event.httpMethod
let resource = event.path.split("/")[1]
let key = event.path.split("/")[2]
if (method == "GET" && resource == "projects") {
body = await db_get(dynamo, "projects", key)
}
else if (method == "GET" && resource == "status") {
body = await db_get(dynamo, "status", key)
}
else {
body = { method, resource, key, message: "not supported at this time" }
}
I like this because it's the closest I was able to get to express-router:
app.get('/users/:userId/books/:bookId', function (req, res) {
res.send(req.params)
})
And has the benefit of being concise, and much more recognizable as a router on first-glance.
Things I'd Improve
I'd probably want to do way more clean-up for an actual interview "REAL" router, but it was still a cool thought exercise. Some definite things I'd want to add / handle:
- The
get-me-all
case is handled by checking for an undefined key. This could probably be guarded for better. - There's currently no guard against someone adding in more than a 1st level parameter (i.e.
/projects/name/something/else
would still get sent to the DB. Thats not great. - THIS IS ALL IN A GIANT IF-ELSE STATEMENT?? That doesn't seem great.
- Limitations: There's no way to do middleware, auth, tracing and a bunch of things that you'd be able to do with express-router (and other routers)
Conclusion
Routers are just giant if-else statements? Idk - this was fun tho.
Top comments (1)
Hmm, that's a good point! It just need to be one policy that could be applied for all the lambdas to access what they need. I just meant for the fact that for example, if the
projects
lambda needed to access a different DynamoDB table than thestatus
table.