Using FastAPI to Deploy ML Models

Notes on using FastAPI as I go through the online documentation
Author

Kedar Dabhadkar

Published

December 19, 2021

Using FastAPI to Deploy ML Models

Notes on using FastAPI as I go through the online documentation



Kedar Dabhadkar
Data Scientist
linkedin.com/in/dkedar7

What is FastAPI?

  • This is what FastAPI is

First steps

  • This is simply how we define an app and write our first GET request.
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
async def root():
    return {"message": "Hello World"}
  • If this code is saved to main.py, use the follwowing to run it from the command line:
uvicorn main:app --reload
  • FastAPI also automatically generates a Swagger documentation at ‘http://127.0.0.1:8000’


## 1. Path parameters

- Path parameters or variables can be defined in the decorator and also as an input to the function.

```python
@app.get("/items/{item_id}")
def read_item(item_id: int):
   return {"item_id": item_id}
  • Value of ‘item_id’ is passed to the function

  • This function is run when a user accesses the endpoint ‘http://127.0.0.1:8000/items/3’

  • Also, order matters.

  • In the following example, although /users/me matches with the pattern /users/{user_id}, it’s executed first because it appears first

@app.get("/users/me")
def read_user_me():
    return {"user_id": "the current user"}

@app.get("/users/{user_id}")
def read_user(user_id: str):
    return {"user_id": user_id}

2. Query Parameters

  • All the function arguments that are not a part of the path parameters are treated as query parameters.
  • Query parameters appear after ? in a URL and are separated by &
@app.get("/items/")
async def read_item(skip: int = 0, limit: int = 10):
    return data[skip : skip + limit]
  • If a default value is set in the function arguments, these parameters become optional. If not, they are mandatory.

Multiple path and query parameters

  • You can declare multiple path parameters and query parameters at the same time, FastAPI knows which is which.
  • They will be detected by name, so the order doesn’t matter.
  • For example,
@app.get("/users/{user_id}/items/{item_id}")
async def read_user_item(
    user_id: int, item_id: str, q: str = None, short: bool = False
)
...

3. Request Body

  • A request body is data sent by the client to your API.
  • To declare a request body, you use Pydantic models.
  • A request body is usually applicable to non-GET APIs (most POST)
from typing import Optional
from fastapi import FastAPI
from pydantic import BaseModel

class Item(BaseModel):
    name: str
    description: Optional[str] = None
    price: float
    tax: Optional[float] = None

app = FastAPI()

@app.post("/items/")
async def create_item(item: Item):
    return item

Request body + path + query parameters

  • You can also declare body, path and query parameters, all at the same time
@app.put("/items/{item_id}")
def create_item(item_id: int, item: Item, q: Optional[str] = None):
    result = {"item_id": item_id, **item.dict()}
    if q:
        result.update({"q": q})
    return result
  • If the parameter is also declared in the path, it will be used as a path parameter.
  • If the parameter is of a singular type (like int, float, str, bool, etc) it will be interpreted as a query parameter
  • If the parameter is declared to be of the type of a Pydantic model, it will be interpreted as a request body.

4. Query Parameters and String Validations

  • FastAPI allows you to declare additional information and validation for your parameters.

Example 1. Length of parameter doesn’t exceed 50 characters and it is optional

from fastapi import FastAPI, Query

@app.get("/items/")
def read_items(q: Optional[str] = Query(None, max_length=50)):
    ...

The Optional keyword is only to guide our code editor. Here, None is the default value.

Example 2. In addition, the minimum length should be 3

@app.get("/items/")
def read_items(q: Optional[str] = Query(None, min_length=3, max_length=50)):
    ...

Example 3. It should match the regex ^fixedquery$

@app.get("/items/")
def read_items(q: Optional[str] = Query(None, min_length=3, max_length=50, regex="^fixedquery$")):
    ...

Example 4. When you want to declare a variable as required while using Query

@app.get("/items/")
def read_items(q: Optional[str] = Query(..., min_length=3)):
    ...

... is called the Ellipsis in Python.

Example 5. Query parameter list / multiple values

@app.get("/items/")
def read_items(q: Optional[List[str]] = Query(None)):
    ...

Example 6. Query parameter list / multiple values with defaults

@app.get("/items/")
def read_items(q: Optional[List[str]] = Query(["foo", "bar"])):
    ...

Example 7. Declare more metadata

@app.get("/items/")
def read_items(
    q: Optional[str] = Query(
        None,
        alias="item-query",
        title="Query string",
        description="Query string for the items to search in the database that have a good match",
        min_length=3,
        max_length=50,
        regex="^fixedquery$",
        deprecated=True,
    )
):
    ...

This indicates that the parameter - Is an alias for item-query - Has the mentioned title and decription - And is deprecated

5. Path parameters and numeric validations

Path parameters

  • You can declare the same type of validations and metadata for path parameters with Path.
  • You can declare all the same parameters as for Query.

For example,

@app.get("/items/{item_id}")
def read_items(
    item_id: int = Path(..., title="The ID of the item to get"),
    q: Optional[str] = Query(None, alias="item-query"),
):
    ...

Numeric validations

  • Just like string validations, you can also define numeric validations.
  • Examples: ge: greater than or equal to, le: less than or equal to, gt: greater than, and lt: less than.
@app.get("/items/{item_id}")
def read_items(
    item_id: int = Path(..., gt=0, lt=1)
):
    ...

6. Multi-body parameters

  • Like Query for query params and Path for path params, FastAPI has Body for body parameters.
  • All the same arguments apply for body params
  • As we saw earlier, FastAPI identifies body parameters if they are instances of BaseModel.

Example of defining path, query and body parameters together

class Item(BaseModel):
    name: str
    description: Optional[str] = None
    price: float
    tax: Optional[float] = None
    
def update_item(
    item_id: int = Path(..., title="The ID of the item to get", ge=0, le=1000),
    q: Optional[str] = None,
    item: Optional[Item] = None,
):
    ...

Specifying multiple body parameters

  • Simply define multiple BaseModel derived classes
class Item(BaseModel):
    name: str
    description: Optional[str] = None
    price: float
    tax: Optional[float] = None


class User(BaseModel):
    username: str
    full_name: Optional[str] = None
  • The expected body should look like this
{
   "item": {
       "name": "Foo",
       "description": "The pretender",
       "price": 42.0,
       "tax": 3.2
   },
   "user": {
       "username": "dave",
       "full_name": "Dave Grohl"
   }
}
  • Or the body parameters can be singular values
def update_item(
    item_id: int, item: Item, user: User, importance: int = Body(..., title="", description="")
):

In this case, FastAPI will expect a body like:

{
    "item": {
        "name": "Foo",
        "description": "The pretender",
        "price": 42.0,
        "tax": 3.2
    },
    "user": {
        "username": "dave",
        "full_name": "Dave Grohl"
    },
    "importance": 5
}

7. Body - Fields

  • You can declare validation and metadata inside of Pydantic models using Pydantic’s Field.

  • Field works the same way as Query, Path and Body, it has all the same parameters, etc.

from pydantic import BaseModel, Field

class Item(BaseModel):
    name: str
    description: Optional[str] = Field(
        None, title="The description of the item", max_length=300
    )
    price: float = Field(..., gt=0, description="The price must be greater than zero")
    tax: Optional[float] = None

8. Body - Nested Models

How do we have arrays/ lists in body parameters?

  • Use List datatype and include which type of objects should be entered in this.

Example:

from typing import List, Optional, Set

class Item(BaseModel):
    name: str
    description: Optional[str] = None
    price: float
    tax: Optional[float] = None
    tags: List[str] = []

How do we have nested models?

  • These Pydantic models act like dicts.
class Image(BaseModel):
    url: str
    name: str

class Item(BaseModel):
    name: str
    description: Optional[str] = None
    price: float
    tax: Optional[float] = None
    tags: Set[str] = []
    image: Optional[Image] = None
  • Or you can also have the nested model as a list:
class Image(BaseModel):
    url: str
    name: str

class Item(BaseModel):
    name: str
    description: Optional[str] = None
    price: float
    tax: Optional[float] = None
    tags: Set[str] = []
    image: Optional[List[Image]] = None

9. Declare Request Example Data

  • You can declare an example for a Pydantic model using Config and schema_extra.
class Item(BaseModel):
    name: str
    description: Optional[str] = None
    price: float
    tax: Optional[float] = None

    class Config:
        schema_extra = {
            "example": {
                "name": "Foo",
                "description": "A very nice Item",
                "price": 35.4,
                "tax": 3.2,
            }
        }

Using example

  • When using any of: Path(), Query(), Header(), Cookie(), Body(), Form(), File() you can also declare a data example or a group of examples with additional information that will be added to OpenAPI.
@app.put("/items/{item_id}")
def update_item(
    item_id: int,
    item: Item = Body(
        ...,
        example={
            "name": "Foo",
            "description": "A very nice Item",
            "price": 35.4,
            "tax": 3.2,
        },
    ),
):
    ...

10. Extra data types

List of additionl data types: https://fastapi.tiangolo.com/tutorial/extra-data-types/

12. Header Parameters

  • You can define Header parameters the same way you define Query, Path and Cookie parameters.
from typing import Optional
from fastapi import FastAPI, Header

app = FastAPI()

@app.get("/items/")
async def read_items(user_agent: Optional[str] = Header(None)):
    return {"User-Agent": user_agent}
  • Additionally with Headers, FastAPI will convert any _s to hyphens by default, since many headers have hyphens in them.
  • This behavior can be changed by changing the convert_underscores argument to False in Query.

Duplicate headers

  • It’s possible for a request to specify duplicate headers.

Like this:

@app.get("/items/")
def read_items(x_token: Optional[List[str]] = Header(None)):
    return {"X-Token values": x_token}

13. Response Model

  • Just like you can specify a model for the input data, you can also specify a model for the response. This model needs to be given as an argument to the decorator.

Example:

class UserIn(BaseModel):
    username: str
    password: str
    email: EmailStr
    full_name: Optional[str] = None

class UserOut(BaseModel):
    username: str
    email: EmailStr
    full_name: Optional[str] = None

@app.post("/user/", response_model=UserOut)
...
  • If your response model has default values that you want to exclude from the response, you can specify the value of the argument response_model_exclude_unset to True.
  • This Pydantic argument is smart enough to different which values were not set at all (for which default got entered) vs the values that were entered but matched the default values by chance.

14. Extra Models

  • Various models can inherit from the other to reduce redundancy in the code. This allows you to write just the difference between models.

Example:

class UserBase(BaseModel):
    username: str
    email: EmailStr
    full_name: Optional[str] = None

class UserIn(UserBase):
    password: str
    
class UserOut(UserBase):
    pass

class UserInDB(UserBase):
    hashed_password: str
  • A response can be a union, intersection or a list of multiple models.
class BaseItem(BaseModel):
    description: str
    type: str

class CarItem(BaseItem):
    type = "car"

class PlaneItem(BaseItem):
    type = "plane"
    size: int
    
@app.get("/items/{item_id}", response_model=Union[PlaneItem, CarItem])
...
  • Response can also be a List of models
from typing import List

class Item(BaseModel):
    name: str
    description: str

items = [
    {"name": "Foo", "description": "There comes my hero"},
    {"name": "Red", "description": "It's my aeroplane"},
]

@app.get("/items/", response_model=List[Item])
def read_items():
    return items

15. Response Status Code

  • Status codes to be returned with a response can be defined in the status_code argument of the decorator.

  • Each error code has a standard use. For example, > 100 and above are for “Information”
    > 200 and above are for “Successful” responses
    > 300 and above are for “Redirection”
    > 400 and above are for “Client error” responses
    > 500 and above are for server errors

  • FastAPI provides status code variables in fastapi.status so that the developer does not have to remember them. For example, status.HTTP_201_CREATED.

16. Form data

  • When you need to receive form fields instead of JSON, you can use Form.
  • To use forms, first install python-multipart with pip install python-multipart.
from fastapi import FastAPI, Form

@app.post("/login/")
def login(username: str = Form(...), password: str = Form(...)):
    return {"username": username}
  • Form parameters work the same way as Body or Query.
@app.post("/login/")

def login(username: str = Form(...), password: str = Form(...)):
    return {"username": username}
  • Data from forms is normally encoded using the “media type” application/x-www-form-urlencoded.

17. Request files

  • You can define files to be uploaded by the client using File.
  • To receive uploaded files, first install python-multipart with pip install python-multipart.
  • The File and UploadFile methods can be used to handle uploaded file contents.

Example:

@app.post("/files/")
def create_file(file: bytes = File(...)):
    return {"file_size": len(file)}


@app.post("/uploadfile/")
def create_upload_file(file: UploadFile = File(...)):
    return {"filename": file.filename}
  • File only works well for small files (it tries to store the content of files on memory). Whereas, UploadFile also works well with large files.
  • More details on handling uploaded file contents is outside the scope of these notes, for now.

18. Request Forms and Files

  • You can define files and form fields at the same time using File and Form.
  • Create file and form parameters the same way you would for Body or Query:
from fastapi import FastAPI, File, Form, UploadFile

@app.post("/files/")
def create_file(
    file: bytes = File(...), fileb: UploadFile = File(...), token: str = Form(...)
):
    return {
        "file_size": len(file),
        "token": token,
        "fileb_content_type": fileb.content_type,
    }

19. Handling Errors