Build a Reading List API with FastAPI

Build and publish a tested FastAPI reading list REST API.

Introduction

30 Second Summary

A reading list is easy to start in a notebook. The trouble begins when books move from planned to in progress to finished.

In this project, you will build a Reading List REST API with FastAPI. The finished project gives other developers a tested contract they can run from your public GitHub repository.

What You'll Build

You will open your Reading List API in interactive documentation to guide a book through a complete create-to-delete journey.

By the end of this project, you'll have:

  • A five-endpoint CRUD API that lets you create, list, retrieve, replace, or delete books while showing the correct success status for each operation.
  • Predictable Pydantic validation that reports invalid input or missing books through the same error envelope.
  • A public GitHub repository another developer can clone. Its tests, README, and OpenAPI documentation make the API easy to verify.
  • Secret Mission: Add optional filtering so GET /books can return only books with a chosen reading status.

Are there any prerequisites?

Basic Python knowledge plus Python 3.10 or newer is enough. No prior FastAPI knowledge is required.

Before We Start

This is your moment to define who the Reading List API serves and what they need from it. That purpose will guide every endpoint you build.

Prepare the Python Project

The Reading List API needs a predictable Python environment before any endpoint code exists. Global package installs can silently reuse incompatible versions from another project.

A virtual environment gives this project its own package boundary. Exact dependency pins make the same setup reproducible later.

In this step, get ready to:
  • Confirm that Python 3.10 or newer is installed.
  • Create the project folder with an isolated virtual environment.
  • Install the pinned dependencies from requirements.txt.
Confirm your Python version

The pinned packages require Python 3.10 or newer. Checking the interpreter now prevents dependency failures during installation.

  • Press Cmd+Space to open Spotlight.
  • Type Terminal into the search field.
  • Press Return to open Terminal.
  • Check your installed Python version by running this command:
python3 --version

What does this command do?

The command asks the python3 executable to report its version. The result tells you whether this interpreter supports every pinned package in the project.

✔️ I see the required version

Good, your interpreter meets the project requirement. Continue if the output reports Python 3.10 or newer.

ⓧ I see an older version

Your current interpreter is below the shared minimum for the pinned packages. Install a current Python release before creating the environment.

  • Open the Python downloads for macOS page.
  • Download the current macOS installer from the latest Python release.
  • Run the downloaded installer.
  • Complete the installer using its default options.
  • Return to Terminal.
  • Check the upgraded interpreter by running this command:
python3 --version

What should I see?

The command should now report Python 3.10 or newer. That confirms the new interpreter can create this project's environment.

ⓧ Command not found

Terminal cannot currently find the Python 3 executable. Installing the current macOS release adds the interpreter needed by this project.

  • Open the Python downloads for macOS page.
  • Download the current macOS installer from the latest Python release.
  • Run the downloaded installer.
  • Complete the installer using its default options.
  • Return to Terminal.
  • Check the installed interpreter by running this command:
python3 --version

What should I see?

The command should print a Python version. Continue when that version is 3.10 or newer.

Still seeing the wrong Python version?

Close Terminal after the installer finishes. Open a fresh Terminal window before repeating the version check.

If the older version still appears, ask for help checking which Python interpreter your Mac is using.

Create the isolated project

The project folder gives every file one predictable location. The environment inside .venv keeps this API's packages separate from your global Python installation.

  • Create the reading-list-api folder on your Desktop by running these commands:
cd ~/Desktop
mkdir reading-list-api

What do these commands do?

  • The first command moves Terminal to your Desktop.
  • The second command creates the reading-list-api folder there.
  • Confirm that the new folder exists by running this command:
ls

What does this check prove?

The ls command lists the contents of your Desktop. Seeing reading-list-api proves the folder was created in the intended location.

You should see reading-list-api in the output.

  • Set up the isolated environment by running these commands:
cd reading-list-api
python3 -m venv .venv
source .venv/bin/activate

How is the environment set up?

  • The first command moves Terminal into reading-list-api.
  • The second command creates the virtual environment in .venv.
  • The final command activates that environment for the current Terminal session.

You should now see (.venv) at the start of your Terminal prompt. That visible prefix confirms future Python commands use the isolated environment.

Don't see the environment prefix?

  • Check that Terminal is inside the reading-list-api folder.
  • Repeat the activation command if the environment exists but the prompt has no (.venv) prefix.

Still stuck? Help me activate the virtual environment on my Mac.

Your files also need a shared workspace for editing. Visual Studio Code opens the project folder as that workspace.

  • Press Cmd+Space to open Spotlight.
  • Type Visual Studio Code into the search field.
  • Press Return to open Visual Studio Code.
  • Select File from the menu bar.
  • Select Open Folder.
  • Select the reading-list-api folder on your Desktop.
  • Select Open.
  • Confirm that you trust the folder if VS Code shows a Workspace Trust prompt.

You should see READING-LIST-API at the top of the Explorer. That confirms VS Code is using the correct project folder.

Install the pinned dependencies

Each dependency has one job in the API workflow. Pinning every version keeps local development reproducible.

What will you install?

  • FastAPI provides the API framework plus interactive documentation.
  • Pydantic validates the book data sent to the API.
  • Uvicorn runs the API as a local server.
  • HTTPX supports the test client used later in the project.
  • The testing package pytest discovers the automated tests.
  • Select the Explorer view in the VS Code Activity Bar.
  • Select the New File button.
  • Enter requirements.txt as the file name.
  • Press Return to create the file.
  • Set the exact dependency versions by copying this content into requirements.txt:
fastapi==0.143.0
pydantic==2.14.0
uvicorn==0.54.0
httpx==0.28.1
pytest==9.1.1

Why pin exact versions?

Each == entry tells the installer to use one exact package release. This prevents future package updates from changing the environment unexpectedly.

  • Save requirements.txt.
  • Switch back to the Terminal window from earlier.
  • Install the project dependencies by running this command:
python -m pip install -r requirements.txt

What does this command do?

The command runs pip through the active Python interpreter. It reads every exact version from requirements.txt before installing the packages into .venv.

The installation output should finish without a dependency error. Your Terminal prompt should still begin with (.venv).

Seeing an installation error?

  • Check that your Terminal prompt begins with (.venv).
  • Check that every line in requirements.txt matches the pinned file above.
  • Confirm that your Python version is 3.10 or newer.

Need a hand? Help me troubleshoot the pinned dependency installation.

Before you run the final check, consider whether all five pinned versions will appear.

  • Inspect the packages installed in the active environment by running this command:
python -m pip list

What does this check prove?

The command lists packages from the active virtual environment. Matching versions prove that the install used the exact dependency file.

You should see fastapi at 0.143.0. You should also see pydantic at 2.14.0.

The list should include uvicorn at 0.54.0. It should include httpx at 0.28.1.

