DEV Community

Cover image for Building and Deploying a FastAPI application in Okteto Cloud
Abdulazeez Abdulazeez for Okteto

Posted on

Building and Deploying a FastAPI application in Okteto Cloud

In this tutorial, you'll learn how to develop a CRUD API with FastAPI and deploy the application to Okteto Cloud.

You'll start by building the code of the application, then we'll define the application with Okteto Stacks, and finally, you will deploy it to Okteto Cloud.

What is FastAPI

FastAPI is a modern Python web framework designed for building fast and efficient backend applications. It comes with built-in support for data validation, authentication, and interactive API documentation powered by OpenAPI and Swagger.

What is Okteto?

Okteto is a developer platform used to accelerate the development workflow of cloud-native applications. We'll use Okteto to run our application once we are done with the code.

Initial Setup

Start by creating a new folder to hold your project called 'fastapi-crud':

$ mkdir fastapi-crud
$ cd fastapi-crud
Enter fullscreen mode Exit fullscreen mode

Next, create and activate a virtual environment in the project folder:

$ python3.9 -m venv venv
$ source venv/bin/activate
$ export PYTHONPATH=$PWD
Enter fullscreen mode Exit fullscreen mode

Virtual environment provides an isolated environment to run our Python applications. FastAPI applications require an isolated environment to manage its dependencies such as Uvicorn which is an Asynchronous GateWay Server Interface server.

Next, create the following files and folder:

├── app
│       ├── __init__.py
│       ├── model.py
│       ├── api.py
├──main.py
└── requirements.txt
Enter fullscreen mode Exit fullscreen mode

In your requirements.txt file, add the following dependencies:

fastapi==0.62.0
uvicorn==0.13.1
Enter fullscreen mode Exit fullscreen mode

The first dependency, fastapi, is the framework on which your application will be built. The second dependency, uivorn, is an Asynchronous Gateway Server Interface (ASGI) that enables us to run our FastAPI application.

Install the dependencies:

(venv)$ pip install -r requirements.txt
Enter fullscreen mode Exit fullscreen mode

With the installation complete, define a base route in app/api.py:

from fastapi import FastAPI

app = FastAPI()

@app.get("/", tags=["Home"])
def get_root() -> dict:
    returrn {
        "message": "Welcome to the okteto's app."
    }
Enter fullscreen mode Exit fullscreen mode

You started by importing the FastAPI class from the fastapi package in the code block above. Then you created an instance of the class in the variable app.

Next, you defined a GET route on "/" which is handled by the get_root() function.

In the main.py file, define an entry point for running the application:

import uvicorn

if __name__ == "__main__":
    uvicorn.run("app.api:app", host="0.0.0.0", port=8080, reload=True)
Enter fullscreen mode Exit fullscreen mode

In the code block above, you imported the uvicorn package itself. Under the initializer block, you invoked the run method, which takes the location of FastAPI's instance, the host, port, and the reload boolean value.

In this application, the location of the FastAPI instance, app = FastAPI() is in the file app/api.py. The host value can be set to a valid IP address within your local machine's configured IP scope, it can also be left blank. Likewise the port and reload value. The port 8080 is chosen since Okteto applications run on port 8080 by default, the reload value is set to True to avoid restarting the application on every change made.

Next, start the application:

$ python main.py
Enter fullscreen mode Exit fullscreen mode

You should get a response like this:

INFO:     Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
INFO:     Started reloader process [24513] using statreload
INFO:     Started server process [24515]
INFO:     Waiting for application startup.
INFO:     Application startup complete.

Enter fullscreen mode Exit fullscreen mode

Navigate to http://localhost:8080 in your browser. You should see:

{
    "message": "Welcome to the okteto's app."
}
Enter fullscreen mode Exit fullscreen mode

Routes

You'll be building a recipe application where you can store, remove, update and delete recipes by sending HTTP requests.

Before you begin writing the routes, define the model schema for the application:

In app/model.py, write the following:

from pydantic import BaseModel, Field
from typing import Optional, List


class RecipeSchema(BaseModel):
    id: Optional[int]
    name: str = Field(...)
    ingredients: List[str] = Field(...)

    class Config:
        schema_extra = {
            "example": {
                "name": "Donuts",
                "ingredients": ["Flour", "Milk", "Sugar", "Vegetable Oil"]
            }
        }


class UpdateRecipeSchema(BaseModel):
    name: Optional[str]
    ingredients: Optional[List[str]]

    class Config:
        schema_extra = {
            "example": {
                "name": "Buns",
                "ingredients": ["Flour", "Milk", "Sugar", "Vegetable Oil"]
            }
        }


Enter fullscreen mode Exit fullscreen mode

In the block of code above, you defined how the recipe would be represented, sent, and retrieved in your in-app database. In a realistic scenario, you should use a separate database like MySQL, Postgres or MongoDB.

Each recipe will have an ID automatically generated once a POST request is sent. Each recipe will also comprise a name and an array of ingredients. If any other type of data not listed in the schema is sent in the request body, an error will be returned.

