Python FastAPI From .Net developer perspective

Vlad Sarunov
11 min readMay 2, 2022

As part of my personal development for my next project I have decided to use Python and FastAPI , I have selected it because it provides out of the box swagger documentation and async support.

In this articles I will try go through the basics of FastAPI with a .Net hat on, on such points as:

  • Project scaffolding
  • Introduce a new endpoint
  • Validation
  • Dependencies (DI)

Project Scaffolding

FastAPI provides a templating documentation, however, IMO, it is very limited. But if the existing templates are your cup of tea, use them, I will be following this part of documentation and building the application from scratch. Creating the below structure to start with:

Initial structure

Introducing new endpoint

OK, now that we have a structure, lets add our first endpoint resource. In the screenshot above we have a books folder with a books.pyfile inside. To start, lets add the necessary imports to indicate that this is an API endpoint.

from fastapi import APIRouter

router = APIRouter()

The APIRouter is essentially something that tells that this file/class is an endpoint, which we will later include in the main FastAPIDefinition. from documentation:

You can think of APIRouter as a "mini FastAPI" class.

Now lets add our endpoints for basic CRUD operations, starting with get_books endpoint.

from fastapi import APIRouter

router = APIRouter()
fake_books = {
"c54272a7-b610-4d83-9910-fbdabb66e138":
{
"author": "Frank Herbert",
"name": "Dune"
},
"26f552e2-9acd-4a47-b05f-df11e4685fa7":
{
"author": "Frank Herbert",
"name": "Children of Dune"
}
}
@router.get(“/books/”, tags=[“books”], status_code = 200)
async def get_books():
return fake_books

Here we have an endpoint with the main path /books/ and with defined tags of books as well. Tags are used for the OpenApi documentation. One thing that is familiar for C# developers here is the good old async keyword, and as you might guest it, we can use async code in python as well together with an await . The principle of working is the same as in .Net , but if you want more details you can read about it here.

The interesting thing is the status_code — this property indicates what status code is returned on a successful response independently, if it has a return body or not. So you do not have to explicitly type return OK(); or other ActionResult like in C# . That is why we just return the collection of the books in our endpoint and let FastApi handle everything for us. Lets add endpoint to get book by id and see how we manage status code when there is no book matching the supplied id:

from fastapi import APIRouter, HTTPException # Adding just the import of exception type.....@router.get("/books/{id}", tags=[“books”], status_code = 200)
async def get_book_by_id(id: str):
if id not in fake_books:
raise HTTPException(status_code=404, detail="book not found")
return {"Name": fake_books[id]["Name"], "Author": fake_books[id]["Author"], "Id": id}

Yes, you read it correctly — in order to return a not found for a book that does not exist, you have to raise an HTTPException, which is odd when you come from C# background. Usually you would either return an ActionResult , as stated previously, or catch an exception, if it is appropriate, and map it to a status code. Here, however you have to raise an HTTPException with a status code. Which has its own advantages. You can also customize the response, add specific headers and etc. Explore more about possible options here.

Next lets add a Post/Create endpoint and with it another import and a class:

from pydantic import BaseModelclass Book(BaseModel):
name: str
author: str
...@router.post("/books/", tags=[“books”], status_code = 201)
async def create_new_book(book: Book):
newBookId = uuid.uuid4()
fake_books[str(newBookId)] = {"author": book.author, "name": book.name}
return {"bookId": newBookId}

The interesting detail here is that we need to import BaseModel from pydantic. Pydantic allows us to defined custom data type and provides type hinting as well as simplifies validation for us.

Finally, lets add put and delete endpoints:

@router.put("/books/{id}",tags=[“books”], status_code = 204)
async def update_book(id: str, book: Book):
if id not in fake_books:
raise HTTPException(status_code=404, detail="book not found")
fake_books[str(id)] = {"author": book.author, "name": book.name}
@router.delete("/books/{id}", tags=[“books”],status_code = 204)
async def delete_book(id: str):
if id not in fake_books:
raise HTTPException(status_code=404, detail="book not found")
fake_books.pop(str(id))