You should also see pytest at 9.1.1. That completes the pinned environment.

✔️ Awesome, I've got everything!

Great, your requirements.txt file is saved. Your active environment contains every pinned dependency.

ⓧ I'd like to double check the full code

Compare your saved requirements.txt file with this complete version.

fastapi==0.143.0
pydantic==2.14.0
uvicorn==0.54.0
httpx==0.28.1
pytest==9.1.1

That's the setup locked down. Next, you'll create the first endpoint and see an empty reading list respond through interactive documentation.

Build the First Endpoint

Your pinned Python environment is ready. It gives your first endpoint a dependable place to run.

An empty reading list gives you a focused first check for the FastAPI application. A response in the automatic documentation proves the server can reach that route before the API becomes more complex.

In this step, get ready to:
  • Build a FastAPI application that exposes an empty GET /books route.
  • Start the local API server with Uvicorn.
  • Verify the empty response through the interactive documentation.
Build the empty reading list endpoint

The application needs a central object that holds its metadata. It also needs an in-memory collection that starts empty whenever the server starts.

  • Select the new-file control beside reading-list-api in the VS Code Explorer sidebar.
  • Enter main.py as the file name.
  • Set up the application metadata plus the empty book collection by copying this code into main.py:
from fastapi import FastAPI

app = FastAPI(
    title="Reading List API",
    description="Manage an in-memory reading list with a consistent error contract.",
    version="1.0.0",
)

books: dict[int, dict] = {}

What does this code do?

  • The FastAPI import provides the application class that serves your API.
  • The app object stores the title plus the description shown in the generated documentation.
  • The books dictionary holds books by integer ID while the server is running.
  • The empty dictionary means the reading list begins with no items.
  • Save main.py.
  • Confirm the unsaved indicator beside main.py disappears.

Seeing editor warnings?

  • Confirm the import matches from fastapi import FastAPI exactly.
  • Check that the closing parenthesis appears before the books declaration.

Still stuck? Help me check the FastAPI application setup in main.py.

The application object now exists. A route connects an incoming request to the Python function that returns the reading list.

  • Add the first route below the books declaration by copying this code:
@app.get("/books")
def list_books() -> list[dict]:
    return list(books.values())

What does this code do?

  • The @app.get("/books") decorator connects GET requests at /books to list_books().
  • The list_books() function converts the dictionary values into a response list.
  • The empty collection produces [] as the first response body.
  • Save main.py.
  • Confirm the unsaved indicator beside main.py disappears.

Is the route underlined?

  • Confirm the route sits below the books declaration.
  • Check that the return line is indented inside list_books().
  • Confirm the route path begins with a forward slash.

Need another pair of eyes? Help me debug the GET /books route in main.py.

✔️ Awesome, I've got everything!

  • Confirm main.py is saved before starting the server.

ⓧ I'd like to double check the full code

  • Compare your complete main.py file with this reference.
from fastapi import FastAPI

app = FastAPI(
    title="Reading List API",
    description="Manage an in-memory reading list with a consistent error contract.",
    version="1.0.0",
)

books: dict[int, dict] = {}


@app.get("/books")
def list_books() -> list[dict]:
    return list(books.values())
Start the local API server

Uvicorn loads the application object from main.py. It keeps running so your browser can send requests to the local API.

  • Switch back to the Terminal from earlier.
  • Check that the prompt begins with (.venv).
  • Launch the app object from main.py by running this command:
uvicorn main:app

What does this command do?

The uvicorn command reads main.py and runs the object named app. The server keeps control of this Terminal while it accepts requests.

You should see startup logs plus a local server address. That output confirms your application loaded successfully.

  • Record the address printed by Uvicorn here: printed server address.

Did the server stop immediately?

  • Confirm the Terminal prompt still begins with (.venv).
  • Check that main.py is saved inside the reading-list-api folder.
  • Review the final Terminal lines for the file name plus the line that needs attention.

If the process still exits, help me diagnose why Uvicorn cannot start main.py.

Verify the endpoint in the docs

FastAPI generates interactive OpenAPI documentation from your route. This page lets you call GET /books without writing a separate client.

  • Press Cmd+Space on macOS or the Windows key on Windows to open application search.
  • Type your browser's name into the search field.
  • Press Enter to open the browser.
  • Enter printed server address in the browser address bar.
  • Append /docs to the address.
  • Press Enter to load the interactive documentation.

You should see the Reading List API documentation with the GET /books operation listed.

  • Select GET /books to expand the operation.
  • Click Try it out.

Before you execute it, decide which result you expect: saved books or an empty list.

  • Click Execute.

You should see response status 200 with the JSON body [].

That first empty list proves the server, route, and interactive documentation are working together.

Don't see the empty list?

  • Confirm Uvicorn is still running in the Terminal from earlier.
  • Check that the browser address uses the exact address printed by Uvicorn plus /docs.
  • Confirm the decorator in main.py uses the path /books.

Still missing the expected response? Help me troubleshoot the GET /books documentation request.

Your empty reading list now travels from the route to the interactive documentation. Next, you'll define its book model before creating real books.

Create and Retrieve Books

Your empty GET /books response proved that the FastAPI server works. Now the reading list needs a clear definition of a valid book.

This step turns the empty collection into a usable resource. You will also inspect how the framework reports failures to clients.

In this step, get ready to:
  • Model validated book input.
  • Add routes for creating and retrieving books.
  • Compare FastAPI's default failure responses.
Define validated book models

Pydantic models define the fields that each request can contain. Their rules stop malformed book data before it reaches your in-memory collection.

  • In main.py, select the first import line.
  • Replace that line with the imports and model definitions below:
from typing import Literal

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, ConfigDict, Field

ReadingStatus = Literal["to-read", "reading", "finished"]


class BookInput(BaseModel):
    model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)

    title: str = Field(min_length=1, max_length=120)
    author: str = Field(min_length=1, max_length=80)
    reading_status: ReadingStatus = "to-read"


class Book(BookInput):
    id: int

What Do These Models Enforce?

  • ReadingStatus limits the status to to-read, reading, or finished.
  • BookInput removes surrounding whitespace. It also rejects fields that the model does not define.
  • Field requires titles to contain between 1 and 120 characters. It requires author names to contain between 1 and 80 characters.
  • reading_status defaults to to-read when a request omits it.
  • Book extends the input model with the integer ID assigned by the API.
  • Save main.py.
  • Switch back to the terminal from earlier.
  • Stop the running Uvicorn process with your terminal's interrupt shortcut.
  • Restart Uvicorn by running:
uvicorn main:app

You should see Uvicorn complete its startup and print the local server address. This confirms that Python can import your new models.

Does the Server Stop During Startup?

  • Check that the three import lines appear before ReadingStatus in main.py.
  • Check the quotation marks around the three allowed status values.
  • Confirm that your terminal prompt still shows the activated .venv environment.

