Build a Task Manager API

Build a REST API with Python and consume it with JavaScript.

Introduction

30 Second Summary

Every time you open a weather app, scroll social media, or check your bank balance, your device is sending requests to a server and getting data back. This back-and-forth happens through an API.

In this project, you will build a REST API from scratch using Python, then write a JavaScript client that talks to it. You'll experience both sides of the API conversation firsthand.

What You'll Build

A Task Manager API with a Python backend powered by FastAPI and a JavaScript client that communicates with it using the Fetch API.

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

  • A FastAPI backend with full CRUD endpoints (Create, Read, Update, Delete) storing tasks in a SQLite database.
  • Auto-generated interactive API documentation (Swagger UI) that lets you test endpoints directly in your browser.
  • A JavaScript client that performs all CRUD operations against your API using the Fetch API.
  • Secret Mission: Add API key authentication to protect your endpoints and update your client to include it.

Are there any prerequisites?

Basic familiarity with Python syntax and JavaScript syntax is helpful. No prior API experience is required.

Before We Start

Before We Start...

Set Up Your Project Environment

Before you write any API code, you need a workspace with the right tools installed.

In this step you will create a project folder, set up a Python virtual environment with FastAPI and SQLModel, and prepare a JavaScript client folder that will talk to your API later.

In this step, get ready to:
  • Create your project folder and Python virtual environment.
  • Verify your installations.
  • Create your JavaScript client folder.
Create your project folder and Python virtual environment
  • In VS Code, open a terminal by pressing Ctrl+` (backtick key, top-left of your keyboard).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir task-manager-api
cd task-manager-api
  • Confirm the folder exists by running:
ls

You should see the task-manager-api folder listed in the output (it will appear empty for now).

  • Open this folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the task-manager-api folder, and click Open.

VS Code now shows task-manager-api as your workspace root.

  • Open a new terminal in VS Code by pressing Ctrl+` again (this terminal starts inside your project folder).
  • Create a Python virtual environment by running this command:

macOS

python3 -m venv venv

Windows

python -m venv venv
  • Activate the virtual environment by running:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt.

What is a virtual environment?

A virtual environment is an isolated Python workspace. Packages you install here stay separate from the rest of your system, so different projects never clash.

  • Install FastAPI and SQLModel by running:
pip install "fastapi[standard]" sqlmodel

What does this command install?

The "fastapi[standard]" package installs FastAPI along with Uvicorn (the development server) and other standard dependencies. sqlmodel installs SQLModel, which combines Pydantic validation with SQLAlchemy's database ORM in a single model class.

You should see output ending with a success message listing the installed packages.

Seeing permission errors or install failures?

Make sure you see (venv) in your terminal prompt. If you do not, the virtual environment is not active. Run the activation command again.

If pip is not found, try pip3 instead.

help me fix pip install errors for FastAPI and SQLModel

Verify your installations
  • Confirm FastAPI installed correctly by running:
python -c "import fastapi; print(fastapi.__version__)"

You should see a version number printed (for example 0.136.1). Your exact version may differ.

  • Confirm SQLModel installed correctly by running:
python -c "import sqlmodel; print('SQLModel OK')"

You should see SQLModel OK printed in the terminal.

Seeing a ModuleNotFoundError?

This means the package did not install into your active environment. Check that (venv) shows in your prompt, then re-run the pip install command from the previous substep.

help me fix ModuleNotFoundError when importing fastapi or sqlmodel

  • Create your main.py entry point file by running:

macOS

touch main.py

Windows

New-Item main.py
  • Confirm the file was created by running:
ls

You should see main.py and the venv folder listed in the output.

Create your JavaScript client folder

Your API will have a client that consumes it using JavaScript's Fetch API. You will set that up in a separate subfolder.

  • Create the client subfolder and its entry file by running:

macOS

mkdir client
touch client/api-client.js

Windows

mkdir client
New-Item client\api-client.js
  • Confirm both were created by running:
ls client

You should see api-client.js listed in the output.

Now verify that Node.js is version 18 or higher (required for native fetch support). Check your version by running:

node --version

✔️ I see version 18 or higher

Your output starts with v18, v20, v22, v24, v26, or any number 18 or above. You are all set.

ⓧ I see a version lower than 18

You need to upgrade Node.js. The recommended approach is to use nvm (Node Version Manager) to install a newer version alongside your current one.

  • On macOS or Linux, install nvm by running:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash
  • Close and reopen your terminal, then install Node.js 22 (the current LTS) by running:
nvm install 22
  • On Windows, download the latest LTS installer from https://nodejs.org/en/download and run it. The installer will replace your current version.
  • After installing, close and reopen your terminal, then run node --version again to confirm you see v18 or higher.

ⓧ Command not found

Node.js is not installed on your system. Install it now.

  • On macOS or Linux, install nvm first by running:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash
  • Close and reopen your terminal, then install Node.js 22 by running:
nvm install 22
  • On Windows, download the LTS installer from https://nodejs.org/en/download and run it. Accept the defaults during installation.
  • After installing, close and reopen your terminal, then run node --version to confirm you see a version number starting with v18 or higher.

Why does Node.js need to be version 18 or higher?

Node.js 18 introduced native fetch() support as a built-in global function. This means your JavaScript client can make HTTP requests without installing any extra packages. The same fetch pattern works in React Native, which is why you are learning it here.

Your environment is ready. Next up, you will build your first API endpoint and see it running in the browser.

Build Your First API Endpoint

Your project environment is ready with FastAPI and SQLModel installed. Now it's time to turn your empty main.py into a working web server.

