Last update: 16 October 2023
In addition to code that works, I believe that having a code that is well structured, easy to read and maintain is equally important. In this article, we will explore a method to achieve this goal.
Although the structure we discuss is applicable to any framework, for the purpose of the illustration, we will build an API to manage users using FastAPI.
You can find the final code here đ https://github.com/pythops/python-webapp
Here is the structure that we will explore through this article
Let's start with a simple boilerplate first, and we'll build on the top of it as we progress in developing the app.
.
âââ app
â âââ __init__.py
â âââ api
â âââ controllers.py
â âââ routes.py
â âââ schemas.py
âââ Justfile
âââ poetry.lock
âââ pypoetry.toml
âââ pyproject.toml
âââ run.py
âââ tests
__init__.py
This is where we define the application factory.
from fastapi import FastAPI
def init_app():
api = FastAPI()
from app.api import routes
api.include_router(routes.router)
return api
Then we call it in the run.py
from app import init_app
app = init_app()
To run the api, we'll need an ASGI web server. We'll use uvicorn in our case.
$ uvicorn run:app --port 8080
schemas.py
This is where we define all the schemas for the routes. This serves for validation and serialization of the requests and the responses.
from pydantic import BaseModel
class User(BaseModel):
username: str
email: str
routes.py
This is where we define all the routes. This should be as simple as possible and should NOT contain any logic as the logic goes to the controllers.
from fastapi import APIRouter, status
from app.api import controllers, schemas
router = APIRouter()
@router.post("/users", status_code=status.HTTP_204_NO_CONTENT)
async def create_new_user(user: schemas.User):
await controllers.create_new_user(username=user.username, email=user.email)
@router.get("/users", status_code=status.HTTP_200_OK)
async def get_users() -> list[schemas.User]:
return await controllers.get_users()
controllers.py
This where the logic/processing happens.
from app.models import User
async def create_new_user(username: str, email: str):
await User.create(username=username, email=email)
async def get_users() -> list:
users = await User.get_all()
return [await user.to_dict() for user in users]
The configuration for the app should be defined in the config.py
module
app
âââ __init__.py
âââ api
â âââ controllers.py
â âââ routes.py
â âââ schemas.py
âââ config.py <<< NEW!
In the config.py
module, we setup the configuration as follows:
import os
class Config:
DB_URI = os.getenv(
"DB_URI", "postgresql+asyncpg://pythops:pythops@127.0.0.1:5432/pythops"
)
config = Config()
Then we can access the configuration in any module inside the app :
from app.config import config
To handle the exceptions properly, we'll need two modules:
app/exceptions.py
app/errors.py
app
âââ __init__.py
âââ api
â âââ controllers.py
â âââ routes.py
â âââ schemas.py
âââ config.py
âââ errors.py <<< NEW!
âââ exceptions.py <<< NEW!
exceptions.py
This is where we define the custom exceptions.
class BaseException(Exception):
def __init__(self, **kwargs):
super().__init__()
self.kwargs = kwargs
class UserAlreadyExists(BaseException):
message = "User Already Exists"
The BaseException
gives us the option to add contextual information when we raise the exception which could be helpful for debugging purposes.
In our case, we could add the username
to get more insights into the affected user.
raise UserAlreadyExists(username=username)
errors.py
This module will catch the exceptions to return a clean and readable response to the client.
If we try to create a user that exists already, we will return a 409 error code.
from fastapi.responses import JSONResponse
from app import exceptions
def error_handler(app):
@app.exception_handler(exceptions.UserAlreadyExists)
async def http_409(request, exception):
return JSONResponse(status_code=409, content={"error": str(exception)})
Then we need to register the errors in the app factory
# app/__init__.py
def init_app():
...
api = FastAPI()
from app.errors import error_handler
error_handler(api)
...
One way to integrate a database is to use an ORM. For our case, we'll be using SQLAlchemy as we'll need a SQL database.
First, we create the app/database.py
module under the app folder
app
âââ __init__.py
âââ api
â âââ controllers.py
â âââ routes.py
â âââ schemas.py
âââ config.py
âââ database.py <<< NEW!
âââ errors.py
âââ exceptions.py
database.py
The database module should be structured this way:
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class Database:
def __init__(self):
self._session: AsyncSession | None = None
def __getattr__(self, name):
return getattr(self._session, name)
def init(self, uri):
engine = create_async_engine(uri, future=True)
self._session = async_sessionmaker(engine)()
db = Database()
in the app/__init__.py
we initialize the database session:
from app.database import db
form app.config import config
def init_app():
...
db.init(uri=config.DB_URI)
..
Then we can call the database session where we need it:
from app.database import db
The last part is to create the app/models.py
module which serves as a place for defining the data structure.
app
âââ __init__.py
âââ api
â âââ controllers.py
â âââ routes.py
â âââ schemas.py
âââ config.py
âââ database.py
âââ errors.py
âââ exceptions.py
âââ models.py <<< NEW!
models.py
Here we define a User
model with some class methods to avoid repetition, and keep the database logic inside the models module.
from uuid import uuid4
from sqlalchemy import Column, String
from sqlalchemy.future import select
from app.database import Base, db
from app.exceptions import UserAlreadyExist
class User(Base):
__tablename__ = "user"
user_id = Column(String, primary_key=True, nullable=False)
email = Column(String, nullable=False)
username = Column(String, nullable=False, unique=True)
@classmethod
async def create(cls, email: str, username: str):
user = await cls.get(username=username)
if user:
raise UserAlreadyExist(username=username)
new_user = cls(
user_id=str(uuid4()),
email=email,
username=username,
)
try:
db.add(new_user)
await db.commit()
except Exception:
await db.rollback()
raise
return user
@classmethod
async def get(cls, username: str) -> list:
query = select(cls).where(cls.username == username)
result = await db.execute(query)
user = result.one().User
return user
@classmethod
async def get_all(cls) -> list:
query = select(cls)
result = await db.execute(query)
users = result.all()
return [user.User for user in users]
async def to_dict(self):
return {
"username": self.username,
"email": self.email,
}
In case we need to use caching in the app. For our case, we'll go with Redis .
We create the app/cache.py
module first
app
âââ __init__.py
âââ api
â âââ controllers.py
â âââ routes.py
â âââ schemas.py
âââ cache.py <<< NEW!
âââ config.py
âââ database.py
âââ errors.py
âââ exceptions.py
âââ models.py
cache.py
import redis.asyncio as redis
from redis.asyncio.client import Redis
class Cache:
def __init__(self):
self._client: Redis | None = None
def __getattr__(self, name):
return getattr(self._client, name)
def init(self, host: str, port: int, password: str, db: str):
self._client = redis.from_url(
f"redis://{host}",
port=port,
password=password,
db=db,
socket_connect_timeout=2.0,
encoding="utf-8",
decode_responses=True,
)
cache = Cache()
In the app/__init__.py
we initialize the cache service :
from app.cache import cache
def init_app():
...
cache.init(...)
...
Now we can access the cache as follows:
from app.cache import cache
When the app requires calling external HTTP services, we initialize an HTTP client during startup and utilize it as needed.
The structure of http client is as follows:
app
âââ __init__.py
âââ api
â âââ controllers.py
â âââ routes.py
â âââ schemas.py
âââ cache.py
âââ config.py
âââ database.py
âââ errors.py
âââ exceptions.py
âââ http <<< NEW!
â âââ __init__.py
â âââ client.py
â âââ exceptions.py
âââ models.py
client.py
This is where we define the HTTP methods we'll be using. We also define the retry and the timeout policy.
exceptions.py
This where we define the exceptions in the case the HTTP call fails for any raison.
In the app/__init__.py
we initialize the http client as follows
from app.http import http
def init_app():
...
http.init()
...
Now we can import the http client in any module.
from app.http import http
response = http.get(url="https://pythops.com")
When using external services, we can put them inside app/services
directory with all their dependencies (exceptions, serializers etc). For instance
app
âââ __init__.py
...
âââ services << NEW!
âââ foo
âââ __init__.py
âââ exceptions.py
âââ serializers.py
âââ service.py
With this structure, it's really easy to navigate through the code, which makes it easy to maintain and that's a huge advantage when you're building something big. Feel free to add your suggestions or comments by opening a Github issue, I'll be curious to know what do you think about this approach.
Read more ...