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:
7
.env.example
Normal file
7
.env.example
Normal 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
|
||||
67
.agents/plans/wf-draft-7f4395e5f64d/DETAIL.md
Normal file
67
.agents/plans/wf-draft-7f4395e5f64d/DETAIL.md
Normal 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)
|
||||
23
.agents/plans/wf-draft-7f4395e5f64d/MAP.md
Normal file
23
.agents/plans/wf-draft-7f4395e5f64d/MAP.md
Normal 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
|
||||
@@ -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"]
|
||||
|
||||
59
README.md
59
README.md
@@ -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` (1–200 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
2
alembic.ini
Normal file
@@ -0,0 +1,2 @@
|
||||
# Schema initialization is currently performed by app.db.init_db at startup.
|
||||
# Adopt Alembic migrations before production schema evolution.
|
||||
@@ -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
26
app/db.py
Normal 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
110
app/main.py
Normal 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
26
app/models.py
Normal 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
39
app/repository.py
Normal 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
64
app/schemas.py
Normal 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
37
docker-compose.yml
Normal 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
5
docs/api-contract.md
Normal 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 1–100 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
3
pyproject.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q"
|
||||
@@ -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
|
||||
|
||||
@@ -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
31
tests/test_tasks.py
Normal 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
|
||||
Reference in New Issue
Block a user