A REST API is a server that listens for HTTP requests and sends back responses. In this step, you'll create your first two endpoints and see them running live in your browser.

In this step, get ready to:
  • Create a FastAPI app with two GET endpoints.
  • Run the development server locally.
  • Explore the auto-generated Swagger UI documentation.
Create the FastAPI application with a welcome endpoint

Every FastAPI project starts with an app instance. The app object is where you register all your endpoints.

  • Switch back to main.py in VS Code and add the following code:
from fastapi import FastAPI

app = FastAPI(title="Task Manager API")


@app.get("/")
def root():
    return {"message": "Welcome to the Task Manager API"}

What does this code do?

  • from fastapi import FastAPI brings in the FastAPI class from the package you installed.
  • app = FastAPI(title="Task Manager API") creates your application instance. The title appears in the auto-generated docs.
  • @app.get("/") is a decorator that registers the function below it as a handler for GET requests to the / path.
  • The function returns a Python dictionary, which FastAPI automatically converts to JSON.
Add a health check endpoint

Health check endpoints let you quickly verify that your API is running. They are standard practice for any production API.

  • Below your root() function in main.py, add this second endpoint:
@app.get("/health")
def health_check():
    return {"status": "healthy"}

What does this code do?

This registers a second GET endpoint at /health. When someone visits that URL, the server responds with a JSON object confirming the API is healthy.

  • Save main.py.

✔️ 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

Here's what your full main.py should look like at this point:

from fastapi import FastAPI

app = FastAPI(title="Task Manager API")


@app.get("/")
def root():
    return {"message": "Welcome to the Task Manager API"}


@app.get("/health")
def health_check():
    return {"status": "healthy"}
Run the development server and explore

FastAPI includes a built-in development server that auto-reloads when you save changes. This means you can edit your code and see the results immediately.

  • In your terminal (with your virtual environment still active), start the server by running:
fastapi dev main.py

You should see output confirming the server is running, including a line mentioning http://127.0.0.1:8000.

Server not starting?

  • Make sure your virtual environment is activated. You should see (venv) or similar at the start of your terminal prompt.
  • Confirm you are in the task-manager-api folder where main.py lives.
  • Check for typos in your code. A missing colon or indent will prevent the server from starting.

help me fix my FastAPI dev server startup error

  • Open your browser and visit http://127.0.0.1:8000.
  • You should see a JSON response: {"message": "Welcome to the Task Manager API"}.
  • Now visit http://127.0.0.1:8000/docs to see the auto-generated Swagger UI documentation.

You should see an interactive page listing both of your endpoints with a Try it out button on each one.

What is Swagger UI?

Swagger UI is an interactive documentation page that FastAPI generates automatically from your code. Every endpoint you add will appear here without any extra work.

It follows the OpenAPI specification, which is the industry standard for describing REST APIs. You can test your endpoints directly from this page by clicking Try it out and hitting Execute.

What just happened?

  • Your Python code is now a running web server accepting HTTP requests.
  • FastAPI automatically generated OpenAPI documentation from your code.
  • Every endpoint you add from now on will appear in the docs automatically.

Your API is alive and responding to requests. Next up, you'll define a data model so your API can store and manage real tasks in a database.

Define Your Data Model and Database

Your API is running and responding to requests, but it has no data to work with yet.

Every API needs a data model that defines the shape of the information it stores and serves. In this step, you'll define a Task model and connect it to a SQLite database so your API can persist data between requests.

In this step, get ready to:
  • Define a Task data model with SQLModel.
  • Configure a SQLite database and session dependency.
  • Set up automatic table creation on app startup.
Create the Task model with SQLModel

SQLModel lets you define both your database table and your data validation in a single Python class. Each field in the class becomes a column in your database and a validated field in your API.

  • Replace the entire contents of main.py with the following imports and Task model:
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Annotated

from fastapi import Depends, FastAPI
from sqlmodel import Field, Session, SQLModel, create_engine


# --- Database Model ---

class Task(SQLModel, table=True):
    # Primary key, auto-generated by the database
    id: int | None = Field(default=None, primary_key=True)
    # The task title, indexed for fast lookups
    title: str = Field(index=True)
    # Optional longer description
    description: str | None = None
    # Whether the task is done
    completed: bool = Field(default=False)
    # Timestamp recorded when the task is created
    created_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())

What does this code do?

The Task class inherits from SQLModel with table=True, which tells SQLModel this class represents a real database table (not just a data shape).

Each field maps to a database column. Field(primary_key=True) makes id auto-increment. Field(index=True) on title makes title lookups faster. default_factory on created_at generates a timestamp automatically when a task is created.

Now you need the database engine and a session dependency that your endpoints will use to talk to the database.

  • Below the Task class, add the database setup section:
# --- Database Setup ---

# SQLite stores everything in a single file
sqlite_url = "sqlite:///database.db"
# Required for SQLite to work across threads in FastAPI
connect_args = {"check_same_thread": False}
engine = create_engine(sqlite_url, connect_args=connect_args)


def create_db_and_tables():
    # Creates all tables defined by SQLModel classes
    SQLModel.metadata.create_all(engine)


def get_session():
    # Provides a database session for each request, then closes it
    with Session(engine) as session:
        yield session


# Type alias so endpoints can declare their session dependency concisely
SessionDep = Annotated[Session, Depends(get_session)]

What does this code do?

The engine is the connection manager that knows how to talk to your SQLite file. The check_same_thread setting is required because FastAPI handles requests across multiple threads, but SQLite normally restricts connections to the thread that created them.

