Free lesson

Create a FastAPI application with path operations

You will build a FastAPI application from scratch with a `GenAIServiceAPI` class that registers path operations for a prompt management service. Implement GET /prompts to list prompts with query parameter filtering (limit, offset, tags), POST /prompts to create prompts with request body validation, GET /prompts/{prompt_id} with path parameter parsing, and DELETE /prompts/{prompt_id} returning 204 No Content. Configure uvicorn programmatically with reload for development.

~25 min read · Free to read — no subscription required.

Create path operations with GET, POST, PUT, DELETE and proper HTTP status codes

Introduction

When you wire a prompt-management service in front of an LLM and return 200 OK whether the prompt was created, missing, or rejected by validation, every client and caching proxy in the chain loses the ability to tell success from failure — clients retry destructively, gateways cache errors as if they were data, and on-call engineers chase phantom bugs that the status line should have surfaced in the first place. By the end of this lesson you will be able to register path operations with @app.get, @app.post, @app.put, and @app.delete, extract path and query parameters via type annotations, and return the correct 2xx success code or raise the correct 4xx error for every CRUD interaction in a FastAPI service.

Key Terminology

  • Path operation — a Python function bound to one HTTP method and one URL pattern by a FastAPI decorator (@app.get("/prompts/{prompt_id}")); FastAPI uses its signature as the single source of truth for parameter parsing, validation, and OpenAPI docs.
  • Path parameter — a typed segment of the URL captured by {name} placeholders (e.g. prompt_id: str); FastAPI extracts and coerces it before your handler runs, so a non-coercible value short-circuits to 422 automatically.
  • Query parameter — a function argument that does NOT appear in the path string (e.g. skip: int = 0); a default value makes it optional, and None plus | None makes it nullable in the OpenAPI schema.
  • Request body model — a Pydantic BaseModel argument (e.g. payload: PromptCreate) that FastAPI parses from JSON and validates field-by-field; rejection produces a structured 422 with no handler code executed.
  • Status code — the integer set via the decorator's status_code= for success responses or via raise HTTPException(status_code=…) for errors; it is the primary success/failure signal HTTP requires, not the body.

Concepts

Method-specific decorators encode intent

FastAPI exposes one decorator per HTTP method — @app.get, @app.post, @app.put, @app.delete — instead of a single route() with a method list. The decorator is not cosmetic: it controls which method the path responds to, sets the default success status code, and emits the matching OpenAPI operation entry. Mismatching the method to the semantics (e.g. mutating state in a GET) breaks HTTP caching and retries silently and never throws an error you can grep for.

Path operations resolve in declaration order

When a request arrives, FastAPI walks registered routes top to bottom and the first path+method match wins. Static segments must therefore be registered before parameterized ones — declaring @app.get("/prompts/{prompt_id}") before @app.get("/prompts/featured") makes featured get captured as a prompt_id and either fail validation or return the wrong record. The diagram below traces the full pipeline (see Code Walkthrough for the concrete handlers).

Loading diagram...

Status codes are the contract, not the body

Choose the success code by the operation's semantics: 200 OK for GET and PUT that return data, 201 Created for POST that produces a resource, 204 No Content for DELETE and any handler that intentionally returns nothing. Choose the error code by the failure kind: 404 for a missing resource (raise it explicitly — do not return None with 200), 422 for validation rejection (FastAPI emits this for you when Pydantic rejects input), 405 when the path exists but the method is not registered. Encoding success/failure in the JSON body while returning 200 breaks proxies, retries, and observability.

Pydantic models double as request and response schemas

A Pydantic BaseModel on a parameter is interpreted as the JSON body; the same kind of model on the return annotation is the response schema. Field constraints (min_length, max_length, ge, le, default values) are validated automatically — the handler never executes when input violates them — and the constraints flow straight into the OpenAPI spec, so consumers see the same rules the server enforces.

Code Walkthrough

The snippet below stitches all four concepts together — method-specific decorators, path + query parameter extraction, Pydantic request and response models, and per-method status codes — into a complete prompt-management CRUD surface that exercises every code path the goal requires.

