Design a Course Catalogue API

Published by Programming Line in FastAPI. Last updated .

A course catalogue API lets clients create, browse, update, and remove course records without knowing how those records are stored. In this capstone lesson, the outcome is a FastAPI design that names the resource, fixes the public schemas, exposes predictable routes, protects ownership, paginates collections, and keeps database work behind a boundary that can later move from memory to SQL without changing the HTTP contract.

Purpose and Outcome

The catalogue is not a shopping cart, learning player, or identity system. Its core resource is a course: a teachable item with a stable identifier, a human title, a URL-safe slug, a level, and an owner. The API should answer four practical questions: can an authorized user create a course, can visitors list courses in bounded pages, can a user retrieve one course by identity, and can only the owner or an administrator mutate it? A good FastAPI design makes those answers visible in path operations, request models, response models, dependency functions, and tests.

How the Mechanism Works Internally

FastAPI turns a path operation into a small request pipeline. First, Starlette matches the HTTP method and path, such as POST /courses. FastAPI then resolves dependencies, parses path and query parameters, validates the body with Pydantic, calls your function, validates or serializes the return value through the declared response model, and finally writes an ASGI response. For a catalogue, that pipeline is useful because each concern has a natural place. Authentication belongs in a dependency such as current_user. Shape and constraints belong in Pydantic models such as CourseIn and CourseOut. Storage belongs in a repository or session dependency, not inside every route.

The main internal boundary is between API schema and persistence model. A request schema describes what a caller may send. A response schema describes what the service promises to reveal. A database row may have extra fields: internal status, audit timestamps, deletion flags, search vectors, or billing metadata. Those fields should not leak merely because an ORM object has them. FastAPI’s response_model is therefore part of the safety mechanism, not just documentation.

API Anatomy

The catalogue route set is small but deliberate. POST /courses creates a course and returns 201. GET /courses returns a page controlled by limit and offset. GET /courses/{course_id} fetches one course or returns 404. PATCH /courses/{course_id} should accept partial editable fields. DELETE /courses/{course_id} should return 204 after removing or archiving the record. Slugs deserve a uniqueness rule because they are often used in URLs; a duplicate slug is a conflict, so 409 is more precise than a generic bad request.

Pagination is a design decision, not an afterthought. An unbounded list endpoint can become slow and memory-heavy as the catalogue grows. Offset pagination is easy to explain and works well for administrative screens. Cursor pagination is better for feeds where new rows are inserted frequently, because offsets can skip or repeat items while the data changes. This lesson uses offset pagination because it is transparent for a capstone API, but it keeps limit bounded so clients cannot ask for the whole database in one response.

Example 1: Model the Course Resource

The first step is to make the public data shape executable. This example constrains titles, slugs, levels, and owner identifiers before any storage work happens.

from pydantic import BaseModel, Field, HttpUrl
from typing import Literal