The one thing that is special about these methods is that they don’t return anything and you do not have to specify that you return just a status code, like it would be usual in .net , for instance return BadRequest() . Here we specify the status_code in the route definition which gets returned, in this case it is 204 . Another gotcha here is that if the defined status code would be anything but 204 and you don’t specify something to return, FastApi automatically returns null response body, that is why 204 gets a special treatment and only returns the http status code and basta.

Now lets add the endpoint to the main.py

from fastapi import FastAPI
import routers.books.books
app = FastAPI()app.include_router(routers.books.books.router)@app.get("/")
async def root():
return {"message": "Hello World"}

Looking at the books.py, it can be simplified by abstracting out some of the common code, such as route prefix and tags by putting them inside the APIroute definition:

router = APIRouter(
prefix="/books",
tags=["books"],
responses={404: {"description": "Not found"}}
)

On top of that, you can add possible responses definitions, like we did above, which would be reflected in your swagger documentation. You can also do the same on individual api endpoints.

We also can remove the /books prefix on the endpoints and just leave the forward slash, as well as remove the tags . You can do the same exact thing on the main.py FastAPI definition in order to configure global route, tags, dependencies and responses.

Validation

Ok, so how do we validate our requests ? We utilize what pydantic has to offer and based on the documentation create a Validator for each field. Lets add two new imports from pydantic , ValidationError and validator .

from pydantic import BaseModel, validator

And add the validators themselves:

class Book(BaseModel):
name: str
author: str
@validator('name')
def name_must_not_be_empty(cls, v):
if not v:
raise ValueError('must not be empty')
return v
@validator('author')
def author_must_not_be_empty(cls, v):
if not v:
raise ValueError('must not be empty')
return v

So as you can see, the validators are part of the class and they are executed when you are trying to create an instance of the Book class. There are different options on how you can simplify the validation and various other cases on how to adjust validation, you can explore these options here. One example, from documentation, would be on how to use other properties to validate another, for instance, if you want to validate if the author actually wrote the book you would check if the name of the book belongs to the author.

One thing that might catch you off guard is the response code when validation fails, as of writing this article, if I make a post request with an empty name, I will get back a 422 Unprocessable Entity http status code response with the following body:

{
"detail": [
{
"loc": [
"body",
"name"
],
"msg": "must not be empty",
"type": "value_error"
}
]
}

In order to override the response code or body, you would need to register a global exception handler to change that. When validation fails, we raise a ValueError which pydantic converts into ValidationError (you can read more on the pydantic validation page).

On the other side, FastAPI raises RequestValidationError when request contains invalid data, which is a sub class of ValidationError, thus it gets translated into a 422 http status code.

In order to implement the exception handler, we need to add some more imports to the books.py .

from fastapi import APIRouter, HTTPException, Request # just Request is needed
from fastapi.responses import JSONResponse # new import

We have added a Request from the fastapi and a completely new import of JSONResponse . Now lets add an exception handler.

async def value_error_exception_handler(request: Request, exc: ValueError):
return JSONResponse(
status_code=400,
content={"message": str(exc)},
)

And the final bit is to import and add it in the main.py

from fastapi import FastAPI
import routers.books.books
from routers.books.books import value_error_exception_handler
from fastapi.exceptions import RequestValidationError
app = FastAPI(
exception_handlers = {RequestValidationError: value_error_exception_handler},
)
app.include_router(routers.books.books.router)@app.get("/")
async def root():
return {"message": "Hello World"}

Dependencies

To begin with, you can think of it as a powerful mechanism for Dependencies injection who can function like a middleware as well. For example, lets look on how dependencies can function as a sort of a middleware. I am going to utilize the example provided in the documentation here.