The get_session() function uses yield to provide a fresh database session for each request, then automatically closes it when the request finishes. SessionDep is a shorthand so your endpoints can just declare session: SessionDep instead of repeating the full dependency annotation.

Why SQLite instead of PostgreSQL or MySQL?

SQLite stores the entire database in a single file on disk. There is no separate server to install, configure, or run. This keeps the focus on learning API patterns rather than database administration.

Because SQLModel uses standard SQL under the hood, the patterns you learn here transfer directly to PostgreSQL or MySQL when you need a production database later.

Set up database table creation on startup

FastAPI needs to know when to create your database tables. The lifespan context manager runs code once before the app starts accepting requests.

  • Below the SessionDep line, add the app setup section and endpoints:
# --- App Setup ---

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Create database tables when the app starts
    create_db_and_tables()
    yield


app = FastAPI(title="Task Manager API", lifespan=lifespan)


# --- Endpoints ---

@app.get("/")
def root():
    return {"message": "Welcome to the Task Manager API"}


@app.get("/health")
def health_check():
    return {"status": "healthy"}

What does the lifespan do?

The code before yield runs once when the app starts. The code after yield would run when the app shuts down (we don't need shutdown logic here, so it's empty).

By calling create_db_and_tables() at startup, your SQLite database file is created automatically the first time the server runs. The two endpoints at the bottom are the same ones you wrote in the previous step.

  • Save main.py.

Seeing a syntax error in your terminal?

Make sure you replaced the entire file contents. If any of the old from fastapi import FastAPI line or the old app = FastAPI(...) line remain alongside the new code, Python will see duplicate definitions.

Check that the @asynccontextmanager decorator is directly above the async def lifespan function with no blank lines between them.

Help me fix a syntax error in my FastAPI main.py after adding the Task model and database setup.

✔️ Awesome, I've got everything!

Great. Make sure you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete main.py at this point. Compare your file against it:

from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Annotated

from fastapi import Depends, FastAPI
from sqlmodel import Field, Session, SQLModel, create_engine


# --- Database Model ---

class Task(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    title: str = Field(index=True)
    description: str | None = None
    completed: bool = Field(default=False)
    created_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())


# --- Database Setup ---

sqlite_url = "sqlite:///database.db"
connect_args = {"check_same_thread": False}
engine = create_engine(sqlite_url, connect_args=connect_args)


def create_db_and_tables():
    SQLModel.metadata.create_all(engine)


def get_session():
    with Session(engine) as session:
        yield session


SessionDep = Annotated[Session, Depends(get_session)]


# --- App Setup ---

@asynccontextmanager
async def lifespan(app: FastAPI):
    create_db_and_tables()
    yield


app = FastAPI(title="Task Manager API", lifespan=lifespan)


# --- Endpoints ---

@app.get("/")
def root():
    return {"message": "Welcome to the Task Manager API"}


@app.get("/health")
def health_check():
    return {"status": "healthy"}
Test that the database initializes

Because you started the server with fastapi dev in the previous step, it auto-reloads whenever you save a file. Your updated code is already running.

  • Switch back to your terminal. You should see a log message indicating the server reloaded successfully.
  • Confirm the database file was created by running:
ls database.db
  • You should see database.db listed in the output. This file is your SQLite database.
  • Visit http://127.0.0.1:8000 in your browser to confirm the API still responds with its welcome message.

Don't see database.db?

Check your terminal for error messages. If the server failed to reload, it means there is a syntax issue in main.py. Fix the error, save the file, and the server will attempt to reload again.

Make sure you are running ls database.db from inside the task-manager-api folder, not from the client subfolder or your Desktop.

Help me figure out why my database.db file was not created when I started my FastAPI server.

Your database is ready and your Task model is defined. Next up, you'll build the full set of CRUD endpoints that let clients create, read, update, and delete tasks through your API.

Build the Full CRUD Endpoints

Your database model is defined and your SQLite database is ready to store tasks.

Now it's time to build the endpoints that let clients create, read, update, and delete tasks. These four operations (known as CRUD) form the backbone of any REST API.

In this step, get ready to:
  • Build a POST endpoint that creates new tasks in your database.
  • Build GET endpoints that retrieve tasks with optional filtering.
  • Build PUT and DELETE endpoints that update and remove tasks.
Create the POST endpoint (Create a task)

The POST endpoint is how clients send new data to your API. When someone sends a POST request to /tasks, your API will save that task to the database and return it with a generated ID.

  • In main.py, find the two import lines at the top of the file:
from fastapi import Depends, FastAPI
from sqlmodel import Field, Session, SQLModel, create_engine
  • Replace both lines with these updated imports that add HTTPException, Query, status, and select:
from fastapi import Depends, FastAPI, HTTPException, Query, status
from sqlmodel import Field, Session, SQLModel, create_engine, select

Why update the imports now?

You'll need all of these across your CRUD endpoints. HTTPException raises error responses (like 404 Not Found). Query lets you accept optional URL parameters. status provides named HTTP status codes. select builds database queries.

  • Now scroll to the bottom of main.py (below the health_check function) and add the POST endpoint by pasting this code:
@app.post("/tasks", response_model=Task, status_code=status.HTTP_201_CREATED)
def create_task(task: Task, session: SessionDep):
    session.add(task)
    session.commit()
    session.refresh(task)
    return task

What does this code do?

The @app.post decorator registers this function for POST requests to /tasks. The status_code=status.HTTP_201_CREATED tells FastAPI to return a 201 status code instead of the default 200.