class CourseCreate(BaseModel):
    title: str = Field(min_length=3, max_length=120)
    slug: str = Field(pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$", max_length=140)
    level: Literal["beginner", "intermediate", "advanced"]
    owner_id: int = Field(gt=0)
    public_url: HttpUrl | None = None

payload = CourseCreate(
    title="FastAPI Foundations",
    slug="fastapi-foundations",
    level="beginner",
    owner_id=42,
)
print(payload.model_dump()["slug"])

Expected behavior: the example prints fastapi-foundations. If the slug is changed to FastAPI Foundations!, Pydantic raises a validation error before a route or database call can accept it. That early rejection matters because slugs often become stable URLs and duplicate or malformed values are expensive to clean up after publication.

Example 2: Add Routes, Ownership, and Pagination

The next version wires the resource model into FastAPI routes. It uses an in-memory list so the route behavior is visible without a database driver, but the route signatures already show the final shape: dependency-provided user, bounded query parameters, response models, and explicit status codes.

from fastapi import FastAPI, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from typing import Literal

app = FastAPI(title="Course Catalogue API")

class User(BaseModel):
    id: int
    is_admin: bool = False

class CourseIn(BaseModel):
    title: str = Field(min_length=3, max_length=120)
    slug: str = Field(pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$", max_length=140)
    level: Literal["beginner", "intermediate", "advanced"] = "beginner"

class CourseOut(CourseIn):
    id: int
    owner_id: int

COURSES: list[CourseOut] = []
NEXT_ID = 1

def current_user() -> User:
    return User(id=42, is_admin=False)

@app.post("/courses", response_model=CourseOut, status_code=status.HTTP_201_CREATED)
def create_course(payload: CourseIn, user: User = Depends(current_user)) -> CourseOut:
    global NEXT_ID
    if any(course.slug == payload.slug for course in COURSES):
        raise HTTPException(status_code=409, detail="slug already exists")
    course = CourseOut(id=NEXT_ID, owner_id=user.id, **payload.model_dump())
    NEXT_ID += 1
    COURSES.append(course)
    return course

@app.get("/courses", response_model=list[CourseOut])
def list_courses(
    limit: int = Query(20, ge=1, le=100),
    offset: int = Query(0, ge=0),
) -> list[CourseOut]:
    return COURSES[offset:offset + limit]

@app.delete("/courses/{course_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_course(course_id: int, user: User = Depends(current_user)) -> None:
    for index, course in enumerate(COURSES):
        if course.id == course_id:
            if course.owner_id != user.id and not user.is_admin:
                raise HTTPException(status_code=403, detail="not course owner")
            COURSES.pop(index)
            return None
    raise HTTPException(status_code=404, detail="course not found")

Expected behavior: posting a valid course returns a JSON object with id, owner_id, title, slug, and level. Listing with ?limit=20&offset=0 returns at most twenty courses. Reusing a slug returns 409. Deleting a course owned by another non-admin user returns 403; asking to delete a missing course returns 404. Those distinctions help clients decide whether to change input, ask for permission, or refresh stale state.

Example 3: Test the Public Contract

A catalogue API is only finished when the contract is tested from the caller’s point of view. This test assumes the previous application is saved as catalog_api.py. It checks creation, listing, and the duplicate-slug failure path.

from fastapi.testclient import TestClient
from catalog_api import COURSES, app

client = TestClient(app)

def test_create_list_and_duplicate_slug():
    COURSES.clear()
    created = client.post(
        "/courses",
        json={"title": "FastAPI Foundations", "slug": "fastapi-foundations", "level": "beginner"},
    )
    assert created.status_code == 201
    assert created.json()["owner_id"] == 42

    listed = client.get("/courses", params={"limit": 10, "offset": 0})
    assert listed.status_code == 200
    assert listed.json()[0]["slug"] == "fastapi-foundations"

    duplicate = client.post(
        "/courses",
        json={"title": "Another Title", "slug": "fastapi-foundations", "level": "advanced"},
    )
    assert duplicate.status_code == 409
    assert duplicate.json()["detail"] == "slug already exists"

Expected behavior: the test passes with no response body printed. If the duplicate check is removed from create_course, the final assertion fails because the second create returns success instead of 409. That is the kind of regression a capstone project should catch: the code still runs, but the resource invariant has been broken.

Design Choices and Trade-offs

Use integer identifiers for compact internal references, or UUIDs when records must be created across systems without central coordination. Use slugs for human-readable URLs, but never use them as the only authorization key. Keep owner_id server-assigned; accepting it from the request body lets one user create courses on behalf of another. Prefer PATCH for partial edits because course records often change one field at a time. Keep request and response models separate so later fields such as published_at or enrollment_count can be returned without becoming client-writable.

The storage boundary should hide whether the implementation uses a list, SQLAlchemy, SQLModel, or another data mapper. A repository function such as create_course(user_id, payload) can enforce slug uniqueness inside a transaction. That matters under concurrency: two simultaneous requests can both pass an in-memory does this slug exist check unless the database also has a unique constraint. Application checks produce friendly errors; database constraints produce correctness.

Failure Modes and Troubleshooting

Symptom: valid-looking course creates return 422. Cause: the request body does not match the Pydantic schema, often because level has an unsupported value or slug contains uppercase letters. Diagnose: inspect the validation error path in the response and compare it with the model field constraints. Correct: normalize input on the client or add an explicit server-side normalization step before validation if the API promises to accept mixed-case slugs.

Symptom: duplicate slugs appear in the catalogue. Cause: uniqueness was checked only in Python or was missing from the database. Diagnose: query records grouped by slug and review concurrent create logs. Correct: add a unique database index, catch the integrity error, and translate it to 409.

Symptom: users can delete courses they do not own. Cause: the route checks only that the user is authenticated, not that the course belongs to that user. Diagnose: write a test with a dependency override for a different user. Correct: compare course.owner_id with user.id unless user.is_admin is true.

Security, Performance, and Reliability

Security starts with server-controlled identity. Do not accept owner_id in create or update bodies. Response models should omit internal fields and any unpublished operational data. Performance depends on bounded list operations and indexed lookup fields such as id and slug. Reliability depends on making state changes atomic: when the catalogue moves to a database, create and update operations should run in transactions, and tests should prove that errors do not leave half-written records.

Hands-on Lab

Prerequisites: a Python environment with FastAPI and its test dependencies installed. Create a temporary working directory so the files can be removed cleanly after the lab.

  1. Save the second example as catalog_api.py.
  2. Save the third example as test_catalog_api.py.
  3. Run pytest. The test should pass and confirm create, list, and duplicate-slug behavior.
  4. Run the app with an ASGI server and send POST /courses using the JSON body from the test.
  5. Send GET /courses?limit=1&offset=0 and verify that the response array contains no more than one course.
  6. Change current_user temporarily to return a different id, seed a course owned by 42, and verify that deleting it returns 403.

Verification: the OpenAPI page should show the request and response schemas, pytest should pass, duplicate slugs should return 409, and invalid pagination such as limit=500 should return 422. Cleanup: stop the server and delete the two temporary files. If you added database tables during an extension, drop the lab database or roll back the migration in your development environment.

Assessment Exercises

  • Design a PATCH /courses/{course_id} schema that allows editing the title and level but not owner_id. Explain how FastAPI should distinguish omitted fields from fields set to null.
  • Replace offset pagination with cursor pagination. What value would you encode in the cursor, and how would your response tell the client whether another page exists?
  • Write a test that proves a non-owner cannot delete a course. Which dependency would you override, and what status code should the route return?
  • Suppose a database unique constraint raises an integrity error during course creation. Where should that exception be translated into an HTTP 409, and why should the raw database error not reach the client?

Summary

A FastAPI course catalogue API is a resource design exercise: define the course shape, expose routes with precise status codes, keep ownership server-side, bound collection reads, and treat the database as the final authority for persistent invariants. The capstone skill is seeing how schemas, dependencies, route functions, tests, and storage boundaries work together so a small tutorial service can grow into a maintainable API without changing its public meaning.