Inside our books.py , lets import from fastapi these two “classes”, Depends and Header and add two new functions to verify headers that are being received.

async def verify_token(x_token: str = Header(...)):
if x_token != "fake-super-secret-token":
raise HTTPException(status_code=400, detail="X-Token header invalid")
async def verify_key(x_key: str = Header(...)):
if x_key != "fake-super-secret-key":
raise HTTPException(status_code=400, detail="X-Key header invalid")
return x_key

Now add them to our get books endpoint:

@router.get("/", dependencies=[Depends(verify_token), Depends(verify_key)], status_code = 200)
async def get_books():
return fake_books

What this will do is add to our open api documentation swagger page that this endpoint is expecting the x_token and x_key headers, and make them required, and if you supply invalid ones you will get a BadRequest back. You can apply this dependencies on per resource level or on a global level. Thus this way you can implement authentication/authorization. For instance, on the router definition:

router = APIRouter(
prefix="/books",
tags=["books"],
responses={404: {"description": "Not found"}},
dependencies=[Depends(verify_token), Depends(verify_key)]
)

Same can be done in the main.py on the FastAPI and thus apply it globally.

So how can we inject functions to our endpoints? Firstly, each endpoint is injected individually, it can be injected with another function via Depends. For example, define a function in books.py and inject it to our get books endpoint:

async def common_parameters(q: Optional[str] = None, skip: int = 0, limit: int = 100):
return {"q": q, "skip": skip, "limit": limit}
@router.get("/", status_code = 200)
async def get_books(commons: dict = Depends(common_parameters)):
return commons

The parameters inside the common_parameters are treated like query parameters for the get request. You can do the same on Post request and ask to accept a request body. For instance:

async def common_body(book: Book):
return book
@router.post("/", status_code = 201)
async def create_new_book(commons: dict = Depends(common_body)):
return commons

This can be made to work in similar principle as MediatR works, you have your command functions and you have your query functions. This as well can be utilized to access a database, lets explore this option.

Firstly, lets create a new file models.py in our book folder and move the books class from books.py to the new file, so that we could share the books across our resource. Secondly, in our books folder, add another file called dependencies.py . This file will have our class and function dependencies, moving all the previously created dependencies there as well. Finally, move our previously created value_error_exception_handler to exception_handlers.py module file in the same books folder, just to keep our resource endpoint clean. On top of this, clean our dependency imports and adjust in our main.py the exception handler import.

Inside our dependencies.py we will add a BooksRepository class with the fake_books, moved from the endpoint, on top of the dependencies that we have moved previously the file content looks like this:

from .models import Book
from fastapi import HTTPException, Header
import uuid
class BookRepository:fake_books = {
"c54272a7-b610-4d83-9910-fbdabb66e138": {
"author": "Frank Herbert",
"name": "Dune"
},
"26f552e2-9acd-4a47-b05f-df11e4685fa7": {
"author": "Frank Herbert",
"name": "Children of Dune"
}
}
def save_book(self, book: Book):
newBookId = uuid.uuid4()
self.fake_books[str(newBookId)] = {
"author": book.author, "name": book.name}
return {"bookId": newBookId}
def get_book_by_id(self, id: str):
if id not in self.fake_books:
raise HTTPException(status_code=404, detail="book not found")
return {"Name": self.fake_books[id]["name"], "Author": self.fake_books[id]["author"], "Id": id}
def get_all_books(self):
return self.fake_books
def update_book(self, id: str, book: Book):
if id not in self.fake_books:
raise HTTPException(status_code=404, detail="book not found")
self.fake_books[str(id)] = {"author": book.author, "name": book.name}
def delete_book(self, id: str):
if id not in self.fake_books:
raise HTTPException(status_code=404, detail="book not found")
self.fake_books.pop(str(id))
# Token dependencies
async def verify_token(x_token: str = Header(...)):
if x_token != "fake-super-secret-token":
raise HTTPException(status_code=400, detail="X-Token header invalid")
async def verify_key(x_key: str = Header(...)):
if x_key != "fake-super-secret-key":
raise HTTPException(status_code=400, detail="X-Key header invalid")
return x_key