The RecipeSchema has a subclass Config. The subclass contains an object variable, schema_extra, which includes a key example used as the mock data in the interactive documentation.

The UpdateRecipeSchema, on the other hand, is the model for guiding request body sent through the UPDATE route. The difference between this model and the RecipeSchema is that the schema values can be passed optionally.

With the model in place, let's write the code for the application's routes. In app/api.py, add the recipes database before the base route "/":

recipes = [
    {
        "id": 1,
        "name": "Donuts",
        "ingredients": ["Flour", "Milk", "Sugar", "Vegetable Oil"]
    }
]
Enter fullscreen mode Exit fullscreen mode

Below the base route, add the GET routes:

@app.get("/recipe", tags=["Recipe"])
def get_recipes() -> dict:
    return {
        "data": recipes
    }

@app.get("/recipe/{id}", tags=["Recipe"])
def get_recipe(id: int) -> dict:
    if id > len(recipes) or id < 1:
        return {
            "error": "Invalid ID passed."
        }

    for recipe in recipes:
        if recipe['id'] == id:
            return {
                "data": [
                    recipe
                ]
            }

    return {
        "error": "No such recipe with ID {} exist".format(id)
    }
Enter fullscreen mode Exit fullscreen mode

You defined GET routes for retrieving all the recipes and a single recipe using its ID in the code block above. An error message is returned if an invalid ID is passed as a parameter in the /recipe/{id} route. Test the routes by visiting http://localhost:8080/recipe:

Recipes

And http://localhost:8080/recipe/1:

Recipe One

Define the POST route for adding new recipes. Start by updating the imports:

from fastapi import FastAPI, Body

from app.model import RecipeSchema, UpdateRecipeSchema
from fastapi.encoders import jsonable_encoder
Enter fullscreen mode Exit fullscreen mode

Next, add the POST route beneath the GET routes:

@app.post("/recipe", tags=["Recipe"])
def add_recipe(recipe: RecipeSchema = Body(...)) -> dict:
    recipe.id = len(recipes) + 1
    recipes.append(recipe.dict())
    return {
        "message": "Recipe added successfully."
    }
Enter fullscreen mode Exit fullscreen mode

In the code block above, we made sure the request body is modeled in our RecipeSchema by setting the type recipe to RecipeSchema in the add_recipe function. The Body(...) imported from FastAPI ensures that the request body is passed.
In an event where the content of the request doesn't match the specified schema type, an error message automatically generated from Pydantic will be generated and then returned.

In the add_recipe function, the recipe ID is computed by incrementing the length of the recipes database by 1. Test the POST route using curl:

$ curl -X POST http://localhost:8080/recipe -d \
'{"name": "Donut", "ingredients": ["Flour", "Milk", "Butter"]}' \
-H 'Content-Type: application/json'
Enter fullscreen mode Exit fullscreen mode

You will get a response:

{
    "message": "Recipe added successfully."
}
Enter fullscreen mode Exit fullscreen mode

To verify that the recipe has been added, retrieve all the recipes:

$ curl -X GET http://localhost:8080/recipe/2 -H 'Content-Type: application/json'
Enter fullscreen mode Exit fullscreen mode

You will get this response:

{
    "data": [
                {"id":1,"name":"Donuts","ingredients":["Flour","Milk","Sugar","Vegetable Oil"]},
                {"id":2,"name":"Donut","ingredients":["Flour","Milk","Butter"]},
            ]
}
Enter fullscreen mode Exit fullscreen mode

You can also retrieve the recipe from its ID:

$ curl -X GET http://localhost:8080/recipe/2 -H 'Content-Type: application/json'
Enter fullscreen mode Exit fullscreen mode

You will get the response:

{
    "data": [
                {"id":2,"name":"Donut","ingredients":["Flour","Milk","Butter"]}
            ]
}
Enter fullscreen mode Exit fullscreen mode

With the POST route in place, define the UPDATE route:

@app.put("/recipe", tags=["Recipe"])
def update_recipe(id: int, recipe_data: UpdateRecipeSchema) -> dict:
    stored_recipe = {}
    for recipe in recipes:
        if recipe["id"] == id:
            stored_recipe = recipe

    if not stored_recipe:
        return {
                "error": "No such recipe exists."
            }

    stored_recipe_model = RecipeSchema(**stored_recipe)
    update_recipe = recipe_data.dict(exclude_unset=True)
    updated_recipe = stored_recipe_model.copy(update=update_recipe)
    recipes[recipes.index(stored_recipe_model)] = jsonable_encoder(updated_recipe)

    return {
        "message": "Recipe updated successfully."
    }

Enter fullscreen mode Exit fullscreen mode

In the code block above, you find a recipe by the ID supplied and perform a partial or full update depending on the content of the request body passed. If no such ID exists, it returns an error message.

Test the UPDATE route by changing the title of the first recipe to Buns using curl:

$ curl -X PUT "http://0.0.0.0:8080/recipe?id=1" -H  "accept: application/json" -H  "Content-Type: application/json" -d "{  \"name\": \"Buns\",}"
Enter fullscreen mode Exit fullscreen mode

The UPDATE request returns this response:

{
  "message": "Recipe updated successfully."
}
Enter fullscreen mode Exit fullscreen mode

Let's define the final route, DELETE. The DELETE route is used to remove recipe in your application using their ID. Beneath the UPDATE route, add the following:

@app.delete("/recipe/{id}", tags=["Recipe"])
def delete_recipe(id: int) -> dict:
    if id > len(recipes) or id < 1:
        return {
            "error": "Invalid ID passed"
        }

    for recipe in recipes:
        if recipe['id'] == id:
            recipes.remove(recipe)
            return {
                "message": "Recipe deleted successfully."
            }

    return {
        "error": "No such recipe with ID {} exist".format(id)
    }

Enter fullscreen mode Exit fullscreen mode

In the code block above, you first check if the recipe whose ID is passed exists. If it does, the recipe is removed. Otherwise, an error message is returned. Test the DELETE route:

$ curl -X DELETE "http://0.0.0.0:8080/recipe/1" -H  "accept: application/json"
Enter fullscreen mode Exit fullscreen mode

The request above return this response:

{
  "message": "Recipe deleted successfully."
}
Enter fullscreen mode Exit fullscreen mode

You have successfully built the CRUD application.

Deploying to Okteto Cloud

In this section, you will be deploying the application to Okteto Cloud.

Why Deployment?

When you are developing locally, your application is inaccessible by other people since it runs only on your machine's localhost. Deploying it on a cloud service like Okteto enables you and your collaborators to access it through its unique URL address, just like your customers. On top of that, any application deployed in Okteto Cloud automatically gets a free HTTPs endpoint.

Create a free developer account on Okteto if you do not have one setup. Additionally, install the Okteto CLI on your machine.

Before proceeding, create a .gitignore file in the project folder to prevent checking in the "venv" folder to git:

$ touch .gitignore
Enter fullscreen mode Exit fullscreen mode

Add the following:

venv
__pycache__
Enter fullscreen mode Exit fullscreen mode

Next, log into your docker hub account from the console:

$ docker login
Enter fullscreen mode Exit fullscreen mode

If you do not have a docker hub account, visit the DockerHub Website to create one. A docker hub account is necessary to store images built from running the deploy command.

With the necessary installations in place, log on to Okteto from your terminal using the command:

$ okteto login
Enter fullscreen mode Exit fullscreen mode

The next step is to create a stack manifest file for Okteto. The manifest file removes the need to deal with the complexities of Kubernetes manifests.

$ touch okteto-stack.yaml
Enter fullscreen mode Exit fullscreen mode

In the okteto-stack.yaml file, define the application using a format that's very similar to docker-compose:

name: fastapi-crud
services:
  fastapi:
    public: true
    image: DOCKERHUBUSERNAME/fastapi-crud:latest
    build: .
    replicas: 1
    ports:
      - 8080
    resources:
      cpu: 100m
      memory: 128Mi
Enter fullscreen mode Exit fullscreen mode

In the manifest file above, you defined your application's name and the services your app is made up of, fastapi. The fastapi service exposes your application via the port 8080 to a public HTTPs endpoint with a valid certificate. The service also allocates itself with 100 mili CPUs and 128Mi of memory. Replace DOCKERHUBUSERNAME in the file above with your docker hub username.

You can learn more about the Okteto Stacks manifest format here.

With the manifest in place, create a Dockerfile to house the build instruction for the application. Dockerfile contains build instruction for images to be deployed on Okteto.

$ touch Dockerfile
Enter fullscreen mode Exit fullscreen mode

Add the following:

FROM python:3.8
ADD requirements.txt /requirements.txt
ADD main.py /main.py
ADD okteto-stack.yaml /okteto-stack.yaml
RUN pip install -r requirements.txt
EXPOSE 8080
COPY ./app app
CMD ["python3", "main.py"]
Enter fullscreen mode Exit fullscreen mode

Let's deploy your application to Okteto. Start by running the command:

$ okteto namespace
Enter fullscreen mode Exit fullscreen mode

The command above setups a namespace on the Okteto cloud. Next, run the command to deploy the application:

$ okteto stack deploy --build
Enter fullscreen mode Exit fullscreen mode

The command above builds the service you indicated in the okteto-stack.yml file and then deploys the application. This command eases the stress of having to build, then manually configuring the application after deployment.

The command above starts the application. Navigate to your dashboard and click the link under the "Endpoints" heading:

Dashboard

Go on and test the endpoints.

Conclusion

In this article, you built a CRUD recipe application and deployed it to Okteto.

You witnessed the simplicity and ease of deployment of an application using Okteto and how fast and simple it is to build FastAPI applications.

The application can also be deployed directly to Okteto from a GitHub repository by following this Step By Step Guide.

The code used in this article can be found in GitHub.

Top comments (0)