Build a Prompt Registry API
Build a REST API to store, search, and manage AI prompts with FastAPI.
Introduction
30 Second Summary
If you've ever saved a great AI prompt in a notes app only to lose track of it later, you know how quickly a prompt collection gets out of control. A dedicated API could let you store, tag, and search your prompts from anywhere.
In this project, you will build a Prompt Registry API using Python and FastAPI. You will design endpoints for managing AI prompts, with Pydantic handling data validation automatically.
What You'll Build
You will see your API running live in FastAPI's auto-generated Swagger UI, where you can create prompts, search by tag, and watch validation errors appear instantly when you send bad data.
By the end of this project, you'll have:
- A running API where you create a prompt by sending its name, content, and tags, and get back a validated JSON response with a generated ID.
- Tag-based filtering where you search your prompt collection and see only the results that match.
- An automated test suite you run with a single terminal command to confirm every endpoint works correctly.
- Secret Mission: A version history system that tracks every edit to a prompt, showing you how it changed over time.
Are there any prerequisites?
This project assumes basic Python knowledge, including variables, functions, dictionaries, and lists. No prior FastAPI or Git experience is required.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up Your Project and Tools
Your Prompt Registry API needs three foundations before you write any application code: Git for tracking every change you make, FastAPI for building the API itself, and a clean project structure to keep things organized.
In this step, you will install Git, create a project directory with a Python virtual environment, and lay down the files you will build on in later steps.
In this step, get ready to:
- Install and configure Git for version control.
- Create the project directory with a virtual environment and install dependencies.
- Set up the file structure and make your first Git commit.
Install and configure Git
Git is a version control tool that records every change you save. It lets you roll back mistakes and track the history of your project.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (backtick key).
First, check whether Git is already installed on your machine.
- Check your Git installation by running this command:
git --version
✔️ I see a version number
Git is ready to go. Skip ahead to the identity configuration below.
ⓧ Command not found
Git is not installed yet. Install it by following these steps.
- Download the Git installer from git-scm.com/download/win.
- Run the installer and accept the default settings on each screen.
- After installation finishes, close your current terminal in VS Code by clicking the trash can icon on the terminal panel.
- Open a fresh terminal with Ctrl+` so it picks up the new Git installation.
- Confirm Git is installed by running:
git --version
You should now see a version number printed in the terminal.
Still not seeing a version number?
Make sure you closed and reopened the terminal after installing Git. The old terminal session does not know about newly installed programs.
If it still does not work, try restarting VS Code entirely and opening a new terminal.
help me troubleshoot why Git is not recognized after installing it on Windows
Now tell Git who you are. Every commit you make gets stamped with your name and email.
- Set your Git username by running this command:
git config --global user.name "Your Name"
- Replace Your Name with your actual name (keep the quotes).
- Set your Git email by running this command:
git config --global user.email "your@email.com"
- Replace your@email.com with your actual email address (keep the quotes).
Why does Git need my name and email?
Git attaches your name and email to every commit so that collaborators can see who made each change. These are stored locally in your Git configuration. They are not sent anywhere.
Create the project directory and install dependencies
You need a dedicated folder for your project, a Python virtual environment to isolate your packages, and the three libraries your API depends on.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder, move into it, and initialize a Git repository by running these commands:
mkdir prompt-registry
cd prompt-registry
git init
You should see a message that says Git initialized an empty repository in your prompt-registry folder.
What is a Git repository?
A Git repository is a hidden .git folder inside your project that tracks every change you commit. Initializing one with git init is always the first step in a new project.
Next, create a virtual environment. This keeps your project's packages separate from other Python projects on your machine.
- Create and activate the virtual environment by running these commands:
macOS
python3 -m venv venv
source venv/bin/activate
Windows
python -m venv venv
venv\Scripts\activate
After activation, your terminal prompt changes to show (venv) at the beginning. This confirms the virtual environment is active.
What is a virtual environment?
A virtual environment is an isolated copy of Python that lives inside your project folder. When you install packages with pip inside an activated environment, they go into the venv/ folder instead of your system-wide Python.
This matters because different projects may need different versions of the same package. Without isolation, installing a package for one project could break another.
🙋♀️ Don't see (venv) in your prompt?
On Windows, PowerShell may block script execution by default. Run this command first, then try the activate command again:
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
On macOS, make sure Python is installed by running python3 --version.
help me activate my Python virtual environment
- Install FastAPI, pytest, and httpx by running this command:
pip install "fastapi[standard]" pytest httpx
You will see pip downloading and installing several packages. This may take a minute.
What are these three packages?
- fastapi[standard] installs FastAPI along with uvicorn (the server) and the fastapi CLI in one command.
- pytest is Python's most popular testing framework. You will use it to write automated tests in Step 5.
- httpx is an HTTP client that FastAPI's TestClient needs under the hood to simulate API requests in tests.
- Confirm FastAPI installed correctly by running this command:
python -m fastapi --help
You should see the FastAPI CLI help text listing commands like dev and run. This confirms FastAPI and its CLI are ready.
Seeing 'No module named fastapi'?
Make sure your virtual environment is active. You should see (venv) at the start of your terminal prompt. If not, re-run the activate command from the platform tab above.
If activation is fine but the command still fails, try running the install command again and check for error messages during installation.
help me fix the fastapi module not being found after pip install
Create the project files and make your first commit
Your project needs three Python files. Each one has a specific job that you will fill in during later steps.
- Create the three empty project files by running this command:
macOS
touch main.py models.py test_main.py
Windows
New-Item -ItemType File -Path main.py, models.py, test_main.py
- Confirm the files were created by running:
ls
You should see main.py, models.py, and test_main.py listed alongside the venv folder.
What are these three files for?
- main.py is where the FastAPI app and all your endpoints will live.
- models.py defines the Pydantic models that validate incoming data.
- test_main.py holds the automated tests that prove your API works.
Before committing, you need to tell Git which files to skip. The venv/ folder and __pycache__/ directory contain generated files that do not belong in version control.
- In the VS Code Explorer sidebar, right-click in the file list and select New File.
- Name the file .gitignore (include the dot at the start) and press Enter.
- Add these lines to .gitignore:
venv/
__pycache__/
- Save .gitignore.
Use a .gitignore file
Any file or folder pattern listed in .gitignore becomes invisible to Git. Without it, git add . would stage the thousands of package files inside venv/. Those files can always be reinstalled with pip install, so they do not belong in your repository.
Now save this initial project setup as your first Git commit.
- Stage all files and commit them by running these commands:
git add .
git commit -m "Initial project setup"
What do these Git commands do?
- git add . stages every file in the current directory, telling Git to include these in the next commit.
- git commit -m "Initial project setup" saves a snapshot of your project with a descriptive message. You can always come back to this exact state.
Before you move on, what do you expect to see when you check the commit history?
- Verify your commit by running:
git log --oneline
You should see one line with a short hash followed by Initial project setup. This confirms your project is tracked by Git and your first snapshot is saved.
Seeing 'Author identity unknown'?
This means Git does not know your name or email. Scroll back up to the identity configuration step and make sure you ran both git config commands. Then try the commit again.
help me fix the Git author identity unknown error
Your project foundation is in place. Next up, you will write your first FastAPI endpoint and see it live in the browser within minutes.
Build Your First Endpoint and See It Live
You have a project folder, a virtual environment, and empty files waiting for code. The fastest way to understand FastAPI is to get a running server you can interact with in your browser.
In this step, you will define your data models, write two endpoints, and see them running live in FastAPI's auto-generated Swagger UI. By the end, you will have created prompts and listed them back through a real REST API.
In this step, get ready to:
- Define Pydantic models that validate incoming prompt data.
- Build POST and GET endpoints that create and list prompts.
- Run the dev server and test your API in Swagger UI.
Define your data models
Before your API can accept data, it needs to know what shape that data should have. Pydantic models act as blueprints that define required fields, types, and defaults.
- Open models.py in VS Code and add the following code:
from pydantic import BaseModel
# Fields the user provides when creating a prompt
class PromptCreate(BaseModel):
name: str
content: str
tags: list[str] = []
model_target: str | None = None
# Full prompt record including server-generated fields
class Prompt(PromptCreate):
id: str
created_at: str
What does this code do?
PromptCreate defines the four fields a user can send when creating a prompt: name, content, tags (defaults to an empty list), and model_target (optional). If someone sends a request missing name or content, Pydantic will reject it automatically.
Prompt extends PromptCreate by adding id and created_at. These are fields the server generates, not the user. This separation keeps the API clean: the user sends a PromptCreate, and the server returns a full Prompt.
- Save models.py.
- Verify your models work by running this command in your terminal:
python -c "from models import PromptCreate, Prompt; print(PromptCreate(name='test', content='hello'))"
You should see output showing the model fields with their values, like name='test' content='hello' tags=[] model_target=None.
Seeing a ModuleNotFoundError?
Make sure your virtual environment is active. You should see (venv) at the start of your terminal prompt. If you do not, activate it by running venv\Scripts\activate in your terminal.
Also confirm you are in the prompt-registry folder where models.py lives.
Still stuck? Help me fix my ModuleNotFoundError when importing my Pydantic models.
Create the app and your first endpoint
Now that your data models are ready, you can build the FastAPI app that receives requests and returns responses. Your first endpoint will let users create a new prompt by sending JSON to the API.
- Open main.py in VS Code and add the following code:
from datetime import datetime, timezone
from uuid import uuid4
from fastapi import FastAPI, HTTPException
from models import Prompt, PromptCreate
# Create the FastAPI application
app = FastAPI(title="Prompt Registry API")
# In-memory storage for prompts (dictionary keyed by prompt ID)
prompts: dict[str, dict] = {}
@app.post("/prompts", status_code=201, response_model=Prompt)
def create_prompt(prompt: PromptCreate):
# Generate a unique ID and UTC timestamp
prompt_id = str(uuid4())
now = datetime.now(timezone.utc).isoformat()
# Convert the Pydantic model to a dictionary and add server fields
prompt_data = prompt.model_dump()
prompt_data["id"] = prompt_id
prompt_data["created_at"] = now
# Store the prompt and return it
prompts[prompt_id] = prompt_data
return prompt_data
What does this code do?
The first few lines import the tools you need: datetime for timestamps, uuid4 for unique IDs, and your Pydantic models from models.py. The HTTPException import will be used for error handling in an upcoming step.
The prompts dictionary stores all your prompts in memory, keyed by their UUID. When a POST request arrives, create_prompt generates a unique ID and timestamp, merges them with the user's data using model_dump(), and stores the result.
The status_code=201 tells FastAPI to return a 201 Created response. The response_model=Prompt ensures the response JSON matches your Prompt model exactly.
- Save main.py.
Before you run the server, what do you think you will see when you open the browser at http://127.0.0.1:8000/docs?
- Start the development server by running this command:
fastapi dev main.py
What does this command do?
The fastapi dev command starts a local development server using Uvicorn under the hood. It auto-detects the app object in main.py and serves it with auto-reload enabled. Any time you save a code change, the server restarts automatically.
This command takes over your terminal. You will see log lines ending with Application startup complete. when it is ready.
- Open your browser and go to http://127.0.0.1:8000/docs.
The Swagger UI loads with your API title "Prompt Registry API" at the top and a POST /prompts endpoint listed. This interactive documentation was generated automatically from your code.
- Click the POST /prompts row to expand it.
- Click Try it out.
- Replace the example JSON in the request body with this:
{
"name": "Summarizer",
"content": "Summarize the following text in 3 bullet points.",
"tags": ["summarization"],
"model_target": "gpt-4"
}
- Click Execute.
You should see a 201 response with a JSON body that includes your prompt data plus a generated id and created_at timestamp. Your first prompt is stored.
Seeing a 422 or 500 error?
A 422 error means the JSON body is malformed or missing required fields. Check that your JSON has both name and content fields with string values. Make sure there are no trailing commas or unquoted keys.
A 500 error usually means a bug in your code. Check the terminal where the server is running for a Python traceback, and compare your code to the code blocks above.
Need more help? Help me debug my FastAPI POST endpoint error.
Add the list endpoint and commit your progress
Creating prompts is only useful if you can retrieve them. Your next endpoint returns all stored prompts as a list.
- In main.py, add the following code below the create_prompt function:
@app.get("/prompts", response_model=list[Prompt])
def list_prompts(tag: str | None = None):
# Retrieve all prompts from the store
all_prompts = list(prompts.values())
# Filter by tag if one was provided
if tag is not None:
return [p for p in all_prompts if tag in p["tags"]]
return all_prompts
What does this code do?
The list_prompts function handles GET requests to /prompts. It pulls every prompt from the prompts dictionary and returns them as a list.
The tag parameter is an optional query parameter that defaults to None. When no tag is provided, all prompts come back. You will put this filtering to use in a later step.
- Save main.py. The dev server auto-reloads with your changes.
- Switch back to Swagger UI in your browser and refresh the page.
You should now see both POST /prompts and GET /prompts listed on the page.
- Create two more prompts using the POST endpoint (use different names and tags for each).
- Expand the GET /prompts row, click Try it out, then click Execute.
You should see a 200 response containing a JSON array with all the prompts you created.
GET returns an empty list?
If the server restarted between your POST and GET requests, all in-memory data was cleared. Create a new prompt with POST first, then immediately try GET. This is expected behavior for in-memory storage.
Still seeing issues? Help me figure out why my GET /prompts endpoint returns an empty list.
✔️ Awesome, I've got everything!
Great. Double check you have saved both models.py and main.py.
ⓧ I'd like to double check the full code
Here is the complete models.py:
from pydantic import BaseModel
class PromptCreate(BaseModel):
name: str
content: str
tags: list[str] = []
model_target: str | None = None
class Prompt(PromptCreate):
id: str
created_at: str
Here is the complete main.py:
from datetime import datetime, timezone
from uuid import uuid4
from fastapi import FastAPI, HTTPException
from models import Prompt, PromptCreate
app = FastAPI(title="Prompt Registry API")
prompts: dict[str, dict] = {}
@app.post("/prompts", status_code=201, response_model=Prompt)
def create_prompt(prompt: PromptCreate):
prompt_id = str(uuid4())
now = datetime.now(timezone.utc).isoformat()
prompt_data = prompt.model_dump()
prompt_data["id"] = prompt_id
prompt_data["created_at"] = now
prompts[prompt_id] = prompt_data
return prompt_data
@app.get("/prompts", response_model=list[Prompt])
def list_prompts(tag: str | None = None):
all_prompts = list(prompts.values())
if tag is not None:
return [p for p in all_prompts if tag in p["tags"]]
return all_prompts
- Stop the dev server by pressing Ctrl+C in the terminal.
- Commit your progress by running these commands:
git add .
git commit -m "Add prompt creation and listing endpoints"
- Confirm the commit succeeded. You should see a message showing the files changed and the commit message.
Git says there is nothing to commit?
Make sure you saved both models.py and main.py before running git add .. Unsaved changes in your editor are not picked up by Git.
Need help? Help me figure out why Git says nothing to commit.
You have a running API with two endpoints, tested live in the browser. Next up, you will add the ability to retrieve, update, and delete individual prompts by ID.
Add Retrieval, Update, and Delete
In the last step, you built POST and GET endpoints and watched prompts appear in Swagger UI. Your API can create prompts and list them all, but that is only half of what a real API needs.
A complete API lets you fetch a single item, update it, and delete it. This pattern is called CRUD (Create, Read, Update, Delete). This step also introduces error handling so your API responds properly when someone requests a prompt that does not exist.
In this step, get ready to:
- Add a GET endpoint to retrieve a single prompt by its ID.
- Add PUT and DELETE endpoints to update and remove prompts.
- Handle missing prompts with proper 404 error responses.
Add the GET by ID endpoint
Your current GET /prompts endpoint returns every prompt at once. To fetch a single prompt, you need a new endpoint that accepts an ID in the URL as a path parameter.
- In main.py, find the list_prompts function at the bottom of the file.
- Add this new endpoint below it:
@app.get("/prompts/{prompt_id}", response_model=Prompt)
def get_prompt(prompt_id: str):
# Return 404 if the prompt ID doesn't exist in our store
if prompt_id not in prompts:
raise HTTPException(status_code=404, detail="Prompt not found")
return prompts[prompt_id]
What does this code do?
The curly braces in /prompts/{prompt_id} tell FastAPI to capture that part of the URL as a function parameter. When someone requests /prompts/abc-123, FastAPI passes "abc-123" into the prompt_id argument.
HTTPException is already imported at the top of your file from the fastapi package. It lets you return a proper HTTP error response with a status code and a detail message.
- Save main.py.
- Switch back to Swagger UI at http://127.0.0.1:8000/docs and refresh the page. The dev server auto-reloads when you save.
Dev server not running?
If you stopped the server earlier, restart it by running fastapi dev main.py in your terminal.
You should see a new GET /prompts/{prompt_id} section in Swagger UI.
- Use the POST /prompts endpoint to create a test prompt. Copy the id value from the response.
- Expand the GET /prompts/{prompt_id} section, click Try it out, paste the ID, and click Execute.
You should see the full JSON response for that single prompt.
Now, what do you think will happen if you paste in a completely made-up ID like fake-id-12345 and hit Execute?
- Replace the ID with fake-id-12345 and click Execute.
You get a 404 response with {"detail": "Prompt not found"}. That is your HTTPException at work.
Not seeing the new endpoint in Swagger UI?
- Make sure you saved main.py. The dev server only reloads when the file is saved to disk.
- Check your terminal for error messages. A syntax error in your code prevents the server from reloading.
- Make sure the new function is outside the list_prompts function. It should be at the same indentation level as the other endpoint functions.
Still stuck? Ask for help.
Add the update endpoint
Fetching a prompt is useful, but you also need to change a prompt after creating it. The PUT endpoint accepts updated fields while preserving the original ID and creation timestamp.
- In main.py, add this endpoint below the get_prompt function:
@app.put("/prompts/{prompt_id}", response_model=Prompt)
def update_prompt(prompt_id: str, prompt: PromptCreate):
# Check if the prompt exists before updating
if prompt_id not in prompts:
raise HTTPException(status_code=404, detail="Prompt not found")
# Preserve the original ID and timestamp
stored = prompts[prompt_id]
updated_data = prompt.model_dump()
updated_data["id"] = stored["id"]
updated_data["created_at"] = stored["created_at"]
prompts[prompt_id] = updated_data
return updated_data
What does this code do?
- The function takes two parameters: prompt_id from the URL path and prompt from the request body. FastAPI automatically knows which is which.
- model_dump() converts the Pydantic model into a plain dictionary so you can modify individual fields.
- The original id and created_at values are copied from the stored prompt. This prevents a client from overwriting them.
- Save main.py and refresh Swagger UI.
- Create a new prompt with POST, then expand the PUT /prompts/{prompt_id} section.
- Click Try it out, paste the prompt's ID, change the name to something new, and click Execute.
The response shows the updated name, but the id and created_at fields stay the same as the original.
Getting a 422 validation error?
A 422 means Pydantic rejected the request body. Make sure your JSON includes at least name and content fields as strings. The tags and model_target fields are optional.
Need more help?
Add the delete endpoint and test the full cycle
The last piece of CRUD is the ability to remove a prompt entirely. After this, your API covers every standard operation.
- In main.py, add this endpoint below the update_prompt function:
@app.delete("/prompts/{prompt_id}")
def delete_prompt(prompt_id: str):
# Check if the prompt exists before deleting
if prompt_id not in prompts:
raise HTTPException(status_code=404, detail="Prompt not found")
del prompts[prompt_id]
return {"detail": "Prompt deleted"}
What does this code do?
- del prompts[prompt_id] removes the entry from the in-memory dictionary. Once deleted, any future GET or PUT for that ID returns a 404.
- The function returns a simple confirmation message rather than the deleted prompt. This is a common API pattern because the resource no longer exists.
- Save main.py and refresh Swagger UI.
Now run through the full CRUD cycle to prove every endpoint works together.
- Create a prompt using POST /prompts. Copy the id from the response.
- Retrieve it with GET /prompts/{prompt_id} using that ID. Confirm the full JSON comes back.
- Update it with PUT /prompts/{prompt_id}. Change the name to something different.
- Retrieve it again with GET to confirm the name changed but the id and created_at stayed the same.
- Delete it with DELETE /prompts/{prompt_id}. You should see {"detail": "Prompt deleted"} in the response.
Before you click Execute on the next one, predict what happens if you try to delete the same prompt a second time.
- Click Execute on the DELETE endpoint again with the same ID.
You get a 404 response with {"detail": "Prompt not found"}. The prompt was already removed, so the second delete correctly reports it as missing.
Seeing a 500 Internal Server Error?
- Check your terminal for a traceback. The most common cause is a typo in a dictionary key like stored["id"] or stored["created_at"] in the update function.
- Make sure each new function starts at the top level of the file (no extra indentation). It should align with the @app decorator above it.
Still stuck? Ask for help.
Time to commit this progress to Git. Stop the dev server by pressing Ctrl+C in the terminal.
- Stage and commit your changes by running these commands:
git add .
git commit -m "Add get, update, and delete endpoints"
- You should see a commit summary listing main.py as modified.
✔️ Awesome, I've got everything!
Make sure you have saved main.py and committed your changes to Git.
ⓧ I'd like to double check the full code
Your main.py has grown over the last two steps. Scroll up and confirm these pieces are present in order:
- Imports at the top: datetime, uuid4, FastAPI, HTTPException, Prompt, and PromptCreate.
- The app instance and the prompts dictionary.
- The create_prompt function (POST).
- The list_prompts function (GET all).
- The get_prompt function (GET by ID) you just added.
- The update_prompt function (PUT) you just added.
- The delete_prompt function (DELETE) you just added.
Your API now handles the full create, read, update, and delete lifecycle. Next up, you will add filtering so you can search your growing collection of prompts by tag.
Add Filtering by Tag
Your API now handles the full CRUD lifecycle. You can create, retrieve, update, and delete prompts through Swagger UI.
But once you have dozens of prompts stored, listing all of them at once becomes impractical. Query parameters are how real APIs handle filtering. In this step, you'll see how your list endpoint already supports filtering by tag, then put it to the test with real data.
In this step, get ready to:
- Understand how query parameter filtering works in your list endpoint.
- Test tag filtering with real prompts in Swagger UI.
- Commit your verified filtering feature with Git.
Understand how query parameter filtering works
Your list_prompts function in main.py already accepts an optional tag parameter. Let's look at how it works.
- In VS Code, open main.py and find the list_prompts function:
@app.get("/prompts", response_model=list[Prompt])
def list_prompts(tag: str | None = None):
all_prompts = list(prompts.values())
if tag is not None:
return [p for p in all_prompts if tag in p["tags"]]
return all_prompts
How does FastAPI know this is a query parameter?
FastAPI looks at your function's parameters. If a parameter name does not appear in the URL path (like {prompt_id} does), FastAPI automatically treats it as a query parameter. That means tag becomes accessible via /prompts?tag=value in the URL.
The type hint str | None = None makes it optional. When a caller omits the tag parameter, it defaults to None and the function returns all prompts. When a caller provides a tag, only matching prompts come back.
Test tag filtering in Swagger UI
Time to see filtering in action. You need a few prompts with different tags to test against.
- Make sure your dev server is running. If you stopped it earlier, start it again by running:
fastapi dev main.py
- Switch to your browser and open the Swagger UI at http://127.0.0.1:8000/docs.
- Expand the POST /prompts endpoint and click Try it out.
- Create a first prompt by replacing the request body with this JSON, then click Execute:
{
"name": "Text Summarizer",
"content": "Summarize the following text in three bullet points.",
"tags": ["summarization"],
"model_target": "gpt-4"
}
- Create a second prompt with a different tag:
{
"name": "Code Reviewer",
"content": "Review this Python function for bugs and suggest improvements.",
"tags": ["code-generation"],
"model_target": "gpt-4"
}
- Create a third prompt with a different tag:
{
"name": "Language Translator",
"content": "Translate the following text from English to Spanish.",
"tags": ["translation"],
"model_target": null
}
- Verify all three prompts exist by expanding the GET /prompts endpoint, clicking Try it out, and clicking Execute.
You should see all three prompts in the response body.
Seeing an empty list?
If you restarted the dev server between creating prompts and listing them, the in-memory store resets. Create the three prompts again after restarting.
Help me figure out why my prompts list is empty.
Now for the real test. Before you filter, think about this: if you call GET /prompts?tag=summarization, how many prompts do you expect to see?
- In the GET /prompts section, find the tag parameter field.
- Enter summarization into the tag field.
- Click Execute.
Only the "Text Summarizer" prompt appears in the response. The other two are filtered out because their tags lists do not contain summarization.
- Try filtering by code-generation next. Enter code-generation in the tag field and click Execute.
Only the "Code Reviewer" prompt comes back this time.
- Now try a tag that no prompt has. Enter debugging in the tag field and click Execute.
You get back an empty list []. No prompts match, so the API returns nothing. No error, just an empty result.
- Finally, clear the tag field so it is empty and click Execute again.
All three prompts appear. When no tag is provided, the endpoint returns everything.
Why return an empty list instead of a 404?
A 404 means "this resource does not exist." An empty list means "the resource exists, there are just no results matching your filter." This distinction matters for API consumers who check status codes to decide how to handle the response.
Commit your progress with Git
- Stop the dev server by pressing Ctrl+C in your terminal.
- Stage and commit your changes by running these commands:
git add .
git commit -m "Add tag filtering to list endpoint"
What if Git says nothing to commit?
If you see nothing to commit, working tree clean, that means your code was already committed in the previous step. This is fine. Your filtering code is safely tracked in Git.
Your API can now filter prompts by tag using query parameters. Next up, you'll write automated tests that prove every endpoint works without needing to click through Swagger UI by hand.
Write Automated Tests with pytest
You have a fully working API with create, read, update, delete, and tag filtering. You tested each endpoint by clicking through Swagger UI and visually inspecting the results.
That works for exploring, but imagine changing one line of code and needing to re-test all five endpoints by hand. Automated tests let you verify every endpoint in seconds with a single command. In this step, you will write tests using pytest and FastAPI's TestClient.
In this step, get ready to:
- Set up TestClient and write your first automated test.
- Add tests covering every endpoint and error case.
- Run the full test suite and commit your work.
Set up TestClient and write your first test
FastAPI includes a TestClient class that lets you call your API endpoints directly in code, without starting a server. Each test function sends a request and checks that the response matches what you expect.
- Open test_main.py in VS Code and add the following code:
from fastapi.testclient import TestClient
from main import app, prompts
# Create a test client that sends requests to your FastAPI app
client = TestClient(app)
def setup_function():
# Clear all prompts before each test so tests don't affect each other
prompts.clear()
def test_create_prompt():
# Send a POST request with a prompt payload
response = client.post(
"/prompts",
json={"name": "Summarizer", "content": "Summarize this text.", "tags": ["summarization"]},
)
# Verify the response status and data
assert response.status_code == 201
data = response.json()
assert data["name"] == "Summarizer"
assert "id" in data
assert "created_at" in data
What does this code do?
- TestClient(app) wraps your FastAPI app so you can call endpoints like client.post() and client.get() without running a live server.
- setup_function() is a pytest hook that runs before every test function. By clearing the prompts dictionary each time, every test starts with a clean slate.
- test_create_prompt() sends a POST to /prompts and uses assert statements to confirm the response has the right status code, name, and auto-generated fields.
- Save test_main.py.
- If the dev server is still running in your terminal, press Ctrl+C to stop it. You do not need the server running to run tests.
- Run your first test by entering this command:
pytest test_main.py -v
You should see output showing test_create_prompt PASSED in green. One test collected, one test passed.
Test not passing?
- If you see ModuleNotFoundError, make sure your virtual environment is active. Run venv\Scripts\activate and try again.
- If you see ImportError for httpx, install it by running pip install httpx. TestClient depends on it.
- Check that your from main import app, prompts line matches exactly. A typo in the import name causes the test to fail before it starts.
Help me debug my failing pytest test.
Add tests for the remaining endpoints
One test proves the pattern works. Now you will add tests for every endpoint your API exposes, including the error cases.
- In test_main.py, add these three test functions below test_create_prompt:
def test_list_prompts():
# Create two prompts, then verify the list returns both
client.post("/prompts", json={"name": "P1", "content": "c1"})
client.post("/prompts", json={"name": "P2", "content": "c2"})
response = client.get("/prompts")
assert response.status_code == 200
assert len(response.json()) == 2
def test_get_prompt():
# Create a prompt, then fetch it by ID
create_response = client.post("/prompts", json={"name": "Test", "content": "c"})
prompt_id = create_response.json()["id"]
response = client.get(f"/prompts/{prompt_id}")
assert response.status_code == 200
assert response.json()["name"] == "Test"
def test_get_prompt_not_found():
# Request a nonexistent ID and expect a 404
response = client.get("/prompts/nonexistent-id")
assert response.status_code == 404
assert response.json()["detail"] == "Prompt not found"
What do these tests cover?
- test_list_prompts creates two prompts, then GETs the full list and checks that both come back.
- test_get_prompt creates a prompt, grabs its ID from the response, and fetches it directly by that ID.
- test_get_prompt_not_found calls a fake ID and verifies the API returns 404 with the correct error detail. Testing error paths is just as important as testing happy paths.
- Save test_main.py.
- Run the tests to confirm your new tests pass alongside the first one:
pytest test_main.py -v
You should see four tests listed, each followed by PASSED in green.
One of the new tests failing?
- If test_get_prompt_not_found fails, check that the detail string in your test matches "Prompt not found" exactly, matching the string in your main.py endpoint.
- If test_list_prompts fails with an unexpected count, make sure setup_function is present. Without it, leftover prompts from other tests contaminate the results.
Help me debug my failing test.
Now add the final three tests for update, delete, and tag filtering.
- In test_main.py, add these test functions below test_get_prompt_not_found:
def test_update_prompt():
# Create a prompt, update it, then verify the changes stuck
create_response = client.post("/prompts", json={"name": "Old", "content": "old content"})
prompt_id = create_response.json()["id"]
response = client.put(
f"/prompts/{prompt_id}",
json={"name": "New", "content": "new content"},
)
assert response.status_code == 200
assert response.json()["name"] == "New"
get_response = client.get(f"/prompts/{prompt_id}")
assert get_response.json()["content"] == "new content"
def test_delete_prompt():
# Delete a prompt, then confirm a second delete returns 404
create_response = client.post("/prompts", json={"name": "ToDelete", "content": "c"})
prompt_id = create_response.json()["id"]
response = client.delete(f"/prompts/{prompt_id}")
assert response.status_code == 200
second_delete = client.delete(f"/prompts/{prompt_id}")
assert second_delete.status_code == 404
def test_filter_by_tag():
# Create prompts with different tags, then filter by one tag
client.post("/prompts", json={"name": "A", "content": "c", "tags": ["summarization"]})
client.post("/prompts", json={"name": "B", "content": "c", "tags": ["code-generation"]})
client.post("/prompts", json={"name": "C", "content": "c", "tags": ["summarization", "code-generation"]})
response = client.get("/prompts?tag=summarization")
assert response.status_code == 200
results = response.json()
assert len(results) == 2
names = [r["name"] for r in results]
assert "A" in names
assert "C" in names
What do these tests cover?
- test_update_prompt creates a prompt, PUTs new data, and then does a separate GET to confirm the changes persisted. This double-check pattern catches bugs where the PUT response looks correct but the data was never actually saved.
- test_delete_prompt deletes a prompt and then tries to delete it again. The second DELETE should return 404, proving the prompt is actually gone.
- test_filter_by_tag creates three prompts with overlapping tags. Filtering by summarization should return prompts A and C (both tagged with it), but not B.
- Save test_main.py.
Seeing a syntax error?
- Make sure each new test function starts with def test_ and is at the top indentation level (not nested inside another function).
- Check for matching parentheses and brackets in the client.post() calls, especially the JSON payloads.
Help me fix a syntax error in my test file.
Run the full test suite and commit
All seven tests are written. Before you run them, take a moment to predict: do you think all seven will pass on the first try?
- Run the full test suite by entering this command:
pytest test_main.py -v
You should see all seven tests listed, each followed by PASSED in green. The -v flag prints each test name so you can see exactly which tests ran.
Why is this better than Swagger UI?
You just verified every endpoint in your API with a single command. If you change a line of code tomorrow, you can re-run these tests in seconds and know immediately whether anything broke. In professional codebases, automated tests run on every commit.
- Stage and commit your test suite by running these commands:
git add .
git commit -m "Add automated tests for all endpoints"
One or more tests failing?
- Read the pytest output carefully. It highlights the exact assert line that failed and shows the expected versus actual values.
- If test_filter_by_tag fails, check that the tag strings in your test JSON match the ones your filter checks. Tags are case-sensitive.
- If multiple tests fail with unexpected counts, confirm that setup_function exists and calls prompts.clear(). Without it, prompts leak between tests.
Help me debug my failing pytest tests.
✔️ Awesome, I've got everything!
Great. Double check that you have saved test_main.py and committed your changes.
ⓧ I'd like to double check the full code
Your test_main.py should contain the following sections in this order. Scroll up and confirm each one is present:
- Imports: TestClient from fastapi.testclient, and app and prompts from main.
- Client: client = TestClient(app) at the module level.
- Setup: setup_function() that calls prompts.clear().
- Seven test functions in order: test_create_prompt, test_list_prompts, test_get_prompt, test_get_prompt_not_found, test_update_prompt, test_delete_prompt, test_filter_by_tag.
The main.py and models.py files are unchanged from the previous step.
Secret mission
Add Prompt Version History
In real AI engineering workflows, prompts evolve through many iterations. Can you add a version history system that tracks every edit to a prompt, so you can see exactly how it changed over time?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine, 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:
- prompt-registry project folder on your Desktop (contains source code, virtual environment, and local Git repository).
- Git (installed system-wide in Step 1).
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the API soon.
- Your project files stay on your Desktop and your virtual environment stays intact.
- Restart the dev server anytime by opening a terminal in the prompt-registry folder and running fastapi dev main.py.
- Git remains installed for future projects.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your project files and virtual environment stay on disk for when you come back.
- If the dev server is still running, stop it by pressing Ctrl+C in the terminal.
- Deactivate the virtual environment by running:
deactivate
- To come back later, open a terminal in the prompt-registry folder and reactivate the virtual environment.
On Windows:
venv\Scripts\activate
On macOS or Linux:
source venv/bin/activate
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your code, virtual environment, and local Git history.
- If the dev server is still running, stop it by pressing Ctrl+C in the terminal.
- Deactivate the virtual environment by running:
deactivate
- Navigate to your Desktop and delete the project folder.
On Windows:
cd ~\Desktop
rmdir /s /q prompt-registry
On macOS or Linux:
cd ~/Desktop
rm -rf prompt-registry
- To uninstall Git on Windows, open Settings.
- Select Apps, then Installed apps.
- Search for Git, click the three-dot menu, and select Uninstall.
Nice Work!
Nice Work!
You just built a fully tested Prompt Registry API using FastAPI. Your API validates data, handles errors, filters by tag, and passes every automated test.
You've learned how to:
- Build a REST API with full CRUD operations and proper error handling using HTTP status codes.
- Validate request data automatically with Pydantic models and filter results using query parameters.
- Write automated tests with pytest and TestClient, and track your project history with Git commits.
- Secret Mission: Add a version history system that tracks every edit to a prompt, a pattern used in real prompt engineering workflows.
Ready to quiz yourself?