Building And Deploying Rock Paper Scissors With Python FastAPI And Deta
See the code for this project on GitHub.
In case you have haven’t heard, FastAPI is Python’s new web framework alternative to Django and flask. It prides itself on being fast - both in terms of performance and codeability. (And no, “codeability” is not a word but it should be.)
Background
I want to a build a SaaS. I have an idea, but I need to build a prototype. Enter FastAPI. I know the basics of web app development, but I’m not a web app developer. I’m a data scientist. I’ve spent the last two days reading up on FastAPI, attempting to understand how it works. So far, so good, but I’m realizing the monumental effort it’s going to take to get even a simple production-ready SaaS up and running. The hard part is not FastAPI. The hard part is deploying FastAPI to a server, standing up and connecting to a database, handling user registration and authentication, payment processing, plan tiers, upgrades and downgrades, version control, backend data administration, learning React? Docker? Google Cloud Run? Firebase? Deta? What the F am I doing? Help me oh God Jesus.
Gripe
The key to learning something difficult is creating and retaining the motivation you need to dredge through the boring but necessary stuff. Unfortunately, most existing courses/tutorials about FastAPI stand over your body, beating the motivation out of you with a thousand wooden bats called “cookies”, “middleware”, and “testing”. Theoretically, there’s someone out there who knows every nook and cranny of FastAPI, who’s never actually deployed something for the world to see and use.
I for one want to build and deploy something - something simple - but I want to put something out there for the world to see - well probably just me and my wife - actually, probably just me. But nonetheless, I want to deploy something that theoretically could be viewed by other people. Not only because deployment has been this giant grey question mark in my head as I’ve been reading through the FastAPI docs but also because dangling the carrot of actually deploying something that is public and works is going to keep me motivated. And when I’m motivated, my brain tends to process the words in the docs better.
Rock Paper Scissors
What to build? It needs to be challenging enough to be interesting, but simple enough to be doable given my current knowledge. For me, that’s the game Rock Paper Scissors. Let’s begin.
Setup
First of all, I’m on a mac, although I don’t think it matters much.
Virtual Environment
I’m going to start by creating a virtual environment; that way I’ll have a fresh and isolated installation of the packages I’ll be using for my app. Most people I’ve seen use python’s built in venv function, but I’m more familiar with conda, so that’s what I’ll use. For me, creating a new virtual environment with conda looks like this
(run in terminal)
conda create -n rockenv python=3.9
Here I’ve created a new virtual environment called rockenv
with python 3.9 installed. Next, I need to install FastAPI on this environment which I can do by
- activating
rockenv
withconda activate rockenv
- installing fastapi with
pip install 'fastapi[all]'
Your experience may differ. See the docs for installation details.
Project Structure
Next, I need to set up an empty folder on my mac which I’ll aptly name rock-paper-scissors/
. This’ll be the root directory of my project.
Since I’m using PyCharm as my IDE, I’ll create a new PyCharm project attached to my rock-paper-scissors/
directory and set my project interpreter as rockenv
(the virtual environment I created above ^^).
Now I’ll create a file called main.py
within rock-paper-scissors/
(so, rock-paper-scissors/main.py
), shamelessly copying and pasting the starter code from the FastAPI docs.
main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def root():
return {"message": "Hello World"}
To see that it works, I’ll run the app locally with uvicorn main:app --reload
.
Then I’ll navigate the to http://127.0.0.1:8000 - uvicorn’s development URL - and observe the JSON response {"message":"Hello World"}
.
Awesome!
Deployment
At this point, I want to focus on deployment. My app is boring, but I can improve it later. After tinkering with deployment options for a while, I’ve found Deta.sh to be the easiest option. The FastAPI docs give a great explanation of deployment with Deta, but I’ll walk you through the process myself.
1. Add requirements.txt
First we create a requirements.txt
file inside rock-paper-scissors/
with one line per required package (and optionally version details). This tells Deta what packages to install to run your app. For me, requirements.txt
is simply a one-line file like this
fastapi==0.68.1
2. Create a free Deta account
You can register here. After registering, you’ll need to confirm your email address by clicking the link inside the auto-generated email sent to you.
3. Install the Deta CLI
Next, install the Deta Command Line Interface on your computer by opening Terminal and running
curl -fsSL https://get.deta.dev/cli.sh | sh
You may need to close Terminal and reopen it for the Deta CLI to be recognized. Run deta --help
to see that it’s working.
4. Login with the Deta CLI
From Terminal, run deta login
and log in to your Deta account.
5. Deploy your app with the Deta CLI
cd
into the root of our project (rock-paper-scissors/
) and then run deta new
. This will deploy the application onto deta in a development state, only accessible by you, assuming you’re logged in to your Deta account. (Tip: You can quickly log in to Deta from Terminal with deta login
).
To make your app public to the world, run deta auth disable
.
6. Check it out
Upon running deta new
, you should get a response with some details about your app like its name, id, endpoint, etc. My endpoint, for example, is https://02rkyn.deta.dev/. When I go there, I see the JSON response {"message":"Hello World"}
just as I did in my development environment.
Sweet.
Implementing Rock Paper Scissors
Now let’s implement rock paper scissors. A sensible interface for this would be to set up an endpoint like /shoot/<weapon>
. For example, a user might hit /shoot/rock
or /shoot/paper
. And then we should return something like
{
user_weapon: rock,
opponent_weapon: scissors,
message: 'You won!'
}
Here’s a first pass at implementing the logic for this endpoint.
from fastapi import FastAPI
from random import randrange
app = FastAPI()
@app.get('/')
async def read_root():
return {'message': 'Hello World'}
@app.get('/shoot/{weapon}')
async def shoot(weapon: str):
game_key = {
('rock', 'rock'): "It's a tie.",
('rock', 'paper'): "You lost.",
('rock', 'scissors'): "You won!",
('paper', 'rock'): "You won!",
('paper', 'paper'): "It's a tie.",
('paper', 'scissors'): "You lost.",
('scissors', 'rock'): "You lost.",
('scissors', 'paper'): "You won!",
('scissors', 'scissors'): "It's a tie.",
}
weapons = ['rock', 'paper', 'scissors']
opp_weapon = weapons[randrange(0, 3)]
message = game_key[(weapon, opp_weapon)]
result = {'user_weapon': weapon, 'opponent_weapon': opp_weapon, 'message': message}
return result
- I use the
@app.get()
decorator as this is a GET method because we’re not inserting, updating, or deleting anything from a database. - I use a path parameter called
weapon
which feeds into my functionshoot(weapon: str):
weapon: str
is a type annotation that basically says “the inputted weapon should be a string”- I use the random module to randomly pick the opponent’s weapon
- I combine the user’s weapon and the opponent’s weapon into a tuple
(weapon, opp_weapon)
to form a key. Then I use that key to lookup the appropriate message to display from my dictionary of all possible weapon combinations.
This works, but with a glaring issue. What if the user inputs garbage like /shoot/bazooka
?
In this case we get “Internal Server Error” because the game_key[(weapon, opp_weapon)]
is looking for a key that doesn’t exist. To prevent this, we’ll create an Enum restricted to the strings ‘rock’, ‘paper’, and ‘scissors’, and then we’ll set the weapon parameter’s type annotation to that enum.
from fastapi import FastAPI
from random import randrange
from enum import Enum
app = FastAPI()
class Weapon(str, Enum):
rock = 'rock'
paper = 'paper'
scissors = 'scissors'
@app.get('/')
async def read_root():
return {'message': 'Hello World'}
@app.get('/shoot/{weapon}')
async def shoot(weapon: Weapon):
game_key = {
('rock', 'rock'): "It's a tie.",
('rock', 'paper'): "You lost.",
('rock', 'scissors'): "You won!",
('paper', 'rock'): "You won!",
('paper', 'paper'): "It's a tie.",
('paper', 'scissors'): "You lost.",
('scissors', 'rock'): "You lost.",
('scissors', 'paper'): "You won!",
('scissors', 'scissors'): "It's a tie.",
}
weapons = ['rock', 'paper', 'scissors']
opp_weapon = weapons[randrange(0, 3)]
message = game_key[(weapon, opp_weapon)]
result = {'user_weapon': weapon, 'opponent_weapon': opp_weapon, 'message': message}
return result
Now when we visit /shoot/bazooka
we get a much more descriptive error message.
There’s one last issue I’d like to address before deploying this puppy to Deta.. Here we’re using what’s called a path parameter to input the weapon
. Technically this works fine, but it’d be better to use a query parameter. “rock” describes a weapon. It’s more of an attribute than an instance of a weapon. Potentially we could expand our game to include things like weapon color and weapon size. Imagine the mess that would create if we kept using path parameters.. People would hit endpoints like
/shoot/rock/red/3
or
/shoot/paper/2/blue
.
There’s no natural hierarchy to these attributes and thus no intuitive order for building a path. A better URL structure would look like
/shoot?weapon=rock&color=red&size=3
or
/shoot?weapon=paper&size=2&color=blue
.
So it makes sense to change weapon
from a path parameter to a query parameter. To do this, we simply change
@app.get('/shoot/{weapon}')
async def shoot(weapon: Weapon):
...
to
@app.get('/shoot')
async def shoot(weapon: Weapon):
...
Now if we visit /shoot
we get {"detail":[{"loc":["query","weapon"],"msg":"field required","type":"value_error.missing"}]}
but if we visit /shoot?weapon=rock
we get something like {"user_weapon":"rock","opponent_weapon":"paper","message":"You lost."}
.
Deployment (version 2)
Now let’s deploy our updated app. Deploying our updates to Deta is as simple as running deta deploy
(keeping in mind we would’ve needed to update requirements.txt
if we had incorporated other packages).
I’ll also set up a subdomain for my app within the Deta dashboard so you can visit my app at https://rockpaperscissors.deta.dev/.
Assuming I haven’t deleted the app by now, you can try it out for yourself.
Lastly, check out the awesome swagger documentation we get for our REST API for free thanks to fastapi.