Scaffolding a new API project involves a significant amount of repetitive setup — defining models, writing route handlers, configuring middleware, and wiring up error handling. These are well-understood patterns, but they still take time to implement correctly every time you start a new service.
In this walkthrough, I use Kiro to scaffold a complete FastAPI task management API from a single natural language description. The result is a production-ready project structure with Pydantic validation, dependency injection, request logging, and auto-generated Swagger documentation.
What I Asked Kiro
I described the API I wanted in plain English:
"Build a FastAPI task management API with CRUD endpoints, Pydantic models for validation, dependency injection, request logging middleware, and global error handling. Use an in-memory database with seed data. Include status and priority filtering on the list endpoint."
Kiro generates code based on the intent of your prompt, not from a fixed template. If you use the same prompt, you will get a structurally similar result — the same endpoints, the same separation of concerns, the same patterns — but the exact variable names, docstrings, seed data, or minor implementation details may differ between runs. Think of it like asking two senior developers to build the same spec: the architecture will align, but the code will not be character-for-character identical.
The screenshots and code snippets in this walkthrough reflect one specific generation. Your output will follow the same patterns and conventions, but may not match line-for-line.
The Generated Project Structure
Kiro produced a clean, modular project layout:
fastapi-task-app/
├── main.py # Application setup, middleware, error handlers
├── models.py # Pydantic request/response schemas
├── database.py # In-memory data store with seed data
├── routes.py # CRUD endpoint handlers
├── dependencies.py # Dependency injection configuration
└── requirements.txt
Each concern is separated into its own module — models, routes, database, and dependencies are all independent. Kiro went with a flat layout, keeping all source files at the top level. This is a common pattern for smaller FastAPI services where a package subdirectory would add unnecessary nesting.
📸 SCREENSHOT: Kiro file explorer showing the generated project tree