FastAPI automatically parses the incoming JSON request body into a Task object. The session adds it to the database, commits the transaction, and refreshes the object to load the auto-generated id and created_at values.

  • Save main.py.
  • Refresh http://127.0.0.1:8000/docs in your browser. You should see a new green POST /tasks endpoint listed in the Swagger UI.

Don't see the POST endpoint?

Make sure you saved main.py. The dev server auto-reloads on save. If the server shows an error in the terminal, check that your new code is below the health_check function and properly indented.

help me fix my POST endpoint

Create the GET endpoints (Read tasks)

Your API needs two ways to read tasks. One endpoint lists all tasks (with optional filtering), and another fetches a single task by its ID.

  • Below the create_task function, add the endpoint that lists all tasks:
@app.get("/tasks", response_model=list[Task])
def list_tasks(
    session: SessionDep,
    completed: bool | None = Query(default=None),
):
    statement = select(Task)
    if completed is not None:
        statement = statement.where(Task.completed == completed)
    tasks = session.exec(statement).all()
    return tasks

What does this code do?

The completed parameter uses Query(default=None) to make it an optional URL query parameter. Clients can request /tasks?completed=true to filter, or just /tasks to get everything.

The select(Task) builds a database query. If the completed filter is provided, .where() narrows the results before executing.

  • Save main.py and refresh the docs page. You should see a GET /tasks endpoint with a completed query parameter shown.

Seeing a server error?

If the terminal shows an ImportError, double-check that you added select to the sqlmodel import line and Query to the fastapi import line at the top of the file.

help me fix my import error in main.py

  • Below the list_tasks function, add the endpoint that fetches a single task by ID:
@app.get("/tasks/{task_id}", response_model=Task)
def get_task(task_id: int, session: SessionDep):
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    return task

What does this code do?

The {task_id} in the URL path becomes a function parameter. FastAPI automatically validates that it's an integer.

If session.get(Task, task_id) returns None, the task doesn't exist. HTTPException raises a 404 response with a clear error message so the client knows exactly what went wrong.

  • Save main.py and refresh the docs page. You should now see both GET /tasks and GET /tasks/{task_id} listed.
Create the PUT and DELETE endpoints (Update and Delete)

The final two endpoints let clients modify existing tasks and remove them entirely. Both need to handle the case where a task ID doesn't exist.

  • Below the get_task function, add the PUT endpoint that updates a task:
@app.put("/tasks/{task_id}", response_model=Task)
def update_task(task_id: int, task_update: Task, session: SessionDep):
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    task_data = task_update.model_dump(exclude_unset=True)
    for key, value in task_data.items():
        if key != "id":
            setattr(task, key, value)
    session.add(task)
    session.commit()
    session.refresh(task)
    return task

What does this code do?

This endpoint first checks that the task exists (returning 404 if not). Then it uses model_dump(exclude_unset=True) to get only the fields the client sent in the request body.

The loop applies each updated field to the existing task using setattr, skipping the id field to prevent clients from changing a task's primary key. Finally, it commits the changes and returns the updated task.

  • Save main.py and refresh the docs page. You should see the PUT /tasks/{task_id} endpoint appear.

Server crashed after saving?

Check the terminal for the error line number. The most common issue is a missing colon after the for loop line or incorrect indentation inside the loop body.

help me debug my update_task endpoint

  • Below the update_task function, add the DELETE endpoint:
@app.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task_id: int, session: SessionDep):
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    session.delete(task)
    session.commit()
    return

What does this code do?

The 204 No Content status code means "the action succeeded but there's nothing to send back." This is the standard response for successful deletions because the resource no longer exists.

The bare return statement sends an empty response body, which matches the 204 convention.

  • Save main.py and refresh http://127.0.0.1:8000/docs.
  • You should now see all five task endpoints listed: POST, two GETs, PUT, and DELETE.

Missing one or more endpoints?

If only some endpoints appear, the server may have hit a syntax error in a later function. Check the terminal output for a traceback and fix the indicated line. Make sure each function is separated by a blank line and starts at the top indentation level (no extra indentation).

help me find the syntax error in my CRUD endpoints

✔️ Awesome, I've got everything!

Great. Double check that you've saved main.py and all five task endpoints appear in your Swagger UI docs.

ⓧ I'd like to double check the full code

Here's the full main.py at this point:

from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, Query, status
from sqlmodel import Field, Session, SQLModel, create_engine, select


# --- Database Model ---

class Task(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    title: str = Field(index=True)
    description: str | None = None
    completed: bool = Field(default=False)
    created_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())


# --- Database Setup ---

sqlite_url = "sqlite:///database.db"
connect_args = {"check_same_thread": False}
engine = create_engine(sqlite_url, connect_args=connect_args)


def create_db_and_tables():
    SQLModel.metadata.create_all(engine)


def get_session():
    with Session(engine) as session:
        yield session


SessionDep = Annotated[Session, Depends(get_session)]


# --- App Setup ---

@asynccontextmanager
async def lifespan(app: FastAPI):
    create_db_and_tables()
    yield


app = FastAPI(title="Task Manager API", lifespan=lifespan)


# --- Endpoints ---

@app.get("/")
def root():
    return {"message": "Welcome to the Task Manager API"}


@app.get("/health")
def health_check():
    return {"status": "healthy"}


@app.post("/tasks", response_model=Task, status_code=status.HTTP_201_CREATED)
def create_task(task: Task, session: SessionDep):
    session.add(task)
    session.commit()
    session.refresh(task)
    return task