After adjusting our books.py resource endpoint and configuring dependencies for the BooksRepository it will look like this:

from fastapi import APIRouter, Depends
from .models import Book
from .dependencies import BookRepository, verify_token, verify_key
router = APIRouter(
prefix="/books",
tags=["books"],
responses={404: {"description": "Not found"}},
dependencies=[Depends(verify_token), Depends(verify_key)]
)
@router.get("/", status_code=200)
async def get_books(repository: BookRepository = Depends()):
return repository.get_all_books()
@router.get("/{id}", status_code=200)
async def get_book_by_id(id: str, repository: BookRepository = Depends()):
return repository.get_book_by_id(id)
@router.post("/", status_code=201)
async def create_new_book(book: Book, repository: BookRepository = Depends()):
newBook = repository.save_book(book)
return newBook
@router.put("/{id}", status_code=204)
async def update_book(id: str, book: Book, repository: BookRepository = Depends()):
repository.update_book(id,book)
@router.delete("/{id}", status_code=204)
async def delete_book(id: str, repository: BookRepository = Depends()):
repository.delete_book(id)

The final bit with dependencies is to look at sub-dependencies. For this we will create a mapper function, which would map from the request book to the domain book. Firstly, add a file request.py and clone our Book model there, renaming it to BookRequest. Then, from our models.py book class, remove the validation (we will leave this for the request model to handle). Finally, in our dependencies.py , add two new functions to handle the acceptance of the BookRequest and mapping.

def book_mapper(book: BookRequest):
return Book(name=book.name, author=book.author)
def book_extractor(
mapper: book_mapper = Depends(book_mapper)
): return mapper

Adjust the post and put endpoints to accept the book_extractor function as a dependency.

@router.post("/", status_code=201)
async def create_new_book(book: Book = Depends(book_extractor), repository: BookRepository = Depends()):
newBook = repository.save_book(book)
return newBook
@router.put("/{id}", status_code=204)
async def update_book(id: str, book: Book = Depends(book_extractor), repository: BookRepository = Depends()):
repository.update_book(id,book)

From the perspective of the endpoint, this will not change what request body is being accepted, however, from the book_extractor side, it will leave the request body definition to the book_mapper , similarly to the common_body example, and the extractor will return the mapped instance.

Explore more ways of dependency injection in FastAPI here.

Summary

Project scaffolding — I have found it limited only to the available templates and would not suit everyone’s needs. Thus, in practice best to create your own.

Adding new endpoint — the definitions and routing should be readable from the start to any .Net developer, however, has some specifics. For example, null response body on post/put requests that return not a 204 http status code. Otherwise, it is easy to specify which response code applies to what scale, is it endpoint level, whole resource or global.

Validation — very flexible and can be adjusted to your needs. Can be used on the request object as much as on a domain. However the handling of the failure gets trickier, with the need to specify a handler in some cases, in order to have a different response from the API.

Dependencies — or simply DI, as we all know it. Compared to the system in .Net, is harder to configure and understand. There is no direct way to configure lifetime of the object and it will instantiate for each endpoint, no constructor for injection. Not clear from the get go if the dependency defines the query, route or body that the endpoint accepts and which dependencies are defining them if you have many. On the other hand, the mechanism to define something injectable and reuse it on different levels is very elastic, e.g a sort of a middleware on a resource or app level.

In conclusion, FastAPI provides a flexible approach for building API’s using python, has an easy learning curve and good documentation. The basic features that you regularly use in .Net, are transferable to FastAPI python project. .Net developer could easily start a new project using the framework and build a good base for their API’s within a days work.

The final project can be found in github repository.

--

--

Vlad Sarunov

.Net developer. Generally curious about how things work.