Still stuck? Help me diagnose why Uvicorn cannot import my new book models.

The collection and list route still use generic dictionaries. Connecting them to Book gives the OpenAPI documentation a defined response shape.

  • In main.py, find the collection declaration and list_books() route.
  • Confirm that the current block looks like this:
books: dict[int, dict] = {}


@app.get("/books")
def list_books() -> list[dict]:
    return list(books.values())

This is the existing dictionary-based block. Its types do not yet describe a validated book.

  • Replace the current block with this typed version:
books: dict[int, Book] = {}


@app.get("/books", response_model=list[Book])
def list_books() -> list[Book]:
    return list(books.values())

What Does the Response Model Do?

  • books now maps each integer ID to a Book object.
  • response_model=list[Book] tells FastAPI that this route returns a list of validated books.
  • list_books() still returns every value stored in the in-memory dictionary.
  • Save main.py.
  • Switch back to the terminal from earlier.
  • Stop the running Uvicorn process with your terminal's interrupt shortcut.
  • Restart Uvicorn by running:
uvicorn main:app

You should see Uvicorn start successfully and print the local server address again.

  • Return to the interactive documentation from earlier.
  • Expand GET /books.
  • Select Try it out.
  • Select Execute.

You should still receive 200 OK with []. Your typed model layer now preserves the working empty-list response.

Add creation and retrieval routes

A create route converts validated input into a stored Book. The API assigns each book the next available integer ID.

  • In main.py, place your cursor below list_books().
  • Add the book creation route by copying this code:
@app.post("/books", response_model=Book, status_code=201)
def create_book(payload: BookInput) -> Book:
    book_id = max(books, default=0) + 1
    book = Book(id=book_id, **payload.model_dump())
    books[book_id] = book
    return book

How Does Book Creation Work?

  • payload contains request data that passed the BookInput rules.
  • book_id starts at 1 for an empty collection. Each later book receives the next integer.
  • payload.model_dump() converts the validated input into values used to construct a Book.
  • status_code=201 makes a successful request return 201 Created.
  • Save main.py.
  • Switch back to the terminal from earlier.
  • Stop the running Uvicorn process with your terminal's interrupt shortcut.
  • Restart Uvicorn by running:
uvicorn main:app

You should see Uvicorn start successfully. The restarted process now includes the creation route.

  • Return to the interactive documentation from earlier.
  • Confirm that POST /books is listed.

The new route in the documentation proves that FastAPI loaded create_book().

Don't See the POST Route?

  • Refresh the interactive documentation after restarting Uvicorn.
  • Check that @app.post begins at the left edge of its line in main.py.
  • Confirm that create_book() appears below list_books().

Still missing? Help me find why POST /books does not appear in my FastAPI documentation.

A retrieval route looks up one integer ID in the collection. A missing ID raises an HTTPException with a 404 response.

  • In main.py, place your cursor below create_book().
  • Add the retrieval route by copying this code:
@app.get("/books/{book_id}", response_model=Book)
def get_book(book_id: int) -> Book:
    book = books.get(book_id)
    if book is None:
        raise HTTPException(status_code=404, detail="Book not found")
    return book

How Does Retrieval Work?

  • book_id comes from the integer value in the request path.
  • books.get(book_id) returns the matching Book object when the ID exists.
  • HTTPException returns 404 Not Found when the lookup produces None.
  • detail="Book not found" supplies the message in the default error response.
  • Save main.py.

✔️ Awesome, I've got everything!

Your main.py now contains the models and three reading-list routes needed for the next check.

ⓧ I'd like to double check the full code

from typing import Literal

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, ConfigDict, Field

ReadingStatus = Literal["to-read", "reading", "finished"]


class BookInput(BaseModel):
    model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)

    title: str = Field(min_length=1, max_length=120)
    author: str = Field(min_length=1, max_length=80)
    reading_status: ReadingStatus = "to-read"


class Book(BookInput):
    id: int


app = FastAPI(
    title="Reading List API",
    description="Manage an in-memory reading list with a consistent error contract.",
    version="1.0.0",
)

books: dict[int, Book] = {}


@app.get("/books", response_model=list[Book])
def list_books() -> list[Book]:
    return list(books.values())


@app.post("/books", response_model=Book, status_code=201)
def create_book(payload: BookInput) -> Book:
    book_id = max(books, default=0) + 1
    book = Book(id=book_id, **payload.model_dump())
    books[book_id] = book
    return book


@app.get("/books/{book_id}", response_model=Book)
def get_book(book_id: int) -> Book:
    book = books.get(book_id)
    if book is None:
        raise HTTPException(status_code=404, detail="Book not found")
    return book
Exercise the success and failure paths

The three routes now provide one successful creation path and two ways to send invalid requests. Running each path lets you inspect the API from a client's perspective.

  • Switch back to the terminal from earlier.
  • Stop the running Uvicorn process with your terminal's interrupt shortcut.
  • Restart the completed API by running:
uvicorn main:app

You should see Uvicorn complete its startup and print the server address. This running process now includes all three routes.

  • Return to the interactive documentation from earlier.

Before you send these requests, which ones do you expect to succeed? Do you think both failure responses will organize their details in the same way?

  • Expand POST /books.
  • Select Try it out.
  • Prepare a valid create request by replacing the request body with this example:
{
  "title": "Parable of the Sower",
  "author": "Octavia E. Butler",
  "reading_status": "to-read"
}

What Will This Request Create?

The body supplies a valid title and author. Its status is one of the three values accepted by ReadingStatus.

  • Select Execute.

You should receive 201 Created. The response contains your book data with id set to 1.

That is your first stored book. The API now validates input and assigns an ID before returning the new resource.

  • Expand GET /books/{book_id}.
  • Select Try it out.
  • Enter 999 in the book_id field.
  • Select Execute.

Keep that response expanded while you send the validation request.

  • Return to POST /books.
  • Select Try it out if the request editor is inactive.
  • Replace the title value with three spaces.
  • Select Execute.

The missing-book request returns 404 Not Found with a text value in detail. The invalid create request returns 422 Unprocessable Content with a list in detail.

You found the intended contract gap. Clients currently need separate parsing logic for two documented failures.

Don't See Both Failures?

  • Confirm that the missing-book request uses 999 after creating only one book.
  • Confirm that the invalid title contains only whitespace.
  • Refresh the interactive documentation if the retrieval route is absent.

Need help? Help me reproduce the default 404 and 422 responses in my Reading List API.

Your API can now create and retrieve validated books. Next up, you will complete the CRUD lifecycle and give every documented failure one predictable error shape.

Complete CRUD and Normalize Errors

Your FastAPI app can create books. It can also retrieve them by ID. The previous step exposed two failure formats that clients would have to interpret separately.

