decomposer: generate deliverable files for Implement the task REST API service with FastAPI, including standard CRUD endpoints, HTTP status semantics, request and response models, and structured error handling.; Add a PostgreSQL persistence layer for the task service using SQLAlchemy models, session management, database initialization or migrations, and durable task storage integrated with the existing API contract.; Define and integrate Pydantic request and response schemas for task fields, status values, identifiers, timestamps, and validation errors across the existing FastAPI service and persistence contract.; Add automated pytest coverage for the task API, including CRUD behavior, request and response validation failures, HTTP status semantics, and persistence interactions using isolated test data.; Containerize the FastAPI task service and PostgreSQL persistence service with Docker Compose, including environment-based configuration, health checks, networking, startup dependencies, and persistent database storage.; Create project documentation and operational guidance covering local setup, Docker Compose configuration, environment variables, API endpoints, validation and error responses, testing commands, persistence behavior, and relevant best-practice notes.

This commit is contained in:
2026-08-19 11:37:10 +00:00
parent 878957c05a
commit 324ddc19db
18 changed files with 550 additions and 5 deletions

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
POSTGRES_DB=tasks
POSTGRES_USER=tasks
POSTGRES_PASSWORD=change-me
DATABASE_URL=postgresql+psycopg2://tasks:change-me@db:5432/tasks
APP_NAME=Task API
LOG_LEVEL=INFO
API_PORT=8000

View File

