Implements production REST API design patterns: resource modeling, unified error envelopes, Pydantic validation, rate limiting, OpenAPI docs, and versioning for maintainable backend systems.
Scanned 9/4/2026
Install to Claude Code
npx -y skills add paulpas/agent-skill-router --skill api-design --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Api Design?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/paulpas-api-design)More formats (shields.io, HTML) on the badges page.
---
name: api-design
description: "Implements production REST API design patterns: resource modeling, unified error envelopes, Pydantic validation, rate limiting, OpenAPI docs, and versioning for maintainable backend systems."
license: MIT
compatibility: opencode
metadata:
version: "1.0.0"
domain: coding
triggers: api design, restful api, resource modeling, pydantic validation, error envelope, api versioning, fastapi patterns, how do i design an api
role: implementation
scope: implementation
output-format: code
content-types: [code, guidance, do-dont, examples]
related-skills: coding-code-review, coding-rest-api-testing, coding-fastapi-patterns
archetypes: [tactical, generation]
anti_triggers: [brainstorming, vague ideation, code golf, over-engineering]
response_profile:
verbosity: low
directive_strength: high
abstraction_level: operational
---
# API Design Architect
I design and implement production-grade REST API interfaces that follow contract-first principles, enforce consistent error handling, structured input validation, rate limiting, and versioning strategies. When loaded, the model produces APIs where every endpoint has a predictable response shape, proper HTTP semantics, and comprehensive OpenAPI documentation.
## TL;DR Checklist
- [ ] Model resources as nouns with plural kebab-case paths (`/project-tasks`, not `/get_project_tasks`)
- [ ] Map HTTP methods to operations: GET=list/query, POST=create, PUT=full update, PATCH=partial, DELETE=remove
- [ ] Validate every input with Pydantic v2 models before it reaches handler logic
- [ ] Return a unified error envelope on every failure path
- [ ] Apply correct HTTP status codes (201 for create, 204 for delete, never 200 for errors)
- [ ] Include rate limit headers on every response
- [ ] Document all endpoints in OpenAPI with examples and response descriptions
---
## When to Use
Use this skill when:
- Designing a new REST API from scratch or refactoring an existing one
- Defining resource hierarchies and endpoint contracts for a backend service
- Implementing consistent error handling across multiple API endpoints
- Adding input validation, rate limiting, or API versioning to a FastAPI application
- Writing OpenAPI documentation that needs examples and response models
- Reviewing API design for HTTP semantics, status code correctness, and resource modeling
---
## When NOT to Use
Avoid this skill for:
- Building GraphQL APIs — use a GraphQL schema-first approach instead
- Defining gRPC service contracts — Protobuf/Protocol Buffers are the right tool
- Simple internal microservice communication where REST overhead is unnecessary (use gRPC or message queues)
- Batch operations involving thousands of records — design dedicated batch endpoints, not per-item REST calls
---
## Core Workflow
1. **Define Resource Model** — Identify all nouns in the domain as resources. Determine parent-child relationships and plan hierarchical paths. Example: `/orgs/{org-id}/projects/{project-id}/tasks` where tasks are nested under projects which are nested under organizations.
**Checkpoint:** Every resource name must be plural, kebab-case, and map to a single entity concept. No verbs in paths.
2. **Design Endpoint Contracts** — Map HTTP methods to operations before writing any handler code:
- `GET /tasks` → list tasks with pagination and filtering
- `POST /tasks` → create a new task
- `GET /tasks/{task-id}` → retrieve a single task
- `PUT /tasks/{task-id}` → full replacement of a task
- `PATCH /tasks/{task-id}` → partial update of selected fields
- `DELETE /tasks/{task-id}` → remove a task
**Checkpoint:** All request and response schemas are defined in Pydantic models before writing route handlers. No implicit shapes.
3. **Implement Validation Layer** — Use Pydantic v2 models for all input validation. Apply field constraints (`min_length`, `pattern`, `ge`, `le`) directly in model definitions rather than in handler logic. Separate Create, Read, Update (CRUD) schemas per resource to enforce different rules per operation.
**Checkpoint:** Every endpoint references explicit request and response Pydantic models — never accept or return raw `dict` objects from handlers.
4. **Build Error Response Handler** — Implement a unified error envelope that returns consistent structure on every failure path: `{"error": {"code": "...", "message": "...", "details": []}}`. Register FastAPI exception handlers for `HTTPException`, `RequestValidationError`, and domain-specific exceptions to ensure no error slips through unenveloped.
**Checkpoint:** Test that every exception type returns the same envelope shape with an appropriate HTTP status code and machine-readable error code.
5. **Add Rate Limiting & Idempotency** — Apply rate limit headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`) to every response via a middleware or dependency. Document idempotency keys for POST operations. Ensure PUT and DELETE endpoints are naturally idempotent by design.
**Checkpoint:** Verify rate limit headers appear on both success and error responses using a test client.
6. **Generate OpenAPI Documentation** — Use FastAPI's built-in OpenAPI generation with descriptive `summary` and `description` fields on every endpoint. Provide request body examples via `json_schema_extra` in Pydantic models and document each response code with its model and description.
**Checkpoint:** Run the API locally, open `/docs`, and verify that every endpoint has a summary, parameters section, request body example (if applicable), and clear response descriptions.
---
## Implementation Patterns
### Pattern 1: Resource Modeling & CRUD Endpoints
Full FastAPI router showing proper resource hierarchy, typed request/response models, pagination params, and correct HTTP status codes. This pattern demonstrates hierarchical nesting (`/orgs/{org-id}/projects/{project-id}`), a `PaginatedResponse` wrapper for list endpoints, and distinct schemas for create vs update operations.
```python
from datetime import datetime
from enum import Enum
from typing import Annotated, Generic, TypeVar
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field, ConfigDict
# --------------------------------------------------------------------------- #
# Schema Definitions
# --------------------------------------------------------------------------- #
class TaskStatus(str, Enum):
"""Allowed task status values."""
TODO = "todo"
IN_PROGRESS = "in_progress"
DONE = "done"
class CreateTaskRequest(BaseModel):
"""Schema for creating a new task. Only required fields are enforced."""
title: str = Field(..., min_length=1, max_length=200, description="Task title")
description: str | None = Field(None, max_length=5000, description="Optional description")
status: TaskStatus = TaskStatus.TODO
assignee_id: int = Field(..., ge=1, description="ID of the assigned user")
class UpdateTaskRequest(BaseModel):
"""Schema for partially updating a task. All fields are optional."""
title: str | None = Field(None, min_length=1, max_length=200)
description: str | None = Field(None, max_length=5000)
status: TaskStatus | None = None
assignee_id: int | None = Field(None, ge=1)
class TaskResponse(BaseModel):
"""Schema returned for single task retrieval and list items."""
model_config = ConfigDict(from_attributes=True)
id: int
title: str
description: str | None = None
status: TaskStatus
assignee_id: int
created_at: datetime
updated_at: datetime
T = TypeVar("T")
class PaginatedResponse(BaseModel, Generic[T]):
"""Standard paginated list response wrapper."""
items: list[T]
total: int
page: int
page_size: int
has_next: bool = Field(..., alias="hasNext")
has_prev: bool = Field(..., alias="hasPrev")
# --------------------------------------------------------------------------- #
# Router Definition
# --------------------------------------------------------------------------- #
router = APIRouter(tags=["tasks"])
@router.get(
"/orgs/{org-id}/projects/{project-id}/tasks",
response_model=PaginatedResponse[TaskResponse],
status_code=status.HTTP_200_OK,
summary="List project tasks",
description=(
"Return a paginated list of tasks for the specified project. "
"Supports filtering by status and assignee."
),
responses={
404: {"description": "Project not found"},
},
)
async def list_tasks(
org_id: int,
project_id: int,
page: Annotated[int, Query(ge=1, description="Page number")] = 1,
page_size: Annotated[int, Query(ge=1, le=100, description="Items per page")] = 20,
status_filter: TaskStatus | None = Query(None, description="Filter by task status"),
) -> PaginatedResponse[TaskResponse]:
"""List tasks with pagination and optional filtering."""
# In production, query the database here.
items = [] # Placeholder — replace with actual DB query
total = len(items)
return PaginatedResponse(
items=items,
total=total,
page=page,
page_size=page_size,
hasNext=(page * page_size) < total,
has_prev=page > 1,
)
@router.post(
"/orgs/{org-id}/projects/{project-id}/tasks",
response_model=TaskResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a new task",
description="Create a new task within the specified project.",
)
async def create_task(
org_id: int,
project_id: int,
body: CreateTaskRequest,
) -> TaskResponse:
"""Create a new task and return it with generated fields."""
# In production: validate org/project ownership, persist to DB.
response = TaskResponse(
id=1,
title=body.title,
description=body.description,
status=body.status,
assignee_id=body.assignee_id,
created_at=datetime.now(),
updated_at=datetime.now(),
)
return response
@router.get(
"/orgs/{org-id}/projects/{project-id}/tasks/{task-id}",
response_model=TaskResponse,
status_code=status.HTTP_200_OK,
summary="Retrieve a task",
)
async def get_task(org_id: int, project_id: int, task_id: int) -> TaskResponse:
"""Return a single task by ID."""
raise HTTPException(status_code=404, detail="Task not found")
@router.put(
"/orgs/{org-id}/projects/{project-id}/tasks/{task-id}",
response_model=TaskResponse,
status_code=status.HTTP_200_OK,
summary="Full update of a task",
)
async def full_update_task(
org_id: int, project_id: int, task_id: int, body: CreateTaskRequest
) -> TaskResponse:
"""Replace all mutable fields of a task."""
raise HTTPException(status_code=404, detail="Task not found")
@router.patch(
"/orgs/{org-id}/projects/{project-id}/tasks/{task-id}",
response_model=TaskResponse,
status_code=status.HTTP_200_OK,
summary="Partial update of a task",
)
async def partial_update_task(
org_id: int, project_id: int, task_id: int, body: UpdateTaskRequest
) -> TaskResponse:
"""Update only the fields provided in the request body."""
raise HTTPException(status_code=404, detail="Task not found")
@router.delete(
"/orgs/{org-id}/projects/{project-id}/tasks/{task-id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a task",
)
async def delete_task(org_id: int, project_id: int, task_id: int) -> None:
"""Remove a task. Returns 204 No Content on success."""
raise HTTPException(status_code=404, detail="Task not found")
```
### Pattern 2: Unified Error Handling
Custom exception handler that maps domain exceptions and validation errors to a consistent error envelope with machine-readable error codes, user-facing messages, and structured details. This ensures API consumers can programmatically handle all error types without parsing arbitrary text.
```python
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import ValidationError
class AppError(Exception):
"""Base application error with a machine-readable code and HTTP status."""
def __init__(
self,
message: str,
code: str = "internal_error",
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR,
details: list[dict] | None = None,
):
self.message = message
self.code = code
self.status_code = status_code
self.details = details or []
class NotFoundError(AppError):
def __init__(self, resource: str, identifier: str):
super().__init__(
message=f"{resource} with id '{identifier}' not found",
code="resource_not_found",
status_code=status.HTTP_404_NOT_FOUND,
details=[{"field": "id", "value": identifier}],
)
class ValidationError as AppValidationError(AppError):
def __init__(self, errors: list[dict]):
super().__init__(
message="Validation failed",
code="validation_error",
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
details=errors,
)
# --------------------------------------------------------------------------- #
# Error Envelope Helper
# --------------------------------------------------------------------------- #
def error_response(
code: str,
message: str,
status_code: int,
details: list[dict] | None = None,
) -> JSONResponse:
"""Construct a standardized API error response."""
return JSONResponse(
status_code=status_code,
content={
"error": {
"code": code,
"message": message,
"details": details or [],
}
},
)
# --------------------------------------------------------------------------- #
# Exception Handlers — register on the FastAPI app
# --------------------------------------------------------------------------- #
app = FastAPI(title="Task Manager API")
@app.exception_handler(AppError)
async def handle_app_error(request: Request, exc: AppError) -> JSONResponse:
"""Handle application-level domain errors."""
return error_response(
code=exc.code,
message=exc.message,
status_code=exc.status_code,
details=exc.details,
)
@app.exception_handler(RequestValidationError)
async def handle_validation_error(
request: Request, exc: RequestValidationError
) -> JSONResponse:
"""Convert Pydantic validation errors into the unified envelope."""
details = []
for err in exc.errors():
field_path = " → ".join(str(loc) for loc in err["loc"] if loc not in ("body",))
details.append(
{
"field": field_path or "(request body)",
"message": err["msg"],
"type": err["type"],
}
)
return error_response(
code="validation_error",
message="One or more fields failed validation",
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
details=details,
)
@app.exception_handler(Exception)
async def handle_unexpected_error(
request: Request, exc: Exception
) -> JSONResponse:
"""Catch-all for unhandled exceptions — never leak internals to the client."""
# Log the full traceback server-side; return a generic envelope.
return error_response(
code="internal_error",
message="An unexpected error occurred",
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
```
### Pattern 3: Input Validation & Rate Limiting (BAD vs GOOD)
This pattern demonstrates the contrast between unvalidated handlers that manually parse query parameters and the correct approach using Pydantic models with field constraints plus a dependency-injected rate limiter.
```python
from fastapi import FastAPI, Header, HTTPException, Request, status
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, field_validator
from typing import Annotated
app = FastAPI(title="Task Manager API")
# =================================================================== #
# ❌ BAD — Manual query param parsing, no validation, inconsistent errors
# =================================================================== #
@app.get("/bad-tasks")
async def bad_list_tasks(
status: str | None = None,
assignee_id: str | None = None,
):
"""This endpoint accepts any string for status and assignee_id.
No type coercion, no bounds checking, no validation schema."""
# ❌ Bug: "12abc" passes as an integer — causes cryptic DB errors later
if assignee_id is not None:
assignee_id = int(assignee_id) # May raise ValueError silently
# ❌ Bug: status can be any random string, no enum enforcement
valid_statuses = ("todo", "in_progress", "done")
if status and status not in valid_statuses:
return JSONResponse(
status_code=400, # ❌ Uses plain text detail, not envelope
content={"detail": f"Invalid status: {status}"},
)
# No rate limit headers, no pagination, returns raw DB rows
# =================================================================== #
# ✅ GOOD — Pydantic v2 models with constraints + dependency-injected limiter
# =================================================================== #
class TaskFilterQuery(BaseModel):
"""Strongly-typed filter parameters with validation baked in."""
status: str | None = Field(
None,
pattern="^(todo|in_progress|done)$",
description="Filter by task status enum value",
)
assignee_id: int | None = Field(
None,
ge=1,
description="Filter by assigned user ID (positive integer)",
)
page: int = Field(default=1, ge=1, description="Page number")
page_size: int = Field(default=20, ge=1, le=100, description="Items per page")
class RateLimitHeaders(BaseModel):
"""Rate limit information injected into response headers."""
limit: int = Field(..., alias="X-RateLimit-Limit")
remaining: int = Field(..., alias="X-RateLimit-Remaining")
reset: int = Field(..., alias="X-RateLimit-Reset")
# Mock rate limiter — replace with Redis-backed implementation in production
def get_rate_limit_info(request: Request) -> dict[str, int]:
"""Determine the rate limit for the current request's client."""
# In production: look up the client IP or API key in a counter store.
return {"limit": 100, "remaining": 99, "reset": 1717000000}
@app.get(
"/api/v1/tasks",
response_model=dict,
status_code=status.HTTP_200_OK,
summary="List tasks with validation and rate limiting",
)
async def list_tasks_good(
filters: TaskFilterQuery = ... ,
request: Request,
):
"""Properly validated endpoint with rate limit headers."""
# Rate limiter is applied transparently
limits = get_rate_limit_info(request)
response = JSONResponse(
content={"items": [], "total": 0},
headers={
"X-RateLimit-Limit": str(limits["limit"]),
"X-RateLimit-Remaining": str(limits["remaining"]),
"X-RateLimit-Reset": str(limits["reset"]),
},
)
# If rate exceeded, reject with proper status and envelope
if limits["remaining"] <= 0:
return JSONResponse(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
headers={"Retry-After": "60"},
content={
"error": {
"code": "rate_limit_exceeded",
"message": "Too many requests. Please retry after the reset time.",
"details": [{"field": "rate-limit", "value": str(limits["reset"])}],
}
},
)
return response
```
---
## Constraints
### MUST DO
- Model resources as nouns with predictable plural kebab-case paths; use nested paths for hierarchical relationships (e.g., `/orgs/{org-id}/projects/{project-id}`)
- Return a unified error envelope on every failure path: `{"error": {"code": "...", "message": "...", "details": []}}`
- Validate ALL inputs with Pydantic v2 models — never trust raw request data directly in handlers; use separate schemas for create vs update operations
- Apply correct HTTP status codes consistently: 200 for GET/PUT/PATCH success, 201 for POST create, 204 for DELETE success, 404 for not found, 429 for rate limit exceeded
- Include rate limit headers on every response: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` (and `Retry-After` on 429 responses)
- Use PUT for idempotent full updates and PATCH for partial updates — never use POST for updates; only use POST to create new resources
- Version APIs via URI prefix (`/api/v1/`) with `Deprecation` and `Sunset` headers when introducing breaking changes that require clients to migrate
- Document every endpoint in OpenAPI with a summary, description, request body examples via `json_schema_extra`, and response model descriptions for each status code
### MUST NOT DO
- Expose internal database column names or ORM objects directly as API responses — always map through explicit Pydantic response models
- Use query parameters for complex filtering/search — define dedicated filter endpoints with structured request bodies for anything beyond simple status/ID filters
- Return 200 status codes for error conditions — use proper HTTP status codes consistently so clients can differentiate success from failure programmatically
- Bypass input validation to "handle errors in the handler" — validate early with Pydantic models at the boundary, never accept raw strings and cast later
- Mix resource types in a single response endpoint (e.g., returning both users and orders in one list) — each endpoint should return one resource type or a clearly defined aggregate
- Use snake_case in API paths — always use kebab-case for path segments (`/project-tasks`, not `/project_tasks`) to follow REST convention
---
## Output Template
When this skill is active, the model's output for an API design task must contain:
1. **Resource Model** — List of resources as nouns with their hierarchical relationships and path definitions
2. **Endpoint Contract Table** — HTTP method, full path, request body schema name (if POST/PUT/PATCH), response schema name, status codes for each endpoint
3. **Pydantic Schema Definitions** — All request/response models with field constraints, types, and descriptions
4. **Error Envelope Definition** — The unified error structure and exception handlers that produce it
5. **Rate Limiting Strategy** — How rate limits are determined, which headers are injected, and how 429 responses look
6. **OpenAPI Documentation Snippet** — At minimum the `summary`, `description`, and `responses` for each endpoint with example values
---
## Related Skills
| Skill | Purpose |
|---|---|
| `coding-code-review` | Review API code for correctness, security, and adherence to these design patterns |
| `coding-rest-api-testing` | Write integration tests that validate API contracts, error envelopes, and status codes |
| `coding-fastapi-patterns` | Deep-dive into FastAPI-specific patterns including dependency injection, background tasks, and middleware |
---
## Live References
> Authoritative documentation links for REST API design with FastAPI.
- [FastAPI Documentation](https://fastapi.tiangolo.com/) — Official guide covering routing, request/response models, validation, and OpenAPI generation
- [PEP 572 — Assignment Expressions](https://peps.python.org/pep-0572/) — Python 3.8+ syntax used in modern FastAPI handlers
- [Pydantic v2 Documentation](https://docs.pydantic.dev/latest/) — Field constraints, validators, and model configuration
- [REST API Design Rulebook](https://www.oreilly.com/library/view/rest-api-design/9781449317907/) — O'Reilly reference for resource modeling, HTTP semantics, and versioning strategies
- [RFC 9110 — HTTP Semantics](https://datatracker.ietf.org/doc/html/rfc9110) — Official specification for HTTP methods, status codes, and headers
- [OpenAPI Specification 3.1](https://spec.openapis.org/oas/v3.1.0) — Contract definition format that FastAPI uses natively
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!