This step gives every documented failure one predictable JSON envelope. You will also add replacement and deletion so the API supports the full CRUD lifecycle.

In this step, get ready to:
  • Build a reusable error envelope for documented failures.
  • Normalize missing-book and validation responses.
  • Complete the API with replacement and deletion operations.
Build the shared error envelope

A stable error contract gives every client the same fields to inspect. The build_error() helper will create that shape from one place.

  • Switch back to main.py in VS Code.
  • Replace the section from class Book(BookInput): through books: dict[int, Book] = {} with this code:
class Book(BookInput):
    id: int


class BookNotFoundError(Exception):
    pass


app = FastAPI(
    title="Reading List API",
    description="Manage an in-memory reading list with a consistent error contract.",
    version="1.0.0",
)

books: dict[int, Book] = {}


def build_error(
    code: str,
    message: str,
    details: list[dict[str, str]] | None = None,
) -> dict[str, object]:
    return {
        "error": {
            "code": code,
            "message": message,
            "details": details or [],
        }
    }

What does this code do?

  • BookNotFoundError represents a missing reading-list resource. A dedicated exception lets one handler control every missing-book response.
  • build_error() puts the error code, message, and details inside one error object.
  • details or [] guarantees that clients receive a list even when no field-level details exist.
  • Save main.py.
  • Return to the terminal panel from earlier.
  • Press Control+C to stop the current Uvicorn process.
  • Restart the API with the updated file by running:
uvicorn main:app

Why restart the server?

The current Uvicorn command loads main.py when the process starts. Restarting loads the new exception and error helper into the running API.

  • Return to /docs in your browser.
  • Refresh the page.

You should still see the Reading List API metadata plus the existing list, create, and retrieve operations. Good progress. The shared error structure now loads without disrupting the working routes.

Does the server stop during startup?

  • Check that BookNotFoundError appears after the complete Book class.
  • Check that every opening brace in build_error() has a matching closing brace.
  • Compare the indentation inside the nested error dictionary with the snippet above.

Still stuck? Help me debug the new error helper in main.py.

Route failures through one contract

The shared helper defines the response shape. Exception handlers now need to catch each failure and return that shape with the correct status code.

  • Add the missing-book handler directly below build_error() in main.py by copying this code:
@app.exception_handler(BookNotFoundError)
async def book_not_found_handler(_request, _exc: BookNotFoundError) -> JSONResponse:
    return JSONResponse(
        status_code=404,
        content=build_error("book_not_found", "Book not found"),
    )

How does this handler work?

  • @app.exception_handler(BookNotFoundError) registers one response path for that exception type.
  • JSONResponse sends the shared envelope with status 404.
  • book_not_found gives clients a stable code they can check without parsing the human-readable message.
  • Replace the route section from @app.get("/books", response_model=list[Book]) through the existing get_book() function with this code:
def get_book_or_404(book_id: int) -> Book:
    book = books.get(book_id)
    if book is None:
        raise BookNotFoundError
    return book


@app.get("/books", response_model=list[Book])
def list_books() -> list[Book]:
    return list(books.values())


@app.post("/books", response_model=Book, status_code=201)
def create_book(payload: BookInput) -> Book:
    book_id = max(books, default=0) + 1
    book = Book(id=book_id, **payload.model_dump())
    books[book_id] = book
    return book


@app.get("/books/{book_id}", response_model=Book)
def get_book(book_id: int) -> Book:
    return get_book_or_404(book_id)

Why use a lookup helper?

  • get_book_or_404() keeps the missing-book check in one place.
  • get_book() now returns the helper result. Missing IDs reach the new exception handler automatically.
  • The list and create routes remain unchanged. Replacing the whole route section preserves their existing behavior.
  • Save main.py.
  • Return to the terminal panel from earlier.
  • Press Control+C to stop Uvicorn.

Before you restart the API, what error shape do you expect when the requested book does not exist?

  • Load the missing-book handler and lookup helper by running:
uvicorn main:app

What does this check load?

Uvicorn imports the updated application. The retrieve route can now raise BookNotFoundError and return the shared envelope.

  • Return to /docs.
  • Refresh the page.
  • Expand GET /books/{book_id}.
  • Click Try it out.
  • Enter 999 as the book ID.
  • Click Execute.

You should see status 404 with error.code set to book_not_found. The response also contains error.message and an empty error.details list.

Still see the old detail response?

  • Confirm that get_book() returns get_book_or_404(book_id).
  • Confirm that get_book_or_404() raises BookNotFoundError for a missing ID.
  • Restart Uvicorn after saving the file.

Need another pair of eyes? Help me trace why my missing-book response still uses detail.

Missing books now follow the contract. The next handler converts request validation failures into the same top-level structure while preserving field-level details.

  • Replace the import section at the top of main.py with this code:
from typing import Literal

from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field

What changed in the imports?

  • RequestValidationError identifies invalid request data that FastAPI rejects before a route runs.
  • JSONResponse lets both handlers control the status code and response body.
  • HTTPException is gone because missing books now use BookNotFoundError.
  • Add the validation handler directly below book_not_found_handler() by copying this code:
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
    _request,
    exc: RequestValidationError,
) -> JSONResponse:
    details = [
        {
            "field": ".".join(str(part) for part in error["loc"]),
            "message": error["msg"],
        }
        for error in exc.errors()
    ]
    return JSONResponse(
        status_code=422,
        content=build_error(
            "validation_error",
            "Request validation failed",
            details,
        ),
    )

How are validation errors normalized?

  • exc.errors() provides the validation failures detected in the request.
  • details converts each failure into a field path plus a readable message.
  • build_error() wraps those details with the stable validation_error code.
  • JSONResponse returns the normalized body with status 422.
  • Save main.py.
  • Return to the terminal panel from earlier.
  • Press Control+C to stop Uvicorn.

Before you restart, do you expect the invalid request to keep its field details inside the new envelope?

  • Load the validation handler by running:
uvicorn main:app

What does this restart activate?

The restarted process registers both custom handlers. Missing resources and invalid requests now pass through the same error-building function.

  • Return to /docs.
  • Refresh the page.
  • Repeat the invalid POST /books request from the previous step.
  • Click Execute.

You should see status 422 with error.code set to validation_error. The error.details list should identify each rejected field.

That closes the contract gap you found earlier. Clients can now inspect the same three error fields for both documented failure types.

Still see a detail list?

  • Check that validation_exception_handler() appears below its @app.exception_handler(RequestValidationError) decorator.
  • Check that the handler returns JSONResponse with status 422.
  • Restart Uvicorn after saving the new imports and handler.

Still seeing the framework response? Help me debug why my validation exception handler is not running.

Add replacement and deletion

