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