From hieutrtr-ai1-skills
API contract design conventions for FastAPI projects with Pydantic v2. Use during the design phase when planning new API endpoints, defining request/response contracts, designing pagination or filtering, standardizing error responses, or planning API versioning. Covers RESTful naming, HTTP method semantics, Pydantic v2 schema naming conventions (XxxCreate/XxxUpdate/XxxResponse), cursor-based pagination, standard error format, and OpenAPI documentation. Does NOT cover implementation details (use python-backend-expert) or system-level architecture (use system-architecture).
npx claudepluginhub joshuarweaver/cascade-code-testing-misc --plugin hieutrtr-ai1-skillsThis skill is limited to using the following tools:
Activate this skill when:
Creates isolated Git worktrees for feature branches with prioritized directory selection, gitignore safety checks, auto project setup for Node/Python/Rust/Go, and baseline verification.
Executes implementation plans in current session by dispatching fresh subagents per independent task, with two-stage reviews: spec compliance then code quality.
Dispatches parallel agents to independently tackle 2+ tasks like separate test failures or subsystems without shared state or dependencies.
Activate this skill when:
Input: If plan.md or architecture.md exists, read for context about the feature scope and architectural decisions. Otherwise, work from the user's request directly.
Output: Write API design to api-design.md. Tell the user: "API design written to api-design.md. Run /task-decomposition to create implementation tasks or /python-backend-expert to implement."
Do NOT use this skill for:
python-backend-expert)system-architecture)pytest-patterns)react-frontend-expert)/users, /orders, /products/order-items, /user-profiles/users/{user_id}, /orders/{order_id}/users/{user_id}/orders (not /users/{user_id}/orders/{order_id}/items/{item_id})POST /orders not /orders/create)/users?role=admin&sort=-created_at/{version}/{resource} → Collection (list, create)
/{version}/{resource}/{id} → Single resource (get, update, delete)
/{version}/{resource}/{id}/{sub-resource} → Nested collection
/{version}/{resource}/actions/{action} → Non-CRUD operations (rarely needed)
| Good | Bad | Reason |
|---|---|---|
GET /v1/users | GET /v1/getUsers | No verbs — HTTP method implies action |
POST /v1/users | POST /v1/user/create | POST to collection = create |
GET /v1/order-items | GET /v1/orderItems | Kebab-case, not camelCase |
GET /v1/users/{id}/orders | GET /v1/users/{id}/orders/{oid}/items | Max 2 nesting levels |
POST /v1/orders/{id}/actions/cancel | POST /v1/cancelOrder/{id} | Action sub-resource for non-CRUD |
| Method | Purpose | Request Body | Success Status | Idempotent |
|---|---|---|---|---|
GET | Retrieve resource(s) | None | 200 OK | Yes |
POST | Create new resource | Required | 201 Created | No |
PUT | Full replace | Required (full) | 200 OK | Yes |
PATCH | Partial update | Required (partial) | 200 OK | No* |
DELETE | Remove resource | None | 204 No Content | Yes |
*PATCH is not inherently idempotent but can be made so with proper implementation.
Response headers for creation:
POST returning 201 SHOULD include a Location header with the URL of the created resourceConditional requests:
If-None-Match / ETag for caching on GET endpoints with frequently-accessed resourcesFollow a consistent naming pattern for all Pydantic schemas:
| Pattern | Purpose | Fields |
|---|---|---|
{Resource}Create | POST request body | Writable fields, no id, no timestamps |
{Resource}Update | PUT request body | All writable fields required |
{Resource}Patch | PATCH request body | All fields Optional |
{Resource}Response | Single resource response | All fields including id, timestamps |
{Resource}ListResponse | Paginated list response | items + pagination metadata |
{Resource}Filter | Query parameters | Optional filter fields |
Schema design rules:
id and timestamps (created_at, updated_at) in Response schemasmodel_validate(orm_instance) to convert ORM models to response schemasmodel_dump(exclude_unset=True) for PATCH operations to distinguish "not provided" from "set to null"references/pydantic-schema-examples.md for concrete examplesUse cursor-based pagination for all list endpoints. It is more performant than offset-based for large datasets and avoids the "shifting window" problem.
Request parameters:
GET /v1/users?cursor=eyJpZCI6MTAwfQ&limit=20
| Parameter | Type | Default | Description |
|---|---|---|---|
cursor | str | None | None | Opaque cursor from previous response |
limit | int | 20 | Items per page (max 100) |
Response format:
{
"items": [...],
"next_cursor": "eyJpZCI6MTIwfQ",
"has_more": true
}
Cursor implementation:
id) as a base64 stringWHERE id > :last_id ORDER BY id ASC LIMIT :limit + 1 — fetch one extra to determine has_moreUse offset-based only when the client needs to jump to arbitrary pages (e.g., admin tables).
{
"items": [...],
"total": 150,
"page": 2,
"page_size": 20,
"total_pages": 8
}
Use query parameters with field names:
GET /v1/users?role=admin&is_active=true&created_after=2024-01-01
Filtering conventions:
?field=value?field_min=10&field_max=100 or ?created_after=...&created_before=...?q=search+term (for full-text search across multiple fields)?status=active&status=pending (OR semantics)Use a sort query parameter with field name and direction prefix:
GET /v1/users?sort=-created_at → descending by created_at
GET /v1/users?sort=name → ascending by name
GET /v1/users?sort=-created_at,name → multi-field sort
Convention: - prefix means descending, no prefix means ascending.
All API errors follow a consistent format:
{
"detail": "Human-readable error message",
"code": "MACHINE_READABLE_CODE",
"field_errors": [
{
"field": "email",
"message": "Invalid email format",
"code": "INVALID_FORMAT"
}
]
}
| HTTP Status | When to Use | Example code |
|---|---|---|
400 | Malformed request | BAD_REQUEST |
401 | Missing or invalid authentication | UNAUTHORIZED |
403 | Authenticated but not authorized | FORBIDDEN |
404 | Resource not found | NOT_FOUND |
409 | Conflict (duplicate, version mismatch) | CONFLICT |
422 | Validation error (Pydantic) | VALIDATION_ERROR |
429 | Rate limit exceeded | RATE_LIMITED |
500 | Unexpected server error | INTERNAL_ERROR |
Error schema (Pydantic v2):
class FieldError(BaseModel):
field: str
message: str
code: str
class ErrorResponse(BaseModel):
detail: str
code: str
field_errors: list[FieldError] = []
/v1/users → Version 1
/v2/users → Version 2
Versioning rules:
/v1/ for all new APIsBreaking changes that require a new version:
Deprecation process:
Deprecation header to the old version: Deprecation: trueSunset header with the retirement date: Sunset: Sat, 01 Mar 2026 00:00:00 GMTLink header pointing to the new version: Link: </v2/users>; rel="successor-version"FastAPI generates OpenAPI schemas automatically. Enhance them with:
@router.get(
"/users/{user_id}",
response_model=UserResponse,
summary="Get user by ID",
description="Retrieve a single user's details by their unique identifier.",
responses={
404: {"model": ErrorResponse, "description": "User not found"},
},
tags=["Users"],
)
async def get_user(user_id: int) -> UserResponse:
...
Documentation conventions:
summary (short) and optional description (detailed)tags matching the resource nameresponse_model for automatic response schema documentationObjective: Design the contract for a /v1/products CRUD endpoint with search and pagination.
Endpoints:
| Method | Path | Description | Request | Response | Status |
|---|---|---|---|---|---|
| GET | /v1/products | List products | Query: cursor, limit, q, category, sort | ProductListResponse | 200 |
| POST | /v1/products | Create product | Body: ProductCreate | ProductResponse | 201 |
| GET | /v1/products/{id} | Get product | — | ProductResponse | 200 |
| PATCH | /v1/products/{id} | Update product | Body: ProductPatch | ProductResponse | 200 |
| DELETE | /v1/products/{id} | Delete product | — | — | 204 |
Schemas:
class ProductCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
description: str | None = None
price_cents: int = Field(gt=0)
category: str
sku: str = Field(pattern=r"^[A-Z0-9-]+$")
class ProductPatch(BaseModel):
name: str | None = None
description: str | None = None
price_cents: int | None = Field(default=None, gt=0)
category: str | None = None
class ProductResponse(BaseModel):
id: int
name: str
description: str | None
price_cents: int
category: str
sku: str
created_at: datetime
updated_at: datetime
class ProductListResponse(BaseModel):
items: list[ProductResponse]
next_cursor: str | None
has_more: bool
Search and filtering:
GET /v1/products?q=laptop&category=electronics&sort=-price_cents&limit=20
See references/endpoint-catalog-template.md for the full documentation template.
See references/pydantic-schema-examples.md for additional schema examples.
For operations on multiple resources at once:
POST /v1/users/bulk
Request:
{
"items": [
{"email": "a@example.com", "name": "Alice"},
{"email": "b@example.com", "name": "Bob"}
]
}
Response (partial success — status 207):
{
"results": [
{"index": 0, "status": "created", "data": {...}},
{"index": 1, "status": "error", "error": {"detail": "Email already exists", "code": "CONFLICT"}}
],
"succeeded": 1,
"failed": 1
}
Use HTTP 207 Multi-Status when individual items can succeed or fail independently.
File uploads use multipart/form-data, not JSON:
@router.post("/v1/files", response_model=FileResponse, status_code=201)
async def upload_file(
file: UploadFile,
description: str = Form(default=""),
) -> FileResponse:
...
Validate file size and MIME type before processing. Return 413 Payload Too Large for oversized files.
For operations that cannot complete within a normal request timeout:
Return 202 Accepted with a status URL:
{"status_url": "/v1/jobs/abc123", "estimated_completion": "2024-01-15T10:30:00Z"}
Client polls the status URL:
GET /v1/jobs/abc123 → {"status": "processing", "progress": 0.65}
GET /v1/jobs/abc123 → {"status": "completed", "result_url": "/v1/reports/xyz"}
When a resource logically belongs to a parent but nesting would exceed 2 levels, use a top-level resource with a filter:
# Instead of: GET /v1/users/{id}/orders/{oid}/items
# Use: GET /v1/order-items?order_id=123
This keeps URLs flat while maintaining the relationship through filtering.
Write the API design to api-design.md at the project root:
# API Design: [Feature Name]
## Endpoints
| Method | URL | Description | Auth |
|--------|-----|-------------|------|
| GET | /v1/users | List users | Required |
| POST | /v1/users | Create user | Required |
## Request/Response Schemas
### UserCreate
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| email | string | Yes | Valid email |
| name | string | Yes | 1-100 chars |
### UserResponse
| Field | Type | Description |
|-------|------|-------------|
| id | uuid | User ID |
| email | string | User email |
## Error Codes
| Code | HTTP Status | Description |
|------|-------------|-------------|
| USER_NOT_FOUND | 404 | User does not exist |
| EMAIL_EXISTS | 409 | Email already registered |
## Next Steps
- Run `/task-decomposition` to create implementation tasks
- Run `/python-backend-expert` to implement endpoints