The API can create and read resources. A complete CRUD lifecycle also needs full replacement plus deletion.

  • Add the replacement route directly below get_book() in main.py by copying this code:
@app.put("/books/{book_id}", response_model=Book)
def replace_book(book_id: int, payload: BookInput) -> Book:
    get_book_or_404(book_id)
    book = Book(id=book_id, **payload.model_dump())
    books[book_id] = book
    return book

What does replacement preserve?

  • get_book_or_404() prevents replacement from creating a book under an unknown ID.
  • Book validates the complete replacement before it reaches the collection.
  • books[book_id] = book stores the new representation under the existing ID.
  • Save main.py.
  • Return to the terminal panel from earlier.
  • Press Control+C to stop Uvicorn.
  • Load the replacement route by running:
uvicorn main:app

What should this restart expose?

The updated route table now includes PUT /books/{book_id}. FastAPI adds it to the interactive documentation when the application starts.

  • Refresh /docs.

You should see a new PUT /books/{book_id} operation alongside the three existing operations.

Is the PUT operation missing?

  • Confirm that the route decorator starts with @app.put.
  • Confirm that replace_book() sits outside get_book() at the left indentation level.
  • Refresh /docs after restarting Uvicorn.

Need help finding the route? Help me debug why PUT /books/{book_id} is missing from my FastAPI docs.

  • Add the deletion route directly below replace_book() by copying this code:
@app.delete("/books/{book_id}", status_code=204)
def delete_book(book_id: int) -> None:
    get_book_or_404(book_id)
    del books[book_id]

Why does deletion return no body?

  • get_book_or_404() keeps the missing-book behavior consistent with retrieval and replacement.
  • del books[book_id] removes the resource from the in-memory collection.
  • 204 confirms successful deletion without returning response content.
  • Save main.py.
  • Return to the terminal panel from earlier.
  • Press Control+C to stop Uvicorn.

Before the final restart, do you expect the documentation to show all five CRUD operations?

  • Load the completed API by running:
uvicorn main:app

What does the final restart load?

The process now loads all five routes plus both custom exception handlers. The interactive documentation provides one place to exercise the complete lifecycle.

  • Refresh /docs.
  • Execute POST /books with the valid book body from the previous step.
  • Record the returned ID as your returned book ID.
  • Execute GET /books.
  • Execute GET /books/{book_id} with your returned book ID.
  • Execute PUT /books/{book_id} with your returned book ID.
  • Set reading_status to finished in the replacement body.
  • Execute DELETE /books/{book_id} with your returned book ID.

You should see 201 for creation. The list, retrieve, and replacement calls should return 200. Deletion should return 204 with no response body.

What does the lifecycle prove?

The same resource moves through creation, listing, retrieval, full replacement, and deletion. Its integer ID stays stable until deletion removes it from the collection.

  • Execute GET /books/{book_id} again with your returned book ID.
  • Repeat the invalid POST /books request from earlier.

The missing book should return 404. The invalid body should return 422. Both responses should contain error.code, error.message, and error.details.

You have completed the API lifecycle. Every promised operation now works through the interactive documentation.

Does the CRUD flow stop early?

  • Use the ID returned by the create response for every later operation.
  • Provide the complete title, author, and reading status when executing the replacement request.
  • Expect a missing-book response if you repeat the delete request after the first deletion succeeds.

Still blocked? Help me trace my five-operation CRUD flow in FastAPI.

✔️ Awesome, I've got everything!

Great. Save main.py and keep Uvicorn running for the next step.

ⓧ I'd like to double check the full code

Compare your complete main.py with this reference:

from typing import Literal

from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field

ReadingStatus = Literal["to-read", "reading", "finished"]


class BookInput(BaseModel):
    model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)

    title: str = Field(min_length=1, max_length=120)
    author: str = Field(min_length=1, max_length=80)
    reading_status: ReadingStatus = "to-read"


class Book(BookInput):
    id: int


class BookNotFoundError(Exception):
    pass


app = FastAPI(
    title="Reading List API",
    description="Manage an in-memory reading list with a consistent error contract.",
    version="1.0.0",
)

books: dict[int, Book] = {}


def build_error(
    code: str,
    message: str,
    details: list[dict[str, str]] | None = None,
) -> dict[str, object]:
    return {
        "error": {
            "code": code,
            "message": message,
            "details": details or [],
        }
    }


@app.exception_handler(BookNotFoundError)
async def book_not_found_handler(_request, _exc: BookNotFoundError) -> JSONResponse:
    return JSONResponse(
        status_code=404,
        content=build_error("book_not_found", "Book not found"),
    )


@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
    _request,
    exc: RequestValidationError,
) -> JSONResponse:
    details = [
        {
            "field": ".".join(str(part) for part in error["loc"]),
            "message": error["msg"],
        }
        for error in exc.errors()
    ]
    return JSONResponse(
        status_code=422,
        content=build_error(
            "validation_error",
            "Request validation failed",
            details,
        ),
    )


def get_book_or_404(book_id: int) -> Book:
    book = books.get(book_id)
    if book is None:
        raise BookNotFoundError
    return book


@app.get("/books", response_model=list[Book])
def list_books() -> list[Book]:
    return list(books.values())


@app.post("/books", response_model=Book, status_code=201)
def create_book(payload: BookInput) -> Book:
    book_id = max(books, default=0) + 1
    book = Book(id=book_id, **payload.model_dump())
    books[book_id] = book
    return book


@app.get("/books/{book_id}", response_model=Book)
def get_book(book_id: int) -> Book:
    return get_book_or_404(book_id)


@app.put("/books/{book_id}", response_model=Book)
def replace_book(book_id: int, payload: BookInput) -> Book:
    get_book_or_404(book_id)
    book = Book(id=book_id, **payload.model_dump())
    books[book_id] = book
    return book


@app.delete("/books/{book_id}", status_code=204)
def delete_book(book_id: int) -> None:
    get_book_or_404(book_id)
    del books[book_id]

What should the full file contain?

  • The imports include the validation exception and JSON response classes.
  • The shared error helper appears before both exception handlers.
  • The lookup helper appears before all five route functions.
  • The replacement and deletion routes appear at the bottom of the file.

Your API now supports full CRUD with predictable failures. Next, you will turn these manual checks into repeatable tests before documenting and publishing the project.

Test, Document, and Publish the API

Your FastAPI API now completes the entire book lifecycle. Its error responses also follow one stable structure.

Manual browser checks prove the idea once. This step makes those checks repeatable for every future change.

Automated integration tests protect the API contract. Markdown explains that contract to other developers.

You will finish by publishing the complete project to GitHub. Anyone can then inspect your code or run the API from a clone.

In this step, get ready to:
  • Add integration tests for the API contract.
  • Document the setup instructions and endpoint behavior.
  • Publish the complete project in a public repository.
Automate the API checks