Kiro generated three distinct schemas — one for creation, one for updates, and one for responses — along with enums for constrained fields:
class Status(str, Enum):
todo = "todo"
in_progress = "in_progress"
done = "done"
class Priority(str, Enum):
low = "low"
medium = "medium"
high = "high"
class TaskCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
description: str = Field(default="", max_length=1000)
status: Status = Status.todo
priority: Priority = Priority.medium
class TaskUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=200)
description: str | None = Field(default=None, max_length=1000)
status: Status | None = None
priority: Priority | None = None
class Task(BaseModel):
id: int
title: str
description: str
status: Status
priority: Priority
created_at: datetime
updated_at: datetime
The TaskUpdate schema uses all optional fields with the modern str | None union syntax, enabling partial updates where only the provided fields are modified. The Field definitions include constraints that automatically appear in the generated API documentation.
📸 [SCREENSHOT: Kiro editor showing models.py with the Pydantic schemas]
Kiro configured a dependency injection pattern for the database layer:
import database
def get_db():
"""Dependency that yields the database module."""
return database
This pattern decouples the route handlers from the data layer. When you move to a real database, you change what get_db() returns — the routes remain untouched. It also makes unit testing straightforward, since you can inject a mock.
CRUD Endpoints
The generated routes follow REST conventions with proper HTTP status codes, query parameter filtering, and consistent error handling:
@router.get("", response_model=list[Task])
def list_tasks(
status: Status | None = Query(default=None, description="Filter by status"),
priority: Priority | None = Query(default=None, description="Filter by priority"),
db: db_module = Depends(get_db),
):
return db.get_all(status=status, priority=priority)
@router.get("/{task_id}", response_model=Task)
def get_task(task_id: int, db: db_module = Depends(get_db)):
task = db.get_by_id(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@router.post("", response_model=Task, status_code=201)
def create_task(body: TaskCreate, db: db_module = Depends(get_db)):
now = datetime.now(timezone.utc)
task = Task(
id=db.next_id(),
title=body.title,
description=body.description,
status=body.status,
priority=body.priority,
created_at=now,
updated_at=now,
)
return db.create(task)
@router.patch("/{task_id}", response_model=Task)
def update_task(task_id: int, body: TaskUpdate, db: db_module = Depends(get_db)):
existing = db.get_by_id(task_id)
if not existing:
raise HTTPException(status_code=404, detail="Task not found")
updated = existing.model_copy(
update={k: v for k, v in body.model_dump(exclude_unset=True).items()},
)
updated.updated_at = datetime.now(timezone.utc)
return db.update(task_id, updated)
@router.delete("/{task_id}", status_code=204)
def delete_task(task_id: int, db: db_module = Depends(get_db)):
if not db.delete(task_id):
raise HTTPException(status_code=404, detail="Task not found")
Key details: the list endpoint supports optional status and priority query parameters, delegating the filtering to the database layer. The create endpoint returns 201 Created, the delete endpoint returns 204 No Content, and missing resources get a 404. For updates, Kiro used PATCH — the correct HTTP verb for partial updates — with Pydantic's model_copy and exclude_unset to only modify the fields that were actually sent.
📸 [SCREENSHOT: Kiro editor showing routes.py with CRUD endpoints]

Kiro added two production-relevant features: request logging middleware and a global exception handler.
The middleware logs every request with its method, path, status code, and response time:
@app.middleware("http")
async def log_requests(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
elapsed_ms = (time.perf_counter() - start) * 1000
logger.info("%s %s → %d (%.1fms)", request.method, request.url.path, response.status_code, elapsed_ms)
return response
The global exception handler ensures that unhandled errors return a clean JSON response rather than exposing internal details:
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
logger.exception("Unhandled error on %s %s", request.method, request.url.path)
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
Kiro also used FastAPI's modern lifespan context manager to seed the database on startup:
@asynccontextmanager
async def lifespan(app: FastAPI):
database.seed()
logger.info("Database seeded with %d tasks", len(database.get_all()))
yield
Running the Application
pip install -r requirements.txt
uvicorn main:app --reload
The Swagger UI is available at http://localhost:8000/docs with full interactive documentation — every endpoint, request/response schema, and enum value is documented automatically from the Pydantic models and route definitions.
📸 [SCREENSHOT: Swagger UI at /docs showing all endpoints and the Task Management API title]
The API includes seed data, so you can verify all endpoints immediately:
# List all tasks
curl http://localhost:8000/tasks
# Filter by status
curl "http://localhost:8000/tasks?status=todo"
# Create a new task
curl -X POST http://localhost:8000/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Ship the feature","priority":"high"}'
# Handle a missing resource
curl http://localhost:8000/tasks/999
# → {"detail": "Task not found"}
All endpoints returned correct responses on the first run — no import errors, no configuration issues, no missing dependencies.
The initial scaffolding gives you a working API, but a real service needs authentication. Rather than wiring that up manually, I asked Kiro to layer it on:
"Add API key authentication middleware to the task management API. Protect all /tasks endpoints. Include a hardcoded API key for demo purposes and return 401 for missing or invalid keys."
📸 [SCREENSHOT: Kiro chat panel showing the authentication follow-up prompt]
and wired it into the existing route structure without touching the business logic. Here is the key piece it generated:
from fastapi import Security, HTTPException, status
from fastapi.security import APIKeyHeader
API_KEY = "demo-secret-key-2024"
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
async def verify_api_key(api_key: str = Security(api_key_header)):
if not api_key or api_key != API_KEY:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing API key",
)
return api_key
The route file then picks up the new dependency alongside the existing database injection:
@router.post("", response_model=Task, status_code=201)
def create_task(
body: TaskCreate,
db: db_module = Depends(get_db),
api_key: str = Depends(verify_api_key),
):
...
This is the strength of the dependency injection pattern Kiro set up in the first pass — adding authentication is additive, not invasive. The database layer, models, and error handling are all untouched.
After restarting the server, the Swagger UI at http://localhost:8000/docs automatically shows an "Authorize" button where you can enter the API key. Requests without the X-API-Key header now return a 401:
📸 [SCREENSHOT: Swagger UI showing the Authorize button at the top after adding auth and showing 401 response when calling GET /tasks without API key]
📸 [SCREENSHOT: Kiro code showing API key that need to be added to authorize the API calls]
📸 [SCREENSHOT: Swagger UI showing successful response after authorizing with the API key]
# Without API key — rejected
curl http://localhost:8000/tasks
# → {"detail": "Invalid or missing API key"}
# With API key — works as before
curl -H "X-API-Key: demo-secret-key-2024" http://localhost:8000/tasks
The point here is the workflow: scaffold first, then iterate with follow-up prompts. Each prompt builds on the existing structure rather than starting over. Kiro understands the codebase it just generated and extends it in place.
Moving Beyond the Demo: Production Authentication
The hardcoded API key above is intentionally simple — it keeps the demo focused on the workflow pattern. In a production service, you would never store secrets in source code. Here is how you might prompt Kiro for a production-ready authentication layer:
"Replace the hardcoded API key with environment variable configuration using python-dotenv. Load the key from a .env file, add .env to .gitignore, and return a clear error on startup if the key is not set."
Or for a more complete auth system:
"Add JWT-based authentication with login and token refresh endpoints. Use python-jose for token handling and passlib for password hashing. Store users in the in-memory database with hashed passwords. Protect all /tasks endpoints with a Bearer token dependency."
The key security considerations for production:
Secrets loaded from environment variables or a secrets manager, never committed to source
Token-based auth (JWT or OAuth2) instead of static API keys for user-facing APIs
Password hashing with bcrypt or argon2, never stored in plaintext
Token expiration and refresh flows to limit the blast radius of a leaked token
HTTPS enforced at the load balancer or reverse proxy level
The dependency injection pattern Kiro set up in the initial scaffolding makes any of these upgrades clean — you swap the auth dependency, and the routes stay the same.
The same follow-up prompt pattern works for other features:
Background tasks — "Send a notification when a task status changes to done" — Kiro can use FastAPI's BackgroundTasks to add async processing without blocking the response.
Persistent storage — "Replace the in-memory store with SQLAlchemy and SQLite" — the dependency injection pattern makes this swap clean; you modify get_db() and the routes stay the same.
Pagination — "Add offset/limit pagination to the list endpoint" — a small change to the query parameters and database method.
Summary
Starting from a single natural language prompt, Kiro produced a complete FastAPI application with validated models, dependency injection, CRUD endpoints with filtering, middleware, error handling, and auto-generated Swagger docs. A follow-up prompt then layered on API key authentication without disrupting the existing code.
Your results will follow the same patterns and architecture, though the exact code may vary between runs — that is the nature of AI-assisted generation. The value is in getting the structure, conventions, and wiring right from the start, so you can focus on the logic that makes your API unique.
@kirodotdev








Top comments (0)