@app.get("/tasks", response_model=list[Task])
def list_tasks(
    session: SessionDep,
    completed: bool | None = Query(default=None),
):
    statement = select(Task)
    if completed is not None:
        statement = statement.where(Task.completed == completed)
    tasks = session.exec(statement).all()
    return tasks


@app.get("/tasks/{task_id}", response_model=Task)
def get_task(task_id: int, session: SessionDep):
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    return task


@app.put("/tasks/{task_id}", response_model=Task)
def update_task(task_id: int, task_update: Task, session: SessionDep):
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    task_data = task_update.model_dump(exclude_unset=True)
    for key, value in task_data.items():
        if key != "id":
            setattr(task, key, value)
    session.add(task)
    session.commit()
    session.refresh(task)
    return task


@app.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task_id: int, session: SessionDep):
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    session.delete(task)
    session.commit()
    return

Your API now supports the full CRUD lifecycle. Next up, you'll put these endpoints through their paces using the interactive Swagger UI to create, read, update, and delete real tasks.

Test Your API with Swagger UI

You have a full set of CRUD endpoints ready to go. Before writing any client code, you need to verify that every endpoint works correctly and understand the exact HTTP details each one produces.

FastAPI auto-generates an interactive documentation page called Swagger UI. It lets you send real requests to your API directly from the browser and inspect the responses. This is the fastest way to confirm your endpoints work before building a client.

In this step, get ready to:
  • Create tasks through Swagger UI's interactive form.
  • Test all CRUD operations and observe HTTP status codes.
  • Identify the URL patterns and JSON structure your JavaScript client will follow.
Use the interactive docs to create tasks

Swagger UI gives you a visual interface for every endpoint your API exposes. You can fill in request bodies, hit execute, and see the raw response without writing any code.

  • Open your browser and navigate to http://127.0.0.1:8000/docs.
  • You should see a page titled "Task Manager API" with all your endpoints listed.
  • Click on the POST /tasks row to expand it.
  • Click the Try it out button in the top-right corner of that section.

The request body editor becomes editable. Replace the example JSON with your first task:

{
  "title": "Learn FastAPI",
  "description": "Build a REST API with Python"
}
  • Click Execute.
  • Scroll down to the Server response section. You should see a response body with your task, including a generated id and created_at timestamp.
  • Notice the response code shows 201. This is the HTTP status code for "Created".

What do these response details mean?

The 201 status code tells the client that a new resource was successfully created. The response body contains the full task object with the auto-generated id and created_at fields.

Your JavaScript client will look for this exact status code and JSON structure to confirm a task was created.

  • Create 2-3 more tasks by editing the request body each time and clicking Execute. Try tasks like:
{
  "title": "Learn Fetch API",
  "description": "Consume APIs with JavaScript"
}
{
  "title": "Build React Native app",
  "description": "Connect mobile app to API"
}
{
  "title": "Deploy to production",
  "description": null
}

Each response should show a unique id incrementing by one.

Test all CRUD operations through Swagger

Now that you have tasks in your database, test the remaining endpoints to verify the full lifecycle works.

  • Expand GET /tasks, click Try it out, then click Execute.
  • You should see a JSON array containing all the tasks you created. The status code is 200.
  • Try the completed filter. Enter false in the completed query parameter field and click Execute again.
  • You should see all your tasks listed since none are marked as completed yet.
  • Expand GET /tasks/{task_id}, click Try it out.
  • Enter 1 in the task_id field and click Execute.
  • You should see a single task object returned with status code 200.
  • Expand PUT /tasks/{task_id}, click Try it out.
  • Enter 1 in the task_id field.
  • In the request body, mark the task as completed:
{
  "title": "Learn FastAPI",
  "completed": true
}
  • Click Execute. The response shows the updated task with "completed": true.
  • Expand DELETE /tasks/{task_id}, click Try it out.
  • Enter the ID of your last task (e.g. 4) in the task_id field.
  • Click Execute. The response code is 204 with no response body. This means the task was deleted successfully.
  • Now test a 404 response. Go back to GET /tasks/{task_id} and enter the ID you just deleted (e.g. 4).
  • Click Execute. You should see a 404 status code with the body {"detail": "Task not found"}.

Why does 404 matter?

When your JavaScript client requests a task that doesn't exist, the API returns a 404 status code. Your client code will check response.ok (which is false for 404) and throw a meaningful error message. This is how real applications handle missing resources gracefully.

Observe the HTTP details

Take a moment to look at the patterns across all the requests you just made. These patterns form the "contract" your JavaScript client will follow.

What patterns should I notice?

  • URL patterns: /tasks for collections, /tasks/{task_id} for individual resources.
  • HTTP methods map to actions: POST creates, GET reads, PUT updates, DELETE removes.
  • Status codes signal outcomes: 201 for created, 200 for success, 204 for deleted (no content), 404 for not found.
  • Request and response bodies are always JSON. The Content-Type header is application/json.

Swagger UI is your debugging tool

Whenever you add or change an endpoint in your API, come back to http://127.0.0.1:8000/docs to test it. The documentation updates automatically every time your server reloads.

Your API is tested and working. Next up, you'll enable cross-origin requests so your JavaScript client can talk to this API from a different origin.

Enable CORS and Build the JavaScript Client

You tested your API through Swagger UI and confirmed every endpoint works. Now it is time to build the other side of the conversation.

In this step, you will add CORS middleware so browser-based clients can reach your API. Then you will write a JavaScript client that performs every CRUD operation using the Fetch API. This is the same pattern you would use in a React Native app to talk to any backend.

In this step, get ready to:
  • Add CORS middleware to allow cross-origin requests.
  • Write JavaScript client functions that perform all CRUD operations with fetch.
  • Run the client to exercise your API from code.
