Skip to content

Commit f3ce82d

Browse files
Added customization.qmd content on project design patterns
1 parent 3356735 commit f3ce82d

File tree

4 files changed

+290
-66
lines changed

4 files changed

+290
-66
lines changed

docs/customization.qmd

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,255 @@
22
title: "Customization"
33
---
44

5+
## Development workflow
6+
7+
### Dependency management with Poetry
8+
9+
The project uses Poetry to manage dependencies:
10+
11+
- Add new dependency: `poetry add <dependency>`
12+
- Add development dependency: `poetry add --dev <dependency>`
13+
- Remove dependency: `poetry remove <dependency>`
14+
- Update lock file: `poetry lock`
15+
- Install dependencies: `poetry install`
16+
- Update all dependencies: `poetry update`
17+
18+
### Testing
19+
20+
The project uses Pytest for unit testing. It's highly recommended to write and run tests before committing code to ensure nothing is broken!
21+
22+
The following fixtures, defined in `tests/conftest.py`, are available in the test suite:
23+
24+
- `engine`: Creates a new SQLModel engine for the test database.
25+
- `set_up_database`: Sets up the test database before running the test suite by dropping all tables and recreating them to ensure a clean state.
26+
- `session`: Provides a session for database operations in tests.
27+
- `clean_db`: Cleans up the database tables before each test by deleting all entries in the `PasswordResetToken` and `User` tables.
28+
- `client`: Provides a `TestClient` instance with the session fixture, overriding the `get_session` dependency to use the test session.
29+
- `test_user`: Creates a test user in the database with a predefined name, email, and hashed password.
30+
31+
To run the tests, use these commands:
32+
33+
- Run all tests: `pytest`
34+
- Run tests in debug mode (includes logs and print statements in console output): `pytest -s`
35+
- Run particular test files by name: `pytest <test_file_name>`
36+
- Run particular tests by name: `pytest -k <test_name>`
37+
38+
### Type checking with mypy
39+
40+
The project uses type annotations and mypy for static type checking. To run mypy, use this command from the root directory:
41+
42+
```bash
43+
mypy
44+
```
45+
46+
We find that mypy is an enormous time-saver, catching many errors early and greatly reducing time spent debugging unit tests. However, note that mypy requires you type annotate every variable, function, and method in your code base, so taking advantage of it is a lifestyle change!
47+
48+
## Project structure
49+
50+
### Customizable folders and files
51+
52+
- FastAPI application entry point and GET routes: `main.py`
53+
- FastAPI POST routes: `routers/`
54+
- User authentication endpoints: `auth.py`
55+
- User profile management endpoints: `user.py`
56+
- Organization management endpoints: `organization.py`
57+
- Role management endpoints: `role.py`
58+
- Jinja2 templates: `templates/`
59+
- Static assets: `static/`
60+
- Unit tests: `tests/`
61+
- Test database configuration: `docker-compose.yml`
62+
- Helper functions: `utils/`
63+
- Auth helpers: `auth.py`
64+
- Database helpers: `db.py`
65+
- Database models: `models.py`
66+
- Environment variables: `.env`
67+
- CI/CD configuration: `.github/`
68+
- Project configuration: `pyproject.toml`
69+
- Quarto documentation:
70+
- Source: `index.qmd` + `docs/`
71+
- Configuration: `_quarto.yml`
72+
73+
Most everything else is auto-generated and should not be manually modified.
74+
75+
### Defining a web backend with FastAPI
76+
77+
We use FastAPI to define the "API endpoints" of our application. An API endpoint is simply a URL that accepts user requests and returns responses. When a user visits a page, their browser sends what's called a "GET" request to an endpoint, and the server processes it (often querying a database), and returns a response (typically HTML). The browser renders the HTML, displaying the page.
78+
79+
We also create POST endpoints, which accept form submissions so the user can create, update, and delete data in the database. This template follows the Post-Redirect-Get (PRG) pattern to handle POST requests. When a form is submitted, the server processes the data and then returns a "redirect" response, which sends the user to a GET endpoint to re-render the page with the updated data. (See [Architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/architecture.html) for more details.)
80+
81+
#### Routing patterns in this template
82+
83+
In this template, GET routes are defined in the main entry point for the application, `main.py`. POST routes are organized into separate modules within the `routers/` directory. We name our GET routes using the convention `read_<name>`, where `<name>` is the name of the page, to indicate that they are read-only endpoints that do not modify the database.
84+
85+
We divide our GET routes into authenticated and unauthenticated routes, using commented section headers in our code that look like this:
86+
87+
```python
88+
# -- Authenticated Routes --
89+
```
90+
91+
Some of our routes take request parameters, which we pass as keyword arguments to the route handler. These parameters should be type annotated for validation purposes. Some parameters are shared across all authenticated or unauthenticated routes, so we define them in the `common_authenticated_parameters` and `common_unauthenticated_parameters` dependencies defined in `main.py`.
92+
93+
### HTML templating with Jinja2
94+
95+
To generate the HTML pages to be returned from our GET routes, we use Jinja2 templates. Jinja2's hierarchical templates allow creating a base template (`templates/base.html`) that defines the overall layout of our web pages (e.g., where the header, body, and footer should go). Individual pages can then extend this base template. We can also template reusable components that can be injected into our layout or page templates.
96+
97+
With Jinja2, we can use the `{% block %}` tag to define content blocks, and the `{% extends %}` tag to extend a base template. We can also use the `{% include %}` tag to include a component in a parent template. See the [Jinja2 documentation on template inheritance](https://jinja.palletsprojects.com/en/stable/templates/#template-inheritance) for more details.
98+
99+
#### Context variables
100+
101+
Context refers to Python variables passed to a template to populate the HTML. In a FastAPI GET route, we can pass context to a template using the `templates.TemplateResponse` method, which takes the request and any context data as arguments. For example:
102+
103+
```python
104+
@app.get("/welcome")
105+
async def welcome(request: Request):
106+
return templates.TemplateResponse(
107+
"welcome.html",
108+
{"username": "Alice"}
109+
)
110+
```
111+
112+
In this example, the `welcome.html` template will receive two pieces of context: the user's `request`, which is always passed automatically by FastAPI, and a `username` variable, which we specify as "Alice". We can then use the `{{ username }}` syntax in the `welcome.html` template (or any of its parent or child templates) to insert the value into the HTML.
113+
114+
### Writing type annotated code
115+
116+
Pydantic is used for data validation and serialization. It ensures that the data received in requests meets the expected format and constraints. Pydantic models are used to define the structure of request and response data, making it easy to validate and parse JSON payloads.
117+
118+
If a user-submitted form contains data that has the wrong number, names, or types of fields, Pydantic will raise a `RequestValidationError`, which is caught by middleware and converted into an HTTP 422 error response.
119+
120+
For other, custom validation logic, we add Pydantic `@field_validator` methods to our Pydantic request models and then add the models as dependencies in the signatures of corresponding POST routes. FastAPI's dependency injection system ensures that dependency logic is executed before the body of the route handler.
121+
122+
#### Defining request models and custom validators
123+
124+
For example, in the `UserRegister` request model in `routers/authentication.py`, we add a custom validation method to ensure that the `confirm_password` field matches the `password` field. If not, it raises a custom `PasswordMismatchError`:
125+
126+
```python
127+
class PasswordMismatchError(HTTPException):
128+
def __init__(self, field: str = "confirm_password"):
129+
super().__init__(
130+
status_code=422,
131+
detail={
132+
"field": field,
133+
"message": "The passwords you entered do not match"
134+
}
135+
)
136+
137+
class UserRegister(BaseModel):
138+
name: str
139+
email: EmailStr
140+
password: str
141+
confirm_password: str
142+
143+
# Custom validators are added as class attributes
144+
@field_validator("confirm_password", check_fields=False)
145+
def validate_passwords_match(cls, v: str, values: dict[str, Any]) -> str:
146+
if v != values["password"]:
147+
raise PasswordMismatchError()
148+
return v
149+
# ...
150+
```
151+
152+
We then add this request model as a dependency in the signature of our POST route:
153+
154+
```python
155+
@app.post("/register")
156+
async def register(request: UserRegister = Depends()):
157+
# ...
158+
```
159+
160+
When the user submits the form, Pydantic will first check that all expected fields are present and match the expected types. If not, it raises a `RequestValidationError`. Then, it runs our custom `field_validator`, `validate_passwords_match`. If it finds that the `confirm_password` field does not match the `password` field, it raises a `PasswordMismatchError`. These exceptions can then be caught and handled by our middleware.
161+
162+
(Note that these examples are simplified versions of the actual code.)
163+
164+
#### Converting form data to request models
165+
166+
In addition to custom validation logic, we also need to define a method on our request models that converts form data into the request model. Here's what that looks like in the `UserRegister` request model from the previous example:
167+
168+
```python
169+
class UserRegister(BaseModel):
170+
# ...
171+
172+
@classmethod
173+
async def as_form(
174+
cls,
175+
name: str = Form(...),
176+
email: EmailStr = Form(...),
177+
password: str = Form(...),
178+
confirm_password: str = Form(...)
179+
):
180+
return cls(
181+
name=name,
182+
email=email,
183+
password=password,
184+
confirm_password=confirm_password
185+
)
186+
```
187+
188+
#### Middleware exception handling
189+
190+
Middlewares—which process requests before they reach the route handlers and responses before they are sent back to the client—are defined in `main.py`. They are commonly used in web development for tasks such as error handling, authentication token validation, logging, and modifying request/response objects.
191+
192+
This template uses middlewares exclusively for global exception handling; they only affect requests that raise an exception. This allows for consistent error responses and centralized error logging. Middleware can catch exceptions raised during request processing and return appropriate HTTP responses.
193+
194+
Middleware functions are decorated with `@app.exception_handler(ExceptionType)` and are executed in the order they are defined in `main.py`, from most to least specific.
195+
196+
Here's a middleware for handling the `PasswordMismatchError` exception from the previous example, which renders the `errors/validation_error.html` template with the error details:
197+
198+
```python
199+
@app.exception_handler(PasswordMismatchError)
200+
async def password_mismatch_exception_handler(request: Request, exc: PasswordMismatchError):
201+
return templates.TemplateResponse(
202+
request,
203+
"errors/validation_error.html",
204+
{
205+
"status_code": 422,
206+
"errors": {"error": exc.detail}
207+
},
208+
status_code=422,
209+
)
210+
```
211+
212+
### Database configuration and access with SQLModel
213+
214+
SQLModel is an Object-Relational Mapping (ORM) library that allows us to interact with our PostgreSQL database using Python classes instead of writing raw SQL. It combines the features of SQLAlchemy (a powerful database toolkit) with Pydantic's data validation.
215+
216+
#### Models and relationships
217+
218+
Our database models are defined in `utils/models.py`. Each model is a Python class that inherits from `SQLModel` and represents a database table. The key models are:
219+
220+
- `Organization`: Represents a company or team
221+
- `User`: Represents a user account
222+
- `Role`: Represents a discrete set of user permissions within an organization
223+
- `Permission`: Represents specific actions a user can perform
224+
- `RolePermissionLink`: Maps roles to their allowed permissions
225+
- `PasswordResetToken`: Manages password reset functionality
226+
227+
Models can have relationships with other models using SQLModel's `Relationship` field. For example:
228+
229+
```python
230+
class User(SQLModel, table=True):
231+
# ... other fields ...
232+
organization: Optional["Organization"] = Relationship(back_populates="users")
233+
role: Optional["Role"] = Relationship(back_populates="users")
234+
```
235+
236+
This creates a many-to-one relationship between users and organizations, and between users and roles.
237+
238+
#### Database operations
239+
240+
Database operations are handled by helper functions in `utils/db.py`. Key functions include:
241+
242+
- `set_up_db()`: Initializes the database schema and default data (which we do on every application start in `main.py`)
243+
- `get_connection_url()`: Creates a database connection URL from environment variables in `.env`
244+
- `get_session()`: Provides a database session for performing operations
245+
246+
To perform database operations in route handlers, inject the database session as a dependency:
247+
248+
```python
249+
@app.get("/users")
250+
async def get_users(session: Session = Depends(get_session)):
251+
users = session.exec(select(User)).all()
252+
return users
253+
```
254+
255+
The session automatically handles transaction management, ensuring that database operations are atomic and consistent.
256+

migrations/set_up_db.py

Lines changed: 0 additions & 30 deletions
This file was deleted.

tests/conftest.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import pytest
2+
from dotenv import load_dotenv
23
from sqlmodel import create_engine, Session, delete
3-
from utils.db import get_connection_url, set_up_db, tear_down_db
4+
from fastapi.testclient import TestClient
5+
from utils.db import get_connection_url, set_up_db, tear_down_db, get_session
46
from utils.models import User, PasswordResetToken
5-
from dotenv import load_dotenv
7+
from utils.auth import get_password_hash
8+
from main import app
69

710
load_dotenv()
811

@@ -49,3 +52,36 @@ def clean_db(session: Session):
4952
session.exec(delete(User)) # type: ignore
5053

5154
session.commit()
55+
56+
57+
# Test client fixture
58+
@pytest.fixture()
59+
def client(session: Session):
60+
"""
61+
Provides a TestClient instance with the session fixture.
62+
Overrides the get_session dependency to use the test session.
63+
"""
64+
def get_session_override():
65+
return session
66+
67+
app.dependency_overrides[get_session] = get_session_override
68+
client = TestClient(app)
69+
yield client
70+
app.dependency_overrides.clear()
71+
72+
73+
# Test user fixture
74+
@pytest.fixture()
75+
def test_user(session: Session):
76+
"""
77+
Creates a test user in the database.
78+
"""
79+
user = User(
80+
name="Test User",
81+
82+
hashed_password=get_password_hash("Test123!@#")
83+
)
84+
session.add(user)
85+
session.commit()
86+
session.refresh(user)
87+
return user

tests/test_authentication.py

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99

1010
from main import app
1111
from utils.models import User, PasswordResetToken
12-
from utils.db import get_session
1312
from utils.auth import (
1413
create_access_token,
1514
create_refresh_token,
@@ -23,39 +22,6 @@
2322
# --- Fixture setup ---
2423

2524

26-
# Test client fixture
27-
@pytest.fixture(name="client")
28-
def client_fixture(session: Session):
29-
"""
30-
Provides a TestClient instance with the session fixture.
31-
Overrides the get_session dependency to use the test session.
32-
"""
33-
def get_session_override():
34-
return session
35-
36-
app.dependency_overrides[get_session] = get_session_override
37-
client = TestClient(app)
38-
yield client
39-
app.dependency_overrides.clear()
40-
41-
42-
# Test user fixture
43-
@pytest.fixture(name="test_user")
44-
def test_user_fixture(session: Session):
45-
"""
46-
Creates a test user in the database.
47-
"""
48-
user = User(
49-
name="Test User",
50-
51-
hashed_password=get_password_hash("Test123!@#")
52-
)
53-
session.add(user)
54-
session.commit()
55-
session.refresh(user)
56-
return user
57-
58-
5925
# Mock email response fixture
6026
@pytest.fixture
6127
def mock_email_response():

0 commit comments

Comments
 (0)