
Api Design
- 159 installs
- 213 repo stars
- Updated August 4, 2026
- yonatangross/orchestkit
Design REST or RPC endpoints, request/response schemas, error contracts, versioning, and auth patterns while building services that agents, web clients, and partners will integrate against.
About
api-design from yonatangross/orchestkit provides structured guidance for designing production-grade HTTP APIs: naming resources, defining payloads, documenting errors, and planning versioning for SaaS and agent backends before implementation and consumer SDK work starts.
- Resource and endpoint modeling
- Schema and error contract design
- Versioning and compatibility guidance
- Auth and rate-limit patterns
- Integration-ready API specs
Api Design by the numbers
- 159 all-time installs (skills.sh)
- Ranked #2,347 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/yonatangross/orchestkit --skill api-designAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 159 |
|---|---|
| repo stars | ★ 213 |
| Last updated | August 4, 2026 |
| Repository | yonatangross/orchestkit ↗ |
What it does
Design REST or RPC endpoints, request/response schemas, error contracts, versioning, and auth patterns while building services that agents, web clients, and partners will integrate against.
Files
API Design
Comprehensive API design patterns covering REST/GraphQL framework design, versioning strategies, and RFC 9457 error handling. Each category has individual rule files in rules/ loaded on-demand.
Quick Reference
| Category | Rules | Impact | When to Use |
|---|---|---|---|
| API Framework | 3 | HIGH | REST conventions, resource modeling, OpenAPI specifications |
| Versioning | 3 | HIGH | URL path versioning, header versioning, deprecation/sunset policies |
| Error Handling | 4 | HIGH | RFC 9457 Problem Details, agent-facing errors, validation errors, error type registries |
| GraphQL | 2 | HIGH | Strawberry code-first, DataLoader, permissions, subscriptions |
| gRPC | 2 | HIGH | Protobuf services, streaming, interceptors, retry |
| Streaming | 2 | HIGH | SSE endpoints, WebSocket bidirectional, async generators |
| Integrations | 2 | HIGH | Messaging platforms (WhatsApp, Telegram), Payload CMS patterns |
Total: 18 rules across 7 categories
API Framework
REST and GraphQL API design conventions for consistent, developer-friendly APIs.
| Rule | File | Key Pattern |
|---|---|---|
| REST Conventions | rules/framework-rest-conventions.md | Plural nouns, HTTP methods, status codes, pagination |
| Resource Modeling | rules/framework-resource-modeling.md | Hierarchical URLs, filtering, sorting, field selection |
| OpenAPI | rules/framework-openapi.md | OpenAPI 3.1 specs, documentation, schema definitions |
Versioning
Strategies for API evolution without breaking clients.
| Rule | File | Key Pattern |
|---|---|---|
| URL Path | rules/versioning-url-path.md | /api/v1/ prefix routing, version-specific schemas |
| Header | rules/versioning-header.md | X-API-Version header, content negotiation |
| Deprecation | rules/versioning-deprecation.md | Sunset headers, lifecycle management, breaking change policy |
Error Handling
RFC 9457 Problem Details for machine-readable, standardized error responses.
| Rule | File | Key Pattern |
|---|---|---|
| Problem Details | rules/errors-problem-details.md | RFC 9457 schema, application/problem+json, exception classes |
| Agent-Facing Errors | rules/errors-agent-facing.md | Agent extensions: retryable, error_category, content negotiation, token efficiency |
| Validation | rules/errors-validation.md | Field-level errors, Pydantic integration, 422 responses |
| Error Catalog | rules/errors-error-catalog.md | Problem type registry, error type URIs, client handling |
GraphQL
Strawberry GraphQL code-first schema with type-safe resolvers and FastAPI integration.
| Rule | File | Key Pattern |
|---|---|---|
| Schema Design | rules/graphql-strawberry.md | Type-safe schema, DataLoader, union errors, Private fields |
| Patterns & Auth | rules/graphql-schema.md | Permission classes, FastAPI integration, subscriptions |
gRPC
High-performance gRPC for internal microservice communication.
| Rule | File | Key Pattern |
|---|---|---|
| Service Definition | rules/grpc-service.md | Protobuf, async server, client timeout, code generation |
| Streaming & Interceptors | rules/grpc-streaming.md | Server/bidirectional streaming, auth, retry backoff |
Streaming
Real-time data streaming with SSE, WebSockets, and proper cleanup.
| Rule | File | Key Pattern |
|---|---|---|
| SSE | rules/streaming-sse.md | SSE endpoints, LLM streaming, reconnection, keepalive |
| WebSocket | rules/streaming-websocket.md | Bidirectional, heartbeat, aclosing(), backpressure |
Integrations
Messaging platform integrations and headless CMS patterns.
| Rule | File | Key Pattern |
|---|---|---|
| Messaging Platforms | rules/messaging-integrations.md | WhatsApp WAHA, Telegram Bot API, webhook security |
| Payload CMS | rules/payload-cms.md | Payload 3.0 collections, access control, CMS selection |
Quick Start Example
# REST endpoint with versioning and RFC 9457 errors
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
router = APIRouter()
@router.get("/api/v1/users/{user_id}")
async def get_user(user_id: str, service: UserService = Depends()):
user = await service.get_user(user_id)
if not user:
raise NotFoundProblem(
resource="User",
resource_id=user_id,
)
return UserResponseV1(id=user.id, name=user.full_name)Key Decisions
| Decision | Recommendation |
|---|---|
| Versioning strategy | URL path (/api/v1/) for public APIs |
| Resource naming | Plural nouns, kebab-case |
| Pagination | Cursor-based for large datasets |
| Error format | RFC 9457 Problem Details with application/problem+json |
| Error type URI | Your API domain + /problems/ prefix |
| Support window | Current + 1 previous version |
| Deprecation notice | 3 months minimum before sunset |
| Sunset period | 6 months after deprecation |
| GraphQL schema | Code-first with Strawberry types |
| N+1 prevention | DataLoader for all nested resolvers |
| GraphQL auth | Permission classes (context-based) |
| gRPC proto | One service per file, shared common.proto |
| gRPC streaming | Server stream for lists, bidirectional for real-time |
| SSE keepalive | Every 30 seconds |
| WebSocket heartbeat | ping-pong every 30 seconds |
| Async generator cleanup | aclosing() for all external resources |
Common Mistakes
1. Verbs in URLs (POST /createUser instead of POST /users) 2. Inconsistent error formats across endpoints 3. Breaking contracts without version bump 4. Plain text error responses instead of Problem Details 5. Sunsetting versions without deprecation headers 6. Exposing internal details (stack traces, DB errors) in errors 7. Missing Content-Type: application/problem+json on error responses 8. Supporting too many concurrent API versions (max 2-3) 9. Caching without considering version isolation
Evaluations
See test-cases.json for 9 test cases across all categories.
Related Skills
fastapi-advanced- FastAPI-specific implementation patternsrate-limiting- Advanced rate limiting implementations and algorithmsobservability-monitoring- Version usage metrics and error trackinginput-validation- Validation patterns beyond API error handlingstreaming-api-patterns- SSE and WebSocket patterns for real-time APIs
Capability Details
rest-design
Keywords: rest, restful, http, endpoint, route, path, resource, CRUD Solves:
- How do I design RESTful APIs?
- REST endpoint patterns and conventions
- HTTP methods and status codes
graphql-design
Keywords: graphql, schema, query, mutation, connection, relay Solves:
- How do I design GraphQL APIs?
- Schema design best practices
- Connection pattern for pagination
endpoint-design
Keywords: endpoint, route, path, resource, CRUD, openapi Solves:
- How do I structure API endpoints?
- What's the best URL pattern for this resource?
- RESTful endpoint naming conventions
url-versioning
Keywords: url version, path version, /v1/, /v2/ Solves:
- How to version REST APIs?
- URL-based API versioning
header-versioning
Keywords: header version, X-API-Version, content negotiation Solves:
- Clean URL versioning
- Header-based API version
deprecation
Keywords: deprecation, sunset, version lifecycle, backward compatible Solves:
- How to deprecate API versions?
- Version sunset policy
- Breaking vs non-breaking changes
problem-details
Keywords: problem details, RFC 9457, RFC 7807, structured error, application/problem+json Solves:
- How to standardize API error responses?
- What format for API errors?
agent-facing-errors
Keywords: agent error, AI agent, retryable, retry_after, error_category, content negotiation, accept header, token efficient, machine readable Solves:
- How to design error responses for AI agent consumers?
- How to reduce token cost of error responses?
- How to enable deterministic agent error handling?
- Content negotiation for agents vs browsers vs LLMs
validation-errors
Keywords: validation, field error, 422, unprocessable, pydantic Solves:
- How to handle validation errors in APIs?
- Field-level error responses
error-registry
Keywords: error registry, problem types, error catalog, error codes Solves:
- How to document all API errors?
- Error type management
asyncapi: 3.0.0
# AsyncAPI specification for event-driven and message-based APIs
# Use for: Kafka, RabbitMQ, WebSockets, MQTT, Server-Sent Events
info:
title: Order Processing Events API
version: 1.0.0
description: |
Event-driven API for order processing system.
Publishes events when orders are created, updated, or completed.
Consumers can subscribe to relevant events for their services.
contact:
name: API Support Team
email: api-support@example.com
url: https://api.example.com/support
license:
name: MIT
url: https://opensource.org/licenses/MIT
# Server definitions
servers:
production:
host: kafka.production.example.com:9092
protocol: kafka
description: Production Kafka cluster
security:
- $ref: '#/components/securitySchemes/saslScram'
tags:
- name: env:production
- name: region:us-east-1
staging:
host: kafka.staging.example.com:9092
protocol: kafka
description: Staging Kafka cluster
tags:
- name: env:staging
development:
host: localhost:9092
protocol: kafka
description: Local Kafka for development
tags:
- name: env:development
# Channel definitions (topics, queues, etc.)
channels:
# Order events topic
order/events:
address: order.events.v1
description: All order lifecycle events
messages:
orderCreated:
$ref: '#/components/messages/OrderCreated'
orderUpdated:
$ref: '#/components/messages/OrderUpdated'
orderCompleted:
$ref: '#/components/messages/OrderCompleted'
orderCancelled:
$ref: '#/components/messages/OrderCancelled'
# Payment events topic
payment/events:
address: payment.events.v1
description: Payment processing events
messages:
paymentAuthorized:
$ref: '#/components/messages/PaymentAuthorized'
paymentCaptured:
$ref: '#/components/messages/PaymentCaptured'
paymentFailed:
$ref: '#/components/messages/PaymentFailed'
# Notification requests topic
notification/requests:
address: notification.requests.v1
description: Notification delivery requests
messages:
emailNotification:
$ref: '#/components/messages/EmailNotification'
smsNotification:
$ref: '#/components/messages/SMSNotification'
# Operations (publish/subscribe)
operations:
# Publishing operations
publishOrderCreated:
action: send
channel:
$ref: '#/channels/order~1events'
summary: Publish order created event
description: Triggered when a new order is placed
messages:
- $ref: '#/channels/order~1events/messages/orderCreated'
traits:
- $ref: '#/components/operationTraits/kafka'
publishPaymentAuthorized:
action: send
channel:
$ref: '#/channels/payment~1events'
summary: Publish payment authorized event
messages:
- $ref: '#/channels/payment~1events/messages/paymentAuthorized'
# Subscribing operations
subscribeToOrderEvents:
action: receive
channel:
$ref: '#/channels/order~1events'
summary: Subscribe to order events
description: |
Listen for order lifecycle events.
Consumers should implement idempotency (events may be delivered multiple times).
messages:
- $ref: '#/channels/order~1events/messages/orderCreated'
- $ref: '#/channels/order~1events/messages/orderUpdated'
- $ref: '#/channels/order~1events/messages/orderCompleted'
- $ref: '#/channels/order~1events/messages/orderCancelled'
subscribeToPaymentEvents:
action: receive
channel:
$ref: '#/channels/payment~1events'
summary: Subscribe to payment events
messages:
- $ref: '#/channels/payment~1events/messages/paymentAuthorized'
- $ref: '#/channels/payment~1events/messages/paymentCaptured'
- $ref: '#/channels/payment~1events/messages/paymentFailed'
# Reusable components
components:
# Message definitions
messages:
# Order messages
OrderCreated:
name: OrderCreated
title: Order Created Event
summary: Fired when a new order is placed
contentType: application/json
traits:
- $ref: '#/components/messageTraits/commonHeaders'
payload:
$ref: '#/components/schemas/OrderCreatedPayload'
examples:
- name: Standard Order
summary: Typical order creation
payload:
eventId: evt_1a2b3c4d5e6f
eventType: order.created
timestamp: '2024-11-01T10:30:00Z'
version: '1.0'
data:
orderId: ord_abc123
customerId: cust_xyz789
items:
- productId: prod_001
quantity: 2
price: 29.99
totalAmount: 59.98
currency: USD
status: pending
createdAt: '2024-11-01T10:30:00Z'
OrderUpdated:
name: OrderUpdated
title: Order Updated Event
summary: Fired when order details change
contentType: application/json
traits:
- $ref: '#/components/messageTraits/commonHeaders'
payload:
$ref: '#/components/schemas/OrderUpdatedPayload'
OrderCompleted:
name: OrderCompleted
title: Order Completed Event
summary: Fired when order is fulfilled
contentType: application/json
traits:
- $ref: '#/components/messageTraits/commonHeaders'
payload:
$ref: '#/components/schemas/OrderCompletedPayload'
OrderCancelled:
name: OrderCancelled
title: Order Cancelled Event
summary: Fired when order is cancelled
contentType: application/json
traits:
- $ref: '#/components/messageTraits/commonHeaders'
payload:
$ref: '#/components/schemas/OrderCancelledPayload'
# Payment messages
PaymentAuthorized:
name: PaymentAuthorized
title: Payment Authorized Event
summary: Payment authorization successful
contentType: application/json
traits:
- $ref: '#/components/messageTraits/commonHeaders'
payload:
$ref: '#/components/schemas/PaymentAuthorizedPayload'
PaymentCaptured:
name: PaymentCaptured
title: Payment Captured Event
summary: Payment funds captured
contentType: application/json
traits:
- $ref: '#/components/messageTraits/commonHeaders'
payload:
$ref: '#/components/schemas/PaymentCapturedPayload'
PaymentFailed:
name: PaymentFailed
title: Payment Failed Event
summary: Payment processing failed
contentType: application/json
traits:
- $ref: '#/components/messageTraits/commonHeaders'
payload:
$ref: '#/components/schemas/PaymentFailedPayload'
# Notification messages
EmailNotification:
name: EmailNotification
title: Email Notification Request
summary: Request to send email
contentType: application/json
payload:
$ref: '#/components/schemas/EmailNotificationPayload'
SMSNotification:
name: SMSNotification
title: SMS Notification Request
summary: Request to send SMS
contentType: application/json
payload:
$ref: '#/components/schemas/SMSNotificationPayload'
# Schema definitions
schemas:
# Base event schema
BaseEvent:
type: object
required:
- eventId
- eventType
- timestamp
- version
properties:
eventId:
type: string
description: Unique event identifier
example: evt_1a2b3c4d5e6f
eventType:
type: string
description: Type of event
example: order.created
timestamp:
type: string
format: date-time
description: Event occurrence time (ISO 8601)
version:
type: string
description: Event schema version
example: '1.0'
correlationId:
type: string
description: Request correlation ID for tracing
example: req_abc123xyz
metadata:
type: object
description: Additional metadata
additionalProperties: true
# Order event payloads
OrderCreatedPayload:
allOf:
- $ref: '#/components/schemas/BaseEvent'
- type: object
required:
- data
properties:
data:
type: object
required:
- orderId
- customerId
- items
- totalAmount
- currency
- status
- createdAt
properties:
orderId:
type: string
example: ord_abc123
customerId:
type: string
example: cust_xyz789
items:
type: array
items:
type: object
properties:
productId:
type: string
quantity:
type: integer
minimum: 1
price:
type: number
format: decimal
totalAmount:
type: number
format: decimal
currency:
type: string
pattern: '^[A-Z]{3}$'
example: USD
status:
type: string
enum: [pending, processing, completed, cancelled]
createdAt:
type: string
format: date-time
OrderUpdatedPayload:
allOf:
- $ref: '#/components/schemas/BaseEvent'
- type: object
properties:
data:
type: object
properties:
orderId:
type: string
updates:
type: object
description: Fields that were updated
additionalProperties: true
OrderCompletedPayload:
allOf:
- $ref: '#/components/schemas/BaseEvent'
- type: object
properties:
data:
type: object
properties:
orderId:
type: string
completedAt:
type: string
format: date-time
OrderCancelledPayload:
allOf:
- $ref: '#/components/schemas/BaseEvent'
- type: object
properties:
data:
type: object
properties:
orderId:
type: string
reason:
type: string
example: Customer requested cancellation
cancelledAt:
type: string
format: date-time
# Payment event payloads
PaymentAuthorizedPayload:
allOf:
- $ref: '#/components/schemas/BaseEvent'
- type: object
properties:
data:
type: object
properties:
paymentId:
type: string
orderId:
type: string
amount:
type: number
format: decimal
currency:
type: string
authorizationCode:
type: string
PaymentCapturedPayload:
allOf:
- $ref: '#/components/schemas/BaseEvent'
- type: object
properties:
data:
type: object
properties:
paymentId:
type: string
orderId:
type: string
amount:
type: number
capturedAt:
type: string
format: date-time
PaymentFailedPayload:
allOf:
- $ref: '#/components/schemas/BaseEvent'
- type: object
properties:
data:
type: object
properties:
paymentId:
type: string
orderId:
type: string
errorCode:
type: string
example: insufficient_funds
errorMessage:
type: string
# Notification payloads
EmailNotificationPayload:
type: object
required:
- to
- subject
- body
properties:
to:
type: string
format: email
subject:
type: string
body:
type: string
from:
type: string
format: email
default: noreply@example.com
cc:
type: array
items:
type: string
format: email
attachments:
type: array
items:
type: object
properties:
filename:
type: string
url:
type: string
format: uri
SMSNotificationPayload:
type: object
required:
- to
- message
properties:
to:
type: string
pattern: '^\+[1-9]\d{1,14}$'
example: '+14155551234'
message:
type: string
maxLength: 160
from:
type: string
# Security schemes
securitySchemes:
saslScram:
type: scramSha256
description: SASL/SCRAM authentication for Kafka
apiKey:
type: apiKey
in: user
description: API key for authentication
# Message traits (reusable message properties)
messageTraits:
commonHeaders:
headers:
type: object
properties:
sentAt:
type: string
format: date-time
description: Message send timestamp
correlationId:
type: string
description: Correlation ID for request tracing
messageId:
type: string
description: Unique message identifier
# Operation traits (reusable operation properties)
operationTraits:
kafka:
bindings:
kafka:
groupId:
type: string
description: Kafka consumer group ID
# Tags for categorization
tags:
- name: order
description: Order-related events
- name: payment
description: Payment-related events
- name: notification
description: Notification-related events
# External documentation
externalDocs:
description: Full API documentation
url: https://docs.example.com/async-api
openapi: 3.1.0
info:
title: Your API Name
version: 1.0.0
description: |
Brief description of what this API does.
## Authentication
This API uses Bearer tokens for authentication.
## Rate Limiting
- 1000 requests per hour per API key
- Rate limit headers included in all responses
## Support
For questions, contact: api-support@company.com
contact:
name: API Support Team
email: api-support@company.com
url: https://api.company.com/support
license:
name: MIT
url: https://opensource.org/licenses/MIT
termsOfService: https://company.com/terms
servers:
- url: https://api.company.com/v1
description: Production server
- url: https://api-staging.company.com/v1
description: Staging server
- url: http://localhost:3000/v1
description: Local development
tags:
- name: users
description: User management operations
- name: orders
description: Order processing operations
- name: authentication
description: Authentication and authorization
paths:
/users:
get:
summary: List users
description: Retrieve a paginated list of users with optional filtering
operationId: listUsers
tags:
- users
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/PageParam'
- $ref: '#/components/parameters/PerPageParam'
- name: status
in: query
description: Filter by user status
schema:
type: string
enum: [active, inactive, pending]
- name: role
in: query
description: Filter by user role
schema:
type: string
enum: [admin, developer, viewer]
responses:
'200':
description: Successful response
headers:
X-RateLimit-Limit:
$ref: '#/components/headers/X-RateLimit-Limit'
X-RateLimit-Remaining:
$ref: '#/components/headers/X-RateLimit-Remaining'
X-RateLimit-Reset:
$ref: '#/components/headers/X-RateLimit-Reset'
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/User'
pagination:
$ref: '#/components/schemas/Pagination'
'401':
$ref: '#/components/responses/UnauthorizedError'
'429':
$ref: '#/components/responses/RateLimitError'
post:
summary: Create user
description: Create a new user account
operationId: createUser
tags:
- users
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateUserRequest'
responses:
'201':
description: User created successfully
headers:
Location:
description: URL of the created resource
schema:
type: string
format: uri
example: /users/123
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'400':
$ref: '#/components/responses/BadRequestError'
'401':
$ref: '#/components/responses/UnauthorizedError'
'422':
$ref: '#/components/responses/ValidationError'
/users/{userId}:
parameters:
- $ref: '#/components/parameters/UserIdParam'
get:
summary: Get user by ID
description: Retrieve detailed information about a specific user
operationId: getUser
tags:
- users
security:
- bearerAuth: []
responses:
'200':
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'401':
$ref: '#/components/responses/UnauthorizedError'
'404':
$ref: '#/components/responses/NotFoundError'
put:
summary: Update user
description: Replace entire user resource
operationId: updateUser
tags:
- users
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateUserRequest'
responses:
'200':
description: User updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'401':
$ref: '#/components/responses/UnauthorizedError'
'404':
$ref: '#/components/responses/NotFoundError'
'422':
$ref: '#/components/responses/ValidationError'
delete:
summary: Delete user
description: Permanently delete a user account
operationId: deleteUser
tags:
- users
security:
- bearerAuth: []
responses:
'204':
description: User deleted successfully
'401':
$ref: '#/components/responses/UnauthorizedError'
'404':
$ref: '#/components/responses/NotFoundError'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: Enter your JWT token
apiKey:
type: apiKey
in: header
name: X-API-Key
description: API key for service-to-service authentication
parameters:
UserIdParam:
name: userId
in: path
required: true
description: Unique identifier of the user
schema:
type: string
format: uuid
example: "550e8400-e29b-41d4-a716-446655440000"
PageParam:
name: page
in: query
description: Page number for pagination
schema:
type: integer
minimum: 1
default: 1
example: 1
PerPageParam:
name: per_page
in: query
description: Number of items per page
schema:
type: integer
minimum: 1
maximum: 100
default: 20
example: 20
headers:
X-RateLimit-Limit:
description: The maximum number of requests allowed per hour
schema:
type: integer
example: 1000
X-RateLimit-Remaining:
description: The number of requests remaining in the current time window
schema:
type: integer
example: 987
X-RateLimit-Reset:
description: Unix timestamp when the rate limit resets
schema:
type: integer
format: int64
example: 1635724800
schemas:
User:
type: object
required:
- id
- email
- name
- role
- created_at
properties:
id:
type: string
format: uuid
description: Unique identifier
example: "550e8400-e29b-41d4-a716-446655440000"
email:
type: string
format: email
description: User's email address
example: "jane@example.com"
name:
type: string
minLength: 2
maxLength: 100
description: User's full name
example: "Jane Smith"
role:
type: string
enum: [admin, developer, viewer]
description: User's role in the system
example: "developer"
status:
type: string
enum: [active, inactive, pending]
description: Current account status
default: active
example: "active"
avatar:
type: string
format: uri
nullable: true
description: URL to user's avatar image
example: "https://cdn.example.com/avatars/jane.jpg"
created_at:
type: string
format: date-time
description: Timestamp when user was created
example: "2025-10-31T10:30:00Z"
updated_at:
type: string
format: date-time
description: Timestamp when user was last updated
example: "2025-10-31T10:30:00Z"
CreateUserRequest:
type: object
required:
- email
- name
- role
properties:
email:
type: string
format: email
example: "jane@example.com"
name:
type: string
minLength: 2
maxLength: 100
example: "Jane Smith"
role:
type: string
enum: [admin, developer, viewer]
example: "developer"
avatar:
type: string
format: uri
nullable: true
example: "https://cdn.example.com/avatars/jane.jpg"
UpdateUserRequest:
type: object
properties:
email:
type: string
format: email
example: "jane.smith@example.com"
name:
type: string
minLength: 2
maxLength: 100
example: "Jane A. Smith"
role:
type: string
enum: [admin, developer, viewer]
example: "admin"
status:
type: string
enum: [active, inactive, pending]
example: "active"
avatar:
type: string
format: uri
nullable: true
example: "https://cdn.example.com/avatars/jane-new.jpg"
Pagination:
type: object
required:
- page
- per_page
- total
- total_pages
properties:
page:
type: integer
description: Current page number
example: 2
per_page:
type: integer
description: Items per page
example: 20
total:
type: integer
description: Total number of items
example: 487
total_pages:
type: integer
description: Total number of pages
example: 25
Error:
type: object
required:
- error
properties:
error:
type: object
required:
- code
- message
properties:
code:
type: string
description: Machine-readable error code
example: "VALIDATION_ERROR"
message:
type: string
description: Human-readable error message
example: "Request validation failed"
details:
type: array
description: Detailed error information
items:
type: object
properties:
field:
type: string
description: Field that caused the error
example: "email"
message:
type: string
description: Error message for this field
example: "Email is already registered"
code:
type: string
description: Field-specific error code
example: "DUPLICATE_EMAIL"
timestamp:
type: string
format: date-time
description: When the error occurred
example: "2025-10-31T10:30:00Z"
request_id:
type: string
description: Unique request identifier for support
example: "req_abc123xyz"
responses:
UnauthorizedError:
description: Authentication required or token invalid
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: "UNAUTHORIZED"
message: "Authentication required"
timestamp: "2025-10-31T10:30:00Z"
request_id: "req_abc123"
NotFoundError:
description: Resource not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: "NOT_FOUND"
message: "User not found"
timestamp: "2025-10-31T10:30:00Z"
request_id: "req_abc123"
BadRequestError:
description: Invalid request
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: "BAD_REQUEST"
message: "Invalid request body"
timestamp: "2025-10-31T10:30:00Z"
request_id: "req_abc123"
ValidationError:
description: Validation failed
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: "VALIDATION_ERROR"
message: "Request validation failed"
details:
- field: "email"
message: "Email is already registered"
code: "DUPLICATE_EMAIL"
- field: "name"
message: "Name must be at least 2 characters"
code: "NAME_TOO_SHORT"
timestamp: "2025-10-31T10:30:00Z"
request_id: "req_abc123"
RateLimitError:
description: Rate limit exceeded
headers:
Retry-After:
description: Number of seconds to wait before retrying
schema:
type: integer
example: 3600
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: "RATE_LIMIT_EXCEEDED"
message: "API rate limit exceeded"
details:
- field: "retry_after"
message: "Retry after 1 hour"
code: "RETRY_AFTER_3600"
timestamp: "2025-10-31T10:30:00Z"
request_id: "req_abc123"
API Design Review Checklist
Use this checklist when designing or reviewing APIs to ensure consistency, usability, and best practices.
Pre-Design Checklist
- [ ] Requirements Gathered: Clear understanding of what the API needs to accomplish
- [ ] Stakeholders Identified: Know who will use this API (frontend teams, partners, public)
- [ ] API Style Chosen: REST, GraphQL, or gRPC based on requirements
- [ ] Versioning Strategy: Decided how API will evolve (URI, header, or query param)
- [ ] Authentication Method: Chosen auth approach (JWT, API keys, OAuth2)
---
REST API Design Checklist
Resource Naming
- [ ] Plural Nouns: Resources use plural nouns (
/users, not/user) - [ ] Hierarchical: Relationships expressed through hierarchy (
/users/123/orders) - [ ] Kebab-Case: Multi-word resources use kebab-case (
/shopping-carts) - [ ] No Verbs: URLs don't contain actions (
/users, not/getUsers) - [ ] Consistent Naming: Same naming pattern across all resources
HTTP Methods
- [ ] GET for Retrieval: Read operations use GET
- [ ] POST for Creation: New resources use POST
- [ ] PUT for Replace: Full replacement uses PUT
- [ ] PATCH for Partial: Partial updates use PATCH
- [ ] DELETE for Removal: Deletions use DELETE
- [ ] Idempotent Operations: PUT, DELETE, GET are idempotent
- [ ] Safe Operations: GET, HEAD don't modify resources
Status Codes
- [ ] 2xx for Success: Appropriate success codes (200, 201, 204)
- [ ] 4xx for Client Errors: Correct client error codes (400, 401, 403, 404, 422, 429)
- [ ] 5xx for Server Errors: Server errors use 5xx (500, 502, 503)
- [ ] Consistent Usage: Same code for same scenarios across API
- [ ] Location Header: 201 responses include
Locationheader
Request/Response
- [ ] JSON Format: Using
application/jsoncontent type - [ ] Consistent Structure: Same response structure across endpoints
- [ ] Error Format: Standardized error response with code, message, details
- [ ] Timestamp Format: ISO 8601 format for all dates/times
- [ ] Field Naming: Consistent convention (snake_case or camelCase)
Pagination
- [ ] Pagination Implemented: Large lists are paginated
- [ ] Cursor or Offset: Chosen appropriate pagination strategy
- [ ] Page Info Included: Response includes pagination metadata
- [ ] Configurable Limit: Clients can specify page size
- [ ] Max Limit Enforced: Prevent excessive page sizes
Filtering & Sorting
- [ ] Filter Parameters: Query params for filtering (e.g.,
?status=active) - [ ] Sort Parameter: Query param for sorting (e.g.,
?sort=created_at:desc) - [ ] Field Selection: Support partial responses (e.g.,
?fields=id,name) - [ ] Consistent Syntax: Same filter/sort syntax across endpoints
Versioning
- [ ] Version Strategy Chosen: URI, header, or query param versioning
- [ ] Version Number Visible: Clear which version is being used
- [ ] Backward Compatibility: Older versions supported for migration period
- [ ] Deprecation Policy: Plan for sunsetting old versions
Authentication & Security
- [ ] Auth Required: Protected endpoints require authentication
- [ ] Authorization Checked: Verify user permissions for actions
- [ ] HTTPS Only: API only accessible over HTTPS in production
- [ ] API Keys Secure: Keys not exposed in URLs or logs
- [ ] Rate Limiting: Implemented to prevent abuse
Rate Limiting
- [ ] Limits Defined: Clear rate limits per endpoint/user
- [ ] Headers Included:
X-RateLimit-*headers in responses - [ ] 429 Status: Returns 429 when limit exceeded
- [ ] Retry-After Header: Tells client when to retry
Error Handling
- [ ] Consistent Format: All errors follow same structure
- [ ] Error Codes: Machine-readable error codes included
- [ ] Helpful Messages: Clear, actionable error messages
- [ ] Field-Level Errors: Validation errors specify which fields failed
- [ ] Request IDs: Each response includes unique request ID for support
---
GraphQL API Design Checklist
Schema Design
- [ ] Nullable by Default: Fields nullable unless explicitly required (!)
- [ ] Connections for Lists: Use Connection pattern for paginated lists
- [ ] Input Types: Mutations use Input types, not inline args
- [ ] Enum Types: Use enums for fixed sets of values
- [ ] Interface/Union Types: Reuse types appropriately
Queries
- [ ] Single Resource Queries: Can fetch individual items by ID
- [ ] List Queries: Can fetch lists with filtering and pagination
- [ ] Nested Queries: Related data fetchable in single query
- [ ] N+1 Prevention: DataLoader or similar for batching
Mutations
- [ ] Input/Payload Pattern: Mutations use
createUserInput→CreateUserPayload - [ ] Return Complete Object: Mutations return updated resource
- [ ] Error Handling: Payload includes errors array
- [ ] Optimistic UI: Mutations designed for optimistic updates
Subscriptions
- [ ] Real-Time Events: Subscriptions for live updates
- [ ] Filtered Subscriptions: Clients can filter events
- [ ] Subscription Cleanup: Proper cleanup on disconnect
---
gRPC API Design Checklist
Proto Files
- [ ] Package Name: Follows convention (company.service.v1)
- [ ] Versioned: Version included in package name
- [ ] Imports Organized: Standard imports (google/protobuf/*)
- [ ] Comments: Services and messages documented
Service Design
- [ ] CRUD Operations: Standard operations defined
- [ ] Request/Response Messages: Each RPC has dedicated messages
- [ ] Streaming Where Appropriate: Uses streaming for large data or live updates
- [ ] Empty Responses: Uses google.protobuf.Empty for no-content responses
Message Design
- [ ] Field Numbers: Sequential, never reused
- [ ] Required Fields: Minimal required fields
- [ ] Repeated Fields: For lists/arrays
- [ ] Oneof Fields: For mutually exclusive fields
- [ ] Enums Have Zero: First enum value is UNSPECIFIED = 0
Error Handling
- [ ] gRPC Status Codes: Uses standard status codes
- [ ] Error Details: Rich error info using google.rpc.Status
- [ ] Retry Logic: Idempotent operations identified
---
API Documentation Checklist
OpenAPI/AsyncAPI Specification
- [ ] Specification Created: OpenAPI 3.1 or AsyncAPI 3.0 document exists
- [ ] Complete Coverage: All endpoints documented
- [ ] Examples Provided: Request/response examples for each endpoint
- [ ] Schema Definitions: Reusable schemas in components section
- [ ] Security Schemes: Authentication methods documented
Documentation Quality
- [ ] Getting Started Guide: Clear intro for new users
- [ ] Authentication Guide: How to authenticate explained
- [ ] Error Handling Guide: Common errors and solutions
- [ ] Code Examples: Working code samples in multiple languages
- [ ] Changelog: Version history and breaking changes documented
API Reference
- [ ] Endpoint List: All endpoints listed with descriptions
- [ ] Parameters Documented: Query, path, header params explained
- [ ] Status Codes: All possible status codes documented
- [ ] Rate Limits: Limits and quotas clearly stated
- [ ] Deprecation Notices: Deprecated endpoints marked
---
Performance Checklist
- [ ] Pagination Default: Reasonable default page size (20-50)
- [ ] Field Selection: Support for partial responses
- [ ] Caching Headers: Cache-Control, ETag headers where appropriate
- [ ] Compression: Gzip/Brotli compression enabled
- [ ] Response Times: < 200ms for simple queries, < 1s for complex
- [ ] N+1 Queries Avoided: Efficient database queries
- [ ] Indexes Created: Database indexes on frequently queried fields
---
Testing Checklist
- [ ] Unit Tests: Business logic tested
- [ ] Integration Tests: API endpoints tested end-to-end
- [ ] Contract Tests: API contracts validated
- [ ] Load Tests: Performance under load verified
- [ ] Security Tests: Common vulnerabilities tested (OWASP)
- [ ] Documentation Tests: Examples in docs actually work
---
Compliance & Standards
- [ ] REST Principles: Follows RESTful conventions (if REST)
- [ ] GraphQL Spec: Adheres to GraphQL specification (if GraphQL)
- [ ] gRPC Style Guide: Follows protobuf style guide (if gRPC)
- [ ] Naming Conventions: Consistent with org standards
- [ ] Security Standards: Meets security requirements
- [ ] Privacy Compliance: GDPR, CCPA compliance where applicable
---
Pre-Launch Checklist
- [ ] All Tests Passing: 100% pass rate on test suite
- [ ] Documentation Complete: All endpoints documented
- [ ] Security Review: Security team approved
- [ ] Load Testing: Performance validated under expected load
- [ ] Monitoring Setup: Metrics, logging, alerting configured
- [ ] Error Tracking: Error monitoring (Sentry, etc.) configured
- [ ] Rollback Plan: Can revert if issues found
- [ ] Stakeholder Approval: Frontend/client teams signed off
---
Post-Launch Checklist
- [ ] Monitor Metrics: Track API usage, error rates, latency
- [ ] Collect Feedback: Gather developer feedback
- [ ] Document Issues: Track bugs and feature requests
- [ ] Iterate: Plan improvements based on real usage
- [ ] Deprecation Plan: Plan for sunsetting old versions if applicable
---
Common API Anti-Patterns to Avoid
❌ Chatty APIs: Too many round-trips required ✅ Fix: Batch operations, nested resources, GraphQL
❌ Overfetching: Returning more data than needed ✅ Fix: Field selection, GraphQL, partial responses
❌ Underfetching: Requiring multiple calls for related data ✅ Fix: Include related resources, nested endpoints, GraphQL
❌ Breaking Changes: Backward-incompatible changes without versioning ✅ Fix: Version API, deprecation periods, additive changes
❌ Unclear Errors: Generic "Error 500" messages ✅ Fix: Specific error codes, helpful messages, troubleshooting info
❌ No Pagination: Returning thousands of items ✅ Fix: Implement pagination with reasonable defaults
❌ Ignoring HTTP: Using POST for everything ✅ Fix: Use appropriate HTTP methods (GET, POST, PUT, DELETE)
❌ Exposing Internal Details: Database fields in API ✅ Fix: Map to business domain, hide implementation
---
Reviewer Sign-Off
Technical Review
- [ ] Backend Architect: Architectural soundness verified
- [ ] Frontend Developer: Developer experience validated
- [ ] Security Team: Security implications reviewed
- [ ] DevOps: Operational concerns addressed
Business Review
- [ ] Product Manager: Business requirements met
- [ ] API Governance: Compliance with API standards
---
Checklist Version: 1.0.0 Skill: api-design-framework v1.0.0 Last Updated: 2025-10-31
Error Handling Implementation Checklist
RFC 9457 Compliance
Response Format
- [ ] All error responses use
application/problem+jsonmedia type - [ ] All responses include required fields:
- [ ]
type- URI reference for problem type - [ ]
status- HTTP status code - [ ] All responses include recommended fields:
- [ ]
title- Human-readable summary - [ ]
detail- Specific error description - [ ]
instance- Request path that caused error
Problem Type URIs
- [ ] Define problem type registry (documented URIs)
- [ ] Each problem type has documentation at its URI
- [ ] URIs are stable (won't change)
- [ ] Using
about:blankfor generic HTTP errors
Standard Problem Types
Define these common error types:
- [ ]
validation-error(422) - Request validation failed - [ ]
resource-not-found(404) - Resource doesn't exist - [ ]
resource-conflict(409) - Duplicate or constraint violation - [ ]
authentication-required(401) - Missing/invalid credentials - [ ]
insufficient-permissions(403) - Not authorized - [ ]
rate-limit-exceeded(429) - Too many requests - [ ]
internal-error(500) - Unexpected server error
Exception Handling
Custom Exceptions
- [ ] Create base
ProblemExceptionclass - [ ] Create specific exception classes:
- [ ]
ResourceNotFoundError - [ ]
ValidationError - [ ]
ConflictError - [ ]
AuthenticationError - [ ]
AuthorizationError - [ ]
RateLimitError
Exception Handlers
- [ ] Register handler for
ProblemException - [ ] Register handler for
RequestValidationError(Pydantic) - [ ] Register handler for
IntegrityError(SQLAlchemy) - [ ] Register catch-all handler for
Exception - [ ] All handlers return
application/problem+json
Validation Errors
- [ ] Include field-level error details
- [ ] Use consistent error structure:
{
"errors": [
{"field": "email", "code": "invalid_format", "message": "..."}
]
}- [ ] Map Pydantic error types to user-friendly codes
- [ ] Include all validation errors, not just first
Observability
Logging
- [ ] Log all 5xx errors with full stack trace
- [ ] Log 4xx errors at warning level
- [ ] Include trace ID in all error logs
- [ ] Include request context (path, method, user)
Trace IDs
- [ ] Generate unique trace ID per request
- [ ] Include trace ID in error responses
- [ ] Include trace ID in logs
- [ ] Pass trace ID through middleware
Monitoring
- [ ] Track error rates by type
- [ ] Track error rates by endpoint
- [ ] Alert on error rate spikes
- [ ] Alert on 5xx errors
Security
Information Disclosure
- [ ] Never expose stack traces in production
- [ ] Never expose database errors to clients
- [ ] Never expose internal service details
- [ ] Sanitize error messages
Consistent Responses
- [ ] Return 404 for missing resources (not 403)
- [ ] Return 401 before 403 (auth before authz)
- [ ] Don't leak existence of resources via errors
Documentation
OpenAPI
- [ ] Document all error responses in OpenAPI
- [ ] Include example error responses
- [ ] Document all problem types
- [ ] Include error schemas
API Docs
- [ ] Document error response format
- [ ] Document common error codes
- [ ] Document retry strategies
- [ ] Provide error handling examples
Testing
Unit Tests
- [ ] Test each exception class
- [ ] Test problem detail serialization
- [ ] Test exception handlers
Integration Tests
- [ ] Test 404 returns problem detail
- [ ] Test 422 includes field errors
- [ ] Test 401/403 responses
- [ ] Test 429 includes retry-after
- [ ] Test 500 doesn't leak details
Error Scenarios
- [ ] Test invalid request body
- [ ] Test missing required fields
- [ ] Test invalid field values
- [ ] Test resource not found
- [ ] Test duplicate resource
- [ ] Test missing authentication
- [ ] Test insufficient permissions
- [ ] Test rate limit exceeded
Client Handling
Document recommended client handling:
# Python example
async def handle_api_error(response):
if response.headers.get("content-type") == "application/problem+json":
problem = await response.json()
if problem["type"].endswith("rate-limit-exceeded"):
await asyncio.sleep(problem["retry_after"])
return await retry_request()
if problem["type"].endswith("validation-error"):
for error in problem.get("errors", []):
display_field_error(error["field"], error["message"])
raise APIError(problem)Quick Reference
| Status | Type Suffix | When to Use |
|---|---|---|
| 400 | bad-request | Malformed request |
| 401 | authentication-required | Missing/invalid auth |
| 403 | insufficient-permissions | Not authorized |
| 404 | resource-not-found | Resource doesn't exist |
| 409 | resource-conflict | Duplicate/constraint |
| 422 | validation-error | Invalid field values |
| 429 | rate-limit-exceeded | Too many requests |
| 500 | internal-error | Unexpected error |
| 503 | service-unavailable | Temporary outage |
API Versioning Implementation Checklist
Planning
Strategy Selection
- [ ] Choose versioning strategy:
- [ ] URL Path (
/api/v1/) - Recommended for public APIs - [ ] Header (
X-API-Version: 1) - For internal APIs - [ ] Query Param (
?version=1) - Avoid if possible - [ ] Content Type (
Accept: application/vnd.api.v1+json) - For strict REST
Version Policy
- [ ] Define what constitutes a breaking change:
- [ ] Removing endpoints
- [ ] Removing/renaming fields
- [ ] Changing field types
- [ ] Changing authentication
- [ ] Changing error format
- [ ] Define deprecation policy:
- [ ] Minimum deprecation period (e.g., 6 months)
- [ ] Communication channels for deprecation notices
- [ ] Migration guide requirements
Implementation
Directory Structure
- [ ] Create versioned directory structure:
app/api/
├── v1/
│ ├── routes/
│ └── schemas/
└── v2/
├── routes/
└── schemas/Router Setup
- [ ] Create version-specific routers:
app.include_router(v1_router, prefix="/api/v1")
app.include_router(v2_router, prefix="/api/v2")- [ ] Configure OpenAPI tags per version
- [ ] Set up version-specific docs endpoints
Schema Management
- [ ] Create version-specific schemas
- [ ] Use inheritance for common fields
- [ ] Document schema changes between versions
Service Layer
- [ ] Keep services version-agnostic
- [ ] Use adapters to convert domain → version-specific response
- [ ] Avoid version logic in service layer
Deprecation
Headers
- [ ] Add deprecation headers to deprecated versions:
response.headers["Deprecation"] = "true"
response.headers["Sunset"] = "Sat, 31 Dec 2025 23:59:59 GMT"
response.headers["Link"] = '</api/v2/users>; rel="successor-version"'Response Warnings
- [ ] Include deprecation info in response body (optional):
{
"_deprecation": {
"message": "This version is deprecated",
"sunset_date": "2025-12-31",
"migration_guide": "https://docs.api.com/migration"
}
}Communication
- [ ] Email notification to API consumers
- [ ] Update API documentation with deprecation notice
- [ ] Add banner to developer portal
- [ ] Track usage of deprecated versions
Documentation
OpenAPI/Swagger
- [ ] Document all versions in OpenAPI
- [ ] Include deprecation status in docs
- [ ] Provide version comparison
- [ ] Link to migration guides
Changelog
- [ ] Maintain changelog per version
- [ ] Document breaking changes clearly
- [ ] Include migration instructions
- [ ] Date each change
Migration Guides
- [ ] Create migration guide for each major version:
- [ ] List all breaking changes
- [ ] Provide before/after examples
- [ ] Include code snippets
- [ ] Explain rationale for changes
Monitoring
Usage Tracking
- [ ] Track requests per version
- [ ] Monitor deprecated version usage
- [ ] Alert on high deprecated version traffic
- [ ] Dashboard for version metrics
Client Identification
- [ ] Track which clients use which versions
- [ ] Reach out to heavy deprecated version users
- [ ] Provide migration assistance
Testing
Version-Specific Tests
- [ ] Test each version independently
- [ ] Verify correct fields in each version
- [ ] Test deprecation headers
- [ ] Test error responses per version
Compatibility Tests
- [ ] Ensure v1 clients work with v1 API
- [ ] Verify v2 doesn't break v1
- [ ] Test header-based version selection
- [ ] Test default version behavior
Migration Tests
- [ ] Test that migrated clients work with new version
- [ ] Verify data compatibility
- [ ] Test edge cases during transition
Sunset Process
Pre-Sunset (6+ months before)
- [ ] Announce deprecation
- [ ] Add deprecation headers
- [ ] Update documentation
- [ ] Contact major API consumers
Active Deprecation (3-6 months before)
- [ ] Increase warning frequency
- [ ] Offer migration support
- [ ] Track migration progress
- [ ] Send reminder emails
Final Warning (1 month before)
- [ ] Final warning to remaining users
- [ ] Prepare for increased support
- [ ] Plan sunset date announcement
Sunset
- [ ] Remove deprecated version
- [ ] Return 410 Gone for old endpoints
- [ ] Keep redirect to migration docs
- [ ] Monitor for issues
Quick Reference
| Action | When |
|---|---|
| Start with v1 | Always, even if no plans for v2 |
| Create v2 | Breaking changes needed |
| Deprecate v1 | 6+ months before sunset |
| Sunset v1 | After deprecation period |
Common Mistakes
- [ ] Not versioning from start: Always start with
/api/v1 - [ ] Breaking v1 silently: Always create new version for breaks
- [ ] Too many versions: Consolidate when possible
- [ ] No deprecation period: Give adequate migration time
- [ ] Version in domain layer: Keep versions in API layer only
- [ ] Inconsistent versioning: Use same strategy everywhere
FastAPI Problem Details Implementation
Complete example implementing RFC 9457 Problem Details in FastAPI.
Problem Detail Schema
# app/core/exceptions.py
from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime, timezone
from typing import Any
class ProblemDetail(BaseModel):
"""RFC 9457 Problem Details response schema."""
type: str = Field(
default="about:blank",
description="URI reference identifying the problem type",
)
title: str = Field(
description="Short, human-readable summary",
)
status: int = Field(
description="HTTP status code",
)
detail: str | None = Field(
default=None,
description="Human-readable explanation specific to this occurrence",
)
instance: str | None = Field(
default=None,
description="URI reference identifying the specific occurrence",
)
# Common extensions
trace_id: str | None = None
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
model_config = ConfigDict(
json_schema_extra={
"example": {
"type": "https://api.orchestkit.dev/problems/validation-error",
"title": "Validation Error",
"status": 422,
"detail": "The url field is required",
"instance": "/api/v1/analyses",
"trace_id": "abc123",
"timestamp": "2026-01-07T10:30:00Z",
}
}
)
class ValidationProblem(ProblemDetail):
"""Problem detail with validation errors."""
errors: list[dict[str, Any]] = Field(
default_factory=list,
description="List of validation errors",
)
class RateLimitProblem(ProblemDetail):
"""Problem detail for rate limiting."""
retry_after: int = Field(description="Seconds until retry is allowed")
limit: int = Field(description="Request limit")
window: str = Field(description="Time window for limit")Custom Exception Classes
# app/core/exceptions.py
from fastapi import HTTPException
class ProblemException(Exception):
"""Base exception that renders as RFC 9457 Problem Detail."""
def __init__(
self,
status_code: int,
problem_type: str,
title: str,
detail: str | None = None,
instance: str | None = None,
**extensions,
):
self.status_code = status_code
self.problem_type = problem_type
self.title = title
self.detail = detail
self.instance = instance
self.extensions = extensions
def to_problem_detail(self, trace_id: str | None = None) -> dict:
"""Convert to Problem Detail dict."""
problem = {
"type": self.problem_type,
"title": self.title,
"status": self.status_code,
"timestamp": datetime.now(timezone.utc).isoformat(),
}
if self.detail:
problem["detail"] = self.detail
if self.instance:
problem["instance"] = self.instance
if trace_id:
problem["trace_id"] = trace_id
problem.update(self.extensions)
return problem
class ResourceNotFoundError(ProblemException):
"""Resource not found error."""
def __init__(
self,
resource_type: str,
resource_id: str,
):
super().__init__(
status_code=404,
problem_type="https://api.orchestkit.dev/problems/resource-not-found",
title="Resource Not Found",
detail=f"{resource_type} with ID '{resource_id}' was not found",
resource_type=resource_type,
resource_id=resource_id,
)
class ValidationError(ProblemException):
"""Validation error with field-level details."""
def __init__(
self,
errors: list[dict],
detail: str = "One or more fields failed validation",
):
super().__init__(
status_code=422,
problem_type="https://api.orchestkit.dev/problems/validation-error",
title="Validation Error",
detail=detail,
errors=errors,
)
class ConflictError(ProblemException):
"""Resource conflict error."""
def __init__(
self,
detail: str,
conflicting_field: str | None = None,
):
super().__init__(
status_code=409,
problem_type="https://api.orchestkit.dev/problems/resource-conflict",
title="Resource Conflict",
detail=detail,
conflicting_field=conflicting_field,
)
class RateLimitError(ProblemException):
"""Rate limit exceeded error."""
def __init__(
self,
retry_after: int,
limit: int,
window: str = "1 minute",
):
super().__init__(
status_code=429,
problem_type="https://api.orchestkit.dev/problems/rate-limit-exceeded",
title="Rate Limit Exceeded",
detail=f"You have exceeded {limit} requests per {window}",
retry_after=retry_after,
limit=limit,
window=window,
)
class AuthenticationError(ProblemException):
"""Authentication required error."""
def __init__(self, detail: str = "Authentication is required"):
super().__init__(
status_code=401,
problem_type="https://api.orchestkit.dev/problems/authentication-required",
title="Authentication Required",
detail=detail,
)
class AuthorizationError(ProblemException):
"""Insufficient permissions error."""
def __init__(
self,
detail: str = "You don't have permission to access this resource",
required_permission: str | None = None,
):
super().__init__(
status_code=403,
problem_type="https://api.orchestkit.dev/problems/insufficient-permissions",
title="Insufficient Permissions",
detail=detail,
required_permission=required_permission,
)Exception Handlers
# app/core/exception_handlers.py
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from sqlalchemy.exc import IntegrityError
from pydantic import ValidationError as PydanticValidationError
from app.core.exceptions import ProblemException
def setup_exception_handlers(app: FastAPI):
"""Register all exception handlers."""
@app.exception_handler(ProblemException)
async def problem_exception_handler(
request: Request,
exc: ProblemException,
) -> JSONResponse:
"""Handle custom problem exceptions."""
trace_id = getattr(request.state, "request_id", None)
exc.instance = request.url.path
return JSONResponse(
status_code=exc.status_code,
content=exc.to_problem_detail(trace_id),
media_type="application/problem+json",
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request,
exc: RequestValidationError,
) -> JSONResponse:
"""Handle Pydantic validation errors."""
errors = []
for error in exc.errors():
errors.append({
"field": ".".join(str(x) for x in error["loc"][1:]), # Skip 'body'
"code": error["type"],
"message": error["msg"],
})
trace_id = getattr(request.state, "request_id", None)
return JSONResponse(
status_code=422,
content={
"type": "https://api.orchestkit.dev/problems/validation-error",
"title": "Validation Error",
"status": 422,
"detail": "Request validation failed",
"instance": request.url.path,
"trace_id": trace_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
"errors": errors,
},
media_type="application/problem+json",
)
@app.exception_handler(IntegrityError)
async def integrity_error_handler(
request: Request,
exc: IntegrityError,
) -> JSONResponse:
"""Handle database integrity errors."""
trace_id = getattr(request.state, "request_id", None)
# Parse constraint name from error
detail = "A database constraint was violated"
if "unique" in str(exc.orig).lower():
detail = "A resource with this value already exists"
return JSONResponse(
status_code=409,
content={
"type": "https://api.orchestkit.dev/problems/resource-conflict",
"title": "Resource Conflict",
"status": 409,
"detail": detail,
"instance": request.url.path,
"trace_id": trace_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
},
media_type="application/problem+json",
)
@app.exception_handler(Exception)
async def generic_exception_handler(
request: Request,
exc: Exception,
) -> JSONResponse:
"""Handle unexpected exceptions."""
import structlog
logger = structlog.get_logger()
trace_id = getattr(request.state, "request_id", None)
# Log the full error
logger.exception(
"unhandled_exception",
trace_id=trace_id,
path=request.url.path,
error=str(exc),
)
return JSONResponse(
status_code=500,
content={
"type": "https://api.orchestkit.dev/problems/internal-error",
"title": "Internal Server Error",
"status": 500,
"detail": "An unexpected error occurred. Please try again later.",
"instance": request.url.path,
"trace_id": trace_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
"support_url": "https://support.orchestkit.dev",
},
media_type="application/problem+json",
)Usage in Routes
# app/api/v1/routes/analyses.py
from fastapi import APIRouter, Depends
from app.core.exceptions import ResourceNotFoundError, ValidationError
router = APIRouter()
@router.get("/analyses/{analysis_id}")
async def get_analysis(
analysis_id: str,
service: AnalysisService = Depends(get_analysis_service),
):
"""Get analysis by ID."""
analysis = await service.get_by_id(analysis_id)
if not analysis:
raise ResourceNotFoundError(
resource_type="Analysis",
resource_id=analysis_id,
)
return AnalysisResponse.from_domain(analysis)
@router.post("/analyses")
async def create_analysis(
request: AnalyzeRequest,
service: AnalysisService = Depends(get_analysis_service),
):
"""Create a new analysis."""
# Custom validation beyond Pydantic
if not is_valid_url(str(request.url)):
raise ValidationError(
errors=[
{
"field": "url",
"code": "invalid_url",
"message": "URL is not accessible or returns an error",
}
]
)
return await service.create(request)OpenAPI Documentation
# app/api/v1/routes/analyses.py
from fastapi import APIRouter
from app.core.exceptions import ProblemDetail, ValidationProblem
router = APIRouter()
@router.get(
"/analyses/{analysis_id}",
responses={
404: {
"model": ProblemDetail,
"description": "Analysis not found",
"content": {
"application/problem+json": {
"example": {
"type": "https://api.orchestkit.dev/problems/resource-not-found",
"title": "Resource Not Found",
"status": 404,
"detail": "Analysis with ID 'abc123' was not found",
}
}
},
},
500: {
"model": ProblemDetail,
"description": "Internal server error",
},
},
)
async def get_analysis(analysis_id: str):
...Testing
# tests/test_error_handling.py
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_not_found_returns_problem_detail(client: AsyncClient):
response = await client.get("/api/v1/analyses/nonexistent")
assert response.status_code == 404
assert response.headers["content-type"] == "application/problem+json"
problem = response.json()
assert problem["type"] == "https://api.orchestkit.dev/problems/resource-not-found"
assert problem["status"] == 404
assert "Analysis" in problem["detail"]
@pytest.mark.asyncio
async def test_validation_error_includes_field_errors(client: AsyncClient):
response = await client.post("/api/v1/analyses", json={"url": "not-a-url"})
assert response.status_code == 422
assert response.headers["content-type"] == "application/problem+json"
problem = response.json()
assert problem["type"] == "https://api.orchestkit.dev/problems/validation-error"
assert "errors" in problem
assert any(e["field"] == "url" for e in problem["errors"])FastAPI API Versioning Examples
Complete examples for implementing API versioning in FastAPI.
URL Path Versioning
Project Structure
app/
├── main.py
├── api/
│ ├── __init__.py
│ ├── v1/
│ │ ├── __init__.py
│ │ ├── routes/
│ │ │ ├── __init__.py
│ │ │ ├── users.py
│ │ │ └── analyses.py
│ │ └── schemas/
│ │ ├── __init__.py
│ │ ├── user.py
│ │ └── analysis.py
│ └── v2/
│ ├── __init__.py
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── users.py
│ │ └── analyses.py
│ └── schemas/
│ ├── __init__.py
│ ├── user.py
│ └── analysis.py
├── core/
│ └── config.py
└── services/ # Shared across versions
├── user_service.py
└── analysis_service.pyVersion Routers
# app/api/v1/__init__.py
from fastapi import APIRouter
from app.api.v1.routes import users, analyses
router = APIRouter(tags=["v1"])
router.include_router(users.router, prefix="/users", tags=["users"])
router.include_router(analyses.router, prefix="/analyses", tags=["analyses"])
# app/api/v2/__init__.py
from fastapi import APIRouter
from app.api.v2.routes import users, analyses
router = APIRouter(tags=["v2"])
router.include_router(users.router, prefix="/users", tags=["users"])
router.include_router(analyses.router, prefix="/analyses", tags=["analyses"])Main App
# app/main.py
from fastapi import FastAPI
from app.api.v1 import router as v1_router
from app.api.v2 import router as v2_router
app = FastAPI(
title="My API",
description="API with versioning",
version="2.0.0",
)
# Mount versioned routers
app.include_router(v1_router, prefix="/api/v1")
app.include_router(v2_router, prefix="/api/v2")
# Optional: Default to latest version
@app.get("/api/users")
async def get_users_latest():
"""Redirect to latest version."""
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/api/v2/users")Version-Specific Schemas
# app/api/v1/schemas/user.py
from pydantic import BaseModel
class UserResponseV1(BaseModel):
"""V1 user response - basic fields only."""
id: str
email: str
name: str
# app/api/v2/schemas/user.py
from pydantic import BaseModel
from datetime import datetime
class UserResponseV2(BaseModel):
"""V2 user response - extended fields."""
id: str
email: str
name: str
avatar_url: str | None = None
created_at: datetime
last_login: datetime | None = None
preferences: dict = {}Version-Specific Routes
# app/api/v1/routes/users.py
from fastapi import APIRouter, Depends
from app.api.v1.schemas.user import UserResponseV1
from app.services.user_service import UserService
router = APIRouter()
@router.get("/{user_id}", response_model=UserResponseV1)
async def get_user(
user_id: str,
service: UserService = Depends(),
) -> UserResponseV1:
user = await service.get_by_id(user_id)
return UserResponseV1(
id=str(user.id),
email=user.email,
name=user.name,
)
# app/api/v2/routes/users.py
from fastapi import APIRouter, Depends
from app.api.v2.schemas.user import UserResponseV2
from app.services.user_service import UserService
router = APIRouter()
@router.get("/{user_id}", response_model=UserResponseV2)
async def get_user(
user_id: str,
service: UserService = Depends(),
) -> UserResponseV2:
user = await service.get_by_id(user_id)
return UserResponseV2(
id=str(user.id),
email=user.email,
name=user.name,
avatar_url=user.avatar_url,
created_at=user.created_at,
last_login=user.last_login,
preferences=user.preferences or {},
)Header-Based Versioning
Version Dependency
# app/api/deps.py
from fastapi import Header, HTTPException
SUPPORTED_VERSIONS = {1, 2}
DEFAULT_VERSION = 2
def get_api_version(
api_version: str | None = Header(
default=None,
alias="X-API-Version",
description="API version (1 or 2)",
),
) -> int:
"""Extract and validate API version from header."""
if api_version is None:
return DEFAULT_VERSION
try:
version = int(api_version)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid API version: {api_version}",
)
if version not in SUPPORTED_VERSIONS:
raise HTTPException(
status_code=400,
detail=f"Unsupported API version: {version}. Supported: {SUPPORTED_VERSIONS}",
)
return versionVersion-Aware Route
# app/api/routes/users.py
from fastapi import APIRouter, Depends
from app.api.deps import get_api_version
from app.api.v1.schemas.user import UserResponseV1
from app.api.v2.schemas.user import UserResponseV2
router = APIRouter()
@router.get("/{user_id}")
async def get_user(
user_id: str,
version: int = Depends(get_api_version),
service: UserService = Depends(),
):
"""Get user - response varies by version."""
user = await service.get_by_id(user_id)
if version == 1:
return UserResponseV1(
id=str(user.id),
email=user.email,
name=user.name,
)
# version 2 (default)
return UserResponseV2(
id=str(user.id),
email=user.email,
name=user.name,
avatar_url=user.avatar_url,
created_at=user.created_at,
)Deprecation Handling
Deprecation Middleware
# app/middleware/deprecation.py
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from datetime import datetime
DEPRECATED_VERSIONS = {
"v1": {
"sunset": datetime(2025, 12, 31),
"successor": "v2",
}
}
class DeprecationMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
# Check if path contains deprecated version
path = request.url.path
for version, info in DEPRECATED_VERSIONS.items():
if f"/api/{version}/" in path:
response.headers["Deprecation"] = "true"
response.headers["Sunset"] = info["sunset"].strftime(
"%a, %d %b %Y %H:%M:%S GMT"
)
successor_path = path.replace(
f"/api/{version}/",
f"/api/{info['successor']}/"
)
response.headers["Link"] = (
f'<{successor_path}>; rel="successor-version"'
)
break
return response
# app/main.py
app.add_middleware(DeprecationMiddleware)Deprecation Warning in Response
# app/api/v1/routes/users.py
from fastapi import APIRouter, Response
router = APIRouter()
DEPRECATION_WARNING = {
"warning": "This API version is deprecated",
"sunset_date": "2025-12-31",
"migration_guide": "https://docs.api.com/migration/v1-to-v2",
}
@router.get("/{user_id}")
async def get_user(
user_id: str,
response: Response,
service: UserService = Depends(),
):
# Add deprecation headers
response.headers["Deprecation"] = "true"
response.headers["Sunset"] = "Sat, 31 Dec 2025 23:59:59 GMT"
user = await service.get_by_id(user_id)
return {
"_deprecation": DEPRECATION_WARNING,
"data": UserResponseV1.from_orm(user).dict(),
}OpenAPI Documentation
Separate Docs per Version
# app/main.py
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
app = FastAPI()
# V1 OpenAPI schema
def get_v1_openapi():
return get_openapi(
title="My API v1",
version="1.0.0",
description="API v1 (Deprecated)",
routes=[r for r in app.routes if "/api/v1" in str(r.path)],
)
# V2 OpenAPI schema
def get_v2_openapi():
return get_openapi(
title="My API v2",
version="2.0.0",
description="API v2 (Current)",
routes=[r for r in app.routes if "/api/v2" in str(r.path)],
)
@app.get("/api/v1/openapi.json", include_in_schema=False)
async def openapi_v1():
return get_v1_openapi()
@app.get("/api/v2/openapi.json", include_in_schema=False)
async def openapi_v2():
return get_v2_openapi()Testing Multiple Versions
# tests/test_versioning.py
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_v1_returns_basic_fields(client: AsyncClient):
response = await client.get("/api/v1/users/123")
assert response.status_code == 200
data = response.json()
assert "id" in data
assert "email" in data
assert "name" in data
# V1 should NOT have these fields
assert "avatar_url" not in data
assert "preferences" not in data
@pytest.mark.asyncio
async def test_v2_returns_extended_fields(client: AsyncClient):
response = await client.get("/api/v2/users/123")
assert response.status_code == 200
data = response.json()
assert "id" in data
assert "email" in data
assert "name" in data
# V2 should have these fields
assert "avatar_url" in data
assert "preferences" in data
@pytest.mark.asyncio
async def test_v1_includes_deprecation_headers(client: AsyncClient):
response = await client.get("/api/v1/users/123")
assert response.headers.get("Deprecation") == "true"
assert "Sunset" in response.headers
assert "Link" in response.headers
@pytest.mark.asyncio
async def test_header_versioning(client: AsyncClient):
# Request with v1 header
response = await client.get(
"/api/users/123",
headers={"X-API-Version": "1"},
)
data = response.json()
assert "avatar_url" not in data
# Request with v2 header
response = await client.get(
"/api/users/123",
headers={"X-API-Version": "2"},
)
data = response.json()
assert "avatar_url" in dataOrchestKit API Design Decisions
Real-world API design decisions from the OrchestKit project, documenting endpoint structure, versioning strategy, and architectural choices.
Project Context
OrchestKit: Intelligent Learning Integration Platform - Multi-agent system for analyzing technical content.
Stack: FastAPI (Python) + React 19 frontend API Base: http://localhost:8500/api/v1 Development Ports:
- Backend API:
localhost:8500 - Frontend:
localhost:5173 - PostgreSQL:
localhost:5437
API Structure
URI Versioning
Decision: Use URI-based versioning (/api/v1/)
Location: backend/app/core/config.py
API_V1_PREFIX = "/api/v1"Rationale:
- Clear visibility in URLs for debugging
- Easy to route different versions to different handlers
- Frontend can easily target specific API versions
- Cache-friendly (CDNs can cache different versions separately)
Implementation: backend/app/main.py
from app.core.config import settings
# Include analysis router with versioned prefix
app.include_router(
analysis_router,
prefix=f"{settings.API_V1_PREFIX}/analyze"
)
# Include artifact router
app.include_router(
artifact_router,
prefix=settings.API_V1_PREFIX
)Endpoint Design
Analysis Endpoints
Location: backend/app/api/v1/analysis/endpoints.py
1. Create Analysis (Async Task Pattern)
POST /api/v1/analyze
Content-Type: application/json
{
"url": "https://example.com/article",
"analysis_id": "optional-custom-id", # Optional
"skill_level": "beginner" # Optional: beginner|intermediate|advanced
}Response: 201 Created
{
"analysis_id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://example.com/article",
"content_type": "article",
"status": "pending",
"sse_endpoint": "/api/v1/analyze/550e8400-e29b-41d4-a716-446655440000/stream"
}Design Decision: Return immediately with analysis_id + SSE endpoint
- Why: Analysis workflow takes 30-120 seconds to complete
- Pattern: Async task creation + progress streaming (see SSE section)
- Client flow: Create analysis → Connect to SSE endpoint → Receive progress updates
Implementation:
@router.post(
"/analyze",
status_code=status.HTTP_201_CREATED,
responses={
422: {"model": ErrorResponse, "description": "Validation error"},
500: {"model": ErrorResponse, "description": "Internal server error"}
}
)
async def create_analysis(
request: AnalyzeRequest,
fastapi_request: Request,
analysis_repo: Annotated[IAnalysisRepository, Depends(get_analysis_repository)]
) -> AnalyzeCreateResponse:
"""Create analysis and start workflow asynchronously."""
# 1. Detect content type
content_type = detect_content_type(str(request.url))
# 2. Normalize custom analysis_id if provided (optional)
analysis_uuid = (
normalize_analysis_id_to_uuid(request.analysis_id)
if request.analysis_id
else None # Let DB generate UUID v7 via server_default
)
# 3. Create Analysis record (status: pending)
# PostgreSQL 18 generates UUID v7 via server_default=text("uuidv7()")
created_analysis = await analysis_repo.create_analysis(
analysis_id=analysis_uuid, # None → DB generates UUID v7
url=url_str,
content_type=content_type,
status="pending"
)
analysis_uuid = cast("AnalysisID", created_analysis.id)
# 4. Start workflow asynchronously (fire-and-forget)
task = asyncio.create_task(
run_workflow_task(analysis_uuid, url_str, request.skill_level)
)
background_tasks = fastapi_request.app.state.background_tasks
background_tasks.add(task)
task.add_done_callback(partial(_handle_task_completion, background_tasks=background_tasks))
# 5. Return immediately with SSE endpoint
sse_endpoint = f"{settings.API_V1_PREFIX}/analyze/{analysis_uuid}/stream"
return AnalyzeCreateResponse(
analysis_id=str(analysis_uuid),
url=url_str,
content_type=content_type,
status="pending",
sse_endpoint=sse_endpoint
)2. Get Analysis Status
GET /api/v1/analyze/{analysis_id}Response: 200 OK
{
"analysis_id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://example.com/article",
"content_type": "article",
"status": "completed",
"title": "Understanding React Server Components",
"artifact_id": "660e8400-e29b-41d4-a716-446655440001",
"created_at": "2025-12-21T10:30:00Z",
"updated_at": "2025-12-21T10:32:45Z"
}Design Decision: Return latest artifact_id in status response
- Why: Frontend needs artifact_id to fetch results
- Alternative considered: Separate endpoint for artifact lookup (rejected: extra round trip)
3. Stream Analysis Progress (SSE)
GET /api/v1/analyze/{analysis_id}/stream
Accept: text/event-streamResponse: Server-Sent Events stream
event: progress
data: {"type":"progress","stage":"extraction","status":"running","timestamp":"2025-12-21T10:30:15Z"}
event: progress
data: {"type":"progress","stage":"extraction","status":"complete","word_count":5234}
event: progress
data: {"type":"progress","stage":"analysis","status":"running","agent":"tech_comparator"}
event: complete
data: {"type":"complete","stage":"artifact_generation","timestamp":"2025-12-21T10:32:45Z"}Design Decision: Use SSE instead of WebSockets
- Why: Unidirectional (server→client) is sufficient for progress updates
- Benefit: Simpler client code (native EventSource API), automatic reconnection
- Trade-off: No client→server messaging (not needed for this use case)
See references/sse-deep-dive.md in streaming-api-patterns skill for details.
Artifact Endpoints
Location: backend/app/api/v1/analysis/artifacts.py
1. Get Artifact by Analysis
GET /api/v1/analyze/{analysis_id}/artifactResponse: 200 OK
{
"artifact_id": "660e8400-e29b-41d4-a716-446655440001",
"analysis_id": "550e8400-e29b-41d4-a716-446655440000",
"markdown_content": "# Understanding React Server Components\n\n...",
"artifact_metadata": {
"word_count": 5234,
"section_count": 8
},
"trace_id": "trace_abc123",
"created_at": "2025-12-21T10:32:45Z"
}Design Decision: Hierarchical URL (/analyze/{id}/artifact)
- Why: Expresses relationship: "artifact belongs to analysis"
- Alternative considered:
/artifacts?analysis_id={id}(rejected: less RESTful)
2. Get Artifact by ID
GET /api/v1/artifacts/{artifact_id}Response: Same as above
Design Decision: Provide both hierarchical AND direct ID lookup
- Why: Support different frontend access patterns
- Use case 1: After analysis complete → use hierarchical endpoint
- Use case 2: Direct link to artifact → use ID endpoint
3. Download Artifact
GET /api/v1/artifacts/{artifact_id}/downloadResponse: 200 OK (file download)
Content-Type: text/markdown
Content-Disposition: attachment; filename="understanding-react-server-components-550e8400.md"
# Understanding React Server Components
...Design Decision: Separate download endpoint with different response type
- Why: Different headers (Content-Disposition) and analytics (download_count)
- Benefit: Clean separation of view vs. download use cases
Implementation:
@router.get("/artifacts/{artifact_id}/download", response_class=Response)
async def download_artifact(
artifact_id: uuid.UUID,
repo: Annotated[IArtifactRepository, Depends(get_artifact_repository)]
) -> Response:
# Get artifact with analysis (for title)
result = await repo.get_artifact_with_analysis(artifact_id)
if not result:
raise HTTPException(status_code=404, detail="Artifact not found")
artifact, analysis = result
# Extract title from analysis metadata
title = None
if analysis.extraction_metadata:
title = analysis.extraction_metadata.get("title")
# Generate filename: "article-title-uuid.md"
filename = generate_filename(title, str(artifact.analysis_id))
# Increment download_count for analytics
await repo.increment_download_count(artifact_id)
# Return with download headers
return Response(
content=artifact.markdown_content,
media_type="text/markdown",
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
)Health Check Endpoint
Location: backend/app/api/v1/health.py
GET /api/v1/healthResponse: 200 OK
{
"status": "healthy",
"version": "0.1.0",
"environment": "development",
"database": {
"status": "connected"
}
}Design Decision: Include database connectivity check
- Why: Kubernetes readiness/liveness probes need to verify DB connection
- Timeout: 5 seconds (configurable via DB_TIMEOUT constant)
- Error response: Still returns 200 OK, but with
database.status: "disconnected"
Error Handling
Standardized Error Format
Location: backend/app/api/schemas/errors.py
from pydantic import BaseModel, ConfigDict
class ErrorResponse(BaseModel):
error: dict[str, Any]
model_config = ConfigDict(
json_schema_extra={
"example": {
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"timestamp": "2025-12-21T10:30:00Z"
}
}
}
)Example Error Responses
404 Not Found:
{
"error": {
"code": "NOT_FOUND",
"message": "Artifact 660e8400-e29b-41d4-a716-446655440001 not found",
"timestamp": "2025-12-21T10:30:00Z"
}
}422 Validation Error:
try:
content_type = detect_content_type(url_str)
except ContentTypeError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid URL format: {e!s}"
) from eResponse:
{
"error": {
"code": "UNPROCESSABLE_ENTITY",
"message": "Invalid URL format: Must be a valid HTTP/HTTPS URL",
"timestamp": "2025-12-21T10:30:00Z"
}
}URL Normalization
UUID Analysis IDs
Decision: Always use UUIDs for analysis_id (not string slugs)
Normalization logic: backend/app/core/utils.py
def normalize_analysis_id_to_uuid(analysis_id: str) -> uuid.UUID:
"""Normalize analysis_id to UUID format.
Supports:
- Full UUID: "550e8400-e29b-41d4-a716-446655440000"
- Short form: "550e8400" (first 8 chars)
"""
# Try parsing as full UUID
try:
return uuid.UUID(analysis_id)
except ValueError:
pass
# Try short form (8 chars)
if len(analysis_id) == 8:
try:
# Pad to full UUID format
full_uuid = f"{analysis_id}-0000-0000-0000-000000000000"
return uuid.UUID(full_uuid)
except ValueError:
pass
raise ValueError(f"Invalid analysis_id format: {analysis_id}")Benefit: Allows short URLs while maintaining UUID uniqueness
Repository Pattern
Dependency Injection
Pattern: Use FastAPI Depends() for repository injection
from typing import Annotated
@router.get("/artifacts/{artifact_id}")
async def get_artifact(
artifact_id: Annotated[uuid.UUID, Path(description="Artifact UUID")],
repo: Annotated[IArtifactRepository, Depends(get_artifact_repository)]
) -> ArtifactMetadataResponse:
artifact = await repo.get_artifact_by_id(artifact_id)
...Benefits:
- Easy testing (mock repository)
- Clean separation of concerns
- Type-safe with Annotated
API Documentation
OpenAPI Spec
Auto-generated: Available at /docs (Swagger UI) and /redoc (ReDoc)
Custom documentation:
@router.get(
"/analyze/{analysis_id}/stream",
responses={
404: {"model": ErrorResponse, "description": "Analysis not found"},
500: {"model": ErrorResponse, "description": "Internal server error"}
}
)
async def stream_analysis_progress_endpoint(
analysis_id: Annotated[uuid.UUID, Path(description="Analysis UUID")],
request: Request
):
"""Stream real-time analysis progress via Server-Sent Events (SSE).
See app.api.v1.sse_handler.stream_analysis_progress for full documentation.
"""
return await stream_analysis_progress_handler(analysis_id, request)Design Principles
1. Immediate Response for Long Operations
Pattern: Create → Return ID + Progress URL
- Example: POST /analyze → Returns analysis_id + sse_endpoint
- Why: Prevents timeout on long-running operations
- Client UX: Show loading state with progress updates
2. Include Related Resource URLs
Pattern: Include navigation URLs in responses
{
"analysis_id": "123",
"sse_endpoint": "/api/v1/analyze/123/stream", ← Progress URL
"artifact_id": "456" ← Related resource
}Benefit: Frontend doesn't need to construct URLs
3. Hierarchical URLs for Relationships
Pattern: /parent/{id}/child for 1:1 or 1:many relationships
/analyze/{analysis_id}/artifact- Analysis has one latest artifact/teams/{team_id}/members- Team has many members
Benefit: Clear relationship modeling
4. UUID Path Parameters
Pattern: Use typed UUID path parameters
analysis_id: Annotated[uuid.UUID, Path(description="Analysis UUID")]Benefit: Automatic validation (400 if not valid UUID)
5. Repository + Dependency Injection
Pattern: Abstract database access behind repository interface
class IArtifactRepository(Protocol):
async def get_artifact_by_id(self, artifact_id: uuid.UUID) -> Artifact | None: ...
def get_artifact_repository() -> IArtifactRepository:
return ArtifactRepository(get_db_session())Benefits:
- Easy to mock for testing
- Clean architecture
- Database-agnostic API layer
Related Files
- SSE Implementation:
backend/app/api/v1/analysis/sse_handler.py - Event Broadcaster:
backend/app/shared/services/messaging/broadcaster.py - Error Schemas:
backend/app/api/schemas/errors.py - Config:
backend/app/core/config.py - API Schemas:
backend/app/domains/analysis/schemas/api.py
References
- See
references/rest-patterns.mdfor general REST patterns - See
streaming-api-patternsskill for SSE implementation details - See
assets/openapi-template.yamlfor OpenAPI specification template
{
"version": "2.0.0",
"organization": "OrchestKit",
"date": "February 2026",
"abstract": "API design patterns covering REST/GraphQL framework design, versioning strategies (URL path, header, content negotiation), and RFC 9457 Problem Details error handling.",
"ruleCount": 9,
"categories": 3,
"consolidatedFrom": [
"api-design-framework",
"api-versioning",
"error-handling-rfc9457"
]
}
Frontend API Integration (2026 Patterns)
Type-safe API consumption with runtime validation.
Runtime Validation with Zod
CRITICAL: TypeScript types are erased at runtime. API responses MUST be validated:
import { z } from 'zod'
const UserSchema = z.object({
id: z.uuid(),
email: z.email(),
name: z.string(),
role: z.enum(['admin', 'developer', 'viewer']),
created_at: z.iso.datetime(),
})
const UsersResponseSchema = z.object({
data: z.array(UserSchema),
pagination: z.object({
next_cursor: z.string().nullable(),
has_more: z.boolean(),
}),
})
type User = z.infer<typeof UserSchema>
async function fetchUsers(cursor?: string): Promise<UsersResponse> {
const response = await fetch(`/api/v1/users${cursor ? `?cursor=${cursor}` : ''}`)
const data = await response.json()
return UsersResponseSchema.parse(data) // Runtime validation!
}Request Interceptors (ky)
import ky from 'ky'
export const api = ky.create({
prefixUrl: import.meta.env.VITE_API_URL,
timeout: 30000,
retry: {
limit: 2,
methods: ['get', 'head', 'options'],
statusCodes: [408, 429, 500, 502, 503, 504],
},
hooks: {
beforeRequest: [
async (request) => {
const token = await getAccessToken()
if (token) {
request.headers.set('Authorization', `Bearer ${token}`)
}
},
],
afterResponse: [
async (request, options, response) => {
if (response.status === 401) {
const newToken = await refreshToken()
if (newToken) {
request.headers.set('Authorization', `Bearer ${newToken}`)
return ky(request, options)
}
}
return response
},
],
},
})Error Enrichment Pattern
class ApiError extends Error {
constructor(
public status: number,
public code: string,
message: string,
public details?: Array<{ field: string; message: string }>
) {
super(message)
this.name = 'ApiError'
}
get isValidationError(): boolean {
return this.status === 422
}
get isAuthError(): boolean {
return this.status === 401 || this.status === 403
}
get isRateLimited(): boolean {
return this.status === 429
}
}TanStack Query Integration
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
export function useUsers(cursor?: string) {
return useQuery({
queryKey: ['users', { cursor }],
queryFn: () => getUsers(cursor),
staleTime: 30_000,
})
}
export function useCreateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (input: CreateUserInput) =>
api.post('users', { json: input }).json().then(UserSchema.parse),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
}Anti-Patterns
// NEVER: Trust API response types blindly
const data = await response.json() as User // Unsafe cast!
// NEVER: Skip validation
const user: User = await response.json() // Runtime crash waiting
// ALWAYS: Validate at the boundary
const user = UserSchema.parse(await response.json())GraphQL API Design
Schema Design Principles
Nullable by Default
type User {
id: ID! # Non-null (required)
email: String! # Non-null
name: String # Nullable (optional)
avatar: String # Nullable
}Use Connections for Lists
type Query {
users(first: Int, after: String): UserConnection!
}
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}Input Types for Mutations
input CreateUserInput {
email: String!
name: String!
role: UserRole!
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
}
type CreateUserPayload {
user: User!
errors: [UserError!]
}
type UserError {
field: String!
message: String!
code: String!
}Query Design
Fetch single resource:
query GetUser {
user(id: "123") {
id
name
email
posts {
id
title
}
}
}Fetch list with filters:
query GetUsers {
users(
first: 10
after: "cursor123"
filter: { role: DEVELOPER, status: ACTIVE }
) {
edges {
node {
id
name
email
}
}
pageInfo {
hasNextPage
endCursor
}
}
}Error Handling
Field-Level Errors:
type CreateUserPayload {
user: User
errors: [UserError!]
}Response:
{
"data": {
"createUser": {
"user": null,
"errors": [
{
"field": "email",
"message": "Email is already taken",
"code": "DUPLICATE_EMAIL"
}
]
}
}
}gRPC API Design
Proto File Structure
syntax = "proto3";
package company.user.v1;
import "google/protobuf/timestamp.proto";
import "google/protobuf/empty.proto";
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse);
rpc DeleteUser(DeleteUserRequest) returns (google.protobuf.Empty);
rpc WatchUsers(WatchUsersRequest) returns (stream UserEvent);
}
message User {
string id = 1;
string email = 2;
string name = 3;
UserRole role = 4;
google.protobuf.Timestamp created_at = 5;
}
enum UserRole {
USER_ROLE_UNSPECIFIED = 0;
USER_ROLE_ADMIN = 1;
USER_ROLE_DEVELOPER = 2;
USER_ROLE_VIEWER = 3;
}
message GetUserRequest {
string id = 1;
}
message GetUserResponse {
User user = 1;
}
message ListUsersRequest {
int32 page_size = 1;
string page_token = 2;
string filter = 3;
}
message ListUsersResponse {
repeated User users = 1;
string next_page_token = 2;
int32 total_size = 3;
}gRPC Status Codes
| Code | HTTP Equivalent | Use Case |
|---|---|---|
| OK | 200 | Success |
| INVALID_ARGUMENT | 400 | Invalid request |
| NOT_FOUND | 404 | Resource not found |
| ALREADY_EXISTS | 409 | Duplicate |
| PERMISSION_DENIED | 403 | Forbidden |
| UNAUTHENTICATED | 401 | Auth required |
| RESOURCE_EXHAUSTED | 429 | Rate limit |
| INTERNAL | 500 | Server error |
Access Control Patterns
RBAC patterns, field-level access, and admin vs API access for Payload CMS 3.0.
Access Function Signature
Every access function receives context and returns boolean or a query constraint.
import { Access } from 'payload'
// Simple boolean — allow or deny
const isAuthenticated: Access = ({ req: { user } }) => Boolean(user)
// Query constraint — Payload auto-filters results
const isOwner: Access = ({ req: { user } }) => {
if (!user) return false
return { createdBy: { equals: user.id } }
}Returning a query object is the most powerful pattern — Payload appends it to the database query automatically, so users only ever see their own data.
Role-Based Access Control
// Define roles on user collection
const Users: CollectionConfig = {
slug: 'users',
auth: true, // Enables authentication
fields: [
{
name: 'role',
type: 'select',
options: ['admin', 'editor', 'viewer'],
required: true,
defaultValue: 'viewer',
},
],
}
// Reusable access helpers
const isAdmin: Access = ({ req: { user } }) => user?.role === 'admin'
const isEditorOrAbove: Access = ({ req: { user } }) =>
['admin', 'editor'].includes(user?.role)
// Composite: admin sees all, editor sees own, viewer sees published
const postAccess: Access = ({ req: { user } }) => {
if (user?.role === 'admin') return true
if (user?.role === 'editor') {
return { author: { equals: user.id } }
}
// Viewer / anonymous — only published
return { status: { equals: 'published' } }
}Collection-Level Access
const Posts: CollectionConfig = {
slug: 'posts',
access: {
read: postAccess, // Who can list/get
create: isEditorOrAbove, // Who can create
update: isAdminOrAuthor, // Who can update
delete: isAdmin, // Who can delete
},
}Each operation (read, create, update, delete) is independent. Omitting one defaults to allowing authenticated users.
Field-Level Access
Hide or protect individual fields based on role.
{
name: 'internalNotes',
type: 'textarea',
access: {
read: isAdmin, // Hidden from API response for non-admins
update: isAdmin, // Not editable by non-admins
create: isAdmin, // Cannot be set on creation by non-admins
},
}Field-level access applies to both the REST/GraphQL API and the admin panel — fields are completely invisible to unauthorized users.
Admin Panel vs API Access
Access control applies uniformly, but you can distinguish context:
const adminOnlyInPanel: Access = ({ req }) => {
// req.user is available in both contexts
// req.headers can distinguish admin panel requests
if (req.user?.role === 'admin') return true
return false
}Important: The Local API (payload.find()) bypasses access control by default. Pass overrideAccess: false when calling from user-facing server code:
// In a Next.js server component — MUST enforce access
const posts = await payload.find({
collection: 'posts',
overrideAccess: false, // Enforce access control
user: req.user, // Pass the current user
})Multi-Tenant Access
Isolate data between tenants using query constraints.
const tenantAccess: Access = ({ req: { user } }) => {
if (user?.role === 'super-admin') return true
if (!user?.tenant) return false
return { tenant: { equals: user.tenant } }
}
// Apply to every collection that is tenant-scoped
const TenantPosts: CollectionConfig = {
slug: 'posts',
access: {
read: tenantAccess,
create: tenantAccess,
update: tenantAccess,
delete: tenantAccess,
},
fields: [
{ name: 'tenant', type: 'relationship', relationTo: 'tenants', required: true },
// ... other fields
],
}Common Access Patterns Summary
| Pattern | Returns | Use Case |
|---|---|---|
() => true | boolean | Public read |
({ req }) => Boolean(req.user) | boolean | Authenticated only |
({ req }) => req.user?.role === 'admin' | boolean | Admin only |
({ req }) => ({ author: { equals: req.user?.id } }) | query | Row-level security |
({ req }) => ({ tenant: { equals: req.user?.tenant } }) | query | Multi-tenant isolation |
Collection Design Patterns
Field types, relationships, blocks, tabs, and validation patterns for Payload CMS 3.0.
Field Types Quick Reference
| Type | Use Case | Key Options |
|---|---|---|
text | Short strings | minLength, maxLength, unique |
textarea | Multi-line text | minLength, maxLength |
richText | Formatted content | Lexical editor (default in 3.0) |
number | Integers/floats | min, max, hasMany |
select | Enum values | options, hasMany |
relationship | Foreign key | relationTo, hasMany, filterOptions |
upload | Media reference | relationTo (upload collection) |
blocks | Polymorphic content | blocks array of block configs |
array | Repeatable groups | fields (nested field config) |
group | Nested object | fields (no separate collection) |
tabs | UI organization | tabs array with fields per tab |
date | Timestamps | admin.date config |
checkbox | Boolean flags | Default false |
json | Arbitrary JSON | Use sparingly — no admin UI |
Relationship Patterns
// One-to-many: Post has one author
{ name: 'author', type: 'relationship', relationTo: 'users', required: true }
// Many-to-many: Post has multiple tags
{ name: 'tags', type: 'relationship', relationTo: 'tags', hasMany: true }
// Polymorphic: Link to different collection types
{
name: 'relatedContent',
type: 'relationship',
relationTo: ['posts', 'pages', 'products'], // Union type
hasMany: true,
}
// Filtered relationship: Only show published posts
{
name: 'featuredPost',
type: 'relationship',
relationTo: 'posts',
filterOptions: { status: { equals: 'published' } },
}Block Patterns (Polymorphic Content)
Blocks are the key pattern for flexible page layouts — each block is a typed content section.
import { Block } from 'payload'
const HeroBlock: Block = {
slug: 'hero',
fields: [
{ name: 'heading', type: 'text', required: true },
{ name: 'image', type: 'upload', relationTo: 'media', required: true },
{ name: 'ctaText', type: 'text' },
{ name: 'ctaLink', type: 'text' },
],
}
const ContentBlock: Block = {
slug: 'content',
fields: [
{ name: 'body', type: 'richText' },
{ name: 'width', type: 'select', options: ['full', 'narrow', 'wide'], defaultValue: 'full' },
],
}
// Use in collection
{
name: 'layout',
type: 'blocks',
blocks: [HeroBlock, ContentBlock, CTABlock, TestimonialBlock],
}Tabs for Complex Collections
const Products: CollectionConfig = {
slug: 'products',
fields: [
{
type: 'tabs',
tabs: [
{
label: 'General',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'richText' },
],
},
{
label: 'Pricing',
fields: [
{ name: 'price', type: 'number', required: true },
{ name: 'currency', type: 'select', options: ['USD', 'EUR', 'GBP'] },
],
},
{
label: 'SEO',
fields: [
{ name: 'metaTitle', type: 'text' },
{ name: 'metaDescription', type: 'textarea' },
],
},
],
},
],
}Validation Patterns
// Custom field validation
{
name: 'slug',
type: 'text',
unique: true,
validate: (value) => {
if (!/^[a-z0-9-]+$/.test(value)) {
return 'Slug must be lowercase alphanumeric with hyphens only'
}
return true
},
}
// Conditional required — required only when status is published
{
name: 'publishedDate',
type: 'date',
admin: {
condition: (data) => data.status === 'published',
},
validate: (value, { siblingData }) => {
if (siblingData.status === 'published' && !value) {
return 'Published date is required for published content'
}
return true
},
}Global Config (Singletons)
Use globals for site-wide settings that don't need multiple documents.
import { GlobalConfig } from 'payload'
const SiteSettings: GlobalConfig = {
slug: 'site-settings',
access: { read: () => true, update: isAdmin },
fields: [
{ name: 'siteName', type: 'text', required: true },
{ name: 'logo', type: 'upload', relationTo: 'media' },
{ name: 'socialLinks', type: 'array', fields: [
{ name: 'platform', type: 'select', options: ['twitter', 'github', 'linkedin'] },
{ name: 'url', type: 'text' },
]},
],
}Payload vs Sanity — CMS Comparison
Detailed comparison and decision matrix for choosing between Payload CMS 3.0, Sanity, Strapi, and WordPress.
Feature Comparison
| Feature | Payload 3.0 | Sanity v3 | Strapi v5 | WordPress |
|---|---|---|---|---|
| Language | TypeScript | TypeScript + GROQ | JavaScript/TS | PHP |
| Framework | Built on Next.js | React (studio) | Koa.js | Monolithic |
| Hosting | Self-hosted | Hosted API + self-hosted studio | Self-hosted | Self/hosted |
| Database | MongoDB or Postgres | Hosted (proprietary) | SQLite/Postgres/MySQL | MySQL |
| Auth | Built-in (JWT + cookies) | Hosted or custom | Built-in (JWT) | Built-in (sessions) |
| API | REST + GraphQL auto-generated | GROQ + GraphQL | REST + GraphQL | REST + GraphQL (plugin) |
| Rich Text | Lexical (built-in) | Portable Text | CKEditor/custom | Gutenberg |
| Admin UI | React + Next.js | React (Sanity Studio) | React | PHP + React (Gutenberg) |
| Type Safety | Config IS the schema | Schema + codegen | Schema + codegen | None natively |
| Plugins | npm packages | npm packages | npm marketplace | Plugin ecosystem (massive) |
| License | MIT (open source) | Freemium (hosted) | MIT with EE features | GPLv2 |
| Live Preview | Built-in | Built-in | Via plugin | Theme preview |
| Versioning | Built-in per collection | Built-in | Via plugin | Built-in (revisions) |
Cost Comparison
| Tier | Payload | Sanity | Strapi |
|---|---|---|---|
| Free | Unlimited (self-host) | 100K API requests/mo, 3 users | Unlimited (self-host) |
| Team | Payload Cloud ($25/mo) | $99/mo (500K requests) | $29/mo (gold support) |
| Enterprise | Custom | Custom | Custom |
Payload is fully open source — cost is infrastructure only. Sanity's cost scales with API usage.
Decision Matrix
Choose Payload When:
- Building a Next.js application — Payload runs inside your Next.js app
- You want full ownership of data and infrastructure
- Your team is TypeScript-first — config-as-code is natural
- You need custom access control beyond simple roles
- Self-hosting is acceptable or preferred
- You want one deployment (CMS + frontend in same app)
Choose Sanity When:
- Content editors are primary users, not developers
- You need real-time collaborative editing (Google Docs-style)
- Your content is consumed by multiple frontends (web, mobile, IoT)
- You want a hosted API with no infrastructure management
- GROQ query language fits your content querying needs
- Editorial workflow and content scheduling are critical
Choose Strapi When:
- You need a quick admin panel with minimal configuration
- Your team prefers a GUI-first content modeling approach
- You want a marketplace of pre-built plugins
- The project is a prototype or MVP that may change CMS later
- You need multi-database support (SQLite for dev, Postgres for prod)
Choose WordPress When:
- Non-technical editors need to manage content independently
- You need the largest plugin ecosystem (100K+ plugins)
- SEO tooling (Yoast, RankMath) is a core requirement
- Budget for development is limited — large talent pool
- Content is primarily blog/marketing pages
Migration Considerations
From Sanity to Payload
1. Export content via GROQ: *[_type == "post"] 2. Map Portable Text to Lexical rich text format 3. Recreate schemas as Payload collection configs 4. Migrate assets from Sanity CDN to local/S3 storage 5. Rebuild GROQ queries as Payload where clauses
From Strapi to Payload
1. Export via Strapi REST API 2. Map Strapi content types to Payload collections 1:1 3. Convert Strapi lifecycle hooks to Payload hooks 4. Migrate media from Strapi uploads to Payload upload collections 5. Replace Strapi custom controllers with Payload custom endpoints
From WordPress to Payload
1. Export via WP REST API (/wp-json/wp/v2/posts) 2. Convert ACF/custom fields to Payload field configs 3. Map WordPress taxonomies to Payload relationship fields 4. Migrate media library to Payload upload collection 5. Convert WordPress template hierarchy to Next.js layouts
Architecture Comparison
Payload 3.0: Sanity:
┌─────────────────────┐ ┌──────────────┐ ┌──────────────┐
│ Your Next.js App │ │ Sanity Studio │ │ Your App │
│ ┌───────────────┐ │ │ (React SPA) │ │ (any framework)│
│ │ Payload CMS │ │ └──────┬───────┘ └──────┬───────┘
│ │ (embedded) │ │ │ │
│ └───────┬───────┘ │ ▼ ▼
│ │ │ ┌──────────────┐ ┌──────────────┐
│ ▼ │ │ Sanity API │ │ Sanity API │
│ ┌───────────────┐ │ │ (hosted) │ │ (hosted) │
│ │ MongoDB/PG │ │ └──────────────┘ └──────────────┘
│ └───────────────┘ │
└─────────────────────┘ Single hosted API, multiple consumers
Single deployment, full controlWhen NOT to Use a Headless CMS
- Static content that rarely changes — use Markdown + static site generator
- Application data (user profiles, orders, analytics) — use a database directly
- Real-time data (chat, live feeds) — use purpose-built real-time tools
- Content that only developers edit — YAML/JSON config files may suffice
REST API Design
Resource Naming Conventions
Use plural nouns for resources:
GET /users
GET /users/123
GET /users/123/ordersUse hierarchical relationships:
GET /users/123/orders # Orders for specific user
GET /teams/5/members # Members of specific team
POST /projects/10/tasks # Create task in project 10Use kebab-case for multi-word resources:
/shopping-carts
/order-items
/user-preferencesHTTP Methods
| Method | Purpose | Idempotent | Safe | Example |
|---|---|---|---|---|
| GET | Retrieve resource(s) | Yes | Yes | GET /users/123 |
| POST | Create resource | No | No | POST /users |
| PUT | Replace entire resource | Yes | No | PUT /users/123 |
| PATCH | Partial update | No* | No | PATCH /users/123 |
| DELETE | Remove resource | Yes | No | DELETE /users/123 |
| HEAD | Metadata only (no body) | Yes | Yes | HEAD /users/123 |
| OPTIONS | Allowed methods | Yes | Yes | OPTIONS /users |
Status Codes
Success (2xx)
- 200 OK: Successful GET, PUT, PATCH, or DELETE
- 201 Created: Successful POST (include
Locationheader) - 202 Accepted: Request accepted, processing async
- 204 No Content: Successful DELETE or PUT with no response body
Client Errors (4xx)
- 400 Bad Request: Invalid request body or parameters
- 401 Unauthorized: Missing or invalid authentication
- 403 Forbidden: Authenticated but not authorized
- 404 Not Found: Resource doesn't exist
- 409 Conflict: Resource conflict (e.g., duplicate)
- 422 Unprocessable Entity: Validation failed
- 429 Too Many Requests: Rate limit exceeded
Server Errors (5xx)
- 500 Internal Server Error: Generic server error
- 502 Bad Gateway: Upstream service error
- 503 Service Unavailable: Temporary unavailability
Request/Response Formats
Request Body (POST/PUT/PATCH):
POST /users
Content-Type: application/json
{
"email": "jane@example.com",
"name": "Jane Smith",
"role": "developer"
}Success Response:
HTTP/1.1 201 Created
Location: /users/123
{
"id": 123,
"email": "jane@example.com",
"name": "Jane Smith",
"created_at": "2025-10-31T10:30:00Z"
}Pagination
Cursor-Based (Recommended)
GET /users?cursor=eyJpZCI6MTIzfQ&limit=20
Response:
{
"data": [...],
"pagination": {
"next_cursor": "eyJpZCI6MTQzfQ",
"has_more": true
}
}Use for: Large datasets, real-time data, infinite scroll
Offset-Based
GET /users?page=2&per_page=20
Response:
{
"data": [...],
"pagination": {
"page": 2,
"per_page": 20,
"total": 487,
"total_pages": 25
}
}Use for: Small datasets, admin panels, known bounds
Filtering and Sorting
GET /users?status=active&role=developer
GET /users?sort=created_at:desc
GET /users?fields=id,name,emailAPI Versioning
URI Versioning (Recommended)
/api/v1/users
/api/v2/usersHeader Versioning
GET /api/users
Accept: application/vnd.company.v2+jsonRate Limiting Headers
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 987
X-RateLimit-Reset: 1635724800Authentication
Bearer Token (JWT):
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...API Key:
X-API-Key: sk_live_abc123...RFC 9457 Problem Details for HTTP APIs
Comprehensive guide to the RFC 9457 specification for machine-readable error responses.
Overview
RFC 9457 (formerly RFC 7807) defines a standard format for expressing API errors as JSON/XML objects. This allows clients to programmatically understand and handle errors.
Problem Details Object
Required Members
| Member | Type | Description |
|---|---|---|
type | URI | A URI reference identifying the problem type |
status | integer | The HTTP status code |
Optional Members
| Member | Type | Description |
|---|---|---|
title | string | Short, human-readable summary |
detail | string | Human-readable explanation specific to this occurrence |
instance | URI | URI reference identifying the specific occurrence |
Extension Members
You can add custom members for additional context:
{
"type": "https://api.orchestkit.dev/problems/validation-error",
"title": "Validation Error",
"status": 422,
"detail": "The request body contains invalid data",
"instance": "/api/v1/analyses/123",
"errors": [
{"field": "url", "message": "Invalid URL format"},
{"field": "depth", "message": "Must be between 1 and 3"}
],
"trace_id": "abc123",
"timestamp": "2026-01-07T10:30:00Z"
}Media Type
Always use the correct media type:
Content-Type: application/problem+jsonFor XML (less common):
Content-Type: application/problem+xmlProblem Type URIs
URI Design Principles
1. Stable: URLs should not change 2. Documented: Each type should have documentation at the URL 3. Versioned: Consider including version in path 4. Hierarchical: Use path segments for categories
Examples
# Good: Specific, documented
https://api.orchestkit.dev/problems/rate-limit-exceeded
https://api.orchestkit.dev/problems/validation-error
https://api.orchestkit.dev/problems/resource-not-found
# Bad: Generic, undocumented
https://example.com/error
about:blankabout:blank
Use about:blank when the problem has no additional semantics beyond the HTTP status:
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "The requested resource was not found"
}Common Problem Types
Validation Error (422)
{
"type": "https://api.orchestkit.dev/problems/validation-error",
"title": "Validation Error",
"status": 422,
"detail": "One or more fields failed validation",
"errors": [
{
"field": "email",
"code": "invalid_format",
"message": "Invalid email format"
},
{
"field": "password",
"code": "too_short",
"message": "Password must be at least 8 characters"
}
]
}Authentication Error (401)
{
"type": "https://api.orchestkit.dev/problems/authentication-required",
"title": "Authentication Required",
"status": 401,
"detail": "Access token is missing or invalid"
}Authorization Error (403)
{
"type": "https://api.orchestkit.dev/problems/insufficient-permissions",
"title": "Insufficient Permissions",
"status": 403,
"detail": "You don't have permission to access this resource",
"required_permission": "analyses:write"
}Resource Not Found (404)
{
"type": "https://api.orchestkit.dev/problems/resource-not-found",
"title": "Resource Not Found",
"status": 404,
"detail": "Analysis with ID 'abc123' was not found",
"resource_type": "analysis",
"resource_id": "abc123"
}Rate Limit Exceeded (429)
{
"type": "https://api.orchestkit.dev/problems/rate-limit-exceeded",
"title": "Rate Limit Exceeded",
"status": 429,
"detail": "You have exceeded 100 requests per minute",
"retry_after": 45,
"limit": 100,
"window": "1 minute"
}Conflict (409)
{
"type": "https://api.orchestkit.dev/problems/resource-conflict",
"title": "Resource Conflict",
"status": 409,
"detail": "A user with this email already exists",
"conflicting_field": "email"
}Internal Server Error (500)
{
"type": "https://api.orchestkit.dev/problems/internal-error",
"title": "Internal Server Error",
"status": 500,
"detail": "An unexpected error occurred. Please try again later.",
"trace_id": "trace-abc123",
"support_url": "https://support.orchestkit.dev"
}Client Handling
Python Client Example
import httpx
from dataclasses import dataclass
@dataclass
class ProblemDetail:
type: str
status: int
title: str | None = None
detail: str | None = None
instance: str | None = None
extensions: dict | None = None
@classmethod
def from_response(cls, response: httpx.Response) -> "ProblemDetail":
if response.headers.get("content-type", "").startswith("application/problem+json"):
data = response.json()
return cls(
type=data.get("type", "about:blank"),
status=data.get("status", response.status_code),
title=data.get("title"),
detail=data.get("detail"),
instance=data.get("instance"),
extensions={
k: v for k, v in data.items()
if k not in ("type", "status", "title", "detail", "instance")
},
)
return cls(
type="about:blank",
status=response.status_code,
title=response.reason_phrase,
)
class APIError(Exception):
def __init__(self, problem: ProblemDetail):
self.problem = problem
super().__init__(problem.detail or problem.title)
async def make_request(url: str) -> dict:
async with httpx.AsyncClient() as client:
response = await client.get(url)
if response.is_error:
problem = ProblemDetail.from_response(response)
raise APIError(problem)
return response.json()TypeScript Client Example
interface ProblemDetail {
type: string;
status: number;
title?: string;
detail?: string;
instance?: string;
[key: string]: unknown; // Extensions
}
class APIError extends Error {
constructor(public problem: ProblemDetail) {
super(problem.detail || problem.title || 'Unknown error');
}
}
async function fetchWithProblemDetails(url: string): Promise<Response> {
const response = await fetch(url);
if (!response.ok) {
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/problem+json')) {
const problem: ProblemDetail = await response.json();
throw new APIError(problem);
}
throw new APIError({
type: 'about:blank',
status: response.status,
title: response.statusText,
});
}
return response;
}Related Files
- See
examples/fastapi-problem-details.mdfor FastAPI implementation - See
checklists/error-handling-checklist.mdfor implementation checklist - See SKILL.md for complete patterns
[Rule Name]
[Brief description — 1-2 sentences.]
Incorrect:
// Bad patternCorrect:
// Good patternKey rules:
- [Rule 1]
- [Rule 2]
- [Rule 3]
Reference: [link]