@@ -0,0 +1,67 @@
# Plan Detail
## Step 0: Implement the task REST API service with FastAPI, including standard CRUD endpoints, HTTP status semantics, request and response models, and structured error handling.
- **Capability:** Build a FastAPI service that exposes validated task CRUD operations and returns appropriate HTTP responses for successful and invalid requests.
- **Plan label:** gap
- **Reusable capability:** False
- **Rationale:** This is the primary deliverable-specific API implementation for the requested tasks resource, not a standalone capability intended for reuse across unrelated future plans.
- **Input schema:** `{'requirements': 'object'}`
- **Output schema:** `{'service_name': 'string', 'source_bundle': 'object', 'api_contract': 'object', 'implementation_notes': 'string[]'}`
- **Acceptance criteria:** (none)
- **Success conditions:** (none)
## Step 1: Add a PostgreSQL persistence layer for the task service using SQLAlchemy models, session management, database initialization or migrations, and durable task storage integrated with the existing API contract.
- **Capability:** Implement task-specific PostgreSQL persistence with validated database models, managed sessions, schema initialization, and CRUD repository operations.
- **Plan label:** gap
- **Reusable capability:** False
- **Rationale:** The persistence implementation is tailored to this API's task schema and deployment; it is part of the deliverable rather than an independently reusable registry agent.
- **Input schema:** `{'service_name': 'string', 'source_bundle': 'object', 'api_contract': 'object', 'implementation_notes': 'string[]'}`
- **Output schema:** `{'service_name': 'string', 'source_bundle': 'object', 'api_contract': 'object', 'persistence_contract': 'object', 'implementation_notes': 'string[]'}`
- **Acceptance criteria:** (none)
- **Success conditions:** (none)
## Step 2: Define and integrate Pydantic request and response schemas for task fields, status values, identifiers, timestamps, and validation errors across the existing FastAPI service and persistence contract.
- **Capability:** Implement typed Pydantic validation models that enforce the task API's input and output schema, including identifier and timestamp serialization rules.
- **Plan label:** gap
- **Reusable capability:** False
- **Rationale:** These schemas define the requested task API contract and are deliverable content for this service, not a general-purpose reusable capability.
- **Input schema:** `{'service_name': 'string', 'source_bundle': 'object', 'api_contract': 'object', 'persistence_contract': 'object', 'implementation_notes': 'string[]'}`
- **Output schema:** `{'service_name': 'string', 'source_bundle': 'object', 'api_contract': 'object', 'persistence_contract': 'object', 'validation_contract': 'object', 'implementation_notes': 'string[]'}`
- **Acceptance criteria:** (none)
- **Success conditions:** (none)
## Step 3: Add automated pytest coverage for the task API, including CRUD behavior, request and response validation failures, HTTP status semantics, and persistence interactions using isolated test data.
- **Capability:** Create repeatable API tests that exercise validated task CRUD operations, error handling, and database-backed persistence behavior.
- **Plan label:** gap
- **Reusable capability:** False
- **Rationale:** Tests verify this specific task API and its chosen persistence stack, so they belong to the single deliverable.
- **Input schema:** `{'service_name': 'string', 'source_bundle': 'object', 'api_contract': 'object', 'persistence_contract': 'object', 'validation_contract': 'object', 'implementation_notes': 'string[]'}`
- **Output schema:** `{'service_name': 'string', 'source_bundle': 'object', 'api_contract': 'object', 'persistence_contract': 'object', 'validation_contract': 'object', 'test_bundle': 'object', 'test_contract': 'object', 'implementation_notes': 'string[]'}`
- **Acceptance criteria:** (none)
- **Success conditions:** (none)
## Step 4: Containerize the FastAPI task service and PostgreSQL persistence service with Docker Compose, including environment-based configuration, health checks, networking, startup dependencies, and persistent database storage.
- **Capability:** Package the API and database as cooperating Docker Compose services with reproducible configuration, health-aware orchestration, isolated networking, and durable volumes.
- **Plan label:** gap
- **Reusable capability:** False
- **Rationale:** The Compose topology and container configuration are specific to this requested project and are not a standalone reusable registry capability.
- **Input schema:** `{'service_name': 'string', 'source_bundle': 'object', 'api_contract': 'object', 'persistence_contract': 'object', 'validation_contract': 'object', 'test_bundle': 'object', 'test_contract': 'object', 'implementation_notes': 'string[]'}`
- **Output schema:** `{'service_name': 'string', 'source_bundle': 'object', 'deployment_bundle': 'object', 'deployment_contract': 'object', 'api_contract': 'object', 'persistence_contract': 'object', 'validation_contract': 'object', 'test_bundle': 'object', 'test_contract': 'object', 'implementation_notes': 'string[]'}`
- **Acceptance criteria:** (none)
- **Success conditions:** (none)
## Step 5: Create project documentation and operational guidance covering local setup, Docker Compose configuration, environment variables, API endpoints, validation and error responses, testing commands, persistence behavior, and relevant best-practice notes.
- **Capability:** Document how to configure, run, use, test, and operate the containerized task REST API and its PostgreSQL dependency.
- **Plan label:** gap
- **Reusable capability:** False
- **Rationale:** Documentation describes and operates this concrete project, so it is deliverable content rather than a reusable capability.
- **Input schema:** `{'service_name': 'string', 'source_bundle': 'object', 'deployment_bundle': 'object', 'deployment_contract': 'object', 'api_contract': 'object', 'persistence_contract': 'object', 'validation_contract': 'object', 'test_bundle': 'object', 'test_contract': 'object', 'implementation_notes': 'string[]'}`
- **Output schema:** `{'service_name': 'string', 'documentation_bundle': 'object', 'documentation_contract': 'object'}`
- **Acceptance criteria:** (none)
- **Success conditions:** (none)

View File

@@ -0,0 +1,23 @@
# Plan Map
**Workflow:** wf-draft-7f4395e5f64d
**Intent:** Build a lightweight REST API using python and docker that includes standard CRUD endpoints, data persistence, schema validation, and basic test coverage. It should be built using microservices and following best practices
**This repo covers:** step 0, step 1, step 2, step 3, step 4, step 5
## Dependency graph
- Step 0: no dependencies
- Step 1: depends on step 0
- Step 2: depends on step 0, step 1
- Step 3: depends on step 0, step 1, step 2
- Step 4: depends on step 0, step 1, step 2, step 3
- Step 5: depends on step 0, step 1, step 2, step 3, step 4
## Phase table
Phase 1: step 0
Phase 2: step 1
Phase 3: step 2
Phase 4: step 3
Phase 5: step 4
Phase 6: step 5

View File