FastAPI's TestClient sends requests directly to your application. Each test starts with an empty books dictionary so previous runs cannot affect the result.

  • Return to the terminal running Uvicorn.
  • Stop Uvicorn by pressing Control+C.
  • In the VS Code Explorer sidebar, create test_main.py inside the reading-list-api folder.
  • Connect the test client to the existing application by copying this code into test_main.py:
from fastapi.testclient import TestClient

from main import app, books

client = TestClient(app)

What Does This Code Do?

  • TestClient gives each test a synchronous way to call your FastAPI routes.
  • app is the application under test.
  • books gives each test access to the in-memory store so it can begin from a known state.
  • Save test_main.py.
  • Confirm that test_main.py now appears beside main.py in the Explorer sidebar.

Test File Missing Imports?

  • Check that the filename is exactly test_main.py.
  • Confirm that test_main.py sits in the same folder as main.py.

Still stuck? Help me check why test_main.py cannot import my FastAPI application.

  • Leave two blank lines below the test client setup.
  • Add the create, list, and retrieve checks by copying this function into test_main.py:
def test_crud_flow() -> None:
    books.clear()

    create_response = client.post(
        "/books",
        json={
            "title": "The Left Hand of Darkness",
            "author": "Ursula K. Le Guin",
            "reading_status": "to-read",
        },
    )
    assert create_response.status_code == 201
    created = create_response.json()
    book_id = created["id"]

    list_response = client.get("/books")
    assert list_response.status_code == 200
    assert len(list_response.json()) == 1

    get_response = client.get(f"/books/{book_id}")
    assert get_response.status_code == 200
    assert get_response.json()["title"] == "The Left Hand of Darkness"

What Does This Test Prove?

  • books.clear() removes state left by earlier tests.
  • client.post() creates a book through the real route.
  • client.get() confirms that the created book appears in both list and retrieval responses.
  • The assertions protect the expected status codes and response data.
  • Save test_main.py.
  • Check the first automated flow by running:
python -m pytest

What Does This Command Do?

The command asks pytest to discover standard test files and run every test function it finds. Running it through Python keeps the command inside your activated virtual environment.

You should see one test pass. That result proves the same create, list, and retrieve behavior can be checked with one command.

First Test Failing?

  • Confirm that the .venv environment remains activated.
  • Check that every route string in test_main.py matches the route in main.py.
  • Read the first failed assertion to identify the response that differs.

Need help reading the failure? Help me debug my first pytest failure.

  • Leave one blank line below the final retrieval assertion.
  • Complete test_crud_flow() by adding this continuation inside the function:
    replace_response = client.put(
        f"/books/{book_id}",
        json={
            "title": "The Left Hand of Darkness",
            "author": "Ursula K. Le Guin",
            "reading_status": "finished",
        },
    )
    assert replace_response.status_code == 200
    assert replace_response.json()["reading_status"] == "finished"

    delete_response = client.delete(f"/books/{book_id}")
    assert delete_response.status_code == 204

    missing_response = client.get(f"/books/{book_id}")
    assert missing_response.status_code == 404
    assert missing_response.json() == {
        "error": {
            "code": "book_not_found",
            "message": "Book not found",
            "details": [],
        }
    }

What Does This Continuation Prove?

  • The replacement request confirms that a full update returns the changed status.
  • The delete request protects the empty response behavior.
  • The final retrieval confirms that a deleted book uses the documented missing-book envelope.
  • Save test_main.py.
  • Check the completed CRUD lifecycle by running:
python -m pytest

What Does This Run Check?

This run now covers all five CRUD operations in one flow. It also checks the exact 404 response after deletion.

You should still see one test pass. The single test now protects the complete CRUD lifecycle.

CRUD Test Failing?

  • Check that the continuation remains indented inside test_crud_flow().
  • Confirm that the replacement payload uses finished as its reading status.
  • Compare the expected missing-book envelope with the handler in main.py.

Still failing? Help me debug the CRUD lifecycle test.

  • Leave two blank lines below test_crud_flow().
  • Add the validation contract test by copying this function into test_main.py:
def test_validation_error_contract() -> None:
    books.clear()

    response = client.post(
        "/books",
        json={
            "title": "   ",
            "author": "Octavia E. Butler",
            "reading_status": "paused",
            "extra_field": "not allowed",
        },
    )

    assert response.status_code == 422
    body = response.json()
    assert body["error"]["code"] == "validation_error"
    assert body["error"]["message"] == "Request validation failed"
    assert body["error"]["details"]

What Does This Test Prove?

  • The request combines an empty title with an unsupported reading status.
  • The request also includes a field that BookInput forbids.
  • The assertions protect the shared 422 error envelope.
  • The final assertion confirms that field-level details remain available to clients.
  • Save test_main.py.

Before you run the full suite, do you expect both valid CRUD behavior and invalid input behavior to pass together?

  • Run the complete test suite with:
python -m pytest

What Does the Final Test Run Check?

This command runs both test functions from a clean process. A passing result protects the successful lifecycle and the validation error contract.

You should see 2 passed. That is a repeatable check for every behavior you built.

Not Seeing Two Passing Tests?

  • Confirm that both function names begin with test_.
  • Check that books.clear() is the first statement inside each test.
  • Fix the first reported failure before rerunning the suite.

Need a second pair of eyes? Help me get both FastAPI tests passing.

✔️ I have both tests passing

Your automated checks are ready. Save test_main.py before continuing.

ⓧ I'd like to double check the full code

Compare your complete test_main.py with this reference:

from fastapi.testclient import TestClient

from main import app, books

client = TestClient(app)


def test_crud_flow() -> None:
    books.clear()

    create_response = client.post(
        "/books",
        json={
            "title": "The Left Hand of Darkness",
            "author": "Ursula K. Le Guin",
            "reading_status": "to-read",
        },
    )
    assert create_response.status_code == 201
    created = create_response.json()
    book_id = created["id"]

    list_response = client.get("/books")
    assert list_response.status_code == 200
    assert len(list_response.json()) == 1

    get_response = client.get(f"/books/{book_id}")
    assert get_response.status_code == 200
    assert get_response.json()["title"] == "The Left Hand of Darkness"

    replace_response = client.put(
        f"/books/{book_id}",
        json={
            "title": "The Left Hand of Darkness",
            "author": "Ursula K. Le Guin",
            "reading_status": "finished",
        },
    )
    assert replace_response.status_code == 200
    assert replace_response.json()["reading_status"] == "finished"

    delete_response = client.delete(f"/books/{book_id}")
    assert delete_response.status_code == 204

    missing_response = client.get(f"/books/{book_id}")
    assert missing_response.status_code == 404
    assert missing_response.json() == {
        "error": {
            "code": "book_not_found",
            "message": "Book not found",
            "details": [],
        }
    }


