In this project, you will build a small REST API for managing notes. The API will expose HTTP endpoints for creating, listing, reading, updating, and deleting notes, with request validation, response models, structured errors, persistence, tests, and clear API design.
The project moves from local command-line programs to networked software. It teaches how Python code becomes a service that other programs can call over HTTP, and how application behavior changes when input comes from clients instead of a terminal.
By the end, the reader should have a working API service, a tested application layer, documented routes, and a practical understanding of how REST-style APIs are designed and implemented in Python.
The first two capstones were local programs.
The Todo CLI managed structured local data.
The File Organizer automated local filesystem work.
This capstone crosses an important boundary:
local program -> networked service
That boundary changes the engineering problem.
A command-line tool receives arguments from one user.
A REST API receives HTTP requests from many possible clients.
A CLI prints output to a terminal.
An API returns status codes and structured response bodies.
A CLI can assume the user is nearby.
An API must treat every request as external input.
A CLI failure can print an error message.
An API failure must return a useful HTTP response.
This project teaches that APIs are not just functions with URLs.
APIs are contracts.
They define how other software interacts with your system.
You will build a notes API.
It will support:
create note
list notes
get one note
update note
delete note
search notes
filter by archived status
archive note
restore note
health check
The API will expose endpoints such as:
GET /health
POST /notes
GET /notes
GET /notes/{note_id}
PATCH /notes/{note_id}
DELETE /notes/{note_id}
POST /notes/{note_id}/archive
POST /notes/{note_id}/restore
The API will store notes persistently.
For this capstone, use SQLite.
SQLite is a good fit because:
- it is part of Python's standard library
- it requires no separate database server
- it teaches SQL and persistence
- it is enough for a learning API
- it creates a natural bridge to later projects
The project should use FastAPI for the HTTP layer.
FastAPI is appropriate here because the book has already covered it, and it makes request validation, response models, and OpenAPI documentation visible.
Create a note:
curl -X POST http://localhost:8000/notes \
-H "Content-Type: application/json" \
-d '{"title": "Read Chapter 83", "body": "Review API design."}'Response:
{
"id": 1,
"title": "Read Chapter 83",
"body": "Review API design.",
"archived": false,
"created_at": "2026-06-15T10:00:00+00:00",
"updated_at": "2026-06-15T10:00:00+00:00"
}List notes:
curl http://localhost:8000/notesGet one note:
curl http://localhost:8000/notes/1Update a note:
curl -X PATCH http://localhost:8000/notes/1 \
-H "Content-Type: application/json" \
-d '{"title": "Review API chapter"}'Delete a note:
curl -X DELETE http://localhost:8000/notes/1This API is small, but it contains most of the core ideas in API engineering.
The REST API must:
- expose HTTP endpoints
- validate request bodies
- return structured JSON responses
- return useful HTTP status codes
- persist notes in SQLite
- separate HTTP layer from service logic
- have application-specific errors
- have tests for service and API behavior
- support local development
- include OpenAPI documentation through FastAPI
It must support these behaviors:
create a note
list notes
filter notes by archived status
search notes by text
read one note
update title and body
archive a note
restore a note
delete a note
return 404 for missing notes
return 422 for invalid request bodies
The API should be deterministic and easy to test.
This capstone will not include:
- user accounts
- authentication
- authorization
- multi-tenant data
- PostgreSQL
- Docker
- deployment
- rate limiting
- background jobs
- full-text search engine
- frontend UI
Those are important topics.
They are intentionally excluded.
The goal is to learn clean API design before adding production complexity.
Authentication and authorization will matter in real systems, but adding them now would distract from the core API lifecycle.
A clean structure:
notes-api/
pyproject.toml
README.md
src/
notes_api/
__init__.py
main.py
api.py
models.py
schemas.py
repository.py
service.py
database.py
errors.py
tests/
test_repository.py
test_service.py
test_api.py
Responsibilities:
main.py creates the FastAPI application.
api.py defines routes.
schemas.py defines request and response models.
models.py defines domain objects.
repository.py handles database access.
service.py implements application operations.
database.py manages SQLite connections and schema setup.
errors.py defines application errors.
The structure should make this clear:
HTTP routes should not contain all business logic
Routes are adapters.
The service layer owns behavior.
The repository owns persistence.
The API should have layers:
client
-> FastAPI route
-> request schema validation
-> service function
-> repository
-> SQLite
-> response schema
-> JSON response
Each layer has a job.
The route understands HTTP.
The schema understands request and response shapes.
The service understands application rules.
The repository understands SQL.
The database stores durable data.
Do not let the route do everything.
Weak route:
@app.post("/notes")
def create_note(payload: dict):
conn = sqlite3.connect("notes.db")
conn.execute("INSERT INTO notes ...")
conn.commit()
return {"ok": True}Better route:
@router.post("/notes", response_model=NoteResponse, status_code=201)
def create_note(payload: CreateNoteRequest, service: NoteService = Depends(get_service)):
return service.create_note(title=payload.title, body=payload.body)The route is small.
The behavior is testable outside HTTP.
A note needs:
- ID
- title
- body
- archived flag
- created timestamp
- updated timestamp
Domain object:
from dataclasses import dataclass
@dataclass(frozen=True)
class Note:
id: int
title: str
body: str
archived: bool
created_at: str
updated_at: strWhy use a domain object if FastAPI already has Pydantic models?
Because API schemas and domain models have different responsibilities.
The domain model represents the application concept.
The API schema represents the external contract.
They may be similar today.
They may differ later.
Keeping them separate teaches clean boundaries.
Create request:
from pydantic import BaseModel, Field
class CreateNoteRequest(BaseModel):
title: str = Field(min_length=1, max_length=120)
body: str = Field(default="", max_length=5000)Update request:
class UpdateNoteRequest(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=120)
body: str | None = Field(default=None, max_length=5000)The update request uses optional fields because a PATCH request may update only part of the resource.
But it should not allow an empty update.
That rule belongs in the service layer:
at least one field must be provided
Validation has two layers:
schema validation -> shape and basic constraints
service validation -> application rules
Both are useful.
Response model:
class NoteResponse(BaseModel):
id: int
title: str
body: str
archived: bool
created_at: str
updated_at: strThe response should not accidentally expose internal details.
For example, if the database later stores:
deleted_at
internal_version
owner_id
those fields should not automatically appear in the API.
Response schemas protect the external contract.
Use status codes intentionally.
Common choices:
200 OK successful read or update
201 Created resource created
204 No Content resource deleted successfully
400 Bad Request invalid application-level request
404 Not Found note does not exist
422 Unprocessable Entity schema validation failed
500 Internal Server Error unexpected server failure
Do not return 200 OK for everything.
Weak API:
{
"success": false,
"error": "not found"
}with status 200.
Better:
{
"detail": "note not found"
}with status 404.
HTTP already has a language.
Use it.
Define application errors:
class NotesError(Exception):
pass
class NoteNotFound(NotesError):
pass
class InvalidNoteUpdate(NotesError):
passThe API layer can translate these into HTTP responses.
For example:
from fastapi import HTTPException
def translate_error(error: NotesError) -> HTTPException:
if isinstance(error, NoteNotFound):
return HTTPException(status_code=404, detail=str(error))
if isinstance(error, InvalidNoteUpdate):
return HTTPException(status_code=400, detail=str(error))
return HTTPException(status_code=500, detail="internal server error")This keeps domain errors independent of HTTP.
The service layer should not raise HTTPException.
Why?
Because HTTPException belongs to FastAPI.
The application behavior should remain testable without a web framework.
The database table:
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL,
archived INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);SQLite does not have a native boolean type.
Use 0 and 1.
In Python:
archived = bool(row["archived"])When writing:
int(note.archived)This is a small but important mapping.
Databases and Python objects do not always represent values the same way.
Repository code owns that translation.
SQLite connections should be managed carefully.
For a small FastAPI app, one simple pattern is:
import sqlite3
from pathlib import Path
def connect(path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
return connrow_factory = sqlite3.Row lets rows behave like mappings:
row["title"]This is clearer than relying on column positions.
Do not scatter sqlite3.connect() everywhere.
Centralize connection creation so tests can use temporary databases.
The repository should contain SQL.
Example methods:
class NoteRepository:
def create(self, title: str, body: str, now: str) -> Note:
...
def list(self, archived: bool | None = None, search: str | None = None) -> list[Note]:
...
def get(self, note_id: int) -> Note | None:
...
def update(self, note_id: int, title: str | None, body: str | None, now: str) -> Note | None:
...
def set_archived(self, note_id: int, archived: bool, now: str) -> Note | None:
...
def delete(self, note_id: int) -> bool:
...The repository returns domain objects.
It should not return raw SQLite rows to the service layer.
That keeps database details contained.
The service layer implements application rules.
Rules:
title cannot be empty
update must include at least one field
missing note raises NoteNotFound
archive sets archived to true
restore sets archived to false
delete missing note raises NoteNotFound
Example:
class NoteService:
def __init__(self, repository: NoteRepository, clock: Clock = utc_now):
self.repository = repository
self.clock = clock
def create_note(self, title: str, body: str) -> Note:
title = normalize_title(title)
now = self.clock()
return self.repository.create(title=title, body=body, now=now)The service layer should be easy to test with a temporary repository.
Or with a fake repository for pure unit tests.
The list endpoint should support query parameters:
GET /notes
GET /notes?archived=false
GET /notes?archived=true
GET /notes?q=python
GET /notes?archived=false&q=python
Search can be simple:
WHERE title LIKE ? OR body LIKE ?This is not a full search engine.
That is fine.
The capstone is teaching API shape, not search infrastructure.
Use parameters:
pattern = f"%{query}%"
cursor.execute(
"""
SELECT * FROM notes
WHERE title LIKE ? OR body LIKE ?
""",
(pattern, pattern),
)Do not build SQL by string concatenation with user input.
This capstone should reinforce injection safety.
Routes should be clear:
@router.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}Create:
@router.post("/notes", response_model=NoteResponse, status_code=201)
def create_note(payload: CreateNoteRequest, service: NoteService = Depends(get_service)):
note = service.create_note(payload.title, payload.body)
return NoteResponse.from_domain(note)Get:
@router.get("/notes/{note_id}", response_model=NoteResponse)
def get_note(note_id: int, service: NoteService = Depends(get_service)):
return NoteResponse.from_domain(service.get_note(note_id))The route should read like a translation layer:
HTTP input -> service call -> HTTP output
That is the right level of responsibility.
FastAPI's Depends can provide the service.
Example:
def get_repository() -> NoteRepository:
conn = connect(settings.database_path)
return NoteRepository(conn)
def get_service(
repository: NoteRepository = Depends(get_repository),
) -> NoteService:
return NoteService(repository)For tests, FastAPI allows dependency overrides.
This is useful.
Test code can use a temporary database instead of the real local database.
The principle is the same as earlier capstones:
do not hard-code production paths into testable logic
Use an application factory:
from fastapi import FastAPI
def create_app() -> FastAPI:
app = FastAPI(title="Notes API")
app.include_router(router)
return app
app = create_app()Why?
Because tests can create a fresh app.
Future configuration can be injected.
The app is not a mysterious global object that everything mutates.
Application factories are common in web projects for a reason.
During development:
uv run fastapi dev src/notes_api/main.pyor:
uv run uvicorn notes_api.main:app --reloadThen open:
http://localhost:8000/docs
FastAPI provides interactive API documentation.
This is one reason it is excellent for learning.
But documentation generated from code is only useful if the code uses clear schemas, status codes, and route definitions.
Good API code produces good API docs.
Test three layers:
repository tests
service tests
API tests
Repository tests use a temporary SQLite database.
Service tests verify application rules.
API tests use FastAPI's test client.
Do not test only through HTTP.
That makes every test slower and more indirect.
Do not test only service functions either.
That misses request validation, routing, status codes, and JSON responses.
Use both.
Repository tests should verify persistence behavior:
create returns a note with an ID
get returns created note
list returns notes in expected order
update changes title and body
archive changes archived flag
delete removes note
search filters notes
Use a temporary database:
def test_create_and_get_note(tmp_path):
db_path = tmp_path / "test.db"
conn = connect(db_path)
initialize_schema(conn)
repo = NoteRepository(conn)
note = repo.create("Title", "Body", now="2026-06-15T10:00:00+00:00")
assert repo.get(note.id) == noteThis test is fast and isolated.
It does not require a running server.
Service tests should verify rules:
empty title fails
missing note raises NoteNotFound
empty update raises InvalidNoteUpdate
archive missing note raises NoteNotFound
delete missing note raises NoteNotFound
The service layer is where application behavior becomes explicit.
If the API route and repository are the only tested layers, business rules may hide in accidental places.
Service tests keep rules visible.
API tests should verify HTTP behavior:
POST /notes returns 201
invalid POST returns 422
GET /notes returns list
GET /notes/{id} returns 200
missing note returns 404
PATCH /notes/{id} returns updated note
DELETE /notes/{id} returns 204
Example:
def test_create_note(client):
response = client.post(
"/notes",
json={"title": "Read", "body": "API chapter"},
)
assert response.status_code == 201
assert response.json()["title"] == "Read"API tests are contract tests.
They tell you whether clients can use the system.
Real list endpoints often need pagination.
For this capstone, include simple pagination:
GET /notes?limit=20&offset=0
Rules:
limit defaults to 20
limit cannot exceed 100
offset defaults to 0
offset cannot be negative
Pagination response:
{
"items": [
{
"id": 1,
"title": "Read",
"body": "API chapter",
"archived": false,
"created_at": "2026-06-15T10:00:00+00:00",
"updated_at": "2026-06-15T10:00:00+00:00"
}
],
"limit": 20,
"offset": 0
}This teaches an important API lesson:
list endpoints should not grow without bounds
Even small APIs should learn the habit.
Some API operations should be idempotent.
Archiving a note can be idempotent:
archive an already archived note -> still archived
Restoring an open note can be idempotent:
restore an already open note -> still open
Delete behavior can be a design choice.
For this project:
DELETE missing note returns 404
That is acceptable.
The important thing is to decide and document.
APIs should be predictable.
Validation should happen at multiple boundaries.
Pydantic validates:
- required fields
- string length
- field types
- query parameter bounds
The service validates:
- title normalization
- empty updates
- note existence
- domain rules
The repository protects SQL boundaries:
- parameterized queries
- explicit column mappings
- database constraints
Validation is layered.
No single layer should carry all responsibility.
This capstone does not include authentication.
But security still matters.
Practice these habits:
- use parameterized SQL
- do not expose tracebacks in normal API responses
- validate request bodies
- limit pagination
- avoid logging sensitive request bodies
- keep database path configurable
- do not run development server as a production deployment
The API is small, but the habits are real.
The project should include dependencies:
[project]
name = "notes-api"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi",
"uvicorn",
]
[dependency-groups]
dev = [
"pytest",
"httpx",
]The exact dependency syntax may vary by tool.
The lesson is:
runtime dependencies and development dependencies are different
FastAPI and Uvicorn are runtime dependencies.
Pytest and HTTPX are test/development dependencies.
Build:
src/notes_apitestspyproject.tomlmain.py- health endpoint
Completion criteria:
GET /health returns {"status": "ok"}.
This milestone proves the app can run.
Build:
NoteCreateNoteRequestUpdateNoteRequestNoteResponse- response conversion helpers
Completion criteria:
The API has clear request and response shapes.
This milestone teaches contracts.
Build:
- SQLite connection helper
- schema initialization
- repository create/get/list/update/delete methods
- repository tests
Completion criteria:
Notes can be persisted and retrieved without HTTP.
This milestone teaches persistence.
Build:
- note creation
- note retrieval
- note listing
- note update
- archive and restore
- deletion
- application errors
Completion criteria:
Application behavior is tested independently from FastAPI.
This milestone teaches business logic.
Build:
- create route
- list route
- get route
- patch route
- delete route
- archive route
- restore route
- error translation
Completion criteria:
The HTTP API exposes the service behavior correctly.
This milestone teaches route design.
Build tests for:
- success responses
- validation errors
- not found errors
- pagination
- filtering
- search
- archive and restore
Completion criteria:
The API contract is protected by tests.
This milestone teaches regression safety.
The first common mistake is putting SQL directly in route functions.
That makes the API hard to test and change.
The second common mistake is returning raw database rows.
That leaks internal representation.
The third common mistake is returning status 200 for errors.
Use HTTP properly.
The fourth common mistake is testing only the happy path.
APIs need tests for invalid input and missing resources.
The fifth common mistake is building SQL with string concatenation.
Use parameters.
The sixth common mistake is allowing unbounded list endpoints.
Use pagination.
The seventh common mistake is letting FastAPI models become the entire domain model.
Schemas are contracts.
Domain models are application concepts.
They may overlap, but they are not the same responsibility.
After the core works, add:
- authentication
- user-owned notes
- tags
- full-text search
- soft delete
- database migrations
- Dockerfile
- structured logging
- request IDs
- rate limiting
- OpenAPI examples
- frontend client
- PostgreSQL support
Add extensions only after the core contract is tested.
This capstone teaches how Python becomes an HTTP service.
It connects:
HTTP
routes
request validation
response models
status codes
SQLite
repositories
service layers
application errors
dependency injection
testing
API contracts
The central lesson is:
an API is a contract between systems
Good APIs make that contract clear.
They validate input.
They return meaningful status codes.
They hide internal details.
They are tested as contracts.
The capstone is complete when:
The app has a health endpoint.
The app has create/list/get/update/delete note endpoints.
The app has archive and restore endpoints.
Request schemas validate input.
Response schemas control output.
SQLite persistence works.
SQL uses parameters.
Missing notes return 404.
Invalid payloads return 422.
Invalid application updates return 400.
List endpoint supports pagination.
List endpoint supports filtering.
List endpoint supports search.
Routes are thin.
Service logic is tested.
Repository logic is tested.
API behavior is tested.
The app can run locally.
The README explains curl examples.
If all of these are true, the reader has built a real REST API foundation.
-
Create the project structure.
-
Add a FastAPI app factory.
-
Add
GET /health. -
Define the
Notedomain model. -
Define create, update, and response schemas.
-
Create the SQLite schema.
-
Implement the repository create method.
-
Implement repository get/list/update/delete methods.
-
Write repository tests with a temporary database.
-
Implement the service layer.
-
Add application-specific errors.
-
Write service tests for missing notes and invalid updates.
-
Add API routes.
-
Translate application errors into HTTP responses.
-
Add pagination to the list endpoint.
-
Add archived filtering.
-
Add search.
-
Write API tests using FastAPI's test client.
-
Run the app locally and test with
curl. -
Document the API in the README.
Capstone 03 built a REST API.
It connected HTTP routes, request validation, response schemas, status codes, SQLite persistence, repository design, service logic, dependency injection, and API tests.
Capstone 04 will build a URL Shortener.
The URL Shortener will deepen API design with redirects, unique code generation, collision handling, analytics counters, validation, persistence, and deployment-oriented thinking.
The transition is:
REST API teaches general resource design
URL Shortener turns one API idea into a product-shaped service
The next project will show how a small web service becomes a coherent product with behavior, constraints, and edge cases.