@@ -1 +1,8 @@
# TODO: generation subagent fills this in. FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -1,3 +1,58 @@
# crucible-agent-build-lightweight-rest # Task API
Auto-generated agent: build-lightweight-rest A containerized FastAPI task REST API backed by PostgreSQL and SQLAlchemy.
## Quick start
```bash
cp .env.example .env
docker compose up --build
curl http://localhost:8000/health
```
The API is available at `http://localhost:8000`; interactive OpenAPI docs are at `/docs`.
For local development, create a virtual environment and run:
```bash
pip install -r requirements.txt
uvicorn app.main:app --reload
pytest -q
```
The default local database URL is PostgreSQL. Tests override the database dependency with isolated SQLite databases, so no database server is required for the test suite.
## Configuration
| Variable | Default | Description |
|---|---|---|
| `DATABASE_URL` | `postgresql+psycopg2://tasks:tasks@db:5432/tasks` | SQLAlchemy database URL |
| `APP_NAME` | `Task API` | API display name |
| `LOG_LEVEL` | `INFO` | Uvicorn log level |
Do not commit production secrets. The Compose database uses the `POSTGRES_*` variables from `.env` and persists data in the `postgres_data` volume.
## API contract
Tasks have a UUID `id`, required `title` (1200 characters), optional `description` (maximum 5000), `status` (`pending`, `in_progress`, or `completed`), optional timezone-aware `due_at`, and server-managed UTC `created_at` and `updated_at` timestamps.
- `POST /tasks` — create; returns `201`
- `GET /tasks` — list, with optional `status`, `skip`, and `limit`; returns `200`
- `GET /tasks/{id}` — fetch; returns `200`, or `404` if absent
- `PATCH /tasks/{id}` — partial update; returns `200`, or `404`
- `DELETE /tasks/{id}` — delete; returns `204`, or `404`
- `GET /health` — database-backed readiness check; returns `200` or `503`
Invalid JSON, fields, UUIDs, or query parameters return a structured `422` response:
```json
{"error":{"code":"VALIDATION_ERROR","message":"Request validation failed","details":[...]}}
```
Not-found and database failures use the same envelope with codes `TASK_NOT_FOUND` and `DATABASE_ERROR`. The API does not expose database internals.
## Operations
`docker compose up --build` waits for the PostgreSQL health check before starting the API. On startup the API calls `Base.metadata.create_all`; this is convenient for this small service. For production, replace it with reviewed Alembic migrations before deploying schema changes. `docker compose down` preserves data; `docker compose down -v` removes the database volume.
Run `pytest -q` for CRUD, validation, status semantics, error envelopes, and persistence-isolation coverage.

2
alembic.ini Normal file
View File

@@ -0,0 +1,2 @@
# Schema initialization is currently performed by app.db.init_db at startup.
# Adopt Alembic migrations before production schema evolution.

View File

@@ -1 +1,11 @@
"""Typed settings.""" from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "Task API"
database_url: str = "postgresql+psycopg2://tasks:tasks@localhost:5432/tasks"
log_level: str = "INFO"
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
settings = Settings()

26
app/db.py Normal file
View File

@@ -0,0 +1,26 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from .config import settings
class Base(DeclarativeBase):
pass
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
def init_db() -> None:
Base.metadata.create_all(bind=engine)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()

110
app/main.py Normal file
View File

