Build a Running Log API with FastAPI
Build a REST API with clean 3-layer architecture, tests, and Docker.
Introduction
30 Second Summary
Every runner knows the feeling of finishing a workout and wanting to track how far and how fast they went. Behind the fitness apps that do this for millions of users sits a backend API that stores data, computes stats, and serves it all up on demand.
In this project, you will build a Running Log API that records your runs, calculates pace, and generates weekly training summaries. You will structure it using clean architecture patterns that real-world Python backends rely on.
What You'll Build
A FastAPI REST API with five endpoints for logging runs, retrieving run history, and computing weekly training statistics with auto-calculated pace.
By the end of this project, you'll have:
- A working REST API serving five endpoints that create, list, retrieve, summarize, and delete runs.
- A clean architecture with three layers (routes, services, repositories) enforcing proper separation of concerns.
- A pytest test suite with both unit tests for service logic and integration tests for the full HTTP request cycle.
- A Dockerfile and docker-compose.yml for containerized deployment, plus a portfolio-ready README.
- Secret Mission: Auto-detect personal records (fastest pace, longest run, farthest distance) whenever a new run is logged.
Are there any prerequisites?
You should be comfortable with basic Python (functions, classes, imports) and have used the terminal before. Familiarity with Git basics (committing and pushing) is helpful but not required.
Before We Start
Before We Start...
Set Up the Project
Every production Python API needs a solid foundation before you write any business logic. Without a clear folder structure and proper dependency management, projects quickly become tangled spaghetti that nobody wants to maintain.
In this step, you will create the project skeleton for your Running Log API. You will set up a clean architecture folder structure that separates your code into distinct layers, install all the packages you need, and wire up the database configuration.
In this step, get ready to:
- Create the project folder, initialize Git, and install Python packages in a virtual environment.
- Build out the clean architecture folder structure with all placeholder files.
- Implement the configuration and database modules that every other layer depends on.
Create the project and install dependencies
A Python virtual environment keeps your project's packages isolated from your system Python. This prevents version conflicts between projects.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir running-log-api
cd running-log-api
- Initialize a Git repository by running:
git init
- Create a Python virtual environment by running:
python3 -m venv venv
- Activate the virtual environment by running the command for your operating system:
macOS
source venv/bin/activate
Windows
.\venv\Scripts\Activate.ps1
You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.
Don't see (venv) in your prompt?
On Windows, if you see an execution policy error, run Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser first, then try the activation command again.
On macOS, make sure you are using the exact path source venv/bin/activate from inside the running-log-api folder.
help me activate my Python virtual environment
- Install all the project dependencies by running:
pip install "fastapi[standard]==0.139.0" sqlalchemy==2.0.51 pydantic-settings==2.14.2 pytest==9.1.1 httpx==0.28.1
What are these packages?
- FastAPI is the web framework for building your API endpoints.
- SQLAlchemy is the ORM that maps Python classes to database tables.
- pydantic-settings provides type-safe configuration loading from environment variables.
- pytest is the testing framework you will use for unit and integration tests.
- httpx powers FastAPI's test client for HTTP-level testing.
You should see a success message for each package once the install finishes.
Seeing install errors?
Make sure (venv) appears in your prompt before running pip install. Installing without the virtual environment active can cause permission errors.
help me fix pip install errors in my virtual environment
- Create the requirements.txt file to pin your dependency versions. Open your project in your editor by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Cursor (or VS Code), and pressing Enter.
- In your editor, click File in the top menu bar.
- Click Open Folder.
- Select the running-log-api folder on your Desktop, then click Open.
- Create a new file called requirements.txt in the project root by pressing Cmd+N (macOS) or Ctrl+N (Windows), then paste the following code:
fastapi[standard]==0.139.0
sqlalchemy==2.0.51
pydantic-settings==2.14.2
pytest==9.1.1
httpx==0.28.1
Why pin versions?
Pinning exact versions in requirements.txt guarantees that anyone who clones your project gets the same package versions you tested with. Without pins, a future install could pull a breaking update.
- Save the file with Cmd+S (macOS) or Ctrl+S (Windows). When prompted for a filename, enter requirements.txt and save it in the project root.
Create the clean architecture folder structure
Clean architecture separates code into layers, each with a single job. Your API will have three layers: routes (HTTP handling), services (business logic), and repositories (database access).
- Create all the directories and placeholder files by running the commands for your operating system:
macOS
mkdir -p app/models app/schemas app/repositories app/services app/api tests
touch app/__init__.py app/main.py app/config.py app/database.py
touch app/models/__init__.py app/models/run.py
touch app/schemas/__init__.py app/schemas/run.py
touch app/repositories/__init__.py app/repositories/run_repository.py
touch app/services/__init__.py app/services/run_service.py
touch app/api/__init__.py app/api/routes_runs.py
touch tests/__init__.py tests/conftest.py tests/test_run_service.py tests/test_api.py
touch Dockerfile docker-compose.yml .gitignore README.md
Windows
mkdir app/models, app/schemas, app/repositories, app/services, app/api, tests
New-Item app/__init__.py, app/main.py, app/config.py, app/database.py
New-Item app/models/__init__.py, app/models/run.py
New-Item app/schemas/__init__.py, app/schemas/run.py
New-Item app/repositories/__init__.py, app/repositories/run_repository.py
New-Item app/services/__init__.py, app/services/run_service.py
New-Item app/api/__init__.py, app/api/routes_runs.py
New-Item tests/__init__.py, tests/conftest.py, tests/test_run_service.py, tests/test_api.py
New-Item Dockerfile, docker-compose.yml, .gitignore, README.md
What does each folder do?
- app/api/ holds the route layer. Routes handle HTTP requests and responses.
- app/services/ holds the service layer. Services contain business logic like pace calculation.
- app/repositories/ holds the repository layer. Repositories talk directly to the database.
- app/models/ holds SQLAlchemy ORM models that map to database tables.
- app/schemas/ holds Pydantic schemas for request validation and response serialization.
- tests/ holds your unit and integration test files.
- Confirm the folder structure was created by running:
ls app/
You should see __init__.py, api, config.py, database.py, main.py, models, repositories, schemas, and services listed in the output.
- Open .gitignore in your editor and paste the following content:
__pycache__/
*.pyc
venv/
.env
*.db
data/
.pytest_cache/
Why a .gitignore?
The .gitignore file tells Git to skip files that should not be committed. Virtual environments, compiled Python files, database files, and environment secrets all stay out of your repository.
- Save .gitignore.
Create the configuration and database modules
Every layer in your API will eventually need to connect to the database. By building the config and database modules first, you create the foundation that all other modules import from.
- Open app/config.py in your editor and paste the following code:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "Running Log API"
database_url: str = "sqlite:///./runs.db"
model_config = {"env_prefix": "RUNNING_LOG_"}
settings = Settings()
What does this code do?
- Settings inherits from BaseSettings, which automatically loads values from environment variables.
- database_url defaults to a local SQLite file called runs.db. SQLite needs zero setup and stores everything in a single file.
- env_prefix means any environment variable starting with RUNNING_LOG_ overrides the defaults. For example, RUNNING_LOG_DATABASE_URL would swap in a different database.
- The settings instance at the bottom is a singleton that other modules import directly.
- Save app/config.py.
- Open app/database.py in your editor and paste the following code:
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.config import settings
engine = create_engine(
settings.database_url,
connect_args={"check_same_thread": False},
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
What does this code do?
- create_engine creates the SQLAlchemy connection to your database using the URL from config.
- check_same_thread: False is required because SQLite connections are thread-local by default, but FastAPI handles requests across threads.
- SessionLocal is a factory that creates new database sessions for each request.
- Base is the parent class for all your ORM models. Every model you create will inherit from it.
- get_db() is a dependency injection function. FastAPI calls it to give each route its own database session, then automatically closes it after the request finishes.
- Save app/database.py.
- Verify everything is wired up correctly by running this in your terminal:
python -c "from app.config import settings; print(settings.database_url)"
You should see sqlite:///./runs.db printed in your terminal.
Seeing a ModuleNotFoundError?
Make sure (venv) appears in your terminal prompt. If it does not, re-activate your virtual environment.
Also confirm that app/__init__.py exists. Without it, Python cannot import from the app package.
help me fix ModuleNotFoundError when importing app.config
✔️ Awesome, I've got everything!
Great. Double check you have saved both app/config.py and app/database.py.
ⓧ I'd like to double check the full code
Here is the full app/config.py:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "Running Log API"
database_url: str = "sqlite:///./runs.db"
model_config = {"env_prefix": "RUNNING_LOG_"}
settings = Settings()
Here is the full app/database.py:
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.config import settings
engine = create_engine(
settings.database_url,
connect_args={"check_same_thread": False},
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
And here is requirements.txt:
fastapi[standard]==0.139.0
sqlalchemy==2.0.51
pydantic-settings==2.14.2
pytest==9.1.1
httpx==0.28.1
And .gitignore:
__pycache__/
*.pyc
venv/
.env
*.db
data/
.pytest_cache/
Your project skeleton is ready. Next up, you will define the database model and build the repository layer that talks directly to SQLite.
Build the Database Model and Repository Layer
Your project skeleton is ready with configuration and database connection code in place. Now it's time to define the data your API will manage.
Every API needs a clear definition of its data shape. In this step, you'll build the bottom two layers of the clean architecture: the database model (how data is stored) and the repository (how data is accessed).
In this step, get ready to:
- Define a SQLAlchemy ORM model for run data.
- Create Pydantic schemas for request validation and response serialization.
- Build repository functions that handle all database access.
Define the Run ORM model
An ORM model maps a Python class to a database table. Each attribute on the class becomes a column in the table.
What is an ORM?
An ORM (Object-Relational Mapper) lets you interact with your database using Python objects instead of writing raw SQL queries. SQLAlchemy is the most popular ORM for Python.
You define a class, and SQLAlchemy handles creating the table, inserting rows, and querying data for you.
- Open app/models/run.py in your editor and add the following code:
from datetime import date, datetime
from sqlalchemy import Date, DateTime, Float, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Run(Base):
__tablename__ = "runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
distance_km: Mapped[float] = mapped_column(Float, nullable=False)
duration_minutes: Mapped[float] = mapped_column(Float, nullable=False)
date: Mapped[date] = mapped_column(Date, nullable=False)
notes: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now()
)
What does this code do?
The Run class maps to a runs table in the database. Each attribute defines a column.
- id is an auto-incrementing primary key that uniquely identifies each run.
- distance_km and duration_minutes store the core run data as floats.
- notes is optional (nullable) so the runner can skip it.
- created_at uses server_default=func.now() so the database itself stamps the time when a row is inserted.
- Save app/models/run.py.
- Now open app/models/__init__.py and add this import so other modules can access Run from the package directly:
from app.models.run import Run
- Save app/models/__init__.py.
Seeing an import error?
Make sure your virtual environment is still activated (you should see (venv) in your terminal prompt). If it's not, reactivate it with source venv/bin/activate.
Also confirm that app/database.py exists and contains the Base class from the previous step.
Help me debug an import error in my SQLAlchemy model.
Define the Pydantic schemas
The ORM model describes how data lives in the database. Pydantic schemas describe how data enters and leaves the API.
Why separate schemas from the ORM model?
The ORM model has database-specific details (primary keys, server defaults) that API consumers don't need to know about. Schemas let you control exactly what data the API accepts and returns.
For example, the created_at timestamp is auto-generated by the database. Clients should never send it. A separate RunCreate schema enforces this.
- Open app/schemas/run.py and add the following schemas:
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, Field
class RunCreate(BaseModel):
distance_km: float = Field(gt=0, description="Distance in kilometers")
duration_minutes: float = Field(gt=0, description="Duration in minutes")
date: date
notes: str | None = None
class RunResponse(BaseModel):
id: int
distance_km: float
duration_minutes: float
date: date
notes: str | None
created_at: datetime
pace_min_per_km: float
model_config = ConfigDict(from_attributes=True)
class WeeklySummary(BaseModel):
week_start: date
week_end: date
total_runs: int
total_distance_km: float
total_duration_minutes: float
average_pace_min_per_km: float | None
What does this code do?
- RunCreate validates incoming data. Field(gt=0) rejects zero or negative distances and durations automatically.
- RunResponse shapes what the API sends back. It includes a computed pace_min_per_km field that doesn't exist in the database.
- model_config = ConfigDict(from_attributes=True) tells Pydantic to read data from ORM model attributes, not just dictionaries.
- WeeklySummary defines the response shape for the weekly stats endpoint you'll build in the next step.
- Save app/schemas/run.py.
- Open app/schemas/__init__.py and add these imports:
from app.schemas.run import RunCreate, RunResponse, WeeklySummary
- Save app/schemas/__init__.py.
Getting a Pydantic validation error?
Double-check that you're importing from pydantic (not pydantic_settings). The BaseModel, ConfigDict, and Field classes all come from the core pydantic package.
Help me fix a Pydantic schema error.
Build the repository layer
The repository layer is the ONLY place in your app that talks directly to the database. Routes and services never touch SQLAlchemy queries.
Why isolate database access in a repository?
If you ever swap SQLite for PostgreSQL, or replace SQLAlchemy with a different ORM, you only change the repository files. The rest of your app stays untouched.
This separation also makes testing easier. You can test business logic without needing a real database by mocking the repository functions.
- Open app/repositories/run_repository.py and add the following functions:
from datetime import date
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.run import Run
def create_run(db: Session, distance_km: float, duration_minutes: float, run_date: date, notes: str | None = None) -> Run:
run = Run(
distance_km=distance_km,
duration_minutes=duration_minutes,
date=run_date,
notes=notes,
)
db.add(run)
db.commit()
db.refresh(run)
return run
def get_run_by_id(db: Session, run_id: int) -> Run | None:
statement = select(Run).where(Run.id == run_id)
return db.execute(statement).scalar_one_or_none()
def get_all_runs(db: Session) -> list[Run]:
statement = select(Run).order_by(Run.date.desc())
return list(db.execute(statement).scalars().all())
def get_runs_by_date_range(db: Session, start_date: date, end_date: date) -> list[Run]:
statement = (
select(Run)
.where(Run.date >= start_date, Run.date <= end_date)
.order_by(Run.date.desc())
)
return list(db.execute(statement).scalars().all())
def delete_run(db: Session, run_id: int) -> bool:
run = get_run_by_id(db, run_id)
if run is None:
return False
db.delete(run)
db.commit()
return True
What does this code do?
Each function handles one specific database operation:
- create_run builds a Run object, adds it to the session, commits the transaction, and returns the refreshed object (now with its auto-generated id and created_at).
- get_run_by_id returns a single run or None if the ID doesn't exist.
- get_all_runs returns all runs sorted by date, newest first.
- get_runs_by_date_range filters runs within a date window. The service layer will use this for weekly summaries.
- delete_run returns True on success or False if the run wasn't found.
- Save app/repositories/run_repository.py.
- Verify the model and repository work together by running this test script in your terminal:
python -c "from app.database import Base, engine; from app.models.run import Run; Base.metadata.create_all(bind=engine); print('Tables created successfully')"
- You should see Tables created successfully printed in the terminal.
- Confirm the database file was created by running:
ls *.db
- You should see runs.db in the output.
Don't see runs.db?
Make sure you're running the command from the running-log-api directory (not from inside app/). The SQLite database URL in your config points to sqlite:///./runs.db which creates the file in whatever directory you run the command from.
Also check that your app/models/__init__.py has the import line from app.models.run import Run. Without it, SQLAlchemy won't discover the model.
Help me troubleshoot why my runs.db file isn't being created.
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved: app/models/run.py, app/schemas/run.py, and app/repositories/run_repository.py.
ⓧ I'd like to double check the full code
Here are all three main files for this step. Compare yours against these:
from datetime import date, datetime
from sqlalchemy import Date, DateTime, Float, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Run(Base):
__tablename__ = "runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
distance_km: Mapped[float] = mapped_column(Float, nullable=False)
duration_minutes: Mapped[float] = mapped_column(Float, nullable=False)
date: Mapped[date] = mapped_column(Date, nullable=False)
notes: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now()
)
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, Field
class RunCreate(BaseModel):
distance_km: float = Field(gt=0, description="Distance in kilometers")
duration_minutes: float = Field(gt=0, description="Duration in minutes")
date: date
notes: str | None = None
class RunResponse(BaseModel):
id: int
distance_km: float
duration_minutes: float
date: date
notes: str | None
created_at: datetime
pace_min_per_km: float
model_config = ConfigDict(from_attributes=True)
class WeeklySummary(BaseModel):
week_start: date
week_end: date
total_runs: int
total_distance_km: float
total_duration_minutes: float
average_pace_min_per_km: float | None
from datetime import date
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.run import Run
def create_run(db: Session, distance_km: float, duration_minutes: float, run_date: date, notes: str | None = None) -> Run:
run = Run(
distance_km=distance_km,
duration_minutes=duration_minutes,
date=run_date,
notes=notes,
)
db.add(run)
db.commit()
db.refresh(run)
return run
def get_run_by_id(db: Session, run_id: int) -> Run | None:
statement = select(Run).where(Run.id == run_id)
return db.execute(statement).scalar_one_or_none()
def get_all_runs(db: Session) -> list[Run]:
statement = select(Run).order_by(Run.date.desc())
return list(db.execute(statement).scalars().all())
def get_runs_by_date_range(db: Session, start_date: date, end_date: date) -> list[Run]:
statement = (
select(Run)
.where(Run.date >= start_date, Run.date <= end_date)
.order_by(Run.date.desc())
)
return list(db.execute(statement).scalars().all())
def delete_run(db: Session, run_id: int) -> bool:
run = get_run_by_id(db, run_id)
if run is None:
return False
db.delete(run)
db.commit()
return True
Your data layer is solid. You have a model that defines the shape, schemas that guard the API boundary, and a repository that handles all database operations. Next up, you'll build the service layer and wire up the actual API routes.
Build the Service Layer and API Routes
Your database layer is complete. The repository handles all direct database operations, but it has no idea what a "pace" is or how to aggregate a week's training data.
The service layer is where business logic lives. It calls the repository for data, then adds calculations, validations, and error handling on top. The API routes sit above everything, translating HTTP requests into service calls and service responses into JSON.
In this step, get ready to:
- Build the service layer with pace calculation and weekly summary logic.
- Create the API routes for all five endpoints.
- Wire everything into the FastAPI app and test via Swagger UI.
Build the service layer
The service layer sits between routes and repositories. Routes handle HTTP, repositories handle SQL, and services handle everything in between: pace math, date ranges, and error responses.
- Open app/services/run_service.py in your editor.
- Add the imports and the first two service functions by pasting the following code:
from datetime import date, timedelta
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.repositories import run_repository
from app.schemas.run import RunCreate, RunResponse, WeeklySummary
def calculate_pace(duration_minutes: float, distance_km: float) -> float:
# Pace = minutes per kilometer
return round(duration_minutes / distance_km, 2)
def create_run(db: Session, run_data: RunCreate) -> RunResponse:
# Delegate database work to the repository
run = run_repository.create_run(
db=db,
distance_km=run_data.distance_km,
duration_minutes=run_data.duration_minutes,
run_date=run_data.date,
notes=run_data.notes,
)
# Build the response with computed pace
return RunResponse(
id=run.id,
distance_km=run.distance_km,
duration_minutes=run.duration_minutes,
date=run.date,
notes=run.notes,
created_at=run.created_at,
pace_min_per_km=calculate_pace(run.duration_minutes, run.distance_km),
)
What does this code do?
The calculate_pace helper divides duration by distance to get minutes per kilometer. Every response the API sends includes this computed value.
The create_run function takes validated input (a RunCreate schema), passes it to the repository for storage, then constructs a RunResponse with the pace calculated. The service never touches SQL directly.
- Below the create_run function, add the retrieval and deletion functions:
def get_run(db: Session, run_id: int) -> RunResponse:
run = run_repository.get_run_by_id(db, run_id)
if run is None:
# Return a 404 error if the run doesn't exist
raise HTTPException(status_code=404, detail="Run not found")
return RunResponse(
id=run.id,
distance_km=run.distance_km,
duration_minutes=run.duration_minutes,
date=run.date,
notes=run.notes,
created_at=run.created_at,
pace_min_per_km=calculate_pace(run.duration_minutes, run.distance_km),
)
def list_runs(db: Session) -> list[RunResponse]:
runs = run_repository.get_all_runs(db)
# Convert each ORM model to a response schema with pace
return [
RunResponse(
id=r.id,
distance_km=r.distance_km,
duration_minutes=r.duration_minutes,
date=r.date,
notes=r.notes,
created_at=r.created_at,
pace_min_per_km=calculate_pace(r.duration_minutes, r.distance_km),
)
for r in runs
]
def delete_run(db: Session, run_id: int) -> None:
deleted = run_repository.delete_run(db, run_id)
if not deleted:
raise HTTPException(status_code=404, detail="Run not found")
What does this code do?
These three functions follow the same pattern: call the repository, then add service-level behavior.
- get_run raises an HTTPException with status 404 if the run does not exist. The repository returns None for missing records, but the service decides what that means for the API consumer.
- list_runs converts every ORM model into a RunResponse with a computed pace using a list comprehension.
- delete_run returns nothing on success (the route will send a 204 No Content). If the repository says the run was not found, it raises a 404.
- Finally, add the weekly summary function below delete_run:
def get_weekly_summary(db: Session) -> WeeklySummary:
# Calculate the start and end of the current week (Monday to Sunday)
today = date.today()
week_start = today - timedelta(days=today.weekday())
week_end = week_start + timedelta(days=6)
# Fetch only runs within this week
runs = run_repository.get_runs_by_date_range(db, week_start, week_end)
# Aggregate totals
total_distance = sum(r.distance_km for r in runs)
total_duration = sum(r.duration_minutes for r in runs)
average_pace = calculate_pace(total_duration, total_distance) if total_distance > 0 else None
return WeeklySummary(
week_start=week_start,
week_end=week_end,
total_runs=len(runs),
total_distance_km=round(total_distance, 2),
total_duration_minutes=round(total_duration, 2),
average_pace_min_per_km=average_pace,
)
What does this code do?
This function computes the current week's boundaries using Python's timedelta. Monday is day 0, so subtracting today.weekday() gives you this Monday's date.
It then asks the repository for runs in that range, sums up distance and duration, and computes average pace. If no runs exist (total distance is 0), pace is None to avoid a division-by-zero error.
- Save app/services/run_service.py.
Why does the service layer exist?
Without a service layer, your route functions would contain SQL queries mixed with pace math and error handling. That works for tiny apps, but becomes impossible to test or maintain as endpoints grow.
By separating concerns, you can unit-test pace calculations without spinning up an HTTP server, and swap databases without rewriting business logic.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
from datetime import date, timedelta
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.repositories import run_repository
from app.schemas.run import RunCreate, RunResponse, WeeklySummary
def calculate_pace(duration_minutes: float, distance_km: float) -> float:
return round(duration_minutes / distance_km, 2)
def create_run(db: Session, run_data: RunCreate) -> RunResponse:
run = run_repository.create_run(
db=db,
distance_km=run_data.distance_km,
duration_minutes=run_data.duration_minutes,
run_date=run_data.date,
notes=run_data.notes,
)
return RunResponse(
id=run.id,
distance_km=run.distance_km,
duration_minutes=run.duration_minutes,
date=run.date,
notes=run.notes,
created_at=run.created_at,
pace_min_per_km=calculate_pace(run.duration_minutes, run.distance_km),
)
def get_run(db: Session, run_id: int) -> RunResponse:
run = run_repository.get_run_by_id(db, run_id)
if run is None:
raise HTTPException(status_code=404, detail="Run not found")
return RunResponse(
id=run.id,
distance_km=run.distance_km,
duration_minutes=run.duration_minutes,
date=run.date,
notes=run.notes,
created_at=run.created_at,
pace_min_per_km=calculate_pace(run.duration_minutes, run.distance_km),
)
def list_runs(db: Session) -> list[RunResponse]:
runs = run_repository.get_all_runs(db)
return [
RunResponse(
id=r.id,
distance_km=r.distance_km,
duration_minutes=r.duration_minutes,
date=r.date,
notes=r.notes,
created_at=r.created_at,
pace_min_per_km=calculate_pace(r.duration_minutes, r.distance_km),
)
for r in runs
]
def get_weekly_summary(db: Session) -> WeeklySummary:
today = date.today()
week_start = today - timedelta(days=today.weekday())
week_end = week_start + timedelta(days=6)
runs = run_repository.get_runs_by_date_range(db, week_start, week_end)
total_distance = sum(r.distance_km for r in runs)
total_duration = sum(r.duration_minutes for r in runs)
average_pace = calculate_pace(total_duration, total_distance) if total_distance > 0 else None
return WeeklySummary(
week_start=week_start,
week_end=week_end,
total_runs=len(runs),
total_distance_km=round(total_distance, 2),
total_duration_minutes=round(total_duration, 2),
average_pace_min_per_km=average_pace,
)
def delete_run(db: Session, run_id: int) -> None:
deleted = run_repository.delete_run(db, run_id)
if not deleted:
raise HTTPException(status_code=404, detail="Run not found")
Create the API routes
Routes are the outermost layer. Each route defines an HTTP method and path, receives the request, calls the service, and returns a response. FastAPI handles serialization and validation automatically using the schemas you defined.
- Open app/api/routes_runs.py and paste the following code:
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.schemas.run import RunCreate, RunResponse, WeeklySummary
from app.services import run_service
# Create a router with a shared prefix and tag for all run endpoints
router = APIRouter(prefix="/runs", tags=["runs"])
@router.post("", response_model=RunResponse, status_code=status.HTTP_201_CREATED)
def create_run(run_data: RunCreate, db: Session = Depends(get_db)):
return run_service.create_run(db, run_data)
@router.get("", response_model=list[RunResponse])
def list_runs(db: Session = Depends(get_db)):
return run_service.list_runs(db)
# weekly-summary MUST come before /{run_id} to avoid path conflicts
@router.get("/weekly-summary", response_model=WeeklySummary)
def get_weekly_summary(db: Session = Depends(get_db)):
return run_service.get_weekly_summary(db)
@router.get("/{run_id}", response_model=RunResponse)
def get_run(run_id: int, db: Session = Depends(get_db)):
return run_service.get_run(db, run_id)
@router.delete("/{run_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_run(run_id: int, db: Session = Depends(get_db)):
run_service.delete_run(db, run_id)
What does this code do?
- APIRouter(prefix="/runs") means every endpoint path starts with /runs automatically. You write "" for the root, not "/runs" again.
- Depends(get_db) is FastAPI's dependency injection. It creates a database session for each request and closes it afterwards.
- The /weekly-summary route is defined before /{run_id}. If you reversed them, FastAPI would try to interpret "weekly-summary" as a run ID and fail.
- Each route function is 1-2 lines. All logic lives in the service layer. This keeps routes thin and testable.
- Save app/api/routes_runs.py.
Seeing a linting error on the imports?
If your editor underlines from app.services import run_service, that is expected until we fill in app/main.py in the next substep. The import resolves correctly at runtime.
Ask for help: Why is my editor showing import errors for app.services?
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.schemas.run import RunCreate, RunResponse, WeeklySummary
from app.services import run_service
router = APIRouter(prefix="/runs", tags=["runs"])
@router.post("", response_model=RunResponse, status_code=status.HTTP_201_CREATED)
def create_run(run_data: RunCreate, db: Session = Depends(get_db)):
return run_service.create_run(db, run_data)
@router.get("", response_model=list[RunResponse])
def list_runs(db: Session = Depends(get_db)):
return run_service.list_runs(db)
@router.get("/weekly-summary", response_model=WeeklySummary)
def get_weekly_summary(db: Session = Depends(get_db)):
return run_service.get_weekly_summary(db)
@router.get("/{run_id}", response_model=RunResponse)
def get_run(run_id: int, db: Session = Depends(get_db)):
return run_service.get_run(db, run_id)
@router.delete("/{run_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_run(run_id: int, db: Session = Depends(get_db)):
run_service.delete_run(db, run_id)
Wire everything together and start the server
The app entry point creates the FastAPI instance, triggers table creation, and mounts the router. Once this file is in place, you can start the dev server.
- Open app/main.py and paste the following code:
from fastapi import FastAPI
from app.api.routes_runs import router as runs_router
from app.database import Base, engine
# Create all database tables on startup
Base.metadata.create_all(bind=engine)
app = FastAPI(
title="Running Log API",
description="A REST API for logging runs and analyzing training data.",
version="1.0.0",
)
# Mount the runs router
app.include_router(runs_router)
@app.get("/health")
def health_check():
return {"status": "healthy"}
What does this code do?
- Base.metadata.create_all(bind=engine) scans all models that inherit from Base and creates their tables in SQLite if they do not already exist.
- app.include_router(runs_router) registers all five /runs endpoints with the main application.
- The /health endpoint is a simple check you can hit to confirm the server is alive without needing a database call.
- Save app/main.py.
- Start the development server by running this command:
fastapi dev app/main.py
- Open your browser and navigate to http://127.0.0.1:8000/docs.
You should see FastAPI's interactive Swagger UI with all five endpoints listed under the "runs" tag.
What is Swagger UI?
FastAPI auto-generates interactive API documentation at /docs. You can expand any endpoint, fill in parameters, click **Try it out**, and see the live response. No external tools like Postman needed.
- Test the POST /runs endpoint: click **Try it out**, paste the following JSON into the request body, then click **Execute**:
{
"distance_km": 5.0,
"duration_minutes": 30.0,
"date": "2026-07-09",
"notes": "Morning run"
}
- Confirm the response shows status 201 with a pace_min_per_km of 6.0.
- Test GET /runs to see the run you just created in the response list.
- Test GET /runs/weekly-summary to see aggregated stats for the current week.
Server won't start?
- Check that your virtual environment is active. You should see (venv) in your terminal prompt.
- If you see ModuleNotFoundError, confirm that app/__init__.py exists (even if empty). Python needs it to treat app/ as a package.
- If you see an import error about run_service, double check that app/services/__init__.py exists.
Ask for help: My FastAPI dev server won't start and I'm seeing an error.
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files before continuing.
ⓧ I'd like to double check the full code
from fastapi import FastAPI
from app.api.routes_runs import router as runs_router
from app.database import Base, engine
Base.metadata.create_all(bind=engine)
app = FastAPI(
title="Running Log API",
description="A REST API for logging runs and analyzing training data.",
version="1.0.0",
)
app.include_router(runs_router)
@app.get("/health")
def health_check():
return {"status": "healthy"}
Your API is live and all five endpoints are working. Next up, you'll write automated tests to make sure this stays working as you evolve the codebase.
Write the Test Suite
Your API is fully wired and responding to requests. The next question is: how do you prove it actually works correctly?
Automated tests answer that question permanently. In this step, you will write a pytest test suite that covers both the service layer logic (unit tests) and the full HTTP request cycle (integration tests). Once the suite passes, any future change that accidentally breaks your API will get caught immediately.
In this step, get ready to:
- Set up test infrastructure with fixtures and a test database.
- Write unit tests for the service layer.
- Write integration tests for the API endpoints.
Set up the test infrastructure
Tests need their own database so they never pollute your real data. The conftest.py file is a special pytest file that provides shared fixtures available to all test modules automatically.
- Open tests/conftest.py and add the following test infrastructure:
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.database import Base, get_db
from app.main import app
# Use a separate test database file
SQLALCHEMY_TEST_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(
SQLALCHEMY_TEST_DATABASE_URL,
connect_args={"check_same_thread": False},
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest.fixture(autouse=True)
def setup_database():
# Create all tables before each test
Base.metadata.create_all(bind=engine)
yield
# Drop all tables after each test for a clean slate
Base.metadata.drop_all(bind=engine)
def override_get_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
# Swap the app's real database dependency with our test database
app.dependency_overrides[get_db] = override_get_db
@pytest.fixture
def client():
return TestClient(app)
@pytest.fixture
def db_session():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
What does this code do?
This file sets up the entire test environment. Here is what each piece does:
- SQLALCHEMY_TEST_DATABASE_URL points to a separate test.db file so tests never touch your real runs.db.
- The setup_database fixture runs automatically before every test (autouse=True). It creates tables, lets the test run, then drops them so each test starts fresh.
- app.dependency_overrides[get_db] tells FastAPI to use the test database session instead of the real one whenever a route calls Depends(get_db).
- The client fixture provides a TestClient that makes HTTP calls to the app without starting a real server.
- The db_session fixture gives unit tests direct access to the test database for inserting seed data.
- Save tests/conftest.py.
Why dependency injection makes testing easy
FastAPI's Depends system is what makes this clean swap possible. Because routes ask for their database session through Depends(get_db) instead of creating one directly, you can replace the entire database layer in tests with a single line.
This pattern is called dependency injection. It keeps your route code unchanged while letting tests control exactly what database the app talks to.
Write unit tests for the service layer
Unit tests verify that your business logic produces the right results in isolation. These tests call service functions directly without going through HTTP.
- Open tests/test_run_service.py and add the following tests:
from datetime import date, timedelta
from app.repositories import run_repository
from app.services.run_service import calculate_pace, get_weekly_summary
def test_calculate_pace_normal():
# 30 minutes / 5 km = 6.0 min/km
pace = calculate_pace(duration_minutes=30.0, distance_km=5.0)
assert pace == 6.0
def test_calculate_pace_fractional():
# 25 minutes / 4 km = 6.25 min/km
pace = calculate_pace(duration_minutes=25.0, distance_km=4.0)
assert pace == 6.25
def test_weekly_summary_empty(db_session):
# With no runs logged, summary should show zeros
summary = get_weekly_summary(db_session)
assert summary.total_runs == 0
assert summary.total_distance_km == 0.0
assert summary.average_pace_min_per_km is None
def test_weekly_summary_with_runs(db_session):
# Insert two runs for today via the repository
today = date.today()
run_repository.create_run(db_session, distance_km=5.0, duration_minutes=30.0, run_date=today)
run_repository.create_run(db_session, distance_km=10.0, duration_minutes=55.0, run_date=today)
summary = get_weekly_summary(db_session)
assert summary.total_runs == 2
assert summary.total_distance_km == 15.0
assert summary.total_duration_minutes == 85.0
assert summary.average_pace_min_per_km == 5.67
What does this code do?
These four tests verify your service layer's core logic:
- The first two tests check calculate_pace with known inputs. They are pure functions with no database needed.
- test_weekly_summary_empty confirms the summary handles zero runs gracefully (no division-by-zero crash).
- test_weekly_summary_with_runs seeds the test database with two runs and verifies the aggregation math: 5 + 10 = 15 km total, 30 + 55 = 85 minutes total, 85 / 15 = 5.67 min/km average pace.
- Save tests/test_run_service.py.
Write integration tests for the API endpoints
Integration tests exercise the full stack. They send real HTTP requests through the router, service, and repository layers to confirm everything works together end-to-end.
- Open tests/test_api.py and add the following tests:
from datetime import date
def test_create_run(client):
# POST a new run and verify 201 response with correct data
response = client.post("/runs", json={
"distance_km": 5.0,
"duration_minutes": 30.0,
"date": str(date.today()),
"notes": "Morning run",
})
assert response.status_code == 201
data = response.json()
assert data["distance_km"] == 5.0
assert data["pace_min_per_km"] == 6.0
assert data["notes"] == "Morning run"
def test_create_run_invalid_distance(client):
# Negative distance should trigger Pydantic validation error
response = client.post("/runs", json={
"distance_km": -1.0,
"duration_minutes": 30.0,
"date": str(date.today()),
})
assert response.status_code == 422
def test_list_runs(client):
# Create a run, then verify it appears in the list
client.post("/runs", json={
"distance_km": 5.0,
"duration_minutes": 30.0,
"date": str(date.today()),
})
response = client.get("/runs")
assert response.status_code == 200
assert len(response.json()) == 1
def test_get_run(client):
# Create a run, then fetch it by ID
create_response = client.post("/runs", json={
"distance_km": 8.0,
"duration_minutes": 44.0,
"date": str(date.today()),
})
run_id = create_response.json()["id"]
response = client.get(f"/runs/{run_id}")
assert response.status_code == 200
assert response.json()["distance_km"] == 8.0
def test_get_run_not_found(client):
# Fetching a non-existent ID should return 404
response = client.get("/runs/999")
assert response.status_code == 404
def test_delete_run(client):
# Create a run, then delete it
create_response = client.post("/runs", json={
"distance_km": 3.0,
"duration_minutes": 18.0,
"date": str(date.today()),
})
run_id = create_response.json()["id"]
response = client.delete(f"/runs/{run_id}")
assert response.status_code == 204
def test_weekly_summary(client):
# Create a run for today, then verify the weekly summary
client.post("/runs", json={
"distance_km": 5.0,
"duration_minutes": 30.0,
"date": str(date.today()),
})
response = client.get("/runs/weekly-summary")
assert response.status_code == 200
data = response.json()
assert data["total_runs"] == 1
assert data["total_distance_km"] == 5.0
What does this code do?
These seven tests cover the complete HTTP contract of your API:
- test_create_run verifies that POST returns 201 with the correct response body, including the computed pace.
- test_create_run_invalid_distance confirms that Pydantic validation rejects invalid input with a 422 status.
- test_list_runs and test_get_run confirm that created runs are retrievable.
- test_get_run_not_found checks that requesting a missing run returns 404.
- test_delete_run verifies the delete flow returns 204 No Content.
- test_weekly_summary confirms the aggregation endpoint works through the full stack.
- Save tests/test_api.py.
- Run the full test suite by running this command:
pytest -v
- You should see all 11 tests listed by name, each followed by PASSED. The final summary line reads 11 passed.
What should I see?
The -v flag shows verbose output. Each test appears on its own line with the format tests/test_file.py::test_name PASSED. You should see all four service tests and all seven API tests pass.
Seeing test failures?
- If you see ModuleNotFoundError, make sure your virtual environment is activated and you are running pytest from the running-log-api folder.
- If a specific assertion fails, double-check that the expected values in your test match the logic in your service layer.
- If you see no tests ran, confirm that your test files are named test_run_service.py and test_api.py (pytest discovers files starting with test_).
Help me debug my failing pytest tests.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three test files before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for each test file:
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.database import Base, get_db
from app.main import app
SQLALCHEMY_TEST_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(
SQLALCHEMY_TEST_DATABASE_URL,
connect_args={"check_same_thread": False},
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest.fixture(autouse=True)
def setup_database():
Base.metadata.create_all(bind=engine)
yield
Base.metadata.drop_all(bind=engine)
def override_get_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
@pytest.fixture
def client():
return TestClient(app)
@pytest.fixture
def db_session():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
from datetime import date, timedelta
from app.repositories import run_repository
from app.services.run_service import calculate_pace, get_weekly_summary
def test_calculate_pace_normal():
pace = calculate_pace(duration_minutes=30.0, distance_km=5.0)
assert pace == 6.0
def test_calculate_pace_fractional():
pace = calculate_pace(duration_minutes=25.0, distance_km=4.0)
assert pace == 6.25
def test_weekly_summary_empty(db_session):
summary = get_weekly_summary(db_session)
assert summary.total_runs == 0
assert summary.total_distance_km == 0.0
assert summary.average_pace_min_per_km is None
def test_weekly_summary_with_runs(db_session):
today = date.today()
run_repository.create_run(db_session, distance_km=5.0, duration_minutes=30.0, run_date=today)
run_repository.create_run(db_session, distance_km=10.0, duration_minutes=55.0, run_date=today)
summary = get_weekly_summary(db_session)
assert summary.total_runs == 2
assert summary.total_distance_km == 15.0
assert summary.total_duration_minutes == 85.0
assert summary.average_pace_min_per_km == 5.67
from datetime import date
def test_create_run(client):
response = client.post("/runs", json={
"distance_km": 5.0,
"duration_minutes": 30.0,
"date": str(date.today()),
"notes": "Morning run",
})
assert response.status_code == 201
data = response.json()
assert data["distance_km"] == 5.0
assert data["pace_min_per_km"] == 6.0
assert data["notes"] == "Morning run"
def test_create_run_invalid_distance(client):
response = client.post("/runs", json={
"distance_km": -1.0,
"duration_minutes": 30.0,
"date": str(date.today()),
})
assert response.status_code == 422
def test_list_runs(client):
client.post("/runs", json={
"distance_km": 5.0,
"duration_minutes": 30.0,
"date": str(date.today()),
})
response = client.get("/runs")
assert response.status_code == 200
assert len(response.json()) == 1
def test_get_run(client):
create_response = client.post("/runs", json={
"distance_km": 8.0,
"duration_minutes": 44.0,
"date": str(date.today()),
})
run_id = create_response.json()["id"]
response = client.get(f"/runs/{run_id}")
assert response.status_code == 200
assert response.json()["distance_km"] == 8.0
def test_get_run_not_found(client):
response = client.get("/runs/999")
assert response.status_code == 404
def test_delete_run(client):
create_response = client.post("/runs", json={
"distance_km": 3.0,
"duration_minutes": 18.0,
"date": str(date.today()),
})
run_id = create_response.json()["id"]
response = client.delete(f"/runs/{run_id}")
assert response.status_code == 204
def test_weekly_summary(client):
client.post("/runs", json={
"distance_km": 5.0,
"duration_minutes": 30.0,
"date": str(date.today()),
})
response = client.get("/runs/weekly-summary")
assert response.status_code == 200
data = response.json()
assert data["total_runs"] == 1
assert data["total_distance_km"] == 5.0
Your test suite is green. Next up, you will containerize the entire application with Docker and write a portfolio-ready README.
Dockerize and Write the README
Your tests pass and the API works locally. The problem is that "works on my machine" doesn't help anyone else run your project.
In this step, you'll package the entire application into a Docker container so it runs anywhere with a single command. You'll also write a portfolio-ready README that documents the architecture and setup for anyone reading your code.
In this step, get ready to:
- Create a Dockerfile and docker-compose.yml for containerized deployment.
- Build and run the container, verifying the API works inside Docker.
- Write a README documenting architecture, setup, and key concepts.
Create the Dockerfile and docker-compose.yml
A Dockerfile is a recipe that tells Docker how to build an image of your application. It installs dependencies, copies your code, and defines how to start the server.
- Open Dockerfile in your editor and paste the following:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
What does this Dockerfile do?
- FROM python:3.12-slim starts from a lightweight Python 3.12 image.
- COPY requirements.txt . copies just the requirements file first. Docker caches this layer, so rebuilds skip the install step if dependencies haven't changed.
- COPY . . copies all your application code into the container.
- CMD runs uvicorn to start the FastAPI server on port 8000, listening on all interfaces.
- Save Dockerfile.
Now you need a way to run the container without typing long docker commands every time. That's what docker-compose.yml handles.
- Open docker-compose.yml and paste the following:
services:
app:
build: .
ports:
- "8000:8000"
volumes:
- ./data:/app/data
environment:
- RUNNING_LOG_DATABASE_URL=sqlite:///./data/runs.db
What does docker-compose.yml configure?
- build: . tells Docker to build the image using the Dockerfile in the current directory.
- ports maps port 8000 inside the container to port 8000 on your machine.
- volumes mounts a local data/ folder into the container so the SQLite database persists between restarts.
- environment sets RUNNING_LOG_DATABASE_URL to point at the mounted volume path. Your pydantic-settings config picks this up automatically.
- Save docker-compose.yml.
Seeing a YAML syntax error?
YAML is whitespace-sensitive. Make sure you're using spaces (not tabs) and that each indentation level is exactly 2 spaces.
help me fix a YAML syntax error in my docker-compose.yml
Build and run the container
With both files in place, you can build the Docker image and start the container in one command.
- Build and start the container by running:
docker compose up --build
What should I see?
Docker downloads the Python base image (first run only), installs your dependencies, and starts uvicorn. You should see log output ending with something like Uvicorn running on http://0.0.0.0:8000.
- Open http://localhost:8000/docs in your browser.
- You should see the Swagger UI with all 5 endpoints listed, just like when you ran locally.
Container won't start?
Make sure Docker Desktop is running. If you see a port conflict, another process is already using port 8000. Stop it or change the host port in docker-compose.yml.
help me debug why my Docker container won't start
- Stop the container by pressing Ctrl+C in the terminal.
- Verify that tests pass inside the container by running:
docker compose run app pytest -v
- You should see all tests passing with green output, just like when you ran pytest -v locally.
Why run tests inside the container?
Running tests inside Docker proves the container has everything it needs. If tests pass locally but fail in Docker, something is missing from your image.
Write the portfolio-ready README
A good README is often the first thing a hiring manager or collaborator sees. It should explain what the project does, how to run it, and what you learned.
- Open README.md and paste the following:
# Running Log API
A REST API for logging runs and analyzing training data, built with FastAPI and clean architecture.
## Architecture
```
HTTP Request
└── Routes (app/api/) # HTTP handling only
└── Services (app/services/) # Business logic
└── Repositories (app/repositories/) # DB access
└── SQLAlchemy Models (app/models/)
```
## Tech Stack
- Python 3.12
- FastAPI 0.139.0
- SQLAlchemy 2.0.51
- Pydantic-settings 2.14.2
- pytest 9.1.1
- httpx 0.28.1
- Docker
## Setup
### Local
```bash
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
fastapi dev app/main.py
```
### Docker
```bash
docker compose up --build
```
## API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| POST | /runs | Log a new run |
| GET | /runs | List all runs |
| GET | /runs/{id} | Get a single run |
| GET | /runs/weekly-summary | Get current week stats |
| DELETE | /runs/{id} | Delete a run |
## Testing
```bash
pytest -v
# or inside Docker:
docker compose run app pytest -v
```
## Key Concepts Learned
- Clean 3-layer architecture (routes, services, repositories)
- Dependency injection with FastAPI Depends
- Pydantic v2 validation and ORM mode
- pytest fixtures and dependency overrides for testing
- Docker containerization
## What I'd Add Next
- PostgreSQL support
- Authentication
- Pagination for run list
- Personal records tracking
What makes this README portfolio-ready?
The architecture diagram shows you understand how the layers connect. The endpoint table gives readers a quick reference. The "Key Concepts Learned" section signals self-awareness about what you practiced.
The "What I'd Add Next" section shows growth mindset. It tells an interviewer you can think beyond the current scope.
- Save README.md.
- Open README.md in your editor's preview mode (or view it on GitHub after pushing). You should see formatted headings, a code block architecture diagram, and a clean endpoint table.
README not rendering correctly?
Make sure there are no extra spaces before the markdown headings. Each ## must be at the start of the line with a space before the title text.
help me fix my README.md formatting
✔️ Awesome, I've got everything!
Great. Make sure you've saved all three files: Dockerfile, docker-compose.yml, and README.md.
ⓧ I'd like to double check the full code
Here are all three files for this step:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
services:
app:
build: .
ports:
- "8000:8000"
volumes:
- ./data:/app/data
environment:
- RUNNING_LOG_DATABASE_URL=sqlite:///./data/runs.db
# Running Log API
A REST API for logging runs and analyzing training data, built with FastAPI and clean architecture.
## Architecture
```
HTTP Request
└── Routes (app/api/) # HTTP handling only
└── Services (app/services/) # Business logic
└── Repositories (app/repositories/) # DB access
└── SQLAlchemy Models (app/models/)
```
## Tech Stack
- Python 3.12
- FastAPI 0.139.0
- SQLAlchemy 2.0.51
- Pydantic-settings 2.14.2
- pytest 9.1.1
- httpx 0.28.1
- Docker
## Setup
### Local
```bash
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
fastapi dev app/main.py
```
### Docker
```bash
docker compose up --build
```
## API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| POST | /runs | Log a new run |
| GET | /runs | List all runs |
| GET | /runs/{id} | Get a single run |
| GET | /runs/weekly-summary | Get current week stats |
| DELETE | /runs/{id} | Delete a run |
## Testing
```bash
pytest -v
# or inside Docker:
docker compose run app pytest -v
```
## Key Concepts Learned
- Clean 3-layer architecture (routes, services, repositories)
- Dependency injection with FastAPI Depends
- Pydantic v2 validation and ORM mode
- pytest fixtures and dependency overrides for testing
- Docker containerization
## What I'd Add Next
- PostgreSQL support
- Authentication
- Pagination for run list
- Personal records tracking
Secret mission
Auto-Detect Personal Records
Your API logs runs, but it doesn't celebrate breakthroughs. Add personal record detection that automatically identifies when a new run sets a PR for fastest pace, longest distance, or longest duration. This adds stateful comparison logic and conditional response enrichment to your clean architecture.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The running-log-api project folder on your Desktop (includes source code, virtual environment, and SQLite databases).
- Docker container and image created by docker compose up --build.
Keep everything running
No action needed. Choose this if you're still actively building or want to keep testing your API.
- Your Docker container keeps running at http://localhost:8000/docs.
- Your project folder stays on your Desktop with the virtual environment intact.
- Your SQLite databases (runs.db, test.db) retain all logged runs.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files stay on disk so you can pick up where you left off.
- Stop the Docker container by pressing Ctrl+C in the terminal where it's running, or run:
docker compose down
- Deactivate your Python virtual environment by running:
deactivate
- To resume later, navigate back to the running-log-api folder, activate the virtual environment with source venv/bin/activate, and run docker compose up.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your code, databases, and Docker artifacts.
- Stop and remove the Docker container, image, and volume by running:
docker compose down --rmi all --volumes
- Navigate to your Desktop by running:
cd ~/Desktop
- Delete the entire project folder by running:
rm -rf running-log-api
- On Windows, use this command instead:
rmdir /s /q running-log-api
- This removes all source code, the virtual environment, SQLite database files, and the test.db file.
Nice Work!
Nice Work!
You just built a production-grade Running Log API from scratch using FastAPI, clean architecture, automated testing, and Docker.
You've learned how to:
- Build a REST API with FastAPI serving endpoints for creating, reading, aggregating, and deleting data.
- Structure a Python backend with clean 3-layer architecture (routes, services, repositories) that enforces proper separation of concerns.
- Write a pytest test suite covering both unit and integration tests, and containerize the app with Docker for portable deployment.
- Secret Mission: Added personal record detection with stateful comparison logic that auto-detects fastest pace, longest distance, and longest duration.
Ready to quiz yourself?