Code snippet python
1from datetime import datetime, timezone 2 3from fastapi import FastAPI, HTTPException, status 4from pydantic import BaseModel, Field 5 6app = FastAPI(title="Prompt Management API", version="1.0.0") 7prompts_db: dict[str, dict] = {} 8 9class PromptCreate(BaseModel): 10 name: str = Field(min_length=1, max_length=100) 11 template: str = Field(min_length=10) 12 model_name: str = Field(default="claude-3-sonnet") 13 temperature: float = Field(default=0.7, ge=0.0, le=2.0) 14 15class PromptResponse(BaseModel): 16 prompt_id: str 17 name: str 18 template: str 19 model_name: str 20 temperature: float 21 created_at: str 22 23@app.get("/prompts", status_code=status.HTTP_200_OK) 24def list_prompts(skip: int = 0, limit: int = 20, model_name: str | None = None): 25 results = list(prompts_db.values()) 26 if model_name is not None: 27 results = [p for p in results if p["model_name"] == model_name] 28 return results[skip : skip + limit] 29 30@app.get("/prompts/{prompt_id}", status_code=status.HTTP_200_OK) 31def get_prompt(prompt_id: str) -> PromptResponse: 32 record = prompts_db.get(prompt_id) 33 if record is None: 34 raise HTTPException( 35 status_code=status.HTTP_404_NOT_FOUND, 36 detail=f"Prompt '{prompt_id}' not found", 37 ) 38 return PromptResponse(**record) 39 40@app.post("/prompts", status_code=status.HTTP_201_CREATED) 41def create_prompt(payload: PromptCreate) -> PromptResponse: 42 prompt_id = f"prompt_{len(prompts_db) + 1:04d}" 43 record = { 44 "prompt_id": prompt_id, 45 **payload.model_dump(), 46 "created_at": datetime.now(timezone.utc).isoformat(), 47 } 48 prompts_db[prompt_id] = record 49 return PromptResponse(**record) 50 51@app.put("/prompts/{prompt_id}", status_code=status.HTTP_200_OK) 52def update_prompt(prompt_id: str, payload: PromptCreate) -> PromptResponse: 53 existing = prompts_db.get(prompt_id) 54 if existing is None: 55 raise HTTPException( 56 status_code=status.HTTP_404_NOT_FOUND, 57 detail=f"Prompt '{prompt_id}' not found", 58 ) 59 updated = { 60 "prompt_id": prompt_id, 61 **payload.model_dump(), 62 "created_at": existing["created_at"], 63 } 64 prompts_db[prompt_id] = updated 65 return PromptResponse(**updated) 66 67@app.delete("/prompts/{prompt_id}", status_code=status.HTTP_204_NO_CONTENT) 68def delete_prompt(prompt_id: str) -> None: 69 if prompts_db.pop(prompt_id, None) is None: 70 raise HTTPException( 71 status_code=status.HTTP_404_NOT_FOUND, 72 detail=f"Prompt '{prompt_id}' not found", 73 ) 74 return None
  • Lines 9-13: PromptCreate is the request body schema; the Field constraints fail invalid input with 422 before the handler runs.
  • Lines 15-21: PromptResponse is the response schema; declaring it as the return type makes server-managed fields like prompt_id and created_at part of the published contract.
  • Lines 23-28: list_prompts shows query parameters (skip, limit, model_name) — defaults make them optional, and str | None plus a default of None makes the filter nullable.
  • Lines 30-38: get_prompt uses a path parameter and raises HTTPException(404) for the missing case — never return None with 200 here.
  • Lines 40-49: create_prompt returns 201 Created (set via status_code= on the decorator) and lets Pydantic enforce every body constraint before the handler executes.
  • Lines 51-65: update_prompt is the idempotent PUT — it preserves created_at so repeated identical calls converge to the same stored record and returns 200, not 201.
  • Lines 67-74: delete_prompt returns 204 No Content; FastAPI sends an empty body, which is the correct REST semantic for a successful delete.

You'll know it works when uvicorn main:app --reload boots cleanly and curl -i -X POST http://localhost:8000/prompts -H 'content-type: application/json' -d '{"name":"q","template":"hello world test"}' returns HTTP/1.1 201 Created, a follow-up GET /prompts/prompt_0001 returns 200, GET /prompts/missing returns 404, DELETE /prompts/prompt_0001 returns 204 with an empty body, and a POST with "template":"x" returns 422 with a structured detail array — and /docs shows all five operations grouped by path with the right method, status, and schema for each.

Do's and Don'ts

Do's

  1. Do raise HTTPException with an explicit status_code — returning None or an error dict with a 200 status hides the failure from clients, proxies, and dashboards that key off the status line.
  2. Do set status_code= on every decoratorPOST should default to 201, DELETE to 204, and PUT/GET to 200; relying on the framework default (200) misrepresents creation and deletion.
  3. Do declare static paths before parameterized ones@app.get("/prompts/featured") must register before @app.get("/prompts/{prompt_id}") or the second route will swallow featured as an ID.

Don'ts

  1. Don't mutate state inside a GET — caches, retries, and link prefetchers assume GET is safe and idempotent; mutating in GET causes silent duplicate writes.
  2. Don't fuzzy-match a missing resource to 200 with an empty body — clients can't distinguish "not found" from "found but empty"; raise 404 so the contract is unambiguous.
  3. Don't parse and validate the request body by hand — typing the parameter as a Pydantic BaseModel gives you 422 for free and keeps the OpenAPI schema in sync with the enforced rules.

Keep going with GenAI Agent Engineering

Create a free account to track your progress and open this lesson in the full learning view. Subscribe to unlock the entire path — every goal, the hands-on labs, quizzes, and your verifiable skill graph — from . Cancel anytime.