Add CORS middleware to your FastAPI app

Browsers block requests from one origin to a different origin by default. Your JavaScript client will run from a different origin than your API server. The middleware tells the browser that your API accepts requests from other origins.

  • In VS Code, open main.py.
  • Find this import line near the top of the file:
from fastapi import Depends, FastAPI, HTTPException, Query, status
  • Add this new import directly below it:
from fastapi.middleware.cors import CORSMiddleware
  • Now find this line in the # --- App Setup --- section:
app = FastAPI(title="Task Manager API", lifespan=lifespan)
  • Add the CORS middleware directly below it by copying this code:
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

What does this code do?

The CORSMiddleware intercepts every incoming request and adds response headers that tell browsers this API is safe to call from other origins.

  • allow_origins=["*"] allows requests from any origin. Fine for local development.
  • allow_methods=["*"] permits all HTTP methods (GET, POST, PUT, DELETE).
  • allow_headers=["*"] accepts any request header, including Content-Type which your JavaScript client sends with JSON requests.

Why does CORS exist?

Browsers enforce a same-origin policy for security. Without it, any website could silently make requests to your bank's API using your saved cookies. CORS gives the server a way to opt in to cross-origin access.

Your Node.js client running in the terminal is not a browser, so it would not be blocked. But adding CORS now is standard practice for any API that web or mobile frontends will consume.

  • Save main.py.
  • Check the terminal where your server is running. It should auto-reload and show no errors.

Server showing an import error?

  • Make sure the import reads from fastapi.middleware.cors import CORSMiddleware exactly. A typo in the module path causes an ImportError.
  • Check that the app.add_middleware(...) call sits after the app = FastAPI(...) line, not before it.

help me fix a CORSMiddleware import error in my FastAPI app

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and the server reloaded without errors.

ⓧ I'd like to double check the full code

from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, Query, status
from fastapi.middleware.cors import CORSMiddleware
from sqlmodel import Field, Session, SQLModel, create_engine, select


# --- Database Model ---

class Task(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    title: str = Field(index=True)
    description: str | None = None
    completed: bool = Field(default=False)
    created_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())


# --- Database Setup ---

sqlite_url = "sqlite:///database.db"
connect_args = {"check_same_thread": False}
engine = create_engine(sqlite_url, connect_args=connect_args)


def create_db_and_tables():
    SQLModel.metadata.create_all(engine)


def get_session():
    with Session(engine) as session:
        yield session


SessionDep = Annotated[Session, Depends(get_session)]


# --- App Setup ---

@asynccontextmanager
async def lifespan(app: FastAPI):
    create_db_and_tables()
    yield


app = FastAPI(title="Task Manager API", lifespan=lifespan)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


# --- Endpoints ---

@app.get("/")
def root():
    return {"message": "Welcome to the Task Manager API"}


@app.get("/health")
def health_check():
    return {"status": "healthy"}


@app.post("/tasks", response_model=Task, status_code=status.HTTP_201_CREATED)
def create_task(task: Task, session: SessionDep):
    session.add(task)
    session.commit()
    session.refresh(task)
    return task


@app.get("/tasks", response_model=list[Task])
def list_tasks(
    session: SessionDep,
    completed: bool | None = Query(default=None),
):
    statement = select(Task)
    if completed is not None:
        statement = statement.where(Task.completed == completed)
    tasks = session.exec(statement).all()
    return tasks


@app.get("/tasks/{task_id}", response_model=Task)
def get_task(task_id: int, session: SessionDep):
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    return task


@app.put("/tasks/{task_id}", response_model=Task)
def update_task(task_id: int, task_update: Task, session: SessionDep):
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    task_data = task_update.model_dump(exclude_unset=True)
    for key, value in task_data.items():
        if key != "id":
            setattr(task, key, value)
    session.add(task)
    session.commit()
    session.refresh(task)
    return task


@app.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task_id: int, session: SessionDep):
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    session.delete(task)
    session.commit()
    return
Write the API client functions with fetch

Now you will switch to the JavaScript side. Each function maps to one API endpoint and uses the native fetch function available in Node.js 18+.

  • Open client/api-client.js in VS Code.
  • Add the base URL constant and the first two client functions by pasting this code:
const BASE_URL = "http://127.0.0.1:8000";

// --- API Client Functions ---

async function createTask(title, description = null) {
  const response = await fetch(`${BASE_URL}/tasks`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ title, description }),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(`Failed to create task: ${error.detail}`);
  }

  return response.json();
}

async function getAllTasks(completed = null) {
  let url = `${BASE_URL}/tasks`;
  if (completed !== null) {
    url += `?completed=${completed}`;
  }

  const response = await fetch(url);

  if (!response.ok) {
    throw new Error(`Failed to fetch tasks: ${response.status}`);
  }

  return response.json();
}

What does this code do?

  • BASE_URL points at your running FastAPI server.
  • createTask sends a POST request with a JSON body. The Content-Type header tells the server the body is JSON.
  • getAllTasks sends a GET request. If completed is passed, it appends a query parameter to filter results.
  • response.ok is true for status codes 200-299. If the server returns an error status, the function throws a meaningful error.
  • Add the remaining three CRUD functions below the getAllTasks function:
async function getTask(taskId) {
  const response = await fetch(`${BASE_URL}/tasks/${taskId}`);

  if (!response.ok) {
    if (response.status === 404) {
      throw new Error(`Task ${taskId} not found`);
    }
    throw new Error(`Failed to fetch task: ${response.status}`);
  }

  return response.json();
}