def test_validation_error_contract() -> None:
    books.clear()

    response = client.post(
        "/books",
        json={
            "title": "   ",
            "author": "Octavia E. Butler",
            "reading_status": "paused",
            "extra_field": "not allowed",
        },
    )

    assert response.status_code == 422
    body = response.json()
    assert body["error"]["code"] == "validation_error"
    assert body["error"]["message"] == "Request validation failed"
    assert body["error"]["details"]
Document the project contract

A repository needs enough context for another developer to run it without guessing. Your README will cover setup, tests, endpoints, errors, status codes, and data lifetime.

It will also point to the interactive documentation and the generated OpenAPI schema. Those paths stay synchronized with the application metadata and routes.

  • In the VS Code Explorer sidebar, create .gitignore inside the reading-list-api folder.
  • Exclude the local environment and Python caches by copying this content into .gitignore:
.venv/
__pycache__/
.pytest_cache/
*.pyc

What Does This File Exclude?

  • .venv/ keeps installed local packages out of the repository.
  • __pycache__/ excludes compiled Python caches.
  • .pytest_cache/ excludes pytest's local cache.
  • *.pyc excludes compiled Python files wherever they appear.
  • Save .gitignore.
  • Confirm that .gitignore appears in the Explorer sidebar.

✔️ My ignore file is ready

Your local environment and generated caches are excluded from the repository.

ⓧ I'd like to double check the full code

Compare your complete .gitignore with this reference:

.venv/
__pycache__/
.pytest_cache/
*.pyc
  • In the VS Code Explorer sidebar, create README.md inside the reading-list-api folder.
  • Add the project overview and setup instructions by copying this first section into README.md:
# Reading List API

A local FastAPI project that manages an in-memory reading list. It stores no personal data and loses all books when the server restarts.

## Requirements

- Python 3.10 or newer
- Git

## Run from a clone

After cloning or downloading this repository, open a terminal in the project directory:

```bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
uvicorn main:app
```

Uvicorn prints the local server address. Add `/docs` to that address for interactive documentation or `/openapi.json` for the OpenAPI schema.

## Run the tests

```bash
python -m pytest
```

What Does This Section Explain?

  • The opening states that the API uses temporary in-memory data.
  • The requirements name the two system tools a developer needs.
  • The setup block reproduces the virtual environment and dependency installation.
  • The documentation paths point developers to interactive exploration and the generated schema.
  • Save README.md.
  • Confirm that the title and setup commands are visible in the editor.

Setup Commands Look Broken?

  • Confirm that the opening and closing code fences each contain three backticks.
  • Check that bash appears immediately after the opening setup fence.

Need help with the formatting? Help me fix the setup section in my README.

  • Leave one blank line below the test command fence.
  • Add the input example and endpoint contract by copying this section into README.md:
## Book input

```json
{
  "title": "Parable of the Sower",
  "author": "Octavia E. Butler",
  "reading_status": "to-read"
}
```

`reading_status` accepts `to-read`, `reading`, or `finished`. It defaults to `to-read`.

## Endpoints

| Method | Path | Success | Purpose |
| --- | --- | --- | --- |
| POST | `/books` | `201 Created` | Create a book |
| GET | `/books` | `200 OK` | List books |
| GET | `/books/{book_id}` | `200 OK` | Retrieve a book |
| PUT | `/books/{book_id}` | `200 OK` | Replace a book |
| DELETE | `/books/{book_id}` | `204 No Content` | Delete a book |

Unknown books return `404 Not Found`. Invalid path or body data returns `422 Unprocessable Content`.

What Does This Section Specify?

  • The JSON example shows the input shape accepted by BookInput.
  • The status description names every allowed reading state.
  • The endpoint table documents all five methods and paths.
  • The final line separates missing resources from invalid request data.
  • Save README.md.
  • Confirm that the editor now shows a five-row endpoint table.

Endpoint Table Misaligned?

  • Check that every table row starts and ends with a vertical bar.
  • Confirm that the separator row sits directly below the table headings.

Still misaligned? Help me repair my Markdown endpoint table.

  • Leave one blank line below the documented failure statuses.
  • Finish the error format and design rationale by copying this section into README.md:
## Error format

```json
{
  "error": {
    "code": "book_not_found",
    "message": "Book not found",
    "details": []
  }
}
```

Validation failures use the same envelope and put field-level messages in `error.details`.

## Status-code choices

- `200 OK` means a list, retrieval, or replacement completed and returns content.
- `201 Created` means a new book was created.
- `204 No Content` means deletion succeeded and returns no body.
- `404 Not Found` means the requested book ID is absent.
- `422 Unprocessable Content` means the request reached the API but failed path or body validation.

## Data lifetime

Books are held in a Python dictionary. Restarting the process resets the reading list. This keeps the project focused on REST behavior rather than database setup.

What Does This Final Section Explain?

  • The error example gives clients one predictable failure shape.
  • The status-code list explains the meaning behind each response choice.
  • The data lifetime section warns developers that restarting the process clears every book.
  • Save README.md.
  • Confirm that the final heading is Data lifetime.

Error Example Not Formatted?

  • Confirm that json appears immediately after the opening error example fence.
  • Check that the closing fence sits below the final brace.

Need help checking the complete document? Help me find the formatting problem in my README.

✔️ My documentation is complete

Your README now gives another developer a complete path from cloning to understanding the API contract.

ⓧ I'd like to double check the full code

Compare your complete README.md with this reference:

# Reading List API

A local FastAPI project that manages an in-memory reading list. It stores no personal data and loses all books when the server restarts.

## Requirements

- Python 3.10 or newer
- Git

## Run from a clone

After cloning or downloading this repository, open a terminal in the project directory:

```bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
uvicorn main:app
```

Uvicorn prints the local server address. Add `/docs` to that address for interactive documentation or `/openapi.json` for the OpenAPI schema.

## Run the tests

```bash
python -m pytest
```

## Book input

```json
{
  "title": "Parable of the Sower",
  "author": "Octavia E. Butler",
  "reading_status": "to-read"
}
```

`reading_status` accepts `to-read`, `reading`, or `finished`. It defaults to `to-read`.

## Endpoints

| Method | Path | Success | Purpose |
| --- | --- | --- | --- |
| POST | `/books` | `201 Created` | Create a book |
| GET | `/books` | `200 OK` | List books |
| GET | `/books/{book_id}` | `200 OK` | Retrieve a book |
| PUT | `/books/{book_id}` | `200 OK` | Replace a book |
| DELETE | `/books/{book_id}` | `204 No Content` | Delete a book |

Unknown books return `404 Not Found`. Invalid path or body data returns `422 Unprocessable Content`.

## Error format

```json
{
  "error": {
    "code": "book_not_found",
    "message": "Book not found",
    "details": []
  }
}
```