@@ -0,0 +1,110 @@
from contextlib import asynccontextmanager
from uuid import UUID
from fastapi import Depends, FastAPI, Query, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from .config import settings
from .db import get_db, init_db
from .models import TaskStatus
from .repository import create_task, delete_task, get_task, list_tasks, update_task
from .schemas import ErrorResponse, TaskCreate, TaskRead, TaskUpdate
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
yield
app = FastAPI(title=settings.app_name, version="1.0.0", lifespan=lifespan)
def error_response(code: str, message: str, details: list | None = None) -> JSONResponse:
return JSONResponse(status_code=404 if code == "TASK_NOT_FOUND" else 500, content={"error": {"code": code, "message": message, "details": details or []}})
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
details = [{"loc": list(error.get("loc", [])), "msg": error.get("msg", "Invalid value"), "type": error.get("type")} for error in exc.errors()]
return JSONResponse(status_code=422, content={"error": {"code": "VALIDATION_ERROR", "message": "Request validation failed", "details": details}})
@app.get("/health")
def health(db: Session = Depends(get_db)):
try:
db.execute(text("SELECT 1"))
return {"status": "ok"}
except SQLAlchemyError:
return JSONResponse(status_code=503, content={"error": {"code": "DATABASE_UNAVAILABLE", "message": "Database is unavailable", "details": []}})
@app.post("/tasks", response_model=TaskRead, status_code=status.HTTP_201_CREATED, responses={422: {"model": ErrorResponse}})
def create(data: TaskCreate, db: Session = Depends(get_db)):
try:
return create_task(db, data)
except SQLAlchemyError:
db.rollback()
return error_response("DATABASE_ERROR", "Unable to create task")
@app.get("/tasks", response_model=list[TaskRead])
def list_all(status_filter: TaskStatus | None = Query(default=None, alias="status"), skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=100)):
# dependency is intentionally declared below to keep query parameters obvious in OpenAPI
return []
@app.get("/tasks", response_model=list[TaskRead], include_in_schema=False)
def _list_shadow(db: Session = Depends(get_db)):
return []
# Replace the parameter-only route above with the database-backed implementation.
app.routes.pop(-2)
@app.get("/tasks", response_model=list[TaskRead])
def list_tasks_endpoint(status_filter: TaskStatus | None = Query(default=None, alias="status"), skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=100), db: Session = Depends(get_db)):
return list_tasks(db, status_filter, skip, limit)
def require_task(task_id: UUID, db: Session) :
task = get_task(db, task_id)
if task is None:
raise ValueError("TASK_NOT_FOUND")
return task
@app.get("/tasks/{task_id}", response_model=TaskRead, responses={404: {"model": ErrorResponse}})
def get_one(task_id: UUID, db: Session = Depends(get_db)):
task = get_task(db, task_id)
if task is None:
return error_response("TASK_NOT_FOUND", "Task not found")
return task
@app.patch("/tasks/{task_id}", response_model=TaskRead, responses={404: {"model": ErrorResponse}})
def patch(task_id: UUID, data: TaskUpdate, db: Session = Depends(get_db)):
task = get_task(db, task_id)
if task is None:
return error_response("TASK_NOT_FOUND", "Task not found")
try:
return update_task(db, task, data)
except SQLAlchemyError:
db.rollback()
return error_response("DATABASE_ERROR", "Unable to update task")
@app.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT, responses={404: {"model": ErrorResponse}})
def remove(task_id: UUID, db: Session = Depends(get_db)):
task = get_task(db, task_id)
if task is None:
return error_response("TASK_NOT_FOUND", "Task not found")
try:
delete_task(db, task)
return None
except SQLAlchemyError:
db.rollback()
return error_response("DATABASE_ERROR", "Unable to delete task")

26
app/models.py Normal file
View File

@@ -0,0 +1,26 @@
import enum
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Enum, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from .db import Base
class TaskStatus(str, enum.Enum):
pending = "pending"
in_progress = "in_progress"
completed = "completed"
class Task(Base):
__tablename__ = "tasks"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
title: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus, name="task_status"), nullable=False, default=TaskStatus.pending)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

39
app/repository.py Normal file
View File

@@ -0,0 +1,39 @@
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.orm import Session
from .models import Task, TaskStatus
from .schemas import TaskCreate, TaskUpdate
def create_task(db: Session, data: TaskCreate) -> Task:
task = Task(**data.model_dump())
db.add(task)
db.commit()
db.refresh(task)
return task
def list_tasks(db: Session, status: TaskStatus | None, skip: int, limit: int) -> list[Task]:
query = select(Task).order_by(Task.created_at.desc()).offset(skip).limit(limit)
if status is not None:
query = query.where(Task.status == status)
return list(db.scalars(query).all())
def get_task(db: Session, task_id: UUID) -> Task | None:
return db.get(Task, str(task_id))
def update_task(db: Session, task: Task, data: TaskUpdate) -> Task:
for key, value in data.model_dump(exclude_unset=True).items():
setattr(task, key, value)
db.commit()
db.refresh(task)
return task
def delete_task(db: Session, task: Task) -> None:
db.delete(task)
db.commit()

64
app/schemas.py Normal file
View File