async function updateTask(taskId, updates) {
  const response = await fetch(`${BASE_URL}/tasks/${taskId}`, {
    method: "PUT",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(updates),
  });

  if (!response.ok) {
    if (response.status === 404) {
      throw new Error(`Task ${taskId} not found`);
    }
    const error = await response.json();
    throw new Error(`Failed to update task: ${error.detail}`);
  }

  return response.json();
}

async function deleteTask(taskId) {
  const response = await fetch(`${BASE_URL}/tasks/${taskId}`, {
    method: "DELETE",
  });

  if (!response.ok) {
    if (response.status === 404) {
      throw new Error(`Task ${taskId} not found`);
    }
    throw new Error(`Failed to delete task: ${response.status}`);
  }

  console.log(`Task ${taskId} deleted successfully`);
}

What does this code do?

  • getTask fetches a single task by ID. It checks specifically for a 404 status to throw a clear "not found" message.
  • updateTask sends a PUT request with the updated fields as JSON in the body.
  • deleteTask sends a DELETE request. The server returns 204 (no content) on success, so there is no JSON to parse.
  • Every function follows the same pattern: call fetch, check response.ok, throw on failure, return parsed JSON on success.
  • Save client/api-client.js.

Not sure where to paste the second block?

Place it directly after the closing brace of the getAllTasks function. Leave one blank line between functions for readability.

help me figure out where to paste the getTask, updateTask, and deleteTask functions in my api-client.js file

Write a main function that exercises the full API

The client functions are ready. Now you will write a main() function that calls each one in sequence so you can see the entire request/response lifecycle in your terminal.

  • Add the main demo function at the bottom of client/api-client.js, below the deleteTask function:
// --- Main Demo ---

async function main() {
  console.log("=== Task Manager API Client ===\n");

  // Create tasks
  console.log("1. Creating tasks...");
  const task1 = await createTask("Learn FastAPI", "Build a REST API with Python");
  console.log("   Created:", task1);

  const task2 = await createTask("Learn Fetch API", "Consume APIs with JavaScript");
  console.log("   Created:", task2);

  const task3 = await createTask("Build React Native app", "Connect mobile app to API");
  console.log("   Created:", task3);

  // List all tasks
  console.log("\n2. Listing all tasks...");
  const allTasks = await getAllTasks();
  console.log(`   Found ${allTasks.length} tasks:`);
  allTasks.forEach((task) => {
    console.log(`   - [${task.completed ? "x" : " "}] ${task.title} (ID: ${task.id})`);
  });

  // Get a single task
  console.log("\n3. Getting task by ID...");
  const singleTask = await getTask(task1.id);
  console.log("   Task details:", singleTask);

  // Update a task
  console.log("\n4. Marking first task as completed...");
  const updatedTask = await updateTask(task1.id, {
    title: task1.title,
    completed: true,
  });
  console.log("   Updated:", updatedTask);

  // Filter completed tasks
  console.log("\n5. Filtering completed tasks...");
  const completedTasks = await getAllTasks(true);
  console.log(`   Completed tasks: ${completedTasks.length}`);

  // Delete a task
  console.log("\n6. Deleting last task...");
  await deleteTask(task3.id);

  // Final list
  console.log("\n7. Final task list:");
  const finalTasks = await getAllTasks();
  finalTasks.forEach((task) => {
    console.log(`   - [${task.completed ? "x" : " "}] ${task.title} (ID: ${task.id})`);
  });

  // Error handling demo
  console.log("\n8. Testing error handling (fetching deleted task)...");
  try {
    await getTask(task3.id);
  } catch (error) {
    console.log(`   Caught error: ${error.message}`);
  }

  console.log("\n=== Done! ===");
}

main().catch(console.error);

What does this code do?

  • main() runs through the full CRUD lifecycle: create three tasks, list them, get one by ID, update one, filter by completed status, delete one, and list the final state.
  • Step 8 intentionally fetches a deleted task to prove your error handling catches the 404 gracefully.
  • main().catch(console.error) at the bottom calls the async function and catches any unhandled promise rejection.
  • Save client/api-client.js.
  • Open a new terminal in VS Code (your API server is still running in the first terminal).
  • Run the JavaScript client by executing this command:
node client/api-client.js

You should see numbered output showing each CRUD operation completing successfully. Step 8 catches and prints the error message for the deleted task.

Seeing a connection refused error?

  • Make sure your FastAPI server is still running in the other terminal. The client needs the server to be active at http://127.0.0.1:8000.
  • If you stopped the server, switch back to that terminal and run fastapi dev main.py again.
  • Check that BASE_URL in your JavaScript file matches the server address exactly, including the port 8000.

help me fix a connection refused error when running my JavaScript API client against FastAPI

✔️ Awesome, I've got everything!

Great. Double check you have saved client/api-client.js and both terminals are clear of errors.

ⓧ I'd like to double check the full code

const BASE_URL = "http://127.0.0.1:8000";

// --- API Client Functions ---

async function createTask(title, description = null) {
  const response = await fetch(`${BASE_URL}/tasks`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ title, description }),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(`Failed to create task: ${error.detail}`);
  }

  return response.json();
}

async function getAllTasks(completed = null) {
  let url = `${BASE_URL}/tasks`;
  if (completed !== null) {
    url += `?completed=${completed}`;
  }

  const response = await fetch(url);

  if (!response.ok) {
    throw new Error(`Failed to fetch tasks: ${response.status}`);
  }

  return response.json();
}

