Architecting a Serverless API with Authentication
Table of Contents
Here I designed and built out an architected API, able to support a (notional at this stage!) mobile application. I went from a toy-level application to a fully implemented serverless API with OpenAPI spec, encryption, authentication, testing, both free and premium tier features, and documentation. This article covers the journey at a high level, focussing on the lessons learned. I’m a big fan of incrementalism/iteration as a development approach. Not just in the sense of the Minimally Viable Product meme, but also as a method for decomposition- perhaps the most important skill for an architect or developer. I hope to show how that worked well here.
Our Starting Point
The starting point was a Python script to generate word searches - as I recently wrote about. I had in mind a mobile app that could call this as a service with various options. I wanted to restrict access to the API to authenticated users of the app with some options for ‘free’ users and some options restricted to premium users. I appreciate that there are arguments for doing everything on the device itself, and that for an app of this sort almost any device would be ‘capable’ but I also wanted to explore API design and architecture in a way that I have less often had the opportunity to do. In my view the important thing is to arrive at a solution. This project is about achieving this in a sensible way, not showing off clever techniques. LLM acceleration is presumed. Since I may or may not go on to develop the mobile app, I am afraid that there is not as much code sharing on this occasion as on others.
Moving to an API model
Since my initial app was in Python and used some very ‘Python’ modules, notably Natural Language Toolkit (NLTK), I decided to stick with that when evaluating API frameworks. I reviewed Flask and FastAPI, neither of which I had worked with before. FastAPI seemed like the better fit so I refactored to implement that, together with Uvicorn as a server. This was a relatively straightforward transition given the simplicity of the initial app and I was able to get something that I could run, i.e. serve and query with CURL, locally quickly.
It would be perhaps going a little far to call it ’testing’ at this stage, but from repeatedly generating output with the locally hosted API it became clear that some of the words produced could be offensive. I’ve come across people using bad-words
in Node in the past to deal with this sort of issue and presumed that there would be something similar in Python. I settled on using better-profanity
to filter for such words in the output. By chance it became clear almost immediately that some words might be ’less obviously’ offensive- think dated/alternative definitions- and so pass the profanity filter. Further research showed that NLTK
supports checking dictionary definitions of words. This had a double payoff:
- I could filter on dictionary definitions locally, without having to access a separate service.
- I could provide dictionary definitions for words to search for as a premium-tier feature
On to the Cloud, But How?
I had already decided I wanted to deploy to Lambda/API Gateway etc. for similar reasons to those for my card shuffling service. I also had in mind using AWS Cognito for authentication. I didn’t want to spec every single thing out as with Terraform or Pulumi. I wanted something that could get me going as quickly as possible, with enough headroom for future extension. I couldn’t find the trailered ‘free’ version for Serverless Framework and wasn’t keen on SST’s bias to Typescript. In respect of all of Serverless Framework, SST and CDK, I think it’s worth highlighting not just the principal advantages and disadvantages but also what has been called ‘the 300% problem’:
To successfully get an application into production, you need to be an expert in the application itself, the deployment target and the deployment methodology.
Whilst searching for alternatives to Serverless Framework, I came across Chalice. On the face of it it looked like a great fit here. I initially started with this and I was indeed able to get an initial deployment for my stack up and running with it very quickly and it was very fast in use. Sadly it did not seem to be as obvious how to associate proper methods with my APIs in API gateway using Chalice and I had issues with including and building all my files correctly. It appeared that I would have to rearchitect my function code completely in order to use it here, which I was less keen to do. The clincher was that I didn’t see anything in the way of state management with Chalice. Whilst this may have been my oversight, this coupled with the above limitations, it being Python-only and with a restricted feature set caused me to move on. I am not comfortable using Infrastructure as code without state management and I believe that state, as deployment configuration, should not be stored with the deployment code - as per the Twelve Factor App manifesto. In the end I gave up on it and decided on using SAM again. Sometimes sticking with what you know is the best way to avoid unnecessary complications and in this case I was already doing enough ’new things’.
At this point I also implemented a proper https DNS entry for my API (edge-optimized) endpoint and took out the PDF generator- it was tough to get it working and no longer seemed a good fit for the use case. I also found that it was possible to natively generate themed categories of words to search for using NLTK and so put this in as a premium feature.
Securing our API
Obviously in order to have any distinction between free and premium users we need to secure our API. I was surprised at just how many options there were but I wanted something managed, serverless and that could be simple now but with the option to integrate with mobile app store identity services in future. I went with Cognito user groups (shocking, I know). This was not so straightforward with oauth callback URLs until giving up on oauth altogether at this point - no app or external IDP to leverage. With an incremental deployment I pinned this down to the AWS::Cognito::IdentityPoolRoleAttachment
and was able to deploy everything else before resolving the formatting the IdentityProvider attribute.
API Testing and the first big challenge
I created some test users via script within AWS who could request their bearer tokens and wanted to look at API testing suites. Postman has ‘changed’ in the recent past. I tried Bruno but wound up falling back to custom scripts anyway to try and work out what the issues were. I also tried Thunder Client (VSCode extension) and then (Kong) Insomnia but each was its own challenge and I was mindful that I was losing focus on the task at hand as I tried to get the (free versions of the) tools working. At this point I gave up in favour of custom scripts, which were a breeze by comparison to simply call the API, making small changes based on the output and then trying again. I determined that I needed to implement some proper tests.
I considered PyTest and BATs (since I had used it before) and went with Pytest. My LLM wanted to race ahead down a blind alley and I had to reign it back and start simple, recreating the user authentication BASH/CURL snippets used earlier. This tight focus was useful in getting the test framework up and running - a reminder that the incremental approach is often preferable.
This wasn’t too hard to get working initially although I did find the output either too brief or too verbose for my taste. I also found it a little challenging to handle the case of expecting failure for unauthorised user. From here I essentially segued into test driven development: I defined test cases based on my acceptance criteria and was able to proceed from there. Here the LLM was a great accelerator, especially since I was not previously familiar with the Pytest framework. I had to be careful to check what exactly was being tested and how in detail. Testing was invaluable in discovering the first big challenge - All my users were being treated as free-tier!
Distinguishing available features by user group
This turned out to be quite an odyssey. It quickly became apparent that I would need effective logging for diagnostics, but nothing came through from my logger
outputs. I had not realised that uvicorn/FastAPI
essentially swallows ‘ordinary’ logger messages in its default state but once I did it was fairly straightforward to remedy. I then had to research how to pass the cognito context from API gateway through to my lambda. I initially went down some blind alleys around inspecting headers and IAM groups before finding the correct dedicated method for achieving this. At this point I was able to see logs from within the lambda of it receiving the cognito context from my test scripts and then able to zero in on the correct attributes to use for my application logic and get to working tests.
Refinement, extension and polish
With functioning logging and cognito context being received I was able to refine and extend my test cases, including a refactor to modularise common content. I was then able to extend to implement other premium features such as search grid size.
By the time I’d finished, as well as the API and deployment, I had scripts to prepare the build environment, prepare and conduct tests etc. I made a feature to enable running locally with a generator script - in order to make use of FastAPI’s ability to generate its own OpenAPI spec within the repo- and then wrote up the documentation as for an external team, or more likely my own future self!
Lessons Learned
Having A Clear Goal In Mind
While the project’s original goal wasn’t exhaustively defined, it was clear enough to maintain a consistent direction. This clarity helped me to stay on course at each step, ensuring that the choices made were aligned with the overall objective. Even though the exact end product might evolve, having a clear purpose serves as a compass, directing decisions throughout the development process and helping to avoid unnecessary detours.
Flexibility / Be Prepared to Pivot
Flexibility in development is not just beneficial—it’s essential. Throughout this project, several initial choices had to be reconsidered, whether it was dropping PDF generation, switching from Chalice to SAM due to state management issues, or moving from commercial API testing tools to custom scripts. Being ready to pivot quickly allowed the project to keep moving forward without getting bogged down in unnecessary complexities. This flexibility also enabled me to take advantage of new opportunities as they arose, allowing the project to adapt and evolve to better meet its goals.
Modularity
The value of modularity in design cannot be overstated. In this project, modularity enabled an incremental approach to both development and deployment, allowing me to pivot or adapt as new challenges emerged. Modularity ensured that changes in one part of the system didn’t necessitate a complete overhaul, making the development process more efficient and manageable. If I proceed with the mobile app, it’s likely that some elements will need to be revisited, but the modular design will make that process far smoother.
Incremental Approach and Decomposition
This project reinforced my belief in the power of an incremental approach to development. By breaking down the API, deployment, testing, and documentation into manageable parts and iterating on each, I was able to steadily build a robust and functional product. This approach not only made complex tasks more manageable—a classic example of decomposition—but also allowed for continuous testing and refinement, ensuring that each component worked as intended before moving on to the next challenge.
Reflections on Decomposition and Knowing When to Give Up
One of the most challenging aspects of development is knowing when to abandon a particular path. Throughout this project, I encountered several dead ends, such as issues with FastAPI logging or difficulties with API testing tools. Decomposing problems into smaller, more manageable parts helped me identify when an approach was fundamentally flawed and needed to be replaced with a more viable alternative. Recognizing these moments early saved time and prevented unnecessary frustration.
Check Proposals in Code and in Testing Carefully
No matter how promising a proposal might seem on paper, its real value is only realized through implementation and testing. This project highlighted the importance of thoroughly vetting each proposed solution, particularly when leveraging LLM acceleration. Whether it was ensuring the correct distinction between user groups or validating that all features worked as expected across different tiers, rigorous testing was crucial in confirming that each part of the API functioned as intended in real-world conditions. Testing also provided valuable insights, allowing me to iterate on my design as new opportunities became visible.
Documentation
I approach documentation as if it’s for a third party, even in personal projects—I like to think I’m doing my future self a favor! In this project, documentation wasn’t just an afterthought; it was an integral part of the development process. This included using the API itself to generate a formal specification, supported by comprehensive testing. Well-structured documentation not only provides a clear reference for future work but also contributes significantly to the maintainability and scalability of the project.