Validation failures use the same envelope and put field-level messages in `error.details`.

## Status-code choices

- `200 OK` means a list, retrieval, or replacement completed and returns content.
- `201 Created` means a new book was created.
- `204 No Content` means deletion succeeded and returns no body.
- `404 Not Found` means the requested book ID is absent.
- `422 Unprocessable Content` means the request reached the API but failed path or body validation.

## Data lifetime

Books are held in a Python dictionary. Restarting the process resets the reading list. This keeps the project focused on REST behavior rather than database setup.
Publish the repository

Git records a snapshot of the tested project. A remote named origin connects that local history to your public GitHub repository.

Git needs an author name and email before it can create a commit. Check the current values before initializing the repository.

  • Check your configured Git author identity by running:
git config --global user.name
git config --global user.email

What Do These Commands Check?

The first command prints the author name Git attaches to commits. The second command prints the associated author email.

You should see one value on each line. Those values become part of the commit metadata.

Author Value Missing?

Need help choosing the correct identity? Help me configure my Git author details safely.

  • Initialize the repository and create the first commit by running:
git init -b main
git add .
git commit -m "First commit"

What Do These Commands Do?

  • git init -b main initializes the local repository with a main branch.
  • git add . stages the project files that are not excluded.
  • git commit -m "First commit" records the staged snapshot with the required commit message.

You should see a commit summary listing the new project files. Your tested API now has its first saved Git snapshot.

Commit Not Created?

  • Complete the author identity setup if Git reports that it cannot identify you.
  • Confirm that the terminal remains inside the reading-list-api folder.
  • Read the final output line to identify which command stopped.

Still blocked? Help me create the First commit in my reading-list-api repository.

The local history is ready. The next actions create an empty public destination for that history.

  • Go to GitHub in your browser.
  • Select New repository.
  • Choose your GitHub account as the repository owner.
  • Enter reading-list-api as the repository name.
  • Select Public as the visibility.
  • Leave every generated starter file option unselected.
  • Click Create repository.

You should see setup instructions for an empty repository. This clean destination is ready to receive your existing local commit.

  • Copy the repository URL shown on the setup page.
  • Record the copied URL here: your repository URL.
  • Replace REMOTE-URL in the command below with your repository URL.
  • Connect the local repository to GitHub by running:
git remote add origin REMOTE-URL

What Does This Command Do?

The command creates a remote named origin. That remote stores the GitHub repository address used for future pushes.

  • Verify the saved remote by running:
git remote -v

What Does This Command Show?

The command lists the addresses assigned to every remote. You should see your copied repository URL associated with origin.

Your repository URL should appear for both fetch and push activity. That confirms the local project points to the intended public repository.

Remote URL Incorrect?

  • Compare the displayed address with the URL you copied from GitHub.
  • Check that you replaced the entire REMOTE-URL placeholder before running the add command.

Need help repairing the connection? Help me correct my Git origin remote.

If GitHub Asks You to Sign In

A browser sign-in prompt may appear when you push. Follow the VS Code browser authentication prompts to authorize the Git operation.

  • Publish the main branch by running:
git push -u origin main

What Does This Command Do?

The command uploads your local main branch to origin. The upstream setting lets future pushes use the same branch connection.

You should see progress output followed by confirmation that the branch was published. Your local commit now exists on GitHub.

Push Not Completing?

  • Complete the browser sign-in flow if VS Code requests authentication.
  • Confirm that git remote -v shows the intended GitHub repository.
  • Check that the repository was created without generated starter files.

Still unable to publish? Help me troubleshoot my GitHub push.

Before you check the repository, which five project files do you expect GitHub to show?

  • Return to your public GitHub repository page.
  • Refresh the repository page.

You should see main.py, test_main.py, requirements.txt, .gitignore, and the rendered README.md. The public file list proves that the tested project and its documentation were published together.

You made the API repeatable, understandable, and reviewable. The complete Reading List API now has automated tests, developer documentation, and a public GitHub home.

Secret mission

Filter Books by Reading Status

Clients currently receive the complete reading list whenever they request the collection. Add an optional reading-status filter so they can request focused results through the existing endpoint.

Clean Up Your Resources

Clean Up Your Resources

Your API uses local files plus a public GitHub repository with no ongoing charges. Decide whether to keep the project, pause it, or delete it.

Resources you used:

  • Local reading-list-api folder on your Desktop. This includes .venv plus your API files.
  • Public GitHub repository containing the committed API plus its filtering feature.
  • Local Uvicorn server process used to serve the API.

Keep everything running

No action is needed. Choose this option if you are still testing or extending the Reading List API.

  • Keep the reading-list-api folder on your Desktop.
  • Leave the public GitHub repository available.
  • Keep Uvicorn running only during active API testing.

Pause - I'll come back to this later

Pausing stops the local server while preserving your code in both locations.

  • Stop Uvicorn by pressing Control+C in its terminal.
  • Keep the reading-list-api folder on your Desktop.
  • Leave the public GitHub repository available for your return.

You've freed the local server process while keeping every project file available for later.

Delete - I don't want to use this again

Deleting both copies is permanent. Your API files plus published Git history disappear unless you have another backup.

  • Stop Uvicorn by pressing Control+C in its terminal.
  • Close that terminal session to end the activated .venv session.
  • Return to the public GitHub repository you published earlier.
  • Open Settings from the repository navigation.
  • Delete the repository through the repository deletion controls near the bottom of Settings.
  • Enter the repository name when GitHub requests confirmation.
  • Confirm the repository deletion.

The published copy is now removed. Your local folder is the only project copy left.

  • Click the Finder icon in the Dock.
  • Select Desktop in the Finder sidebar.
  • Select the reading-list-api folder.
  • Drag the folder to the Trash.
  • Empty the Trash to remove the folder permanently.

Your local project folder plus its virtual environment are now removed. No project resources remain running or stored.

Nice Work!

Nice Work!

You did it! Your tested FastAPI Reading List REST API is now documented in a public GitHub repository.

You've learned how to:

  • Build five CRUD routes for creating, listing, retrieving, replacing, and deleting books. Use meaningful HTTP status codes such as 200, 201, 204, 404, and 422.
  • Define Pydantic models that reject invalid lengths, unsupported reading statuses, and unexpected fields. Normalize missing-book failures under one predictable JSON error envelope. Send invalid-input failures through that same contract.
  • Prove the complete API flow with repeatable integration tests powered by pytest. Document setup in README.md. Explain each endpoint. Justify the status-code choices. Point developers to the generated OpenAPI documentation. Publish the finished code with Git history.
  • Complete the optional Secret Mission by adding a typed reading_status filter to GET /books. Verify successful filters with an integration test. Confirm unsupported values keep the existing 422 error envelope.

Ready to quiz yourself?