async function getTask(taskId) {
  const response = await fetch(`${BASE_URL}/tasks/${taskId}`);

  if (!response.ok) {
    if (response.status === 404) {
      throw new Error(`Task ${taskId} not found`);
    }
    throw new Error(`Failed to fetch task: ${response.status}`);
  }

  return response.json();
}

async function updateTask(taskId, updates) {
  const response = await fetch(`${BASE_URL}/tasks/${taskId}`, {
    method: "PUT",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(updates),
  });

  if (!response.ok) {
    if (response.status === 404) {
      throw new Error(`Task ${taskId} not found`);
    }
    const error = await response.json();
    throw new Error(`Failed to update task: ${error.detail}`);
  }

  return response.json();
}

async function deleteTask(taskId) {
  const response = await fetch(`${BASE_URL}/tasks/${taskId}`, {
    method: "DELETE",
  });

  if (!response.ok) {
    if (response.status === 404) {
      throw new Error(`Task ${taskId} not found`);
    }
    throw new Error(`Failed to delete task: ${response.status}`);
  }

  console.log(`Task ${taskId} deleted successfully`);
}

// --- Main Demo ---

async function main() {
  console.log("=== Task Manager API Client ===\n");

  // Create tasks
  console.log("1. Creating tasks...");
  const task1 = await createTask("Learn FastAPI", "Build a REST API with Python");
  console.log("   Created:", task1);

  const task2 = await createTask("Learn Fetch API", "Consume APIs with JavaScript");
  console.log("   Created:", task2);

  const task3 = await createTask("Build React Native app", "Connect mobile app to API");
  console.log("   Created:", task3);

  // List all tasks
  console.log("\n2. Listing all tasks...");
  const allTasks = await getAllTasks();
  console.log(`   Found ${allTasks.length} tasks:`);
  allTasks.forEach((task) => {
    console.log(`   - [${task.completed ? "x" : " "}] ${task.title} (ID: ${task.id})`);
  });

  // Get a single task
  console.log("\n3. Getting task by ID...");
  const singleTask = await getTask(task1.id);
  console.log("   Task details:", singleTask);

  // Update a task
  console.log("\n4. Marking first task as completed...");
  const updatedTask = await updateTask(task1.id, {
    title: task1.title,
    completed: true,
  });
  console.log("   Updated:", updatedTask);

  // Filter completed tasks
  console.log("\n5. Filtering completed tasks...");
  const completedTasks = await getAllTasks(true);
  console.log(`   Completed tasks: ${completedTasks.length}`);

  // Delete a task
  console.log("\n6. Deleting last task...");
  await deleteTask(task3.id);

  // Final list
  console.log("\n7. Final task list:");
  const finalTasks = await getAllTasks();
  finalTasks.forEach((task) => {
    console.log(`   - [${task.completed ? "x" : " "}] ${task.title} (ID: ${task.id})`);
  });

  // Error handling demo
  console.log("\n8. Testing error handling (fetching deleted task)...");
  try {
    await getTask(task3.id);
  } catch (error) {
    console.log(`   Caught error: ${error.message}`);
  }

  console.log("\n=== Done! ===");
}

main().catch(console.error);

Your JavaScript client is now talking to your Python API over HTTP. You have successfully built both sides of the API conversation. Next up, you will extend this system with authentication in the Secret Mission.

Secret mission

Add API Key Authentication

Your API is wide open right now. Anyone who knows the URL can create, read, update, and delete tasks. In this secret mission, you'll add a simple API key authentication layer and update your JavaScript client to include it.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The task-manager-api project folder on your Desktop (contains main.py, client/api-client.js, and database.db).
  • A Python virtual environment inside the project folder.
  • The FastAPI dev server (running process).

Keep everything running

No action needed. Choose this if you're still actively building or want to experiment more with your API.

  • Your FastAPI server will keep running until you close the terminal or press Ctrl+C.
  • Your project files and database remain on your Desktop.
  • You can restart the server at any time by activating the virtual environment and running fastapi dev main.py.

Pause - I'll come back to this later

Shut down the running server to free up your terminal. Your files and database stay intact for next time.

  • Switch to the terminal where your FastAPI server is running.
  • Press Ctrl+C to stop the server.
  • Deactivate your virtual environment by running:
deactivate

When you're ready to come back, navigate to your project folder, activate the virtual environment, and start the server again.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes your code, database, and virtual environment permanently.

  • Switch to the terminal where your FastAPI server is running and press Ctrl+C to stop it.
  • Deactivate your virtual environment by running:
deactivate
  • Navigate to your Desktop and delete the entire project folder by running:

macOS

cd ~/Desktop
rm -rf task-manager-api

Windows

cd ~\Desktop
rmdir /s /q task-manager-api
  • Confirm the folder is gone by running:
ls

You should no longer see task-manager-api in the output.

That's a Wrap!

That's a Wrap!

Nice work! You just built a complete API system from both sides of the conversation. You have a Python backend serving data over HTTP and a JavaScript client that consumes it.

You learned how to:

  • Built a REST API from scratch using FastAPI with full CRUD endpoints and a SQLite database powered by SQLModel.
  • Explored auto-generated interactive API documentation (Swagger UI) and understood the full HTTP request/response lifecycle including methods, status codes, headers, and JSON bodies.
  • Built a JavaScript client using the Fetch API with async/await, proper error handling, and CORS configuration.
  • Secret Mission: Added API key authentication to your backend and updated both server and client to use it.

The patterns you just learned (making HTTP requests, sending JSON, handling responses, adding auth headers) are exactly what you'll use in a React Native app when connecting to any backend API.

Ready to quiz yourself?