Controller
A controller receives a request, then calls a use case, before finally returning a response.
The controller (adapter layer) is responsible for validating and transforming requests into an understandable format for the use cases (application layer). The format is defined inside the use cases by the request and response models. The controller takes the user input (the request), converts it into the request model defined by the use case and passes the request model to the use case, and at the end return the response model.
from fastapi import APIRouter, Depends
from authentication.authentication import auth_with_jwt
from authentication.models import User
from features.todo.repository.todo_repository import get_todo_repository
from features.todo.repository.todo_repository_interface import TodoRepositoryInterface
from features.todo.use_cases.add_todo import (
AddTodoRequest,
AddTodoResponse,
add_todo_use_case,
)
from features.todo.use_cases.delete_todo_by_id import (
DeleteTodoByIdResponse,
delete_todo_use_case,
)
from features.todo.use_cases.get_todo_all import GetTodoAllResponse, get_todo_all_use_case
from features.todo.use_cases.get_todo_by_id import (
GetTodoByIdResponse,
get_todo_by_id_use_case,
)
from features.todo.use_cases.update_todo import (
UpdateTodoRequest,
UpdateTodoResponse,
update_todo_use_case,
)
router = APIRouter(tags=["todos"], prefix="/todos")
@router.post("", operation_id="create")
def add_todo(
data: AddTodoRequest,
user: User = Depends(auth_with_jwt),
todo_repository: TodoRepositoryInterface = Depends(get_todo_repository),
) -> AddTodoResponse:
return add_todo_use_case(data=data, user_id=user.user_id, todo_repository=todo_repository)
@router.get("/{id}", operation_id="get_by_id")
def get_todo_by_id(
id: str,
user: User = Depends(auth_with_jwt),
todo_repository: TodoRepositoryInterface = Depends(get_todo_repository),
) -> GetTodoByIdResponse:
return get_todo_by_id_use_case(id=id, user_id=user.user_id, todo_repository=todo_repository)
@router.delete("/{id}", operation_id="delete_by_id")
def delete_todo_by_id(
id: str,
user: User = Depends(auth_with_jwt),
todo_repository: TodoRepositoryInterface = Depends(get_todo_repository),
) -> DeleteTodoByIdResponse:
return delete_todo_use_case(id=id, user_id=user.user_id, todo_repository=todo_repository)
@router.get("", operation_id="get_all")
def get_todo_all(
user: User = Depends(auth_with_jwt), todo_repository: TodoRepositoryInterface = Depends(get_todo_repository)
) -> list[GetTodoAllResponse]:
return get_todo_all_use_case(user_id=user.user_id, todo_repository=todo_repository) # type: ignore
@router.put("/{id}", operation_id="update_by_id")
def update_todo(
id: str,
data: UpdateTodoRequest,
user: User = Depends(auth_with_jwt),
todo_repository: TodoRepositoryInterface = Depends(get_todo_repository),
) -> UpdateTodoResponse:
return update_todo_use_case(id=id, data=data, user_id=user.user_id, todo_repository=todo_repository)
Required
- The controller needs to be decorated with the
create_response
decorator, which handles exceptions and returns a unified response type. - The controller needs to have set the
response_model
andrequest_model
, that is used to generate API documentation and used for validation.
- The controller needs to be decorated with the
Optional
- Add repository interface to handle communication to external services such as databases and inject the repository implementations to the controller endpoint and pass the injected repository implementations to the use case.
FastAPI is built around the OpenAPI Specification (formerly known as swagger) standards. In FastAPI, by coding your endpoints, you are automatically writing your API documentation. FastAPI maps your endpoint details to a JSON Schema document. Under the hood, FastAPI uses Pydantic for data validation. With Pydantic along with type hints, you get a nice editor experience with autocompletion.
Testing controllers
Use the test_client
fixture to populate the database with test data and test_app
fixture to perform REST API calls.
import pytest
from starlette.status import (
HTTP_200_OK,
HTTP_404_NOT_FOUND,
HTTP_422_UNPROCESSABLE_ENTITY,
)
from starlette.testclient import TestClient
from data_providers.clients.client_interface import ClientInterface
class TestTodo:
@pytest.fixture(autouse=True)
def setup_database(self, test_client: ClientInterface):
test_client.insert_many(
[
{"_id": "1", "id": "1", "title": "title 1", "user_id": "nologin"},
{"_id": "2", "id": "2", "title": "title 2", "user_id": "nologin"},
]
)
def test_get_todo_all(self, test_app: TestClient):
response = test_app.get("/todos")
items = response.json()
assert response.status_code == HTTP_200_OK
assert len(items) == 2
assert items[0]["id"] == "1"
assert items[0]["title"] == "title 1"
assert items[1]["id"] == "2"
assert items[1]["title"] == "title 2"
def test_get_todo_by_id(self, test_app: TestClient):
response = test_app.get("/todos/1")
assert response.status_code == HTTP_200_OK
assert response.json()["id"] == "1"
assert response.json()["title"] == "title 1"
def test_get_todo_should_return_not_found(self, test_app: TestClient):
response = test_app.get("/todos/unknown")
assert response.status_code == HTTP_404_NOT_FOUND
def test_add_todo(self, test_app: TestClient):
response = test_app.post("/todos", json={"title": "title 3"})
item = response.json()
assert response.status_code == HTTP_200_OK
assert item["title"] == "title 3"
def test_add_todo_should_return_unprocessable_when_invalid_entity(self, test_app: TestClient):
response = test_app.post("/todos", json=None)
assert response.status_code == HTTP_422_UNPROCESSABLE_ENTITY
def test_update_todo(self, test_app):
response = test_app.put("/todos/1", json={"title": "title 1 updated", "is_completed": False})
assert response.status_code == HTTP_200_OK
assert response.json()["success"]
def test_update_todo_should_return_not_found(self, test_app):
response = test_app.put("/todos/unknown", json={"title": "something", "is_completed": False})
assert response.status_code == HTTP_404_NOT_FOUND
def test_update_todo_should_return_unprocessable_when_invalid_entity(self, test_app: TestClient):
response = test_app.put("/todos/1", json={"title": ""})
assert response.status_code == HTTP_422_UNPROCESSABLE_ENTITY
def test_delete_todo(self, test_app: TestClient):
response = test_app.delete("/todos/1")
assert response.status_code == HTTP_200_OK
assert response.json()["success"]
def test_delete_todo_should_return_not_found(self, test_app: TestClient):
response = test_app.delete("/todos/unknown")
assert response.status_code == HTTP_404_NOT_FOUND
Mark it as integration test.