
Api Design Patterns
- 58 installs
- 8 repo stars
- Updated February 6, 2026
- hieutrtr/ai1-skills
Provides API contract design conventions for FastAPI and Pydantic v2 projects: RESTful naming, schema conventions, pagination, error format, and versioning.
About
Defines design-phase conventions for FastAPI endpoints including RESTful naming, HTTP method semantics, Pydantic v2 schema naming, cursor pagination, and a standard error format. A developer uses it when planning new endpoints and request/response contracts.
- XxxCreate/XxxUpdate/XxxResponse Pydantic v2 schema naming
- Cursor-based pagination and standardized error responses
Api Design Patterns by the numbers
- 58 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #3,178 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/hieutrtr/ai1-skills --skill api-design-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 58 |
|---|---|
| repo stars | ★ 8 |
| Last updated | February 6, 2026 |
| Repository | hieutrtr/ai1-skills ↗ |
What it does
Provides API contract design conventions for FastAPI and Pydantic v2 projects: RESTful naming, schema conventions, pagination, error format, and versioning.
Files
API Design Patterns
When to Use
Activate this skill when:
- Designing new API endpoints or modifying existing endpoint contracts
- Defining request/response schemas for a feature
- Standardizing pagination, filtering, or sorting across endpoints
- Designing a consistent error response format
- Planning API versioning or deprecation strategy
- Reviewing API contracts for consistency before implementation
- Documenting endpoint specifications for frontend/backend coordination
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:
- Writing implementation code (use
python-backend-expert) - System-level architecture decisions (use
system-architecture) - Writing tests for endpoints (use
pytest-patterns) - Frontend data fetching implementation (use
react-frontend-expert)
Instructions
URL Naming Conventions
Resource Naming Rules
1. Plural nouns for collections: /users, /orders, /products 2. Kebab-case for multi-word resources: /order-items, /user-profiles 3. Singular resource by ID: /users/{user_id}, /orders/{order_id} 4. Maximum 2 nesting levels: /users/{user_id}/orders (not /users/{user_id}/orders/{order_id}/items/{item_id}) 5. No verbs in URLs: use HTTP methods instead (POST /orders not /orders/create) 6. Query parameters for filtering, sorting, pagination: /users?role=admin&sort=-created_at
URL Structure Template
/{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)Naming Examples
| 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 |
HTTP Method Semantics
| 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:
POSTreturning201SHOULD include aLocationheader with the URL of the created resource
Conditional requests:
- Support
If-None-Match/ETagfor caching on GET endpoints with frequently-accessed resources
Schema Naming Conventions (Pydantic v2)
Follow 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:
- Never expose internal fields (hashed_password, internal_notes) in Response schemas
- Always include
idand timestamps (created_at,updated_at) in Response schemas - Use
model_validate(orm_instance)to convert ORM models to response schemas - Use
model_dump(exclude_unset=True)for PATCH operations to distinguish "not provided" from "set to null" - Reference
references/pydantic-schema-examples.mdfor concrete examples
Pagination
Cursor-Based Pagination (Default)
Use 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 |
limit | int | 20 | Items per page (max 100) |
Response format:
{
"items": [...],
"next_cursor": "eyJpZCI6MTIwfQ",
"has_more": true
}Cursor implementation:
- Encode the last item's sort key (usually
id) as a base64 string - The cursor is opaque to the client — they must not parse or construct it
- Use
WHERE id > :last_id ORDER BY id ASC LIMIT :limit + 1— fetch one extra to determinehas_more
Offset-Based Pagination (When Needed)
Use 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
}Filtering and Sorting
Filtering
Use query parameters with field names:
GET /v1/users?role=admin&is_active=true&created_after=2024-01-01Filtering conventions:
- Exact match:
?field=value - Range:
?field_min=10&field_max=100or?created_after=...&created_before=... - Search:
?q=search+term(for full-text search across multiple fields) - Multiple values:
?status=active&status=pending(OR semantics)
Sorting
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 sortConvention: - prefix means descending, no prefix means ascending.
Error Response Format
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"
}
]
}Standard Error Codes and Status Mapping
| 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] = []API Versioning
Strategy: URL Prefix Versioning
/v1/users → Version 1
/v2/users → Version 2Versioning rules: 1. Start with /v1/ for all new APIs 2. Increment major version only for breaking changes 3. Non-breaking changes (new optional fields, new endpoints) do NOT require a new version 4. Support at most 2 active versions simultaneously
Breaking changes that require a new version:
- Removing a field from a response
- Changing a field's type
- Making an optional request field required
- Changing the URL structure for existing endpoints
- Changing error response format
Deprecation process: 1. Add Deprecation header to the old version: Deprecation: true 2. Add Sunset header with the retirement date: Sunset: Sat, 01 Mar 2026 00:00:00 GMT 3. Add Link header pointing to the new version: Link: </v2/users>; rel="successor-version" 4. Log usage of deprecated endpoints for monitoring 5. Remove the old version after the sunset date
OpenAPI Documentation
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:
- Every endpoint has a
summary(short) and optionaldescription(detailed) - Document all non-200 responses with their schema
- Group endpoints by
tagsmatching the resource name - Use
response_modelfor automatic response schema documentation
Examples
Designing a Products API Contract
Objective: 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: boolSearch and filtering:
GET /v1/products?q=laptop&category=electronics&sort=-price_cents&limit=20See references/endpoint-catalog-template.md for the full documentation template. See references/pydantic-schema-examples.md for additional schema examples.
Edge Cases
Bulk Operations
For operations on multiple resources at once:
POST /v1/users/bulkRequest:
{
"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 Upload Endpoints
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.
Long-Running Operations
For operations that cannot complete within a normal request timeout:
1. Return 202 Accepted with a status URL:
{"status_url": "/v1/jobs/abc123", "estimated_completion": "2024-01-15T10:30:00Z"}2. 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"}Sub-Resource Design
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=123This keeps URLs flat while maintaining the relationship through filtering.
Output File
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 endpointsEndpoint Catalog Template
Use this template to document all API endpoints for a feature or module. This serves as the contract between frontend and backend teams.
---
Module: [Module Name]
Base URL: /v1/{resource} Auth Required: Yes / No Tags: [OpenAPI tags]
---
Endpoints
GET /v1/{resource}
Summary: List {resources} with pagination and filtering.
Query Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
cursor | string | No | null | Pagination cursor from previous response |
limit | integer | No | 20 | Items per page (1-100) |
sort | string | No | -created_at | Sort field with direction prefix |
q | string | No | null | Full-text search query |
Response: 200 OK
{
"items": [
{
"id": 1,
"field": "value",
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
],
"next_cursor": "eyJpZCI6MjB9",
"has_more": true
}Errors:
| Status | Code | When |
|---|---|---|
401 | UNAUTHORIZED | Missing or invalid auth token |
---
POST /v1/{resource}
Summary: Create a new {resource}.
Request Body: {Resource}Create
{
"field1": "value",
"field2": 42
}Response: 201 Created
{
"id": 1,
"field1": "value",
"field2": 42,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}Response Headers:
Location: /v1/{resource}/1
Errors:
| Status | Code | When |
|---|---|---|
401 | UNAUTHORIZED | Missing or invalid auth token |
409 | CONFLICT | Resource with same unique field exists |
422 | VALIDATION_ERROR | Request body fails validation |
---
GET /v1/{resource}/{id}
Summary: Get a single {resource} by ID.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id | integer | Resource identifier |
Response: 200 OK
{
"id": 1,
"field1": "value",
"field2": 42,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}Errors:
| Status | Code | When |
|---|---|---|
401 | UNAUTHORIZED | Missing or invalid auth token |
404 | NOT_FOUND | Resource does not exist |
---
PATCH /v1/{resource}/{id}
Summary: Partially update a {resource}.
Request Body: {Resource}Patch (all fields optional)
{
"field1": "new value"
}Response: 200 OK — Returns the full updated resource.
Errors:
| Status | Code | When |
|---|---|---|
401 | UNAUTHORIZED | Missing or invalid auth token |
404 | NOT_FOUND | Resource does not exist |
409 | CONFLICT | Update conflicts with another resource |
422 | VALIDATION_ERROR | Request body fails validation |
---
DELETE /v1/{resource}/{id}
Summary: Delete a {resource}.
Response: 204 No Content — Empty body.
Errors:
| Status | Code | When |
|---|---|---|
401 | UNAUTHORIZED | Missing or invalid auth token |
404 | NOT_FOUND | Resource does not exist |
---
Auth Requirements
| Endpoint | Auth | Roles |
|---|---|---|
GET /v1/{resource} | Required | Any authenticated user |
POST /v1/{resource} | Required | Admin, Editor |
GET /v1/{resource}/{id} | Required | Any authenticated user |
PATCH /v1/{resource}/{id} | Required | Admin, Owner |
DELETE /v1/{resource}/{id} | Required | Admin only |
---
Rate Limits
| Endpoint | Limit | Window |
|---|---|---|
GET /v1/{resource} | 100 | per minute |
POST /v1/{resource} | 20 | per minute |
PATCH /v1/{resource}/{id} | 30 | per minute |
DELETE /v1/{resource}/{id} | 10 | per minute |
Rate limit headers included in every response:
X-RateLimit-Limit: Maximum requests allowedX-RateLimit-Remaining: Requests remaining in windowX-RateLimit-Reset: Unix timestamp when the window resets
Pydantic v2 Schema Examples
Concrete schema examples following the API design patterns conventions. Use these as templates when designing new API contracts.
---
User Schemas
UserCreate (POST request body)
from pydantic import BaseModel, EmailStr, Field
class UserCreate(BaseModel):
"""Schema for creating a new user. No id or timestamps."""
email: EmailStr
password: str = Field(min_length=8, max_length=128)
display_name: str = Field(min_length=1, max_length=100)
role: str = Field(default="member", pattern=r"^(admin|editor|member)$")UserUpdate (PUT request body — full replace)
class UserUpdate(BaseModel):
"""Schema for full user update. All writable fields required."""
email: EmailStr
display_name: str = Field(min_length=1, max_length=100)
role: str = Field(pattern=r"^(admin|editor|member)$")
is_active: boolUserPatch (PATCH request body — partial update)
class UserPatch(BaseModel):
"""Schema for partial user update. All fields Optional."""
email: EmailStr | None = None
display_name: str | None = Field(default=None, min_length=1, max_length=100)
role: str | None = Field(default=None, pattern=r"^(admin|editor|member)$")
is_active: bool | None = NoneUsage in route:
@router.patch("/users/{user_id}", response_model=UserResponse)
async def patch_user(user_id: int, data: UserPatch, ...):
# model_dump(exclude_unset=True) only includes fields the client sent
update_data = data.model_dump(exclude_unset=True)
# update_data might be {"display_name": "New Name"} — only what was sentUserResponse (GET response)
from datetime import datetime
class UserResponse(BaseModel):
"""Schema for user in API responses. Includes id and timestamps."""
model_config = ConfigDict(from_attributes=True)
id: int
email: str # Note: EmailStr not needed in response
display_name: str
role: str
is_active: bool
created_at: datetime
updated_at: datetime
# Note: hashed_password is NEVER included in responseUserListResponse (paginated list)
class UserListResponse(BaseModel):
"""Paginated list of users with cursor-based pagination."""
items: list[UserResponse]
next_cursor: str | None = None
has_more: boolUserFilter (query parameters)
class UserFilter(BaseModel):
"""Query parameters for filtering users."""
role: str | None = None
is_active: bool | None = None
q: str | None = Field(default=None, description="Search by name or email")
created_after: datetime | None = None
created_before: datetime | None = None---
Error Schemas
ErrorResponse
class FieldError(BaseModel):
"""Individual field-level error."""
field: str
message: str
code: str
class ErrorResponse(BaseModel):
"""Standard error response format for all API errors."""
detail: str
code: str
field_errors: list[FieldError] = []Example error response (422 Validation Error):
{
"detail": "Validation failed",
"code": "VALIDATION_ERROR",
"field_errors": [
{
"field": "email",
"message": "Invalid email format",
"code": "INVALID_FORMAT"
},
{
"field": "password",
"message": "Must be at least 8 characters",
"code": "TOO_SHORT"
}
]
}Example error response (404 Not Found):
{
"detail": "User with id 42 not found",
"code": "NOT_FOUND",
"field_errors": []
}---
Pagination Schemas
CursorPagination (cursor-based)
import base64
import json
from pydantic import BaseModel, Field
from typing import Generic, TypeVar
T = TypeVar("T")
class CursorPage(BaseModel, Generic[T]):
"""Generic cursor-based pagination response."""
items: list[T]
next_cursor: str | None = None
has_more: bool
class CursorParams(BaseModel):
"""Query parameters for cursor-based pagination."""
cursor: str | None = None
limit: int = Field(default=20, ge=1, le=100)Cursor encoding helper:
def encode_cursor(last_id: int) -> str:
return base64.urlsafe_b64encode(json.dumps({"id": last_id}).encode()).decode()
def decode_cursor(cursor: str) -> dict:
return json.loads(base64.urlsafe_b64decode(cursor.encode()).decode())OffsetPagination (offset-based)
class OffsetPage(BaseModel, Generic[T]):
"""Generic offset-based pagination response."""
items: list[T]
total: int
page: int
page_size: int
total_pages: int
class OffsetParams(BaseModel):
"""Query parameters for offset-based pagination."""
page: int = Field(default=1, ge=1)
page_size: int = Field(default=20, ge=1, le=100)---
Nested Resource Schemas
Order with Items
class OrderItemResponse(BaseModel):
"""Single item within an order."""
model_config = ConfigDict(from_attributes=True)
id: int
product_id: int
product_name: str
quantity: int
unit_price_cents: int
total_price_cents: int
class OrderResponse(BaseModel):
"""Order with nested items."""
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int
status: str
items: list[OrderItemResponse]
subtotal_cents: int
tax_cents: int
total_cents: int
created_at: datetime
updated_at: datetime
class OrderCreate(BaseModel):
"""Create order with items in a single request."""
items: list[OrderItemCreate] = Field(min_length=1)
shipping_address_id: int
class OrderItemCreate(BaseModel):
"""Single item to add to an order."""
product_id: int
quantity: int = Field(ge=1, le=999)---
Schema Conversion Patterns
ORM to Response
# In the route handler or service:
user = await repo.get_by_id(user_id)
if user is None:
raise NotFoundError(f"User {user_id} not found")
# Convert ORM model to Pydantic response
response = UserResponse.model_validate(user)Partial Update with PATCH
# Get only the fields the client actually sent
update_data = patch_schema.model_dump(exclude_unset=True)
# Apply updates to the ORM model
for field, value in update_data.items():
setattr(user, field, value)
await session.flush()Discriminated Union for Polymorphic Responses
from typing import Annotated, Literal, Union
from pydantic import Discriminator, Tag
class EmailNotification(BaseModel):
type: Literal["email"] = "email"
recipient: EmailStr
subject: str
body: str
class PushNotification(BaseModel):
type: Literal["push"] = "push"
device_token: str
title: str
body: str
Notification = Annotated[
Union[
Annotated[EmailNotification, Tag("email")],
Annotated[PushNotification, Tag("push")],
],
Discriminator("type"),
]