@@ -0,0 +1,64 @@
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator
from .models import TaskStatus
class TaskBase(BaseModel):
title: str = Field(min_length=1, max_length=200)
description: str | None = Field(default=None, max_length=5000)
status: TaskStatus = TaskStatus.pending
due_at: datetime | None = None
@field_validator("title")
@classmethod
def title_not_blank(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("title must not be blank")
return value
class TaskCreate(TaskBase):
pass
class TaskUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=200)
description: str | None = Field(default=None, max_length=5000)
status: TaskStatus | None = None
due_at: datetime | None = None
@field_validator("title")
@classmethod
def title_not_blank(cls, value: str | None) -> str | None:
if value is not None:
value = value.strip()
if not value:
raise ValueError("title must not be blank")
return value
class TaskRead(TaskBase):
id: UUID
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class ErrorDetail(BaseModel):
loc: list[str | int] = []
msg: str
type: str | None = None
class ErrorBody(BaseModel):
code: str
message: str
details: list[ErrorDetail] = []
class ErrorResponse(BaseModel):
error: ErrorBody

37
docker-compose.yml Normal file
View File

@@ -0,0 +1,37 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-tasks}
POSTGRES_USER: ${POSTGRES_USER:-tasks}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-tasks}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
volumes:
- postgres_data:/var/lib/postgresql/data
networks: [tasks_net]
api:
build: .
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg2://tasks:tasks@db:5432/tasks}
APP_NAME: ${APP_NAME:-Task API}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
ports:
- "${API_PORT:-8000}:8000"
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 10s
timeout: 5s
retries: 5
networks: [tasks_net]
volumes:
postgres_data:
networks:
tasks_net:
driver: bridge

5
docs/api-contract.md Normal file
View File

@@ -0,0 +1,5 @@
# API contract
All successful task responses use the `TaskRead` schema. `status` is one of `pending`, `in_progress`, or `completed`. IDs are UUID strings and timestamps are ISO-8601 values emitted by Pydantic. Create requires a non-blank title; update is partial and rejects an empty title. Query `limit` is 1100 and `skip` is non-negative.
Errors are always wrapped as `{ "error": { "code", "message", "details" } }`. Validation errors are 422, missing tasks are 404, delete success is 204 with no body, and database readiness failures are 503.

3
pyproject.toml Normal file
View File

@@ -0,0 +1,3 @@
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"

View File

@@ -0,0 +1,7 @@
fastapi>=0.110,<1.0
uvicorn[standard]>=0.29,<1.0
SQLAlchemy>=2.0,<3.0
psycopg2-binary>=2.9,<3.0
pydantic>=2.6,<3.0
pytest>=8.0,<9.0
httpx>=0.27,<1.0

View File

@@ -1 +1,27 @@
# Env var setup that must run before app.config is imported. import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.db import Base, get_db
from app.main import app
@pytest.fixture()
def client(tmp_path):
engine = create_engine(f"sqlite:///{tmp_path}/test.db", connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
TestingSession = sessionmaker(bind=engine, expire_on_commit=False)
def override_db():
db = TestingSession()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
with TestClient(app) as test_client:
yield test_client
app.dependency_overrides.clear()
Base.metadata.drop_all(engine)

31
tests/test_tasks.py Normal file
View File

@@ -0,0 +1,31 @@
def test_crud_and_persistence(client):
response = client.post('/tasks', json={'title': 'Ship API', 'description': 'release it'})
assert response.status_code == 201
task = response.json()
assert task['status'] == 'pending'
task_id = task['id']
assert client.get('/tasks').status_code == 200
assert client.get(f'/tasks/{task_id}').json()['title'] == 'Ship API'
updated = client.patch(f'/tasks/{task_id}', json={'status': 'completed'}).json()
assert updated['status'] == 'completed'
assert client.delete(f'/tasks/{task_id}').status_code == 204
assert client.get(f'/tasks/{task_id}').status_code == 404
def test_validation_and_status_semantics(client):
result = client.post('/tasks', json={'title': ' '})
assert result.status_code == 422
assert result.json()['error']['code'] == 'VALIDATION_ERROR'
assert client.post('/tasks', json={'title': 'x', 'status': 'unknown'}).status_code == 422
assert client.get('/tasks/not-a-uuid').status_code == 422
assert client.get('/tasks?limit=0').status_code == 422
def test_filter_and_missing_resource(client):
client.post('/tasks', json={'title': 'one', 'status': 'completed'})
client.post('/tasks', json={'title': 'two', 'status': 'pending'})
response = client.get('/tasks?status=completed')
assert response.status_code == 200
assert len(response.json()) == 1
assert client.patch('/tasks/00000000-0000-0000-0000-000000000000', json={'title': 'x'}).status_code == 404