Initial commit
This commit is contained in:
12
.claude-plugin/plugin.json
Normal file
12
.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "axiom-web-backend",
|
||||||
|
"description": "Web backend development expertise across FastAPI, Django, Express, REST/GraphQL APIs, microservices, and production deployment patterns",
|
||||||
|
"version": "1.0.2",
|
||||||
|
"author": {
|
||||||
|
"name": "tachyon-beep",
|
||||||
|
"email": "zhongweili@tubi.tv"
|
||||||
|
},
|
||||||
|
"skills": [
|
||||||
|
"./skills"
|
||||||
|
]
|
||||||
|
}
|
||||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# axiom-web-backend
|
||||||
|
|
||||||
|
Web backend development expertise across FastAPI, Django, Express, REST/GraphQL APIs, microservices, and production deployment patterns
|
||||||
89
plugin.lock.json
Normal file
89
plugin.lock.json
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"$schema": "internal://schemas/plugin.lock.v1.json",
|
||||||
|
"pluginId": "gh:tachyon-beep/skillpacks:plugins/axiom-web-backend",
|
||||||
|
"normalized": {
|
||||||
|
"repo": null,
|
||||||
|
"ref": "refs/tags/v20251128.0",
|
||||||
|
"commit": "0764e5b504aacbd4da2f48bdec21358663278d74",
|
||||||
|
"treeHash": "364f2c0aaeb1af51d1ccf2720bc551e971fce5f23cd7f2c2b18f63f76a1f6ac7",
|
||||||
|
"generatedAt": "2025-11-28T10:28:31.553642Z",
|
||||||
|
"toolVersion": "publish_plugins.py@0.2.0"
|
||||||
|
},
|
||||||
|
"origin": {
|
||||||
|
"remote": "git@github.com:zhongweili/42plugin-data.git",
|
||||||
|
"branch": "master",
|
||||||
|
"commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390",
|
||||||
|
"repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data"
|
||||||
|
},
|
||||||
|
"manifest": {
|
||||||
|
"name": "axiom-web-backend",
|
||||||
|
"description": "Web backend development expertise across FastAPI, Django, Express, REST/GraphQL APIs, microservices, and production deployment patterns",
|
||||||
|
"version": "1.0.2"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "README.md",
|
||||||
|
"sha256": "f3e380170674e70ad093e5df7e66f9da0491072355d9e909bd2739bf674f46a2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".claude-plugin/plugin.json",
|
||||||
|
"sha256": "041ef2dbea7ec879a9b9b0ad4c19907669881b319c0833a96ca9ff4c618f6cf8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/using-web-backend/database-integration.md",
|
||||||
|
"sha256": "67d46b6ba3e8c054d5b5e2f0a7834de8b8449f2a3f02522bc47176f928efb41a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/using-web-backend/api-authentication.md",
|
||||||
|
"sha256": "a39ba1396e008ab506196585cbac3ca1b05d22890db6dce3fee1a74ea346330f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/using-web-backend/fastapi-development.md",
|
||||||
|
"sha256": "5625b5728fe03930ddc68fd37457eee2e86037e78b08fed0dd319b11d05eb40d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/using-web-backend/rest-api-design.md",
|
||||||
|
"sha256": "612c2c0e04c74868b2f623c1802bcd83b4a4d10958e599a30260eb0be1f477e1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/using-web-backend/microservices-architecture.md",
|
||||||
|
"sha256": "b0deaa7b2652ce38dd653402db0feb6c41dc584b3013f2d44d1e68466158d75b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/using-web-backend/api-documentation.md",
|
||||||
|
"sha256": "b385ed6a4b78ac2c43cafe90cba81e944574073023a4c6b10d82cf8d48729987"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/using-web-backend/api-testing.md",
|
||||||
|
"sha256": "bdd989e1ce000b8cff10bfb7a60e68beb12278cb8161805e90855dca16bf4946"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/using-web-backend/message-queues.md",
|
||||||
|
"sha256": "82069fb6e4d2fea25c60c1ea762a5addf5a8649b67f451fa2c7f626b6601c670"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/using-web-backend/graphql-api-design.md",
|
||||||
|
"sha256": "5415ec8cec79237f4c60cb1c7949b7091028f669f95853af3baf15c25bf83e55"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/using-web-backend/SKILL.md",
|
||||||
|
"sha256": "eb0a96a46e863f347512c29ef57624657c3380f7f056023339d3679771659541"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/using-web-backend/express-development.md",
|
||||||
|
"sha256": "af60cd4f592ea5ba20ff4749e2337932278fb3f4b21fe36834b9a07c6c481fb5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/using-web-backend/django-development.md",
|
||||||
|
"sha256": "338e5ac3e7d6cda4138618abe42c0af640497b26e74b1f0c934cf97a493ab078"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dirSha256": "364f2c0aaeb1af51d1ccf2720bc551e971fce5f23cd7f2c2b18f63f76a1f6ac7"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"scannedAt": null,
|
||||||
|
"scannerVersion": null,
|
||||||
|
"flags": []
|
||||||
|
}
|
||||||
|
}
|
||||||
135
skills/using-web-backend/SKILL.md
Normal file
135
skills/using-web-backend/SKILL.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
---
|
||||||
|
name: using-web-backend
|
||||||
|
description: Use when building web APIs, backend services, or encountering FastAPI/Django/Express/GraphQL questions, microservices architecture, authentication, or message queues - routes to 11 specialist skills rather than giving surface-level generic advice
|
||||||
|
---
|
||||||
|
|
||||||
|
# Using Web Backend Skills
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**This router directs you to specialized web backend skills. Each specialist provides deep expertise in their domain.**
|
||||||
|
|
||||||
|
**Core principle:** Different backend challenges require different specialist knowledge. Routing to the right skill gives better results than generic advice.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
Use this router when encountering:
|
||||||
|
|
||||||
|
- **Framework-specific questions**: FastAPI, Django, Express implementation details
|
||||||
|
- **API design**: REST or GraphQL architecture, versioning, schema design
|
||||||
|
- **Architecture patterns**: Microservices, message queues, event-driven systems
|
||||||
|
- **Backend infrastructure**: Authentication, database integration, deployment
|
||||||
|
- **Testing & documentation**: API testing strategies, documentation approaches
|
||||||
|
|
||||||
|
## Quick Reference - Routing Table
|
||||||
|
|
||||||
|
| User Question Contains | Route To | Why |
|
||||||
|
|------------------------|----------|-----|
|
||||||
|
| FastAPI, Pydantic, async Python APIs | [fastapi-development.md](fastapi-development.md) | FastAPI-specific patterns, dependency injection, async |
|
||||||
|
| Django, ORM, views, middleware | [django-development.md](django-development.md) | Django conventions, ORM optimization, settings |
|
||||||
|
| Express, Node.js backend, middleware | [express-development.md](express-development.md) | Express patterns, error handling, async flow |
|
||||||
|
| REST API, endpoints, versioning, pagination | [rest-api-design.md](rest-api-design.md) | REST principles, resource design, hypermedia |
|
||||||
|
| GraphQL, schema, resolvers, N+1 | [graphql-api-design.md](graphql-api-design.md) | Schema design, query optimization, federation |
|
||||||
|
| Microservices, service mesh, boundaries | [microservices-architecture.md](microservices-architecture.md) | Service design, communication, consistency |
|
||||||
|
| Message queues, RabbitMQ, Kafka, events | [message-queues.md](message-queues.md) | Queue patterns, reliability, event-driven |
|
||||||
|
| JWT, OAuth2, API keys, auth | [api-authentication.md](api-authentication.md) | Auth patterns, token management, security |
|
||||||
|
| Database connections, ORM, migrations | [database-integration.md](database-integration.md) | Connection pooling, query optimization, migrations |
|
||||||
|
| API testing, integration tests, mocking | [api-testing.md](api-testing.md) | Testing strategies, contract testing, mocking |
|
||||||
|
| OpenAPI, Swagger, API docs | [api-documentation.md](api-documentation.md) | API docs (also see: muna-technical-writer) |
|
||||||
|
|
||||||
|
## Cross-References to Other Packs
|
||||||
|
|
||||||
|
**Before routing, check if these packs are more appropriate:**
|
||||||
|
|
||||||
|
- **Security concerns** → `ordis-security-architect` (threat modeling, OWASP, security patterns)
|
||||||
|
- **API usability/UX** → `lyra-ux-designer` (error messages, API ergonomics)
|
||||||
|
- **Python code patterns** → `axiom-python-engineering` (Python-specific engineering)
|
||||||
|
- **Documentation writing** → `muna-technical-writer` (technical writing, clarity)
|
||||||
|
|
||||||
|
## How to Route
|
||||||
|
|
||||||
|
**STOP: Do not attempt to answer web backend questions yourself.**
|
||||||
|
|
||||||
|
**Instead:**
|
||||||
|
|
||||||
|
1. **Identify the specialist domain** from the routing table above
|
||||||
|
2. **State which specialist skill you're using**: "I'll use the `[skill-name]` skill for this"
|
||||||
|
3. **Apply the specialist skill** to provide deep, expert guidance
|
||||||
|
|
||||||
|
## Red Flags - Using Generic Knowledge Instead of Specialists
|
||||||
|
|
||||||
|
If you catch yourself doing any of these, STOP and route to a specialist:
|
||||||
|
|
||||||
|
- ❌ "I'll answer all these diverse backend questions myself"
|
||||||
|
- ❌ Giving code examples from "general backend knowledge"
|
||||||
|
- ❌ Providing "competent but broad" answers across multiple domains
|
||||||
|
- ❌ Not mentioning specialized skills would give better guidance
|
||||||
|
- ❌ "The specialist skill isn't available right now, so I'll answer myself"
|
||||||
|
|
||||||
|
**All of these mean: You're being a jack-of-all-trades. Route to the specialist.**
|
||||||
|
|
||||||
|
## Rationalization Table
|
||||||
|
|
||||||
|
| Excuse | Reality |
|
||||||
|
|--------|---------|
|
||||||
|
| "I can handle this backend question myself" | Specialists provide deeper expertise than generic knowledge |
|
||||||
|
| "These are all backend topics" | Backend spans 11 distinct specialist domains |
|
||||||
|
| "I'll just give a quick answer" | Quick answers miss edge cases and production patterns |
|
||||||
|
| "The user didn't ask for specialized help" | Better results come from routing to specialists proactively |
|
||||||
|
| "Specialist skill not available, I'll answer instead" | State which skill would help, explain routing, guide user to install if needed |
|
||||||
|
|
||||||
|
## Example Routing
|
||||||
|
|
||||||
|
**User**: "I need to build a REST API with FastAPI. How should I structure my dependency injection?"
|
||||||
|
|
||||||
|
**Your response**:
|
||||||
|
"I'll use the [fastapi-development.md](fastapi-development.md) skill to help you structure dependency injection properly. This specialist skill covers FastAPI patterns in depth."
|
||||||
|
|
||||||
|
[Then apply fastapi-development skill]
|
||||||
|
|
||||||
|
**User**: "My GraphQL API has N+1 problems and we're splitting our monolith into microservices."
|
||||||
|
|
||||||
|
**Your response**:
|
||||||
|
"You have two distinct specialist areas:
|
||||||
|
1. GraphQL optimization → I'll use [graphql-api-design.md](graphql-api-design.md)
|
||||||
|
2. Microservices architecture → I'll use [microservices-architecture.md](microservices-architecture.md)
|
||||||
|
|
||||||
|
Let me address the GraphQL N+1 problem first with the graphql-api-design skill..."
|
||||||
|
|
||||||
|
[Apply each specialist skill to its domain]
|
||||||
|
|
||||||
|
## Why This Matters
|
||||||
|
|
||||||
|
**Without routing**: Surface-level answers covering multiple domains broadly
|
||||||
|
**With routing**: Deep expertise addressing edge cases, production patterns, and domain-specific best practices
|
||||||
|
|
||||||
|
Specialist skills = better results.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web Backend Specialist Skills Catalog
|
||||||
|
|
||||||
|
After routing, load the appropriate specialist skill for detailed guidance:
|
||||||
|
|
||||||
|
### Framework-Specific Skills
|
||||||
|
|
||||||
|
1. [fastapi-development.md](fastapi-development.md) - FastAPI patterns, dependency injection, async/await, Pydantic validation, background tasks
|
||||||
|
2. [django-development.md](django-development.md) - Django conventions, ORM optimization, middleware, settings, management commands
|
||||||
|
3. [express-development.md](express-development.md) - Express patterns, middleware chains, error handling, async flow control
|
||||||
|
|
||||||
|
### API Design Skills
|
||||||
|
|
||||||
|
4. [rest-api-design.md](rest-api-design.md) - REST principles, resource design, versioning, pagination, HATEOAS, HTTP semantics
|
||||||
|
5. [graphql-api-design.md](graphql-api-design.md) - GraphQL schema design, resolver patterns, N+1 query optimization, federation
|
||||||
|
|
||||||
|
### Architecture & Infrastructure
|
||||||
|
|
||||||
|
6. [microservices-architecture.md](microservices-architecture.md) - Service boundaries, communication patterns, distributed consistency, service mesh
|
||||||
|
7. [message-queues.md](message-queues.md) - Queue patterns, reliability guarantees, event-driven architecture, RabbitMQ/Kafka
|
||||||
|
|
||||||
|
### Cross-Cutting Concerns
|
||||||
|
|
||||||
|
8. [api-authentication.md](api-authentication.md) - JWT, OAuth2, API keys, token management, auth patterns
|
||||||
|
9. [database-integration.md](database-integration.md) - Connection pooling, query optimization, migrations, ORM patterns
|
||||||
|
10. [api-testing.md](api-testing.md) - Testing strategies, contract testing, integration tests, mocking
|
||||||
|
11. [api-documentation.md](api-documentation.md) - OpenAPI/Swagger, API documentation patterns, schema generation
|
||||||
1381
skills/using-web-backend/api-authentication.md
Normal file
1381
skills/using-web-backend/api-authentication.md
Normal file
File diff suppressed because it is too large
Load Diff
944
skills/using-web-backend/api-documentation.md
Normal file
944
skills/using-web-backend/api-documentation.md
Normal file
@@ -0,0 +1,944 @@
|
|||||||
|
|
||||||
|
# API Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**API documentation specialist covering OpenAPI specs, documentation-as-code, testing docs, SDK generation, and preventing documentation debt.**
|
||||||
|
|
||||||
|
**Core principle**: Documentation is a product feature that directly impacts developer adoption - invest in keeping it accurate, tested, and discoverable.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Use when encountering:
|
||||||
|
|
||||||
|
- **OpenAPI/Swagger**: Auto-generating docs, customizing Swagger UI, maintaining specs
|
||||||
|
- **Documentation testing**: Ensuring examples work, preventing stale docs
|
||||||
|
- **Versioning**: Managing multi-version docs, deprecation notices
|
||||||
|
- **Documentation-as-code**: Keeping docs in sync with code changes
|
||||||
|
- **SDK generation**: Generating client libraries from OpenAPI specs
|
||||||
|
- **Documentation debt**: Detecting and preventing outdated documentation
|
||||||
|
- **Metrics**: Tracking documentation usage and effectiveness
|
||||||
|
- **Community docs**: Managing contributions, improving discoverability
|
||||||
|
|
||||||
|
**Do NOT use for**:
|
||||||
|
- General technical writing (see `muna-technical-writer` skill)
|
||||||
|
- API design principles (see `rest-api-design`, `graphql-api-design`)
|
||||||
|
- Authentication implementation (see `api-authentication`)
|
||||||
|
|
||||||
|
## OpenAPI Specification Best Practices
|
||||||
|
|
||||||
|
### Production-Quality OpenAPI Specs
|
||||||
|
|
||||||
|
**Complete FastAPI example**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import FastAPI, Path, Query, Body
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Payment Processing API",
|
||||||
|
description="""
|
||||||
|
# Payment API
|
||||||
|
|
||||||
|
Process payments with PCI-DSS compliance.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Multiple payment methods (cards, ACH, digital wallets)
|
||||||
|
- Fraud detection
|
||||||
|
- Webhook notifications
|
||||||
|
- Test mode for development
|
||||||
|
|
||||||
|
## Rate Limits
|
||||||
|
- Standard: 100 requests/minute
|
||||||
|
- Premium: 1000 requests/minute
|
||||||
|
|
||||||
|
## Support
|
||||||
|
- Documentation: https://docs.example.com
|
||||||
|
- Status: https://status.example.com
|
||||||
|
- Support: api-support@example.com
|
||||||
|
""",
|
||||||
|
version="2.1.0",
|
||||||
|
terms_of_service="https://example.com/terms",
|
||||||
|
contact={
|
||||||
|
"name": "API Support",
|
||||||
|
"url": "https://example.com/support",
|
||||||
|
"email": "api-support@example.com"
|
||||||
|
},
|
||||||
|
license_info={
|
||||||
|
"name": "Apache 2.0",
|
||||||
|
"url": "https://www.apache.org/licenses/LICENSE-2.0.html"
|
||||||
|
},
|
||||||
|
servers=[
|
||||||
|
{"url": "https://api.example.com", "description": "Production"},
|
||||||
|
{"url": "https://sandbox-api.example.com", "description": "Sandbox"}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tag organization
|
||||||
|
tags_metadata = [
|
||||||
|
{
|
||||||
|
"name": "payments",
|
||||||
|
"description": "Payment operations",
|
||||||
|
"externalDocs": {
|
||||||
|
"description": "Payment Guide",
|
||||||
|
"url": "https://docs.example.com/guides/payments"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
app = FastAPI(openapi_tags=tags_metadata)
|
||||||
|
|
||||||
|
# Rich schema with examples
|
||||||
|
class PaymentRequest(BaseModel):
|
||||||
|
amount: float = Field(
|
||||||
|
...,
|
||||||
|
gt=0,
|
||||||
|
le=999999.99,
|
||||||
|
description="Payment amount in USD",
|
||||||
|
example=99.99
|
||||||
|
)
|
||||||
|
currency: str = Field(
|
||||||
|
default="USD",
|
||||||
|
pattern="^[A-Z]{3}$",
|
||||||
|
description="ISO 4217 currency code",
|
||||||
|
example="USD"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
schema_extra = {
|
||||||
|
"examples": [
|
||||||
|
{
|
||||||
|
"amount": 149.99,
|
||||||
|
"currency": "USD",
|
||||||
|
"payment_method": "card_visa_4242",
|
||||||
|
"description": "Premium subscription"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"amount": 29.99,
|
||||||
|
"currency": "EUR",
|
||||||
|
"payment_method": "paypal_account",
|
||||||
|
"description": "Monthly plan"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Comprehensive error documentation
|
||||||
|
@app.post(
|
||||||
|
"/payments",
|
||||||
|
summary="Create payment",
|
||||||
|
description="""
|
||||||
|
Creates a new payment transaction.
|
||||||
|
|
||||||
|
## Processing Time
|
||||||
|
Typically 2-5 seconds for card payments.
|
||||||
|
|
||||||
|
## Idempotency
|
||||||
|
Use `Idempotency-Key` header to prevent duplicates.
|
||||||
|
|
||||||
|
## Test Mode
|
||||||
|
Use test payment methods in sandbox environment.
|
||||||
|
""",
|
||||||
|
responses={
|
||||||
|
201: {"description": "Payment created", "model": PaymentResponse},
|
||||||
|
400: {
|
||||||
|
"description": "Invalid request",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"examples": {
|
||||||
|
"invalid_amount": {
|
||||||
|
"summary": "Amount validation failed",
|
||||||
|
"value": {
|
||||||
|
"error_code": "INVALID_AMOUNT",
|
||||||
|
"message": "Amount must be between 0.01 and 999999.99"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
402: {"description": "Payment declined"},
|
||||||
|
429: {"description": "Rate limit exceeded"}
|
||||||
|
},
|
||||||
|
tags=["payments"]
|
||||||
|
)
|
||||||
|
async def create_payment(payment: PaymentRequest):
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom OpenAPI Generation
|
||||||
|
|
||||||
|
**Add security schemes, custom extensions**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi.openapi.utils import get_openapi
|
||||||
|
|
||||||
|
def custom_openapi():
|
||||||
|
if app.openapi_schema:
|
||||||
|
return app.openapi_schema
|
||||||
|
|
||||||
|
openapi_schema = get_openapi(
|
||||||
|
title=app.title,
|
||||||
|
version=app.version,
|
||||||
|
description=app.description,
|
||||||
|
routes=app.routes,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Security schemes
|
||||||
|
openapi_schema["components"]["securitySchemes"] = {
|
||||||
|
"ApiKeyAuth": {
|
||||||
|
"type": "apiKey",
|
||||||
|
"in": "header",
|
||||||
|
"name": "X-API-Key",
|
||||||
|
"description": "Get your API key at https://dashboard.example.com/api-keys"
|
||||||
|
},
|
||||||
|
"OAuth2": {
|
||||||
|
"type": "oauth2",
|
||||||
|
"flows": {
|
||||||
|
"authorizationCode": {
|
||||||
|
"authorizationUrl": "https://auth.example.com/oauth/authorize",
|
||||||
|
"tokenUrl": "https://auth.example.com/oauth/token",
|
||||||
|
"scopes": {
|
||||||
|
"payments:read": "Read payment data",
|
||||||
|
"payments:write": "Create payments"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"clientCredentials": {
|
||||||
|
"tokenUrl": "https://auth.example.com/oauth/token",
|
||||||
|
"scopes": {
|
||||||
|
"payments:read": "Read payment data",
|
||||||
|
"payments:write": "Create payments"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Global security requirement
|
||||||
|
openapi_schema["security"] = [{"ApiKeyAuth": []}]
|
||||||
|
|
||||||
|
# Custom extensions for tooling
|
||||||
|
openapi_schema["x-api-id"] = "payments-api-v2"
|
||||||
|
openapi_schema["x-audience"] = "external"
|
||||||
|
openapi_schema["x-ratelimit-default"] = 100
|
||||||
|
|
||||||
|
# Add code samples extension (for Swagger UI)
|
||||||
|
for path_data in openapi_schema["paths"].values():
|
||||||
|
for operation in path_data.values():
|
||||||
|
if isinstance(operation, dict) and "operationId" in operation:
|
||||||
|
operation["x-code-samples"] = [
|
||||||
|
{
|
||||||
|
"lang": "curl",
|
||||||
|
"source": generate_curl_example(operation)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lang": "python",
|
||||||
|
"source": generate_python_example(operation)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
app.openapi_schema = openapi_schema
|
||||||
|
return app.openapi_schema
|
||||||
|
|
||||||
|
app.openapi = custom_openapi
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation-as-Code
|
||||||
|
|
||||||
|
### Keep Docs in Sync with Code
|
||||||
|
|
||||||
|
**Anti-pattern**: Docs in separate repo, manually updated, always stale
|
||||||
|
|
||||||
|
**Pattern**: Co-locate docs with code, auto-generate from source
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Source of truth: Pydantic models
|
||||||
|
class PaymentRequest(BaseModel):
|
||||||
|
"""
|
||||||
|
Payment request model.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
Basic payment:
|
||||||
|
```python
|
||||||
|
payment = PaymentRequest(
|
||||||
|
amount=99.99,
|
||||||
|
currency="USD",
|
||||||
|
payment_method="pm_card_visa"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
amount: float = Field(..., description="Amount in USD")
|
||||||
|
currency: str = Field(default="USD", description="ISO 4217 currency code")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
schema_extra = {
|
||||||
|
"examples": [
|
||||||
|
{"amount": 99.99, "currency": "USD", "payment_method": "pm_card_visa"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Docs auto-generated from model
|
||||||
|
# - OpenAPI spec from Field descriptions
|
||||||
|
# - Examples from schema_extra
|
||||||
|
# - Code samples from docstring examples
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prevent schema drift**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
def test_openapi_schema_matches_committed():
|
||||||
|
"""Ensure OpenAPI spec is committed and up-to-date"""
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
# Get current OpenAPI spec
|
||||||
|
current_spec = client.get("/openapi.json").json()
|
||||||
|
|
||||||
|
# Load committed spec
|
||||||
|
with open("docs/openapi.json") as f:
|
||||||
|
committed_spec = json.load(f)
|
||||||
|
|
||||||
|
# Fail if specs don't match
|
||||||
|
assert current_spec == committed_spec, \
|
||||||
|
"OpenAPI spec has changed. Run 'make update-openapi-spec' and commit"
|
||||||
|
|
||||||
|
def test_all_endpoints_have_examples():
|
||||||
|
"""Ensure all endpoints have request/response examples"""
|
||||||
|
client = TestClient(app)
|
||||||
|
spec = client.get("/openapi.json").json()
|
||||||
|
|
||||||
|
for path, methods in spec["paths"].items():
|
||||||
|
for method, details in methods.items():
|
||||||
|
if method in ["get", "post", "put", "patch", "delete"]:
|
||||||
|
# Check request body has example
|
||||||
|
if "requestBody" in details:
|
||||||
|
assert "examples" in details["requestBody"]["content"]["application/json"], \
|
||||||
|
f"{method.upper()} {path} missing request examples"
|
||||||
|
|
||||||
|
# Check responses have examples
|
||||||
|
for status_code, response in details.get("responses", {}).items():
|
||||||
|
if "content" in response and "application/json" in response["content"]:
|
||||||
|
assert "examples" in response["content"]["application/json"] or \
|
||||||
|
"example" in response["content"]["application/json"]["schema"], \
|
||||||
|
f"{method.upper()} {path} response {status_code} missing examples"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Documentation Pre-Commit Hook
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .git/hooks/pre-commit
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Regenerate OpenAPI spec
|
||||||
|
python -c "
|
||||||
|
from app.main import app
|
||||||
|
import json
|
||||||
|
|
||||||
|
with open('docs/openapi.json', 'w') as f:
|
||||||
|
json.dump(app.openapi(), f, indent=2)
|
||||||
|
"
|
||||||
|
|
||||||
|
# Check if spec changed
|
||||||
|
git add docs/openapi.json
|
||||||
|
|
||||||
|
# Validate spec
|
||||||
|
npm run validate:openapi
|
||||||
|
|
||||||
|
# Run doc tests
|
||||||
|
pytest tests/test_documentation.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation Testing
|
||||||
|
|
||||||
|
### Ensure Examples Actually Work
|
||||||
|
|
||||||
|
**Problem**: Examples in docs become stale, don't work
|
||||||
|
|
||||||
|
**Solution**: Test every code example automatically
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Extract examples from OpenAPI spec
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
def get_all_examples_from_openapi():
|
||||||
|
"""Extract all examples from OpenAPI spec"""
|
||||||
|
spec = app.openapi()
|
||||||
|
examples = []
|
||||||
|
|
||||||
|
for path, methods in spec["paths"].items():
|
||||||
|
for method, details in methods.items():
|
||||||
|
if "examples" in details.get("requestBody", {}).get("content", {}).get("application/json", {}):
|
||||||
|
for example_name, example_data in details["requestBody"]["content"]["application/json"]["examples"].items():
|
||||||
|
examples.append({
|
||||||
|
"path": path,
|
||||||
|
"method": method,
|
||||||
|
"example_name": example_name,
|
||||||
|
"data": example_data["value"]
|
||||||
|
})
|
||||||
|
|
||||||
|
return examples
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("example", get_all_examples_from_openapi(), ids=lambda e: f"{e['method']}_{e['path']}_{e['example_name']}")
|
||||||
|
def test_openapi_examples_are_valid(example, client):
|
||||||
|
"""Test that all OpenAPI examples are valid requests"""
|
||||||
|
method = example["method"]
|
||||||
|
path = example["path"]
|
||||||
|
data = example["data"]
|
||||||
|
|
||||||
|
response = client.request(method, path, json=data)
|
||||||
|
|
||||||
|
# Examples should either succeed or fail with expected error
|
||||||
|
assert response.status_code in [200, 201, 400, 401, 402, 403, 404], \
|
||||||
|
f"Example {example['example_name']} for {method.upper()} {path} returned unexpected status {response.status_code}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test markdown code samples**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
def extract_code_blocks_from_markdown(markdown_file):
|
||||||
|
"""Extract code blocks from markdown"""
|
||||||
|
with open(markdown_file) as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Find code blocks with language
|
||||||
|
pattern = r'```(\w+)\n(.*?)```'
|
||||||
|
return re.findall(pattern, content, re.DOTALL)
|
||||||
|
|
||||||
|
def test_python_examples_in_quickstart():
|
||||||
|
"""Test that Python examples in quickstart.md execute without errors"""
|
||||||
|
code_blocks = extract_code_blocks_from_markdown("docs/quickstart.md")
|
||||||
|
|
||||||
|
for lang, code in code_blocks:
|
||||||
|
if lang == "python":
|
||||||
|
# Write code to temp file
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
|
||||||
|
# Replace placeholders
|
||||||
|
code = code.replace("sk_test_abc123...", "test_api_key")
|
||||||
|
code = code.replace("https://api.example.com", "http://localhost:8000")
|
||||||
|
f.write(code)
|
||||||
|
f.flush()
|
||||||
|
|
||||||
|
# Run code
|
||||||
|
result = subprocess.run(
|
||||||
|
["python", f.name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, \
|
||||||
|
f"Python example failed:\n{code}\n\nError:\n{result.stderr}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Documentation Coverage Metrics
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_documentation_coverage():
|
||||||
|
"""Ensure all endpoints are documented"""
|
||||||
|
from fastapi.openapi.utils import get_openapi
|
||||||
|
|
||||||
|
spec = get_openapi(title="Test", version="1.0.0", routes=app.routes)
|
||||||
|
|
||||||
|
missing_docs = []
|
||||||
|
|
||||||
|
for path, methods in spec["paths"].items():
|
||||||
|
for method, details in methods.items():
|
||||||
|
# Check summary
|
||||||
|
if not details.get("summary"):
|
||||||
|
missing_docs.append(f"{method.upper()} {path}: Missing summary")
|
||||||
|
|
||||||
|
# Check description
|
||||||
|
if not details.get("description"):
|
||||||
|
missing_docs.append(f"{method.upper()} {path}: Missing description")
|
||||||
|
|
||||||
|
# Check examples
|
||||||
|
if "requestBody" in details:
|
||||||
|
content = details["requestBody"].get("content", {}).get("application/json", {})
|
||||||
|
if "examples" not in content and "example" not in content.get("schema", {}):
|
||||||
|
missing_docs.append(f"{method.upper()} {path}: Missing request example")
|
||||||
|
|
||||||
|
assert not missing_docs, \
|
||||||
|
f"Documentation incomplete:\n" + "\n".join(missing_docs)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Interactive Documentation
|
||||||
|
|
||||||
|
### Swagger UI Customization
|
||||||
|
|
||||||
|
**Custom Swagger UI with branding**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.openapi.docs import get_swagger_ui_html
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
app = FastAPI(docs_url=None) # Disable default docs
|
||||||
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|
||||||
|
@app.get("/docs", include_in_schema=False)
|
||||||
|
async def custom_swagger_ui_html():
|
||||||
|
return get_swagger_ui_html(
|
||||||
|
openapi_url=app.openapi_url,
|
||||||
|
title=f"{app.title} - API Documentation",
|
||||||
|
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
|
||||||
|
swagger_js_url="/static/swagger-ui-bundle.js",
|
||||||
|
swagger_css_url="/static/swagger-ui.css",
|
||||||
|
swagger_favicon_url="/static/favicon.png",
|
||||||
|
swagger_ui_parameters={
|
||||||
|
"deepLinking": True,
|
||||||
|
"displayRequestDuration": True,
|
||||||
|
"filter": True,
|
||||||
|
"showExtensions": True,
|
||||||
|
"tryItOutEnabled": True,
|
||||||
|
"persistAuthorization": True,
|
||||||
|
"defaultModelsExpandDepth": 1,
|
||||||
|
"defaultModelExpandDepth": 1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add "Try It Out" authentication**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi.openapi.docs import get_swagger_ui_html
|
||||||
|
|
||||||
|
@app.get("/docs")
|
||||||
|
async def custom_swagger_ui():
|
||||||
|
return get_swagger_ui_html(
|
||||||
|
openapi_url="/openapi.json",
|
||||||
|
title="API Docs",
|
||||||
|
init_oauth={
|
||||||
|
"clientId": "swagger-ui-client",
|
||||||
|
"appName": "API Documentation",
|
||||||
|
"usePkceWithAuthorizationCodeGrant": True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### ReDoc Customization
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi.openapi.docs import get_redoc_html
|
||||||
|
|
||||||
|
@app.get("/redoc", include_in_schema=False)
|
||||||
|
async def redoc_html():
|
||||||
|
return get_redoc_html(
|
||||||
|
openapi_url="/openapi.json",
|
||||||
|
title="API Documentation - ReDoc",
|
||||||
|
redoc_js_url="/static/redoc.standalone.js",
|
||||||
|
redoc_favicon_url="/static/favicon.png",
|
||||||
|
with_google_fonts=True
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**ReDoc configuration options**:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- static/redoc-config.html -->
|
||||||
|
<redoc
|
||||||
|
spec-url="/openapi.json"
|
||||||
|
expand-responses="200,201"
|
||||||
|
required-props-first="true"
|
||||||
|
sort-props-alphabetically="true"
|
||||||
|
hide-download-button="false"
|
||||||
|
native-scrollbars="false"
|
||||||
|
path-in-middle-panel="true"
|
||||||
|
theme='{
|
||||||
|
"colors": {
|
||||||
|
"primary": {"main": "#32329f"}
|
||||||
|
},
|
||||||
|
"typography": {
|
||||||
|
"fontSize": "14px",
|
||||||
|
"fontFamily": "Roboto, sans-serif"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
></redoc>
|
||||||
|
```
|
||||||
|
|
||||||
|
## SDK Generation
|
||||||
|
|
||||||
|
### Generate Client SDKs from OpenAPI
|
||||||
|
|
||||||
|
**OpenAPI Generator**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install openapi-generator
|
||||||
|
npm install -g @openapitools/openapi-generator-cli
|
||||||
|
|
||||||
|
# Generate Python SDK
|
||||||
|
openapi-generator-cli generate \
|
||||||
|
-i docs/openapi.json \
|
||||||
|
-g python \
|
||||||
|
-o sdks/python \
|
||||||
|
--additional-properties=packageName=payment_api,projectName=payment-api-python
|
||||||
|
|
||||||
|
# Generate TypeScript SDK
|
||||||
|
openapi-generator-cli generate \
|
||||||
|
-i docs/openapi.json \
|
||||||
|
-g typescript-fetch \
|
||||||
|
-o sdks/typescript \
|
||||||
|
--additional-properties=npmName=@example/payment-api,supportsES6=true
|
||||||
|
|
||||||
|
# Generate Go SDK
|
||||||
|
openapi-generator-cli generate \
|
||||||
|
-i docs/openapi.json \
|
||||||
|
-g go \
|
||||||
|
-o sdks/go \
|
||||||
|
--additional-properties=packageName=paymentapi
|
||||||
|
```
|
||||||
|
|
||||||
|
**Automate SDK generation in CI**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/generate-sdks.yml
|
||||||
|
name: Generate SDKs
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'docs/openapi.json'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
generate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Generate Python SDK
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
-v ${PWD}:/local \
|
||||||
|
openapitools/openapi-generator-cli generate \
|
||||||
|
-i /local/docs/openapi.json \
|
||||||
|
-g python \
|
||||||
|
-o /local/sdks/python
|
||||||
|
|
||||||
|
- name: Test Python SDK
|
||||||
|
run: |
|
||||||
|
cd sdks/python
|
||||||
|
pip install -e .
|
||||||
|
pytest
|
||||||
|
|
||||||
|
- name: Publish to PyPI
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
run: |
|
||||||
|
cd sdks/python
|
||||||
|
python -m build
|
||||||
|
twine upload dist/*
|
||||||
|
env:
|
||||||
|
TWINE_USERNAME: __token__
|
||||||
|
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Custom SDK templates**:
|
||||||
|
|
||||||
|
```
|
||||||
|
templates/
|
||||||
|
├── python/
|
||||||
|
│ ├── api.mustache # Custom API client template
|
||||||
|
│ ├── model.mustache # Custom model template
|
||||||
|
│ └── README.mustache # Custom README
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate with custom templates
|
||||||
|
openapi-generator-cli generate \
|
||||||
|
-i docs/openapi.json \
|
||||||
|
-g python \
|
||||||
|
-o sdks/python \
|
||||||
|
-t templates/python \
|
||||||
|
--additional-properties=packageName=payment_api
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation Versioning
|
||||||
|
|
||||||
|
### Version Documentation Separately from API
|
||||||
|
|
||||||
|
**Documentation versions**:
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/
|
||||||
|
├── v1/
|
||||||
|
│ ├── quickstart.md
|
||||||
|
│ ├── api-reference.md
|
||||||
|
│ └── migration-to-v2.md ← Deprecation notice
|
||||||
|
├── v2/
|
||||||
|
│ ├── quickstart.md
|
||||||
|
│ ├── api-reference.md
|
||||||
|
│ └── whats-new.md
|
||||||
|
└── latest -> v2/ # Symlink to current version
|
||||||
|
```
|
||||||
|
|
||||||
|
**Documentation routing**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
|
env = Environment(loader=FileSystemLoader("docs"))
|
||||||
|
|
||||||
|
@app.get("/docs")
|
||||||
|
async def docs_redirect():
|
||||||
|
"""Redirect to latest docs"""
|
||||||
|
return RedirectResponse(url="/docs/v2/")
|
||||||
|
|
||||||
|
@app.get("/docs/{version}/{page}")
|
||||||
|
async def serve_docs(version: str, page: str):
|
||||||
|
"""Serve versioned documentation"""
|
||||||
|
if version not in ["v1", "v2"]:
|
||||||
|
raise HTTPException(404)
|
||||||
|
|
||||||
|
# Add deprecation warning for v1
|
||||||
|
deprecated = version == "v1"
|
||||||
|
|
||||||
|
template = env.get_template(f"{version}/{page}.md")
|
||||||
|
content = template.render(deprecated=deprecated)
|
||||||
|
|
||||||
|
return HTMLResponse(content)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deprecation banner**:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- docs/templates/base.html -->
|
||||||
|
{% if deprecated %}
|
||||||
|
<div class="deprecation-banner">
|
||||||
|
⚠️ <strong>Deprecated</strong>: This documentation is for API v1,
|
||||||
|
which will be sunset on June 1, 2025.
|
||||||
|
<a href="/docs/v2/migration">Migrate to v2</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation Debt Detection
|
||||||
|
|
||||||
|
### Prevent Stale Documentation
|
||||||
|
|
||||||
|
**Detect outdated docs**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
def test_documentation_freshness():
|
||||||
|
"""Ensure docs have been updated recently"""
|
||||||
|
docs_modified = datetime.fromtimestamp(
|
||||||
|
os.path.getmtime("docs/api-reference.md")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fail if docs haven't been updated in 90 days
|
||||||
|
max_age = timedelta(days=90)
|
||||||
|
age = datetime.now() - docs_modified
|
||||||
|
|
||||||
|
assert age < max_age, \
|
||||||
|
f"API docs are {age.days} days old. Review and update or add exemption comment."
|
||||||
|
```
|
||||||
|
|
||||||
|
**Track documentation TODOs**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_no_documentation_todos():
|
||||||
|
"""Ensure no TODO comments in docs"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
doc_files = glob.glob("docs/**/*.md", recursive=True)
|
||||||
|
todos = []
|
||||||
|
|
||||||
|
for doc_file in doc_files:
|
||||||
|
with open(doc_file) as f:
|
||||||
|
for line_num, line in enumerate(f, 1):
|
||||||
|
if re.search(r'TODO|FIXME|XXX', line):
|
||||||
|
todos.append(f"{doc_file}:{line_num}: {line.strip()}")
|
||||||
|
|
||||||
|
assert not todos, \
|
||||||
|
f"Documentation has {len(todos)} TODOs:\n" + "\n".join(todos)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Broken link detection**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
import re
|
||||||
|
|
||||||
|
def extract_links_from_markdown(markdown_file):
|
||||||
|
"""Extract all HTTP(S) links from markdown"""
|
||||||
|
with open(markdown_file) as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Find markdown links [text](url)
|
||||||
|
links = re.findall(r'\[([^\]]+)\]\(([^)]+)\)', content)
|
||||||
|
return [(text, url) for text, url in links if url.startswith('http')]
|
||||||
|
|
||||||
|
def test_no_broken_links_in_docs():
|
||||||
|
"""Ensure all external links in docs are valid"""
|
||||||
|
doc_files = glob.glob("docs/**/*.md", recursive=True)
|
||||||
|
broken_links = []
|
||||||
|
|
||||||
|
for doc_file in doc_files:
|
||||||
|
for text, url in extract_links_from_markdown(doc_file):
|
||||||
|
try:
|
||||||
|
response = requests.head(url, timeout=5, allow_redirects=True)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
broken_links.append(f"{doc_file}: {url} ({response.status_code})")
|
||||||
|
except requests.RequestException as e:
|
||||||
|
broken_links.append(f"{doc_file}: {url} (error: {e})")
|
||||||
|
|
||||||
|
assert not broken_links, \
|
||||||
|
f"Found {len(broken_links)} broken links:\n" + "\n".join(broken_links)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation Metrics
|
||||||
|
|
||||||
|
### Track Documentation Usage
|
||||||
|
|
||||||
|
**Analytics integration**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import Request
|
||||||
|
import analytics
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def track_doc_views(request: Request, call_next):
|
||||||
|
if request.url.path.startswith("/docs"):
|
||||||
|
# Track page view
|
||||||
|
analytics.track(
|
||||||
|
user_id="anonymous",
|
||||||
|
event="Documentation Viewed",
|
||||||
|
properties={
|
||||||
|
"page": request.url.path,
|
||||||
|
"version": request.url.path.split("/")[2] if len(request.url.path.split("/")) > 2 else "latest",
|
||||||
|
"referrer": request.headers.get("referer")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Track "Try It Out" usage**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Inject into Swagger UI
|
||||||
|
const originalExecute = swagger.presets.apis.execute;
|
||||||
|
swagger.presets.apis.execute = function(spec) {
|
||||||
|
// Track API call from docs
|
||||||
|
analytics.track('API Call from Docs', {
|
||||||
|
endpoint: spec.path,
|
||||||
|
method: spec.method,
|
||||||
|
success: spec.response.status < 400
|
||||||
|
});
|
||||||
|
|
||||||
|
return originalExecute(spec);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Documentation health dashboard**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/admin/docs-metrics")
|
||||||
|
async def get_doc_metrics(db: Session = Depends(get_db)):
|
||||||
|
"""Dashboard for documentation health"""
|
||||||
|
|
||||||
|
# Page views by version
|
||||||
|
views_by_version = analytics.query(
|
||||||
|
"Documentation Viewed",
|
||||||
|
group_by="version",
|
||||||
|
since=datetime.now() - timedelta(days=30)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Most viewed pages
|
||||||
|
top_pages = analytics.query(
|
||||||
|
"Documentation Viewed",
|
||||||
|
group_by="page",
|
||||||
|
since=datetime.now() - timedelta(days=30),
|
||||||
|
limit=10
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try it out usage
|
||||||
|
api_calls = analytics.query(
|
||||||
|
"API Call from Docs",
|
||||||
|
since=datetime.now() - timedelta(days=30)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Documentation freshness
|
||||||
|
freshness = {
|
||||||
|
"quickstart.md": get_file_age("docs/quickstart.md"),
|
||||||
|
"api-reference.md": get_file_age("docs/api-reference.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"views_by_version": views_by_version,
|
||||||
|
"top_pages": top_pages,
|
||||||
|
"api_calls_from_docs": api_calls,
|
||||||
|
"freshness": freshness,
|
||||||
|
"health_score": calculate_doc_health_score()
|
||||||
|
}
|
||||||
|
|
||||||
|
def calculate_doc_health_score():
|
||||||
|
"""Calculate documentation health (0-100)"""
|
||||||
|
score = 100
|
||||||
|
|
||||||
|
# Deduct for stale docs (>90 days old)
|
||||||
|
for doc_file in glob.glob("docs/**/*.md", recursive=True):
|
||||||
|
age_days = (datetime.now() - datetime.fromtimestamp(os.path.getmtime(doc_file))).days
|
||||||
|
if age_days > 90:
|
||||||
|
score -= 10
|
||||||
|
|
||||||
|
# Deduct for broken links
|
||||||
|
broken_links = count_broken_links()
|
||||||
|
score -= min(broken_links * 5, 30)
|
||||||
|
|
||||||
|
# Deduct for missing examples
|
||||||
|
endpoints_without_examples = count_endpoints_without_examples()
|
||||||
|
score -= min(endpoints_without_examples * 3, 20)
|
||||||
|
|
||||||
|
return max(score, 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
| Anti-Pattern | Why Bad | Fix |
|
||||||
|
|--------------|---------|-----|
|
||||||
|
| **Docs in separate repo** | Always out of sync | Co-locate with code |
|
||||||
|
| **Manual example updates** | Examples become stale | Test examples in CI |
|
||||||
|
| **No deprecation notices** | Breaking changes surprise users | Document deprecation 6+ months ahead |
|
||||||
|
| **Generic descriptions** | Doesn't help developers | Specific use cases, edge cases |
|
||||||
|
| **No versioned docs** | Can't reference old versions | Version docs separately |
|
||||||
|
| **Untested SDKs** | Generated SDKs don't work | Test generated SDKs in CI |
|
||||||
|
| **No documentation metrics** | Can't measure effectiveness | Track page views, usage |
|
||||||
|
| **Single example per endpoint** | Doesn't show edge cases | Multiple examples (success, errors) |
|
||||||
|
|
||||||
|
## Cross-References
|
||||||
|
|
||||||
|
**Related skills**:
|
||||||
|
- **Technical writing** → `muna-technical-writer` (writing style, organization)
|
||||||
|
- **API design** → `rest-api-design`, `graphql-api-design` (design patterns)
|
||||||
|
- **API testing** → `api-testing` (contract testing, examples)
|
||||||
|
- **Authentication** → `api-authentication` (auth flow documentation)
|
||||||
|
|
||||||
|
## Further Reading
|
||||||
|
|
||||||
|
- **OpenAPI Specification**: https://spec.openapis.org/oas/v3.1.0
|
||||||
|
- **FastAPI docs**: https://fastapi.tiangolo.com/tutorial/metadata/
|
||||||
|
- **Swagger UI**: https://swagger.io/docs/open-source-tools/swagger-ui/
|
||||||
|
- **ReDoc**: https://redoc.ly/docs/
|
||||||
|
- **Write the Docs**: https://www.writethedocs.org/
|
||||||
1013
skills/using-web-backend/api-testing.md
Normal file
1013
skills/using-web-backend/api-testing.md
Normal file
File diff suppressed because it is too large
Load Diff
1117
skills/using-web-backend/database-integration.md
Normal file
1117
skills/using-web-backend/database-integration.md
Normal file
File diff suppressed because it is too large
Load Diff
890
skills/using-web-backend/django-development.md
Normal file
890
skills/using-web-backend/django-development.md
Normal file
@@ -0,0 +1,890 @@
|
|||||||
|
|
||||||
|
# Django Development
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**Django development specialist covering Django ORM optimization, DRF best practices, caching strategies, migrations, testing, and production deployment.**
|
||||||
|
|
||||||
|
**Core principle**: Django's "batteries included" philosophy is powerful but requires understanding which battery to use when - master Django's tools to avoid reinventing wheels or choosing wrong patterns.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Use when encountering:
|
||||||
|
|
||||||
|
- **ORM optimization**: N+1 queries, select_related vs prefetch_related, query performance
|
||||||
|
- **DRF patterns**: Serializers, ViewSets, permissions, nested relationships
|
||||||
|
- **Caching**: Cache framework, per-view caching, template fragment caching
|
||||||
|
- **Migrations**: Zero-downtime migrations, data migrations, squashing
|
||||||
|
- **Testing**: Django TestCase, fixtures, factories, mocking
|
||||||
|
- **Deployment**: Gunicorn, static files, database pooling
|
||||||
|
- **Async Django**: Channels, async views, WebSockets
|
||||||
|
- **Admin customization**: Custom admin actions, list filters, inlines
|
||||||
|
|
||||||
|
**Do NOT use for**:
|
||||||
|
- General Python patterns (use `axiom-python-engineering`)
|
||||||
|
- API design principles (use `rest-api-design`)
|
||||||
|
- Database-agnostic patterns (use `database-integration`)
|
||||||
|
- Authentication flows (use `api-authentication`)
|
||||||
|
|
||||||
|
## Django ORM Optimization
|
||||||
|
|
||||||
|
### select_related vs prefetch_related
|
||||||
|
|
||||||
|
**Decision matrix**:
|
||||||
|
|
||||||
|
| Relationship | Method | SQL Strategy | Use When |
|
||||||
|
|--------------|--------|--------------|----------|
|
||||||
|
| ForeignKey (many-to-one) | `select_related` | JOIN | Book → Author |
|
||||||
|
| OneToOneField | `select_related` | JOIN | User → Profile |
|
||||||
|
| Reverse ForeignKey (one-to-many) | `prefetch_related` | Separate query + IN | Author → Books |
|
||||||
|
| ManyToManyField | `prefetch_related` | Separate query + IN | Book → Tags |
|
||||||
|
|
||||||
|
**Example - select_related (JOIN)**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD: N+1 queries (1 + N)
|
||||||
|
books = Book.objects.all()
|
||||||
|
for book in books:
|
||||||
|
print(book.author.name) # Additional query per book
|
||||||
|
|
||||||
|
# GOOD: Single JOIN query
|
||||||
|
books = Book.objects.select_related('author').all()
|
||||||
|
for book in books:
|
||||||
|
print(book.author.name) # No additional queries
|
||||||
|
|
||||||
|
# SQL generated:
|
||||||
|
# SELECT book.*, author.* FROM book JOIN author ON book.author_id = author.id
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example - prefetch_related (IN query)**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD: N+1 queries
|
||||||
|
authors = Author.objects.all()
|
||||||
|
for author in authors:
|
||||||
|
print(author.books.count()) # Query per author
|
||||||
|
|
||||||
|
# GOOD: 2 queries total
|
||||||
|
authors = Author.objects.prefetch_related('books').all()
|
||||||
|
for author in authors:
|
||||||
|
print(author.books.count()) # No additional queries
|
||||||
|
|
||||||
|
# SQL generated:
|
||||||
|
# Query 1: SELECT * FROM author
|
||||||
|
# Query 2: SELECT * FROM book WHERE author_id IN (1, 2, 3, ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nested prefetching**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.db.models import Prefetch
|
||||||
|
|
||||||
|
# Fetch authors → books → reviews (3 queries)
|
||||||
|
authors = Author.objects.prefetch_related(
|
||||||
|
Prefetch('books', queryset=Book.objects.prefetch_related('reviews'))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Custom filtering on prefetch
|
||||||
|
recent_books = Book.objects.filter(
|
||||||
|
published_date__gte=timezone.now() - timedelta(days=30)
|
||||||
|
).order_by('-published_date')
|
||||||
|
|
||||||
|
authors = Author.objects.prefetch_related(
|
||||||
|
Prefetch('books', queryset=recent_books, to_attr='recent_books')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Access via custom attribute
|
||||||
|
for author in authors:
|
||||||
|
for book in author.recent_books: # Only recent books
|
||||||
|
print(book.title)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Debugging
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.db import connection, reset_queries
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
# Enable in settings.py: DEBUG = True
|
||||||
|
# Or use django-debug-toolbar
|
||||||
|
|
||||||
|
def debug_queries(func):
|
||||||
|
"""Decorator to debug query counts"""
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
reset_queries()
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
print(f"Queries: {len(connection.queries)}")
|
||||||
|
for query in connection.queries:
|
||||||
|
print(f" {query['time']}s: {query['sql'][:100]}")
|
||||||
|
return result
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
@debug_queries
|
||||||
|
def get_books():
|
||||||
|
return list(Book.objects.select_related('author').prefetch_related('tags'))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Django Debug Toolbar** (production alternative - django-silk):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# settings.py
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'debug_toolbar',
|
||||||
|
# ...
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'debug_toolbar.middleware.DebugToolbarMiddleware',
|
||||||
|
# ...
|
||||||
|
]
|
||||||
|
|
||||||
|
INTERNAL_IPS = ['127.0.0.1']
|
||||||
|
|
||||||
|
# For production: use django-silk for profiling
|
||||||
|
INSTALLED_APPS += ['silk']
|
||||||
|
MIDDLEWARE += ['silk.middleware.SilkyMiddleware']
|
||||||
|
```
|
||||||
|
|
||||||
|
### Annotation and Aggregation
|
||||||
|
|
||||||
|
**Annotate** (add computed fields):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.db.models import Count, Avg, Sum, F, Q
|
||||||
|
|
||||||
|
# Add book count to each author
|
||||||
|
authors = Author.objects.annotate(
|
||||||
|
book_count=Count('books'),
|
||||||
|
avg_rating=Avg('books__rating'),
|
||||||
|
total_sales=Sum('books__sales')
|
||||||
|
)
|
||||||
|
|
||||||
|
for author in authors:
|
||||||
|
print(f"{author.name}: {author.book_count} books, avg rating {author.avg_rating}")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Aggregate** (single value across queryset):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.db.models import Avg
|
||||||
|
|
||||||
|
# Get average rating across all books
|
||||||
|
avg_rating = Book.objects.aggregate(Avg('rating'))
|
||||||
|
# Returns: {'rating__avg': 4.2}
|
||||||
|
|
||||||
|
# Multiple aggregations
|
||||||
|
stats = Book.objects.aggregate(
|
||||||
|
avg_rating=Avg('rating'),
|
||||||
|
total_sales=Sum('sales'),
|
||||||
|
book_count=Count('id')
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Conditional aggregation with Q**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.db.models import Q, Count
|
||||||
|
|
||||||
|
# Count books by rating category
|
||||||
|
Author.objects.annotate(
|
||||||
|
high_rated_books=Count('books', filter=Q(books__rating__gte=4.0)),
|
||||||
|
low_rated_books=Count('books', filter=Q(books__rating__lt=3.0))
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Django REST Framework Patterns
|
||||||
|
|
||||||
|
### ViewSet vs APIView
|
||||||
|
|
||||||
|
**Decision matrix**:
|
||||||
|
|
||||||
|
| Use | Pattern | When |
|
||||||
|
|-----|---------|------|
|
||||||
|
| Standard CRUD | `ModelViewSet` | Full REST API for model |
|
||||||
|
| Custom actions only | `ViewSet` | Non-standard endpoints |
|
||||||
|
| Read-only API | `ReadOnlyModelViewSet` | GET/LIST only |
|
||||||
|
| Fine control | `APIView` or `@api_view` | Custom business logic |
|
||||||
|
|
||||||
|
**ModelViewSet** (full CRUD):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from rest_framework import viewsets, filters
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
class BookViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
Provides: list, create, retrieve, update, partial_update, destroy
|
||||||
|
"""
|
||||||
|
queryset = Book.objects.select_related('author').prefetch_related('tags')
|
||||||
|
serializer_class = BookSerializer
|
||||||
|
permission_classes = [IsAuthenticatedOrReadOnly]
|
||||||
|
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
|
||||||
|
search_fields = ['title', 'author__name']
|
||||||
|
ordering_fields = ['published_date', 'rating']
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Optimize queryset based on action"""
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
|
||||||
|
if self.action == 'list':
|
||||||
|
# List doesn't need full detail
|
||||||
|
return queryset.only('id', 'title', 'author__name')
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def publish(self, request, pk=None):
|
||||||
|
"""Custom action: POST /books/123/publish/"""
|
||||||
|
book = self.get_object()
|
||||||
|
book.status = 'published'
|
||||||
|
book.published_date = timezone.now()
|
||||||
|
book.save()
|
||||||
|
return Response({'status': 'published'})
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def bestsellers(self, request):
|
||||||
|
"""Custom list action: GET /books/bestsellers/"""
|
||||||
|
books = self.get_queryset().filter(sales__gte=10000).order_by('-sales')[:10]
|
||||||
|
serializer = self.get_serializer(books, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Serializer Patterns
|
||||||
|
|
||||||
|
**Basic serializer with validation**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from rest_framework import serializers
|
||||||
|
from django.contrib.auth.password_validation import validate_password
|
||||||
|
|
||||||
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
password = serializers.CharField(
|
||||||
|
write_only=True,
|
||||||
|
required=True,
|
||||||
|
validators=[validate_password]
|
||||||
|
)
|
||||||
|
password_confirm = serializers.CharField(write_only=True, required=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ['id', 'username', 'email', 'password', 'password_confirm']
|
||||||
|
read_only_fields = ['id']
|
||||||
|
|
||||||
|
# Field-level validation
|
||||||
|
def validate_email(self, value):
|
||||||
|
if User.objects.filter(email__iexact=value).exists():
|
||||||
|
raise serializers.ValidationError("Email already in use")
|
||||||
|
return value.lower()
|
||||||
|
|
||||||
|
# Object-level validation (cross-field)
|
||||||
|
def validate(self, attrs):
|
||||||
|
if attrs['password'] != attrs['password_confirm']:
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
'password_confirm': "Passwords don't match"
|
||||||
|
})
|
||||||
|
attrs.pop('password_confirm')
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
password = validated_data.pop('password')
|
||||||
|
user = User.objects.create(**validated_data)
|
||||||
|
user.set_password(password)
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nested serializers (read-only)**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class AuthorSerializer(serializers.ModelSerializer):
|
||||||
|
book_count = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Author
|
||||||
|
fields = ['id', 'name', 'bio', 'book_count']
|
||||||
|
|
||||||
|
class BookSerializer(serializers.ModelSerializer):
|
||||||
|
author = AuthorSerializer(read_only=True)
|
||||||
|
author_id = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=Author.objects.all(),
|
||||||
|
source='author',
|
||||||
|
write_only=True
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Book
|
||||||
|
fields = ['id', 'title', 'author', 'author_id', 'published_date']
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dynamic fields** (include/exclude fields via query params):
|
||||||
|
|
||||||
|
```python
|
||||||
|
class DynamicFieldsModelSerializer(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
Usage: /api/books/?fields=id,title,author
|
||||||
|
"""
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
request = self.context.get('request')
|
||||||
|
if request:
|
||||||
|
fields = request.query_params.get('fields')
|
||||||
|
if fields:
|
||||||
|
fields = fields.split(',')
|
||||||
|
allowed = set(fields)
|
||||||
|
existing = set(self.fields.keys())
|
||||||
|
for field_name in existing - allowed:
|
||||||
|
self.fields.pop(field_name)
|
||||||
|
|
||||||
|
class BookSerializer(DynamicFieldsModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Book
|
||||||
|
fields = '__all__'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Django Caching
|
||||||
|
|
||||||
|
### Cache Framework Setup
|
||||||
|
|
||||||
|
```python
|
||||||
|
# settings.py
|
||||||
|
|
||||||
|
# Redis cache (production)
|
||||||
|
CACHES = {
|
||||||
|
'default': {
|
||||||
|
'BACKEND': 'django_redis.cache.RedisCache',
|
||||||
|
'LOCATION': 'redis://127.0.0.1:6379/1',
|
||||||
|
'OPTIONS': {
|
||||||
|
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
|
||||||
|
'CONNECTION_POOL_KWARGS': {'max_connections': 50},
|
||||||
|
'PARSER_CLASS': 'redis.connection.HiredisParser',
|
||||||
|
},
|
||||||
|
'KEY_PREFIX': 'myapp',
|
||||||
|
'TIMEOUT': 300, # Default 5 minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Memcached (alternative)
|
||||||
|
CACHES = {
|
||||||
|
'default': {
|
||||||
|
'BACKEND': 'django.core.cache.backends.memcached.PyMemcacheCache',
|
||||||
|
'LOCATION': '127.0.0.1:11211',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Local memory (development only)
|
||||||
|
CACHES = {
|
||||||
|
'default': {
|
||||||
|
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||||||
|
'LOCATION': 'unique-snowflake',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Per-View Caching
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.views.decorators.cache import cache_page
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
|
||||||
|
# Function-based view
|
||||||
|
@cache_page(60 * 15) # Cache for 15 minutes
|
||||||
|
def book_list(request):
|
||||||
|
books = Book.objects.all()
|
||||||
|
return render(request, 'books/list.html', {'books': books})
|
||||||
|
|
||||||
|
# Class-based view
|
||||||
|
class BookListView(ListView):
|
||||||
|
model = Book
|
||||||
|
|
||||||
|
@method_decorator(cache_page(60 * 15))
|
||||||
|
def dispatch(self, *args, **kwargs):
|
||||||
|
return super().dispatch(*args, **kwargs)
|
||||||
|
|
||||||
|
# DRF ViewSet
|
||||||
|
from rest_framework_extensions.cache.decorators import cache_response
|
||||||
|
|
||||||
|
class BookViewSet(viewsets.ModelViewSet):
|
||||||
|
@cache_response(timeout=60*15, key_func='calculate_cache_key')
|
||||||
|
def list(self, request, *args, **kwargs):
|
||||||
|
return super().list(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def calculate_cache_key(self, view_instance, view_method, request, args, kwargs):
|
||||||
|
# Custom cache key including user, filters
|
||||||
|
return f"books:list:{request.user.id}:{request.GET.urlencode()}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Low-Level Cache API
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
# Set cache
|
||||||
|
cache.set('my_key', 'my_value', timeout=300)
|
||||||
|
|
||||||
|
# Get cache
|
||||||
|
value = cache.get('my_key')
|
||||||
|
if value is None:
|
||||||
|
value = expensive_computation()
|
||||||
|
cache.set('my_key', value, timeout=300)
|
||||||
|
|
||||||
|
# Get or set (atomic)
|
||||||
|
value = cache.get_or_set('my_key', lambda: expensive_computation(), timeout=300)
|
||||||
|
|
||||||
|
# Delete cache
|
||||||
|
cache.delete('my_key')
|
||||||
|
|
||||||
|
# Clear all
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
# Multiple keys
|
||||||
|
cache.set_many({'key1': 'value1', 'key2': 'value2'}, timeout=300)
|
||||||
|
values = cache.get_many(['key1', 'key2'])
|
||||||
|
|
||||||
|
# Increment/decrement
|
||||||
|
cache.set('counter', 0)
|
||||||
|
cache.incr('counter') # 1
|
||||||
|
cache.incr('counter', delta=5) # 6
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache Invalidation Patterns
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.db.models.signals import post_save, post_delete
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
@receiver([post_save, post_delete], sender=Book)
|
||||||
|
def invalidate_book_cache(sender, instance, **kwargs):
|
||||||
|
"""Invalidate cache when book changes"""
|
||||||
|
cache.delete(f'book:{instance.id}')
|
||||||
|
cache.delete('books:list') # Invalidate list cache
|
||||||
|
cache.delete(f'author:{instance.author_id}:books')
|
||||||
|
|
||||||
|
# Pattern: Cache with version tags
|
||||||
|
def get_books():
|
||||||
|
version = cache.get('books:version', 0)
|
||||||
|
cache_key = f'books:list:v{version}'
|
||||||
|
books = cache.get(cache_key)
|
||||||
|
|
||||||
|
if books is None:
|
||||||
|
books = list(Book.objects.all())
|
||||||
|
cache.set(cache_key, books, timeout=3600)
|
||||||
|
|
||||||
|
return books
|
||||||
|
|
||||||
|
def invalidate_books():
|
||||||
|
"""Bump version to invalidate all book caches"""
|
||||||
|
version = cache.get('books:version', 0)
|
||||||
|
cache.set('books:version', version + 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Django Migrations
|
||||||
|
|
||||||
|
### Zero-Downtime Migration Pattern
|
||||||
|
|
||||||
|
**Adding NOT NULL column to large table**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Step 1: Add nullable field (migration 0002)
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='department',
|
||||||
|
field=models.CharField(max_length=100, null=True, blank=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Step 2: Populate data in batches (migration 0003)
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
def populate_department(apps, schema_editor):
|
||||||
|
User = apps.get_model('myapp', 'User')
|
||||||
|
|
||||||
|
# Batch update for performance
|
||||||
|
batch_size = 10000
|
||||||
|
total = User.objects.filter(department__isnull=True).count()
|
||||||
|
|
||||||
|
for offset in range(0, total, batch_size):
|
||||||
|
users = User.objects.filter(department__isnull=True)[offset:offset+batch_size]
|
||||||
|
for user in users:
|
||||||
|
user.department = determine_department(user) # Your logic
|
||||||
|
User.objects.bulk_update(users, ['department'], batch_size=batch_size)
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [('myapp', '0002_add_department')],
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(populate_department, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Step 3: Make NOT NULL (migration 0004)
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [('myapp', '0003_populate_department')],
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='user',
|
||||||
|
name='department',
|
||||||
|
field=models.CharField(max_length=100), # NOT NULL
|
||||||
|
),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Concurrent Index Creation (PostgreSQL)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.contrib.postgres.operations import AddIndexConcurrently
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
atomic = False # Required for CONCURRENTLY operations
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
AddIndexConcurrently(
|
||||||
|
model_name='book',
|
||||||
|
index=models.Index(fields=['published_date'], name='book_published_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Squashing Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Squash migrations 0001 through 0020 into single migration
|
||||||
|
python manage.py squashmigrations myapp 0001 0020
|
||||||
|
|
||||||
|
# This creates migrations/0001_squashed_0020.py
|
||||||
|
# After deploying squashed migration, delete originals:
|
||||||
|
# migrations/0001.py through migrations/0020.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Django Testing
|
||||||
|
|
||||||
|
### TestCase vs TransactionTestCase
|
||||||
|
|
||||||
|
| Feature | TestCase | TransactionTestCase |
|
||||||
|
|---------|----------|---------------------|
|
||||||
|
| Speed | Fast (no DB reset between tests) | Slow (resets DB each test) |
|
||||||
|
| Transactions | Wrapped in transaction, rolled back | No automatic transaction |
|
||||||
|
| Use for | Most tests | Testing transaction behavior, signals |
|
||||||
|
|
||||||
|
**Example - TestCase**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.test import TestCase
|
||||||
|
from myapp.models import Book
|
||||||
|
|
||||||
|
class BookModelTest(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
"""Run once for entire test class (fast)"""
|
||||||
|
cls.author = Author.objects.create(name="Test Author")
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Run before each test method"""
|
||||||
|
self.book = Book.objects.create(
|
||||||
|
title="Test Book",
|
||||||
|
author=self.author
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_book_str(self):
|
||||||
|
self.assertEqual(str(self.book), "Test Book")
|
||||||
|
|
||||||
|
def test_book_author_relationship(self):
|
||||||
|
self.assertEqual(self.book.author.name, "Test Author")
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Testing with DRF
|
||||||
|
|
||||||
|
```python
|
||||||
|
from rest_framework.test import APITestCase, APIClient
|
||||||
|
from rest_framework import status
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
class BookAPITest(APITestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username='testuser',
|
||||||
|
password='testpass123'
|
||||||
|
)
|
||||||
|
self.book = Book.objects.create(title="Test Book")
|
||||||
|
|
||||||
|
def test_list_books_unauthenticated(self):
|
||||||
|
response = self.client.get('/api/books/')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def test_create_book_authenticated(self):
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
data = {'title': 'New Book', 'author': self.author.id}
|
||||||
|
response = self.client.post('/api/books/', data)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
self.assertEqual(Book.objects.count(), 2)
|
||||||
|
|
||||||
|
def test_update_book_unauthorized(self):
|
||||||
|
other_user = User.objects.create_user(username='other', password='pass')
|
||||||
|
self.client.force_authenticate(user=other_user)
|
||||||
|
data = {'title': 'Updated Title'}
|
||||||
|
response = self.client.patch(f'/api/books/{self.book.id}/', data)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Factory Pattern with factory_boy
|
||||||
|
|
||||||
|
```python
|
||||||
|
# tests/factories.py
|
||||||
|
import factory
|
||||||
|
from myapp.models import Author, Book
|
||||||
|
|
||||||
|
class AuthorFactory(factory.django.DjangoModelFactory):
|
||||||
|
class Meta:
|
||||||
|
model = Author
|
||||||
|
|
||||||
|
name = factory.Faker('name')
|
||||||
|
bio = factory.Faker('text', max_nb_chars=200)
|
||||||
|
|
||||||
|
class BookFactory(factory.django.DjangoModelFactory):
|
||||||
|
class Meta:
|
||||||
|
model = Book
|
||||||
|
|
||||||
|
title = factory.Faker('sentence', nb_words=4)
|
||||||
|
author = factory.SubFactory(AuthorFactory)
|
||||||
|
published_date = factory.Faker('date_this_decade')
|
||||||
|
isbn = factory.Sequence(lambda n: f'978-0-{n:09d}')
|
||||||
|
|
||||||
|
# Usage in tests
|
||||||
|
class BookTest(TestCase):
|
||||||
|
def test_book_creation(self):
|
||||||
|
book = BookFactory.create() # Creates Author too
|
||||||
|
self.assertIsNotNone(book.id)
|
||||||
|
|
||||||
|
def test_multiple_books(self):
|
||||||
|
books = BookFactory.create_batch(10) # Create 10 books
|
||||||
|
self.assertEqual(len(books), 10)
|
||||||
|
|
||||||
|
def test_author_with_books(self):
|
||||||
|
author = AuthorFactory.create()
|
||||||
|
BookFactory.create_batch(5, author=author)
|
||||||
|
self.assertEqual(author.books.count(), 5)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Django Settings Organization
|
||||||
|
|
||||||
|
### Multiple Environment Configs
|
||||||
|
|
||||||
|
```
|
||||||
|
myproject/
|
||||||
|
└── settings/
|
||||||
|
├── __init__.py
|
||||||
|
├── base.py # Common settings
|
||||||
|
├── development.py # Dev overrides
|
||||||
|
├── production.py # Prod overrides
|
||||||
|
└── test.py # Test overrides
|
||||||
|
```
|
||||||
|
|
||||||
|
**settings/base.py**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||||
|
|
||||||
|
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'django.contrib.admin',
|
||||||
|
# ...
|
||||||
|
'rest_framework',
|
||||||
|
'myapp',
|
||||||
|
]
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.postgresql',
|
||||||
|
'NAME': os.environ.get('DB_NAME'),
|
||||||
|
'USER': os.environ.get('DB_USER'),
|
||||||
|
'PASSWORD': os.environ.get('DB_PASSWORD'),
|
||||||
|
'HOST': os.environ.get('DB_HOST', 'localhost'),
|
||||||
|
'PORT': os.environ.get('DB_PORT', '5432'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**settings/development.py**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .base import *
|
||||||
|
|
||||||
|
DEBUG = True
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = ['localhost', '127.0.0.1']
|
||||||
|
|
||||||
|
# Use console email backend
|
||||||
|
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||||
|
|
||||||
|
# Local cache
|
||||||
|
CACHES = {
|
||||||
|
'default': {
|
||||||
|
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Debug toolbar
|
||||||
|
INSTALLED_APPS += ['debug_toolbar']
|
||||||
|
MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware']
|
||||||
|
INTERNAL_IPS = ['127.0.0.1']
|
||||||
|
```
|
||||||
|
|
||||||
|
**settings/production.py**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .base import *
|
||||||
|
|
||||||
|
DEBUG = False
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = [os.environ.get('ALLOWED_HOST')]
|
||||||
|
|
||||||
|
# Security settings
|
||||||
|
SECURE_SSL_REDIRECT = True
|
||||||
|
SESSION_COOKIE_SECURE = True
|
||||||
|
CSRF_COOKIE_SECURE = True
|
||||||
|
SECURE_HSTS_SECONDS = 31536000
|
||||||
|
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||||
|
SECURE_HSTS_PRELOAD = True
|
||||||
|
|
||||||
|
# Redis cache
|
||||||
|
CACHES = {
|
||||||
|
'default': {
|
||||||
|
'BACKEND': 'django_redis.cache.RedisCache',
|
||||||
|
'LOCATION': os.environ.get('REDIS_URL'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Real email
|
||||||
|
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||||
|
EMAIL_HOST = os.environ.get('EMAIL_HOST')
|
||||||
|
EMAIL_PORT = int(os.environ.get('EMAIL_PORT', 587))
|
||||||
|
EMAIL_USE_TLS = True
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
export DJANGO_SETTINGS_MODULE=myproject.settings.development
|
||||||
|
python manage.py runserver
|
||||||
|
|
||||||
|
# Production
|
||||||
|
export DJANGO_SETTINGS_MODULE=myproject.settings.production
|
||||||
|
gunicorn myproject.wsgi:application
|
||||||
|
```
|
||||||
|
|
||||||
|
## Django Deployment
|
||||||
|
|
||||||
|
### Gunicorn Configuration
|
||||||
|
|
||||||
|
```python
|
||||||
|
# gunicorn_config.py
|
||||||
|
import multiprocessing
|
||||||
|
|
||||||
|
bind = "0.0.0.0:8000"
|
||||||
|
workers = multiprocessing.cpu_count() * 2 + 1
|
||||||
|
worker_class = "sync" # or "gevent" for async
|
||||||
|
worker_connections = 1000
|
||||||
|
max_requests = 1000 # Restart workers after N requests (prevent memory leaks)
|
||||||
|
max_requests_jitter = 100
|
||||||
|
timeout = 30
|
||||||
|
keepalive = 2
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
accesslog = "-" # stdout
|
||||||
|
errorlog = "-" # stderr
|
||||||
|
loglevel = "info"
|
||||||
|
|
||||||
|
# Process naming
|
||||||
|
proc_name = "myproject"
|
||||||
|
|
||||||
|
# Server mechanics
|
||||||
|
daemon = False
|
||||||
|
pidfile = "/var/run/gunicorn.pid"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Systemd service**:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/system/myproject.service
|
||||||
|
[Unit]
|
||||||
|
Description=MyProject Django Application
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=notify
|
||||||
|
User=www-data
|
||||||
|
Group=www-data
|
||||||
|
WorkingDirectory=/var/www/myproject
|
||||||
|
Environment="DJANGO_SETTINGS_MODULE=myproject.settings.production"
|
||||||
|
ExecStart=/var/www/myproject/venv/bin/gunicorn \
|
||||||
|
--config /var/www/myproject/gunicorn_config.py \
|
||||||
|
myproject.wsgi:application
|
||||||
|
ExecReload=/bin/kill -s HUP $MAINPID
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
### Static and Media Files
|
||||||
|
|
||||||
|
```python
|
||||||
|
# settings/production.py
|
||||||
|
STATIC_URL = '/static/'
|
||||||
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
|
||||||
|
MEDIA_URL = '/media/'
|
||||||
|
MEDIA_ROOT = BASE_DIR / 'media'
|
||||||
|
|
||||||
|
# Use WhiteNoise for static files
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'whitenoise.middleware.WhiteNoiseMiddleware', # After SecurityMiddleware
|
||||||
|
# ...
|
||||||
|
]
|
||||||
|
|
||||||
|
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Collect static files**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py collectstatic --noinput
|
||||||
|
```
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
| Anti-Pattern | Why Bad | Fix |
|
||||||
|
|--------------|---------|-----|
|
||||||
|
| **Lazy loading in loops** | N+1 queries | Use `select_related`/`prefetch_related` |
|
||||||
|
| **No database indexing** | Slow queries | Add `db_index=True` or Meta indexes |
|
||||||
|
| **Signals for async work** | Blocks requests | Use Celery tasks instead |
|
||||||
|
| **Generic serializers for everything** | Over-fetching data | Create optimized serializers per use case |
|
||||||
|
| **No caching** | Repeated expensive queries | Cache querysets, views, template fragments |
|
||||||
|
| **Migrations in production without testing** | Downtime, data loss | Test on production-sized datasets first |
|
||||||
|
| **DEBUG=True in production** | Security risk, slow | Always DEBUG=False in production |
|
||||||
|
| **No connection pooling** | Exhausts DB connections | Use pgBouncer or django-db-geventpool |
|
||||||
|
|
||||||
|
## Cross-References
|
||||||
|
|
||||||
|
**Related skills**:
|
||||||
|
- **Database optimization** → `database-integration` (connection pooling, migrations)
|
||||||
|
- **API testing** → `api-testing` (DRF testing patterns)
|
||||||
|
- **Authentication** → `api-authentication` (DRF token auth, JWT)
|
||||||
|
- **REST API design** → `rest-api-design` (API patterns)
|
||||||
|
|
||||||
|
## Further Reading
|
||||||
|
|
||||||
|
- **Django docs**: https://docs.djangoproject.com/
|
||||||
|
- **DRF docs**: https://www.django-rest-framework.org/
|
||||||
|
- **Two Scoops of Django**: Best practices book
|
||||||
|
- **Classy Class-Based Views**: https://ccbv.co.uk/
|
||||||
|
- **Classy Django REST Framework**: https://www.cdrf.co/
|
||||||
872
skills/using-web-backend/express-development.md
Normal file
872
skills/using-web-backend/express-development.md
Normal file
@@ -0,0 +1,872 @@
|
|||||||
|
|
||||||
|
# Express Development
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**Express.js development specialist covering middleware organization, error handling, validation, database integration, testing, and production deployment.**
|
||||||
|
|
||||||
|
**Core principle**: Express's minimalist philosophy requires disciplined patterns - without structure, Express apps become tangled middleware chains with inconsistent error handling and poor testability.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Use when encountering:
|
||||||
|
|
||||||
|
- **Middleware organization**: Ordering, async error handling, custom middleware
|
||||||
|
- **Error handling**: Centralized handlers, custom error classes, async/await errors
|
||||||
|
- **Request validation**: Zod, express-validator, type-safe validation
|
||||||
|
- **Database patterns**: Connection pooling, transactions, graceful shutdown
|
||||||
|
- **Testing**: Supertest, mocking, middleware isolation
|
||||||
|
- **Production deployment**: PM2, clustering, Docker, environment management
|
||||||
|
- **Performance**: Compression, caching, clustering
|
||||||
|
- **Security**: Helmet, rate limiting, CORS, input sanitization
|
||||||
|
|
||||||
|
**DO NOT use for**:
|
||||||
|
- General TypeScript patterns (use `axiom-python-engineering` equivalents)
|
||||||
|
- API design principles (use `rest-api-design`)
|
||||||
|
- Database-agnostic patterns (use `database-integration`)
|
||||||
|
|
||||||
|
## Middleware Organization
|
||||||
|
|
||||||
|
### Correct Middleware Order
|
||||||
|
|
||||||
|
**Order matters** - middleware executes top to bottom:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import express from 'express';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import cors from 'cors';
|
||||||
|
import compression from 'compression';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// 1. Security (FIRST - before any parsing)
|
||||||
|
app.use(helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 2. CORS (before routes)
|
||||||
|
app.use(cors({
|
||||||
|
origin: process.env.ALLOWED_ORIGINS?.split(','),
|
||||||
|
credentials: true,
|
||||||
|
maxAge: 86400, // 24 hours
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 3. Parsing
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||||
|
|
||||||
|
// 4. Compression
|
||||||
|
app.use(compression());
|
||||||
|
|
||||||
|
// 5. Logging
|
||||||
|
app.use(morgan('combined', { stream: logger.stream }));
|
||||||
|
|
||||||
|
// 6. Authentication (before routes that need it)
|
||||||
|
app.use('/api', authenticationMiddleware);
|
||||||
|
|
||||||
|
// 7. Routes
|
||||||
|
app.use('/api/users', userRoutes);
|
||||||
|
app.use('/api/posts', postRoutes);
|
||||||
|
|
||||||
|
// 8. 404 handler (AFTER all routes)
|
||||||
|
app.use((req, res) => {
|
||||||
|
res.status(404).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Route not found',
|
||||||
|
path: req.path,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 9. Error handler (LAST)
|
||||||
|
app.use(errorHandler);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Async Error Wrapper
|
||||||
|
|
||||||
|
**Problem**: Express doesn't catch async errors automatically
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/middleware/asyncHandler.ts
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
export const asyncHandler = <T>(
|
||||||
|
fn: (req: Request, res: Response, next: NextFunction) => Promise<T>
|
||||||
|
) => {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
router.get('/:id', asyncHandler(async (req, res) => {
|
||||||
|
const user = await userService.findById(req.params.id);
|
||||||
|
if (!user) throw new NotFoundError('User not found');
|
||||||
|
res.json(user);
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative**: Use express-async-errors (automatic)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// At top of app.ts (BEFORE routes)
|
||||||
|
import 'express-async-errors';
|
||||||
|
|
||||||
|
// Now all async route handlers auto-catch errors
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
const user = await userService.findById(req.params.id);
|
||||||
|
res.json(user);
|
||||||
|
}); // Errors automatically forwarded to error handler
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Custom Error Classes
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/errors/AppError.ts
|
||||||
|
export class AppError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly message: string,
|
||||||
|
public readonly statusCode: number,
|
||||||
|
public readonly isOperational: boolean = true
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
Error.captureStackTrace(this, this.constructor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/errors/HttpErrors.ts
|
||||||
|
export class BadRequestError extends AppError {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnauthorizedError extends AppError {
|
||||||
|
constructor(message = 'Unauthorized') {
|
||||||
|
super(message, 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ForbiddenError extends AppError {
|
||||||
|
constructor(message = 'Forbidden') {
|
||||||
|
super(message, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotFoundError extends AppError {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message, 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConflictError extends AppError {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message, 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TooManyRequestsError extends AppError {
|
||||||
|
constructor(message = 'Too many requests', public retryAfter?: number) {
|
||||||
|
super(message, 429);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Centralized Error Handler
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/middleware/errorHandler.ts
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { AppError } from '../errors/AppError';
|
||||||
|
import { logger } from '../config/logger';
|
||||||
|
|
||||||
|
export const errorHandler = (
|
||||||
|
err: Error,
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) => {
|
||||||
|
// Log error with context
|
||||||
|
logger.error('Error occurred', {
|
||||||
|
error: {
|
||||||
|
message: err.message,
|
||||||
|
stack: err.stack,
|
||||||
|
name: err.name,
|
||||||
|
},
|
||||||
|
request: {
|
||||||
|
method: req.method,
|
||||||
|
url: req.url,
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.get('user-agent'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Operational errors (expected)
|
||||||
|
if (err instanceof AppError && err.isOperational) {
|
||||||
|
const response: any = {
|
||||||
|
status: 'error',
|
||||||
|
message: err.message,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add retry-after for rate limiting
|
||||||
|
if (err instanceof TooManyRequestsError && err.retryAfter) {
|
||||||
|
res.setHeader('Retry-After', err.retryAfter);
|
||||||
|
response.retryAfter = err.retryAfter;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(err.statusCode).json(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation errors (Zod, express-validator)
|
||||||
|
if (err.name === 'ZodError') {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Validation failed',
|
||||||
|
errors: (err as any).errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database constraint violations
|
||||||
|
if ((err as any).code === '23505') { // PostgreSQL unique violation
|
||||||
|
return res.status(409).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Resource already exists',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((err as any).code === '23503') { // Foreign key violation
|
||||||
|
return res.status(400).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Invalid reference',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unexpected errors (don't leak details in production)
|
||||||
|
res.status(500).json({
|
||||||
|
status: 'error',
|
||||||
|
message: process.env.NODE_ENV === 'production'
|
||||||
|
? 'Internal server error'
|
||||||
|
: err.message,
|
||||||
|
...(process.env.NODE_ENV !== 'production' && { stack: err.stack }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Global Error Handlers
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/server.ts
|
||||||
|
process.on('unhandledRejection', (reason: Error) => {
|
||||||
|
logger.error('Unhandled Rejection', { reason });
|
||||||
|
// Graceful shutdown
|
||||||
|
server.close(() => process.exit(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('uncaughtException', (error: Error) => {
|
||||||
|
logger.error('Uncaught Exception', { error });
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Request Validation
|
||||||
|
|
||||||
|
### Zod Integration (Type-Safe)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/schemas/userSchema.ts
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const createUserSchema = z.object({
|
||||||
|
body: z.object({
|
||||||
|
email: z.string().email('Invalid email'),
|
||||||
|
password: z.string()
|
||||||
|
.min(8, 'Password must be at least 8 characters')
|
||||||
|
.regex(/[A-Z]/, 'Password must contain uppercase')
|
||||||
|
.regex(/[0-9]/, 'Password must contain number'),
|
||||||
|
name: z.string().min(2).max(100),
|
||||||
|
age: z.number().int().positive().max(150).optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getUserSchema = z.object({
|
||||||
|
params: z.object({
|
||||||
|
id: z.string().regex(/^\d+$/, 'ID must be numeric'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getUsersSchema = z.object({
|
||||||
|
query: z.object({
|
||||||
|
page: z.string().regex(/^\d+$/).transform(Number).default('1'),
|
||||||
|
limit: z.string().regex(/^\d+$/).transform(Number).default('10'),
|
||||||
|
search: z.string().optional(),
|
||||||
|
sortBy: z.enum(['name', 'created_at', 'updated_at']).optional(),
|
||||||
|
order: z.enum(['asc', 'desc']).optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type inference
|
||||||
|
export type CreateUserInput = z.infer<typeof createUserSchema>['body'];
|
||||||
|
export type GetUserParams = z.infer<typeof getUserSchema>['params'];
|
||||||
|
export type GetUsersQuery = z.infer<typeof getUsersSchema>['query'];
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation middleware**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/middleware/validate.ts
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { AnyZodObject, ZodError } from 'zod';
|
||||||
|
|
||||||
|
export const validate = (schema: AnyZodObject) => {
|
||||||
|
return async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const validated = await schema.parseAsync({
|
||||||
|
body: req.body,
|
||||||
|
query: req.query,
|
||||||
|
params: req.params,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Replace with validated data (transforms applied)
|
||||||
|
req.body = validated.body || req.body;
|
||||||
|
req.query = validated.query || req.query;
|
||||||
|
req.params = validated.params || req.params;
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Validation failed',
|
||||||
|
errors: error.errors.map(err => ({
|
||||||
|
field: err.path.join('.'),
|
||||||
|
message: err.message,
|
||||||
|
code: err.code,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage in routes**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { validate } from '../middleware/validate';
|
||||||
|
import * as schemas from '../schemas/userSchema';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post('/', validate(schemas.createUserSchema), async (req, res) => {
|
||||||
|
// req.body is now typed as CreateUserInput
|
||||||
|
const user = await userService.create(req.body);
|
||||||
|
res.status(201).json(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', validate(schemas.getUserSchema), async (req, res) => {
|
||||||
|
// req.params.id is validated
|
||||||
|
const user = await userService.findById(req.params.id);
|
||||||
|
if (!user) throw new NotFoundError('User not found');
|
||||||
|
res.json(user);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Connection Pooling
|
||||||
|
|
||||||
|
### PostgreSQL with pg
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/config/database.ts
|
||||||
|
import { Pool, PoolConfig } from 'pg';
|
||||||
|
import { logger } from './logger';
|
||||||
|
|
||||||
|
const config: PoolConfig = {
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: Number(process.env.DB_PORT) || 5432,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
max: Number(process.env.DB_POOL_MAX) || 20,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 2000,
|
||||||
|
statement_timeout: 30000, // 30s query timeout
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pool = new Pool(config);
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
pool.on('connect', (client) => {
|
||||||
|
logger.debug('Database client connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
pool.on('acquire', (client) => {
|
||||||
|
logger.debug('Client acquired from pool');
|
||||||
|
});
|
||||||
|
|
||||||
|
pool.on('error', (err, client) => {
|
||||||
|
logger.error('Unexpected pool error', { error: err });
|
||||||
|
process.exit(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
export const testConnection = async () => {
|
||||||
|
try {
|
||||||
|
const client = await pool.connect();
|
||||||
|
const result = await client.query('SELECT NOW()');
|
||||||
|
client.release();
|
||||||
|
logger.info('Database connection successful', {
|
||||||
|
serverTime: result.rows[0].now,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Database connection failed', { error: err });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
export const closePool = async () => {
|
||||||
|
logger.info('Closing database pool');
|
||||||
|
await pool.end();
|
||||||
|
logger.info('Database pool closed');
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transaction Helper
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/utils/transaction.ts
|
||||||
|
import { Pool, PoolClient } from 'pg';
|
||||||
|
|
||||||
|
export async function withTransaction<T>(
|
||||||
|
pool: Pool,
|
||||||
|
callback: (client: PoolClient) => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const result = await callback(client);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
import { pool } from '../config/database';
|
||||||
|
|
||||||
|
async function createUserWithProfile(userData, profileData) {
|
||||||
|
return withTransaction(pool, async (client) => {
|
||||||
|
const userResult = await client.query(
|
||||||
|
'INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id',
|
||||||
|
[userData.email, userData.name]
|
||||||
|
);
|
||||||
|
const userId = userResult.rows[0].id;
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
'INSERT INTO profiles (user_id, bio) VALUES ($1, $2)',
|
||||||
|
[userId, profileData.bio]
|
||||||
|
);
|
||||||
|
|
||||||
|
return userId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Integration Tests with Supertest
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// tests/integration/userRoutes.test.ts
|
||||||
|
import request from 'supertest';
|
||||||
|
import app from '../../src/app';
|
||||||
|
import { pool } from '../../src/config/database';
|
||||||
|
|
||||||
|
describe('User Routes', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await pool.query('CREATE TABLE IF NOT EXISTS users (...)');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await pool.query('TRUNCATE TABLE users CASCADE');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/users', () => {
|
||||||
|
it('should create user with valid data', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/users')
|
||||||
|
.send({
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: 'Test User',
|
||||||
|
password: 'Password123',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('id');
|
||||||
|
expect(response.body.email).toBe('test@example.com');
|
||||||
|
expect(response.body).not.toHaveProperty('password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for invalid email', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/users')
|
||||||
|
.send({
|
||||||
|
email: 'invalid',
|
||||||
|
name: 'Test',
|
||||||
|
password: 'Password123',
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.status).toBe('error');
|
||||||
|
expect(response.body.errors).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
field: 'body.email',
|
||||||
|
message: expect.stringContaining('email'),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/users/:id', () => {
|
||||||
|
it('should return user by ID', async () => {
|
||||||
|
const createRes = await request(app)
|
||||||
|
.post('/api/users')
|
||||||
|
.send({
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: 'Test User',
|
||||||
|
password: 'Password123',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get(`/api/users/${createRes.body.id}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.id).toBe(createRes.body.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent user', async () => {
|
||||||
|
await request(app)
|
||||||
|
.get('/api/users/99999')
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unit Tests with Mocks
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// tests/unit/userService.test.ts
|
||||||
|
import { userService } from '../../src/services/userService';
|
||||||
|
import { pool } from '../../src/config/database';
|
||||||
|
|
||||||
|
jest.mock('../../src/config/database');
|
||||||
|
|
||||||
|
const mockPool = pool as jest.Mocked<typeof pool>;
|
||||||
|
|
||||||
|
describe('UserService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findById', () => {
|
||||||
|
it('should return user when found', async () => {
|
||||||
|
mockPool.query.mockResolvedValue({
|
||||||
|
rows: [{ id: 1, email: 'test@example.com', name: 'Test' }],
|
||||||
|
command: 'SELECT',
|
||||||
|
rowCount: 1,
|
||||||
|
oid: 0,
|
||||||
|
fields: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await userService.findById('1');
|
||||||
|
|
||||||
|
expect(result).toEqual(
|
||||||
|
expect.objectContaining({ id: 1, email: 'test@example.com' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when not found', async () => {
|
||||||
|
mockPool.query.mockResolvedValue({
|
||||||
|
rows: [],
|
||||||
|
command: 'SELECT',
|
||||||
|
rowCount: 0,
|
||||||
|
oid: 0,
|
||||||
|
fields: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await userService.findById('999');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
### PM2 Configuration
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ecosystem.config.js
|
||||||
|
module.exports = {
|
||||||
|
apps: [{
|
||||||
|
name: 'api',
|
||||||
|
script: './dist/server.js',
|
||||||
|
instances: 'max', // Use all CPU cores
|
||||||
|
exec_mode: 'cluster',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: 3000,
|
||||||
|
},
|
||||||
|
error_file: './logs/err.log',
|
||||||
|
out_file: './logs/out.log',
|
||||||
|
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||||
|
merge_logs: true,
|
||||||
|
max_memory_restart: '500M',
|
||||||
|
wait_ready: true,
|
||||||
|
listen_timeout: 10000,
|
||||||
|
kill_timeout: 5000,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Graceful shutdown with PM2**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/server.ts
|
||||||
|
const server = app.listen(PORT, () => {
|
||||||
|
logger.info(`Server started on port ${PORT}`);
|
||||||
|
|
||||||
|
// Signal PM2 ready
|
||||||
|
if (process.send) {
|
||||||
|
process.send('ready');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
logger.info('SIGINT received, closing server');
|
||||||
|
|
||||||
|
server.close(async () => {
|
||||||
|
await closePool();
|
||||||
|
logger.info('Server closed');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force shutdown after 10s
|
||||||
|
setTimeout(() => {
|
||||||
|
logger.error('Forcing shutdown');
|
||||||
|
process.exit(1);
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dockerfile
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Multi-stage build
|
||||||
|
FROM node:18-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
# Build TypeScript
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production image
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install production dependencies only
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
|
# Copy built files
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
|
adduser -S nodejs -u 1001
|
||||||
|
|
||||||
|
USER nodejs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "dist/server.js"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Check Endpoint
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/routes/healthRoutes.ts
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { pool } from '../config/database';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/health', async (req, res) => {
|
||||||
|
const health = {
|
||||||
|
uptime: process.uptime(),
|
||||||
|
message: 'OK',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pool.query('SELECT 1');
|
||||||
|
health.database = 'connected';
|
||||||
|
} catch (error) {
|
||||||
|
health.database = 'disconnected';
|
||||||
|
return res.status(503).json(health);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(health);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/health/ready', async (req, res) => {
|
||||||
|
// Readiness check
|
||||||
|
try {
|
||||||
|
await pool.query('SELECT 1');
|
||||||
|
res.status(200).json({ status: 'ready' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(503).json({ status: 'not ready' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/health/live', (req, res) => {
|
||||||
|
// Liveness check (simpler)
|
||||||
|
res.status(200).json({ status: 'alive' });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
|
||||||
|
### Response Caching
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
const redis = new Redis({
|
||||||
|
host: process.env.REDIS_HOST,
|
||||||
|
port: Number(process.env.REDIS_PORT),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cacheMiddleware = (duration: number) => {
|
||||||
|
return async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (req.method !== 'GET') return next();
|
||||||
|
|
||||||
|
const key = `cache:${req.originalUrl}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cached = await redis.get(key);
|
||||||
|
if (cached) {
|
||||||
|
return res.json(JSON.parse(cached));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture response
|
||||||
|
const originalJson = res.json.bind(res);
|
||||||
|
res.json = (body: any) => {
|
||||||
|
redis.setex(key, duration, JSON.stringify(body));
|
||||||
|
return originalJson(body);
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
router.get('/users', cacheMiddleware(300), async (req, res) => {
|
||||||
|
const users = await userService.findAll();
|
||||||
|
res.json(users);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import RedisStore from 'rate-limit-redis';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
const redis = new Redis();
|
||||||
|
|
||||||
|
export const apiLimiter = rateLimit({
|
||||||
|
store: new RedisStore({ client: redis }),
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 100, // 100 requests per window
|
||||||
|
message: 'Too many requests, please try again later',
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const authLimiter = rateLimit({
|
||||||
|
store: new RedisStore({ client: redis }),
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 5, // 5 attempts
|
||||||
|
skipSuccessfulRequests: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
app.use('/api/', apiLimiter);
|
||||||
|
app.use('/api/auth/login', authLimiter);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
| Anti-Pattern | Why Bad | Fix |
|
||||||
|
|--------------|---------|-----|
|
||||||
|
| **No async error handling** | Crashes server | Use asyncHandler or express-async-errors |
|
||||||
|
| **Inconsistent error responses** | Poor DX | Centralized error handler |
|
||||||
|
| **New DB connection per request** | Exhausts connections | Use connection pool |
|
||||||
|
| **No graceful shutdown** | Data loss, broken requests | Handle SIGTERM/SIGINT |
|
||||||
|
| **Logging to console in production** | Lost logs, no structure | Use Winston/Pino with transports |
|
||||||
|
| **No request validation** | Security vulnerabilities | Zod/express-validator |
|
||||||
|
| **Synchronous operations in routes** | Blocks event loop | Use async/await |
|
||||||
|
| **No health checks** | Can't monitor service | /health endpoints |
|
||||||
|
|
||||||
|
## Cross-References
|
||||||
|
|
||||||
|
**Related skills**:
|
||||||
|
- **Database patterns** → `database-integration` (pooling, transactions)
|
||||||
|
- **API testing** → `api-testing` (supertest patterns)
|
||||||
|
- **REST design** → `rest-api-design` (endpoint patterns)
|
||||||
|
- **Authentication** → `api-authentication` (JWT, sessions)
|
||||||
|
|
||||||
|
## Further Reading
|
||||||
|
|
||||||
|
- **Express docs**: https://expressjs.com/
|
||||||
|
- **Express.js Best Practices**: https://expressjs.com/en/advanced/best-practice-performance.html
|
||||||
|
- **Node.js Production Best Practices**: https://github.com/goldbergyoni/nodebestpractices
|
||||||
500
skills/using-web-backend/fastapi-development.md
Normal file
500
skills/using-web-backend/fastapi-development.md
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
|
||||||
|
# FastAPI Development
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**FastAPI specialist skill providing production-ready patterns, anti-patterns to avoid, and testing strategies.**
|
||||||
|
|
||||||
|
**Core principle**: FastAPI's type hints, dependency injection, and async-first design enable fast, maintainable APIs - but require understanding async/sync boundaries, proper dependency management, and production hardening patterns.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Use when encountering:
|
||||||
|
|
||||||
|
- **Dependency injection**: Database connections, auth, shared resources, testing overrides
|
||||||
|
- **Async/sync boundaries**: Mixing blocking I/O with async endpoints, performance issues
|
||||||
|
- **Background tasks**: Choosing between BackgroundTasks, Celery, or other task queues
|
||||||
|
- **File uploads**: Streaming large files, memory management
|
||||||
|
- **Testing**: Dependency overrides, async test clients, fixture patterns
|
||||||
|
- **Production deployment**: ASGI servers, lifespan management, connection pooling
|
||||||
|
- **Security**: SQL injection, CORS, authentication patterns
|
||||||
|
- **Performance**: Connection pooling, query optimization, caching
|
||||||
|
|
||||||
|
## Quick Reference - Common Patterns
|
||||||
|
|
||||||
|
| Pattern | Use Case | Code Snippet |
|
||||||
|
|---------|----------|--------------|
|
||||||
|
| **DB dependency with pooling** | Per-request database access | `def get_db(): db = SessionLocal(); try: yield db; finally: db.close()` |
|
||||||
|
| **Dependency override for testing** | Test with mock/test DB | `app.dependency_overrides[get_db] = override_get_db` |
|
||||||
|
| **Lifespan events** | Startup/shutdown resources | `@asynccontextmanager async def lifespan(app): ... yield ...` |
|
||||||
|
| **Streaming file upload** | Large files without memory issues | `async with aiofiles.open(...) as f: while chunk := await file.read(CHUNK_SIZE): await f.write(chunk)` |
|
||||||
|
| **Background tasks (short)** | < 30 sec tasks | `background_tasks.add_task(func, args)` |
|
||||||
|
| **Task queue (long)** | > 1 min tasks, retries needed | Use Celery/Arq with Redis |
|
||||||
|
| **Parameterized queries** | Prevent SQL injection | `cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))` |
|
||||||
|
|
||||||
|
## Core Patterns
|
||||||
|
|
||||||
|
### 1. Dependency Injection Architecture
|
||||||
|
|
||||||
|
**Pattern: Connection pooling with yield dependencies**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker, Session
|
||||||
|
from fastapi import Depends, FastAPI
|
||||||
|
|
||||||
|
# One-time pool creation at module level
|
||||||
|
engine = create_engine(
|
||||||
|
"postgresql://user:pass@localhost/db",
|
||||||
|
pool_size=20, # Max connections
|
||||||
|
max_overflow=0, # No overflow beyond pool_size
|
||||||
|
pool_pre_ping=True, # Verify connection health before use
|
||||||
|
pool_recycle=3600 # Recycle connections every hour
|
||||||
|
)
|
||||||
|
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
# Dependency pattern with automatic cleanup
|
||||||
|
def get_db() -> Session:
|
||||||
|
"""
|
||||||
|
Yields database session from pool.
|
||||||
|
Ensures cleanup even if endpoint raises exception.
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# Usage in endpoints
|
||||||
|
@app.get("/items/{item_id}")
|
||||||
|
def get_item(item_id: int, db: Session = Depends(get_db)):
|
||||||
|
return db.query(Item).filter(Item.id == item_id).first()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this pattern**:
|
||||||
|
- Pool created once (expensive operation)
|
||||||
|
- Per-request connections from pool (cheap)
|
||||||
|
- `yield` ensures cleanup on success AND exceptions
|
||||||
|
- `pool_pre_ping` prevents stale connection errors
|
||||||
|
- `pool_recycle` prevents long-lived connection issues
|
||||||
|
|
||||||
|
**Testing pattern**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# conftest.py
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_db():
|
||||||
|
"""Test database fixture"""
|
||||||
|
db = TestSessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.rollback()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(test_db):
|
||||||
|
"""Test client with overridden dependencies"""
|
||||||
|
def override_get_db():
|
||||||
|
yield test_db
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
with TestClient(app) as c:
|
||||||
|
yield c
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
# test_items.py
|
||||||
|
def test_get_item(client, test_db):
|
||||||
|
# Setup test data
|
||||||
|
test_db.add(Item(id=1, name="Test"))
|
||||||
|
test_db.commit()
|
||||||
|
|
||||||
|
# Test endpoint
|
||||||
|
response = client.get("/items/1")
|
||||||
|
assert response.status_code == 200
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Async/Sync Boundary Management
|
||||||
|
|
||||||
|
**❌ Anti-pattern: Blocking calls in async endpoints**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD - Blocks event loop
|
||||||
|
@app.get("/users/{user_id}")
|
||||||
|
async def get_user(user_id: int):
|
||||||
|
conn = psycopg2.connect(...) # Blocking!
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(...) # Blocking!
|
||||||
|
return cursor.fetchone()
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ Pattern: Use async libraries or run_in_threadpool**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# GOOD Option 1: Async database library
|
||||||
|
from databases import Database
|
||||||
|
|
||||||
|
database = Database("postgresql://...")
|
||||||
|
|
||||||
|
@app.get("/users/{user_id}")
|
||||||
|
async def get_user(user_id: int):
|
||||||
|
query = "SELECT * FROM users WHERE id = :user_id"
|
||||||
|
return await database.fetch_one(query=query, values={"user_id": user_id})
|
||||||
|
|
||||||
|
# GOOD Option 2: Run blocking code in thread pool
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
|
||||||
|
def blocking_db_call(user_id: int):
|
||||||
|
conn = psycopg2.connect(...)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
|
||||||
|
return cursor.fetchone()
|
||||||
|
|
||||||
|
@app.get("/users/{user_id}")
|
||||||
|
async def get_user(user_id: int):
|
||||||
|
return await run_in_threadpool(blocking_db_call, user_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Decision table**:
|
||||||
|
|
||||||
|
| Scenario | Use |
|
||||||
|
|----------|-----|
|
||||||
|
| PostgreSQL with async needed | `asyncpg` or `databases` library |
|
||||||
|
| PostgreSQL, sync is fine | `psycopg2` with `def` (not `async def`) endpoints |
|
||||||
|
| MySQL with async | `aiomysql` |
|
||||||
|
| SQLite | `aiosqlite` (async) or sync with `def` endpoints |
|
||||||
|
| External API calls | `httpx.AsyncClient` |
|
||||||
|
| CPU-intensive work | `run_in_threadpool` or Celery |
|
||||||
|
|
||||||
|
### 3. Lifespan Management (Modern Pattern)
|
||||||
|
|
||||||
|
**✅ Use lifespan context manager** (replaces deprecated `@app.on_event`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
# Global resources
|
||||||
|
resources = {}
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# Startup
|
||||||
|
resources["db_pool"] = await create_async_pool(
|
||||||
|
"postgresql://...",
|
||||||
|
min_size=10,
|
||||||
|
max_size=20
|
||||||
|
)
|
||||||
|
resources["redis"] = await aioredis.create_redis_pool("redis://...")
|
||||||
|
resources["ml_model"] = load_ml_model() # Can be sync or async
|
||||||
|
|
||||||
|
yield # Application runs
|
||||||
|
|
||||||
|
# Shutdown
|
||||||
|
await resources["db_pool"].close()
|
||||||
|
resources["redis"].close()
|
||||||
|
await resources["redis"].wait_closed()
|
||||||
|
resources.clear()
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
# Access resources in endpoints
|
||||||
|
@app.get("/predict")
|
||||||
|
async def predict(data: dict):
|
||||||
|
model = resources["ml_model"]
|
||||||
|
return {"prediction": model.predict(data)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. File Upload Patterns
|
||||||
|
|
||||||
|
**For 100MB+ files: Stream to disk, never load into memory**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import UploadFile, File, HTTPException
|
||||||
|
import aiofiles
|
||||||
|
import os
|
||||||
|
|
||||||
|
UPLOAD_DIR = "/var/uploads"
|
||||||
|
CHUNK_SIZE = 1024 * 1024 # 1MB chunks
|
||||||
|
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500MB
|
||||||
|
|
||||||
|
@app.post("/upload")
|
||||||
|
async def upload_large_file(file: UploadFile = File(...)):
|
||||||
|
# Validate content type
|
||||||
|
if not file.content_type.startswith("video/"):
|
||||||
|
raise HTTPException(400, "Only video files accepted")
|
||||||
|
|
||||||
|
filepath = os.path.join(UPLOAD_DIR, f"{uuid.uuid4()}_{file.filename}")
|
||||||
|
size = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiofiles.open(filepath, 'wb') as f:
|
||||||
|
while chunk := await file.read(CHUNK_SIZE):
|
||||||
|
size += len(chunk)
|
||||||
|
if size > MAX_FILE_SIZE:
|
||||||
|
raise HTTPException(413, "File too large")
|
||||||
|
await f.write(chunk)
|
||||||
|
except Exception as e:
|
||||||
|
# Cleanup on failure
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
os.remove(filepath)
|
||||||
|
raise
|
||||||
|
|
||||||
|
return {"filename": file.filename, "size": size}
|
||||||
|
```
|
||||||
|
|
||||||
|
**For very large files (1GB+): Direct S3 upload with presigned URLs**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
@app.post("/upload/presigned-url")
|
||||||
|
async def get_presigned_upload_url(filename: str):
|
||||||
|
s3_client = boto3.client('s3')
|
||||||
|
presigned_post = s3_client.generate_presigned_post(
|
||||||
|
Bucket='my-bucket',
|
||||||
|
Key=f'uploads/{uuid.uuid4()}_{filename}',
|
||||||
|
ExpiresIn=3600
|
||||||
|
)
|
||||||
|
return presigned_post # Client uploads directly to S3
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Background Task Decision Matrix
|
||||||
|
|
||||||
|
| Task Duration | Needs Retries? | Needs Monitoring? | Solution |
|
||||||
|
|---------------|----------------|-------------------|----------|
|
||||||
|
| < 30 seconds | No | No | `BackgroundTasks` |
|
||||||
|
| < 30 seconds | Yes | Maybe | Celery/Arq |
|
||||||
|
| > 1 minute | Don't care | Don't care | Celery/Arq |
|
||||||
|
| Any | Yes | Yes | Celery/Arq with monitoring |
|
||||||
|
|
||||||
|
**BackgroundTasks pattern** (simple, in-process):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import BackgroundTasks
|
||||||
|
|
||||||
|
async def send_email(email: str):
|
||||||
|
await asyncio.sleep(2) # Async work
|
||||||
|
print(f"Email sent to {email}")
|
||||||
|
|
||||||
|
@app.post("/register")
|
||||||
|
async def register(email: str, background_tasks: BackgroundTasks):
|
||||||
|
# ... save user ...
|
||||||
|
background_tasks.add_task(send_email, email)
|
||||||
|
return {"status": "registered"} # Returns immediately
|
||||||
|
```
|
||||||
|
|
||||||
|
**Celery pattern** (distributed, persistent):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# celery_app.py
|
||||||
|
from celery import Celery
|
||||||
|
|
||||||
|
celery_app = Celery('tasks', broker='redis://localhost:6379/0')
|
||||||
|
|
||||||
|
@celery_app.task(bind=True, max_retries=3)
|
||||||
|
def process_video(self, filepath: str):
|
||||||
|
try:
|
||||||
|
# Long-running work
|
||||||
|
extract_frames(filepath)
|
||||||
|
except Exception as exc:
|
||||||
|
raise self.retry(exc=exc, countdown=60)
|
||||||
|
|
||||||
|
# main.py
|
||||||
|
from celery_app import process_video
|
||||||
|
|
||||||
|
@app.post("/upload")
|
||||||
|
async def upload(file: UploadFile):
|
||||||
|
filepath = await save_file(file)
|
||||||
|
task = process_video.delay(filepath)
|
||||||
|
return {"task_id": task.id}
|
||||||
|
|
||||||
|
@app.get("/status/{task_id}")
|
||||||
|
async def get_status(task_id: str):
|
||||||
|
from celery_app import celery_app
|
||||||
|
result = celery_app.AsyncResult(task_id)
|
||||||
|
return {"status": result.state, "result": result.result}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Patterns
|
||||||
|
|
||||||
|
### SQL Injection Prevention
|
||||||
|
|
||||||
|
**❌ NEVER use f-strings or string concatenation**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# DANGEROUS
|
||||||
|
cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")
|
||||||
|
cursor.execute("SELECT * FROM users WHERE email = '" + email + "'")
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ ALWAYS use parameterized queries**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# SQLAlchemy ORM (safe)
|
||||||
|
db.query(User).filter(User.id == user_id).first()
|
||||||
|
|
||||||
|
# Raw SQL (safe with parameters)
|
||||||
|
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
|
||||||
|
cursor.execute("SELECT * FROM users WHERE email = :email", {"email": email})
|
||||||
|
```
|
||||||
|
|
||||||
|
### CORS Configuration
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["https://yourdomain.com"], # Specific origins, not "*" in production
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication Pattern
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||||
|
token = credentials.credentials
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
|
||||||
|
user_id = payload.get("sub")
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid token")
|
||||||
|
return await get_user_by_id(user_id)
|
||||||
|
except jwt.JWTError:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid token")
|
||||||
|
|
||||||
|
@app.get("/protected")
|
||||||
|
async def protected_route(current_user = Depends(get_current_user)):
|
||||||
|
return {"user": current_user}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Middleware Ordering
|
||||||
|
|
||||||
|
**Critical: Middleware wraps in order added, executes in reverse for responses**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Correct order:
|
||||||
|
app.add_middleware(CORSMiddleware, ...) # 1. FIRST - handles preflight
|
||||||
|
app.add_middleware(RequestLoggingMiddleware) # 2. Logs entire request
|
||||||
|
app.add_middleware(ErrorHandlingMiddleware) # 3. Catches errors from auth/routes
|
||||||
|
app.add_middleware(AuthenticationMiddleware) # 4. LAST - closest to routes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Anti-Patterns
|
||||||
|
|
||||||
|
| Anti-Pattern | Why Bad | Fix |
|
||||||
|
|--------------|---------|-----|
|
||||||
|
| Global database connection | Not thread-safe, connection leaks | Use connection pool with dependency injection |
|
||||||
|
| `async def` with blocking I/O | Blocks event loop, kills performance | Use async libraries or `run_in_threadpool` |
|
||||||
|
| `time.sleep()` in async code | Blocks entire event loop | Use `asyncio.sleep()` |
|
||||||
|
| Loading large files into memory | Memory exhaustion, OOM crashes | Stream with `aiofiles` and chunks |
|
||||||
|
| BackgroundTasks for long work | Lost on restart, no retries | Use Celery/Arq |
|
||||||
|
| String formatting in SQL | SQL injection vulnerability | Parameterized queries only |
|
||||||
|
| `allow_origins=["*"]` with credentials | Security vulnerability | Specify exact origins |
|
||||||
|
| Not closing database connections | Connection pool exhaustion | Use `yield` in dependencies |
|
||||||
|
|
||||||
|
## Testing Best Practices
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
# Sync tests (simpler, faster for most cases)
|
||||||
|
def test_read_item(client):
|
||||||
|
response = client.get("/items/1")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Async tests (needed for testing async endpoints with real async operations)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_endpoint():
|
||||||
|
async with AsyncClient(app=app, base_url="http://test") as ac:
|
||||||
|
response = await ac.get("/items/1")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Dependency override pattern
|
||||||
|
def test_with_mock_db(client):
|
||||||
|
def override_get_db():
|
||||||
|
yield mock_db
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
response = client.get("/items/1")
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
assert response.status_code == 200
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
**ASGI server configuration** (Uvicorn + Gunicorn):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# gunicorn with uvicorn workers (production)
|
||||||
|
gunicorn main:app \
|
||||||
|
--workers 4 \
|
||||||
|
--worker-class uvicorn.workers.UvicornWorker \
|
||||||
|
--bind 0.0.0.0:8000 \
|
||||||
|
--timeout 120 \
|
||||||
|
--graceful-timeout 30 \
|
||||||
|
--keep-alive 5
|
||||||
|
```
|
||||||
|
|
||||||
|
**Environment-based configuration**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
database_url: str
|
||||||
|
redis_url: str
|
||||||
|
secret_key: str
|
||||||
|
debug: bool = False
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
# Use in app
|
||||||
|
engine = create_engine(settings.database_url)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cross-References
|
||||||
|
|
||||||
|
**Related skills**:
|
||||||
|
- **Security** → `ordis-security-architect` (threat modeling, OWASP top 10)
|
||||||
|
- **Python patterns** → `axiom-python-engineering` (async patterns, type hints)
|
||||||
|
- **API testing** → `api-testing` (contract testing, integration tests)
|
||||||
|
- **API documentation** → `api-documentation` or `muna-technical-writer`
|
||||||
|
- **Database optimization** → `database-integration` (query optimization, migrations)
|
||||||
|
- **Authentication deep dive** → `api-authentication` (OAuth2, JWT patterns)
|
||||||
|
- **GraphQL alternative** → `graphql-api-design`
|
||||||
|
|
||||||
|
## Performance Tips
|
||||||
|
|
||||||
|
1. **Use connection pooling** - Create pool once, not per-request
|
||||||
|
2. **Enable response caching** - Use `fastapi-cache2` for expensive queries
|
||||||
|
3. **Limit response size** - Paginate large result sets
|
||||||
|
4. **Use async for I/O** - Database, HTTP calls, file operations
|
||||||
|
5. **Profile slow endpoints** - Use `starlette-prometheus` for monitoring
|
||||||
|
6. **Enable gzip compression** - `GZipMiddleware` for large JSON responses
|
||||||
|
|
||||||
|
## When NOT to Use FastAPI
|
||||||
|
|
||||||
|
- **Simple CRUD with admin panel** → Django (has built-in admin)
|
||||||
|
- **Heavy template rendering** → Django or Flask
|
||||||
|
- **Mature ecosystem needed** → Django (more third-party packages)
|
||||||
|
- **Team unfamiliar with async** → Flask or Django (simpler mental model)
|
||||||
|
|
||||||
|
FastAPI excels at: Modern APIs, microservices, ML model serving, real-time features, high performance requirements.
|
||||||
954
skills/using-web-backend/graphql-api-design.md
Normal file
954
skills/using-web-backend/graphql-api-design.md
Normal file
@@ -0,0 +1,954 @@
|
|||||||
|
|
||||||
|
# GraphQL API Design
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**GraphQL API specialist covering schema design, query optimization, real-time subscriptions, federation, and production patterns.**
|
||||||
|
|
||||||
|
**Core principle**: GraphQL enables clients to request exactly the data they need in a single query - but requires careful schema design, batching strategies, and security measures to prevent performance and security issues.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Use when encountering:
|
||||||
|
|
||||||
|
- **N+1 query problems**: Too many database queries for nested resolvers
|
||||||
|
- **Schema design**: Types, interfaces, unions, input types, directives
|
||||||
|
- **Pagination**: Connections, cursors, offset patterns
|
||||||
|
- **Performance**: Query complexity, caching, batching, persisted queries
|
||||||
|
- **Real-time**: Subscriptions, WebSocket patterns, live queries
|
||||||
|
- **Federation**: Splitting schema across multiple services
|
||||||
|
- **Security**: Query depth limiting, cost analysis, allowlisting
|
||||||
|
- **Testing**: Schema validation, resolver testing, integration tests
|
||||||
|
- **Migrations**: Schema evolution, deprecation, versioning
|
||||||
|
|
||||||
|
**Do NOT use for**:
|
||||||
|
- REST API design → `rest-api-design`
|
||||||
|
- Framework-specific implementation → `fastapi-development`, `express-development`
|
||||||
|
- Microservices architecture → `microservices-architecture` (use with Federation)
|
||||||
|
|
||||||
|
## GraphQL vs REST Decision Matrix
|
||||||
|
|
||||||
|
| Factor | Choose GraphQL | Choose REST |
|
||||||
|
|--------|----------------|-------------|
|
||||||
|
| **Client needs** | Mobile apps, varying data needs | Uniform data requirements |
|
||||||
|
| **Over/under-fetching** | Problem | Not a problem |
|
||||||
|
| **Real-time features** | Subscriptions built-in | Need SSE/WebSockets separately |
|
||||||
|
| **Schema-first** | Strong typing required | Flexible, schema optional |
|
||||||
|
| **Caching** | Complex (field-level) | Simple (HTTP caching) |
|
||||||
|
| **File uploads** | Non-standard (multipart) | Native (multipart/form-data) |
|
||||||
|
| **Team expertise** | GraphQL experience | REST experience |
|
||||||
|
| **API consumers** | Known clients | Public/third-party |
|
||||||
|
| **Rate limiting** | Complex (field-level) | Simple (endpoint-level) |
|
||||||
|
|
||||||
|
**Hybrid approach**: GraphQL for internal/mobile, REST for public APIs
|
||||||
|
|
||||||
|
## Quick Reference - Core Patterns
|
||||||
|
|
||||||
|
| Pattern | Use Case | Key Concept |
|
||||||
|
|---------|----------|-------------|
|
||||||
|
| **DataLoader** | N+1 queries | Batch and cache within request |
|
||||||
|
| **Connection** | Pagination | Cursor-based with edges/nodes |
|
||||||
|
| **Union** | Heterogeneous results | Search, activity feeds |
|
||||||
|
| **Interface** | Shared fields | Polymorphic types with guarantees |
|
||||||
|
| **Directive** | Field behavior | @auth, @deprecated, custom logic |
|
||||||
|
| **Input types** | Mutations | Type-safe input validation |
|
||||||
|
| **Federation** | Microservices | Distributed schema composition |
|
||||||
|
| **Subscription** | Real-time | WebSocket-based live updates |
|
||||||
|
|
||||||
|
## N+1 Query Optimization
|
||||||
|
|
||||||
|
### The Problem
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Schema
|
||||||
|
type Post {
|
||||||
|
id: ID!
|
||||||
|
title: String!
|
||||||
|
author: User! // Requires fetching user
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
posts: [Post!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Naive resolver (N+1 problem)
|
||||||
|
const resolvers = {
|
||||||
|
Query: {
|
||||||
|
posts: () => db.posts.findAll() // 1 query
|
||||||
|
},
|
||||||
|
Post: {
|
||||||
|
author: (post) => db.users.findOne(post.authorId) // N queries!
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Result: 100 posts = 101 database queries
|
||||||
|
```
|
||||||
|
|
||||||
|
### DataLoader Solution
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const DataLoader = require('dataloader');
|
||||||
|
|
||||||
|
// Batch loading function
|
||||||
|
const batchUsers = async (userIds) => {
|
||||||
|
const users = await db.users.findMany({
|
||||||
|
where: { id: { in: userIds } }
|
||||||
|
});
|
||||||
|
|
||||||
|
// CRITICAL: Return in same order as requested IDs
|
||||||
|
const userMap = new Map(users.map(u => [u.id, u]));
|
||||||
|
return userIds.map(id => userMap.get(id) || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create loader per-request (avoid stale cache)
|
||||||
|
const createLoaders = () => ({
|
||||||
|
user: new DataLoader(batchUsers),
|
||||||
|
post: new DataLoader(batchPosts),
|
||||||
|
// ... other loaders
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add to context
|
||||||
|
const server = new ApolloServer({
|
||||||
|
typeDefs,
|
||||||
|
resolvers,
|
||||||
|
context: () => ({
|
||||||
|
loaders: createLoaders(),
|
||||||
|
db,
|
||||||
|
user: getCurrentUser()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use in resolver
|
||||||
|
const resolvers = {
|
||||||
|
Post: {
|
||||||
|
author: (post, args, { loaders }) => {
|
||||||
|
return loaders.user.load(post.authorId); // Batched!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result**: 100 posts = 2 queries (1 for posts, 1 batched for unique authors)
|
||||||
|
|
||||||
|
### Advanced DataLoader Patterns
|
||||||
|
|
||||||
|
**Composite Keys**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// For multi-field lookups
|
||||||
|
const batchUsersByEmail = async (keys) => {
|
||||||
|
// keys = [{domain: 'example.com', email: 'user@example.com'}, ...]
|
||||||
|
const users = await db.users.findMany({
|
||||||
|
where: {
|
||||||
|
OR: keys.map(k => ({ email: k.email, domain: k.domain }))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const userMap = new Map(
|
||||||
|
users.map(u => [`${u.domain}:${u.email}`, u])
|
||||||
|
);
|
||||||
|
|
||||||
|
return keys.map(k => userMap.get(`${k.domain}:${k.email}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
const userByEmailLoader = new DataLoader(batchUsersByEmail, {
|
||||||
|
cacheKeyFn: (key) => `${key.domain}:${key.email}`
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Priming Cache**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// After fetching posts, prime user loader
|
||||||
|
const posts = await db.posts.findAll();
|
||||||
|
posts.forEach(post => {
|
||||||
|
if (post.authorData) {
|
||||||
|
loaders.user.prime(post.authorId, post.authorData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return posts;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Handling in Batch**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const batchUsers = async (userIds) => {
|
||||||
|
const users = await db.users.findMany({
|
||||||
|
where: { id: { in: userIds } }
|
||||||
|
});
|
||||||
|
|
||||||
|
const userMap = new Map(users.map(u => [u.id, u]));
|
||||||
|
|
||||||
|
return userIds.map(id => {
|
||||||
|
const user = userMap.get(id);
|
||||||
|
if (!user) {
|
||||||
|
return new Error(`User ${id} not found`); // Per-item error
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schema Design Patterns
|
||||||
|
|
||||||
|
### Interface vs Union
|
||||||
|
|
||||||
|
**Interface** (shared fields enforced):
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
interface Node {
|
||||||
|
id: ID!
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Timestamped {
|
||||||
|
createdAt: DateTime!
|
||||||
|
updatedAt: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
type User implements Node & Timestamped {
|
||||||
|
id: ID!
|
||||||
|
createdAt: DateTime!
|
||||||
|
updatedAt: DateTime!
|
||||||
|
email: String!
|
||||||
|
name: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Post implements Node & Timestamped {
|
||||||
|
id: ID!
|
||||||
|
createdAt: DateTime!
|
||||||
|
updatedAt: DateTime!
|
||||||
|
title: String!
|
||||||
|
content: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
node(id: ID!): Node # Can return any Node implementer
|
||||||
|
nodes(ids: [ID!]!): [Node!]!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query**:
|
||||||
|
```graphql
|
||||||
|
{
|
||||||
|
node(id: "user_123") {
|
||||||
|
id
|
||||||
|
... on User {
|
||||||
|
email
|
||||||
|
name
|
||||||
|
}
|
||||||
|
... on Post {
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Union** (no shared fields required):
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
union SearchResult = User | Post | Comment
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
search(query: String!): [SearchResult!]!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use each**:
|
||||||
|
|
||||||
|
| Use Case | Pattern | Why |
|
||||||
|
|----------|---------|-----|
|
||||||
|
| Global ID lookup | Interface (Node) | Guarantees `id` field |
|
||||||
|
| Polymorphic lists with shared fields | Interface | Can query shared fields without fragments |
|
||||||
|
| Heterogeneous results | Union | No shared field requirements |
|
||||||
|
| Activity feeds | Union | Different event types |
|
||||||
|
| Search results | Union | Mixed content types |
|
||||||
|
|
||||||
|
### Input Types and Validation
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
input CreatePostInput {
|
||||||
|
title: String!
|
||||||
|
content: String!
|
||||||
|
tags: [String!]
|
||||||
|
publishedAt: DateTime
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdatePostInput {
|
||||||
|
title: String
|
||||||
|
content: String
|
||||||
|
tags: [String!]
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mutation {
|
||||||
|
createPost(input: CreatePostInput!): Post!
|
||||||
|
updatePost(id: ID!, input: UpdatePostInput!): Post!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
- Reusable across multiple mutations
|
||||||
|
- Clear separation of create vs update requirements
|
||||||
|
- Type-safe in generated code
|
||||||
|
- Can add descriptions per field
|
||||||
|
|
||||||
|
### Custom Directives
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
directive @auth(requires: Role = USER) on FIELD_DEFINITION
|
||||||
|
directive @rateLimit(limit: Int!, window: Int!) on FIELD_DEFINITION
|
||||||
|
directive @deprecated(reason: String) on FIELD_DEFINITION | ENUM_VALUE
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
USER
|
||||||
|
ADMIN
|
||||||
|
SUPER_ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
publicData: String
|
||||||
|
userData: User @auth(requires: USER)
|
||||||
|
adminData: String @auth(requires: ADMIN)
|
||||||
|
expensiveQuery: Result @rateLimit(limit: 10, window: 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
type User {
|
||||||
|
id: ID!
|
||||||
|
email: String! @auth(requires: USER) # Only authenticated users
|
||||||
|
internalId: String @deprecated(reason: "Use `id` instead")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pagination Patterns
|
||||||
|
|
||||||
|
### Relay Connection Specification
|
||||||
|
|
||||||
|
**Standard connection pattern**:
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
type PostConnection {
|
||||||
|
edges: [PostEdge!]!
|
||||||
|
pageInfo: PageInfo!
|
||||||
|
totalCount: Int # Optional
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostEdge {
|
||||||
|
node: Post!
|
||||||
|
cursor: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageInfo {
|
||||||
|
hasNextPage: Boolean!
|
||||||
|
hasPreviousPage: Boolean!
|
||||||
|
startCursor: String
|
||||||
|
endCursor: String
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
posts(
|
||||||
|
first: Int
|
||||||
|
after: String
|
||||||
|
last: Int
|
||||||
|
before: String
|
||||||
|
): PostConnection!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const resolvers = {
|
||||||
|
Query: {
|
||||||
|
posts: async (parent, { first, after, last, before }) => {
|
||||||
|
const limit = first || last || 10;
|
||||||
|
const cursor = after || before;
|
||||||
|
|
||||||
|
// Decode cursor
|
||||||
|
const offset = cursor ? decodeCursor(cursor) : 0;
|
||||||
|
|
||||||
|
// Fetch one extra to determine hasNextPage
|
||||||
|
const posts = await db.posts.findMany({
|
||||||
|
skip: offset,
|
||||||
|
take: limit + 1,
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasNextPage = posts.length > limit;
|
||||||
|
const edges = posts.slice(0, limit).map((post, index) => ({
|
||||||
|
node: post,
|
||||||
|
cursor: encodeCursor(offset + index)
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
edges,
|
||||||
|
pageInfo: {
|
||||||
|
hasNextPage,
|
||||||
|
hasPreviousPage: offset > 0,
|
||||||
|
startCursor: edges[0]?.cursor,
|
||||||
|
endCursor: edges[edges.length - 1]?.cursor
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Opaque cursor encoding
|
||||||
|
const encodeCursor = (offset) =>
|
||||||
|
Buffer.from(`arrayconnection:${offset}`).toString('base64');
|
||||||
|
const decodeCursor = (cursor) =>
|
||||||
|
parseInt(Buffer.from(cursor, 'base64').toString().split(':')[1]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative: Offset pagination** (simpler but less robust):
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
type PostPage {
|
||||||
|
items: [Post!]!
|
||||||
|
total: Int!
|
||||||
|
page: Int!
|
||||||
|
pageSize: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
posts(page: Int = 1, pageSize: Int = 20): PostPage!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
|
||||||
|
### Query Complexity Analysis
|
||||||
|
|
||||||
|
**Prevent expensive queries**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const depthLimit = require('graphql-depth-limit');
|
||||||
|
const { createComplexityLimitRule } = require('graphql-validation-complexity');
|
||||||
|
|
||||||
|
const server = new ApolloServer({
|
||||||
|
typeDefs,
|
||||||
|
resolvers,
|
||||||
|
validationRules: [
|
||||||
|
depthLimit(10), // Max 10 levels deep
|
||||||
|
createComplexityLimitRule(1000, {
|
||||||
|
scalarCost: 1,
|
||||||
|
objectCost: 2,
|
||||||
|
listFactor: 10
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Custom complexity**:
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
type Query {
|
||||||
|
posts(first: Int!): [Post!]! @cost(complexity: 10, multipliers: ["first"])
|
||||||
|
expensiveAnalytics: AnalyticsReport! @cost(complexity: 1000)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automatic Persisted Queries (APQ)
|
||||||
|
|
||||||
|
**Client sends hash instead of full query**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Client
|
||||||
|
const query = gql`
|
||||||
|
query GetUser($id: ID!) {
|
||||||
|
user(id: $id) { name email }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const queryHash = sha256(query);
|
||||||
|
|
||||||
|
// First request: Send hash only
|
||||||
|
fetch('/graphql', {
|
||||||
|
body: JSON.stringify({
|
||||||
|
extensions: {
|
||||||
|
persistedQuery: {
|
||||||
|
version: 1,
|
||||||
|
sha256Hash: queryHash
|
||||||
|
}
|
||||||
|
},
|
||||||
|
variables: { id: '123' }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// If server doesn't have it (PersistedQueryNotFound)
|
||||||
|
// Second request: Send full query + hash
|
||||||
|
fetch('/graphql', {
|
||||||
|
body: JSON.stringify({
|
||||||
|
query,
|
||||||
|
extensions: {
|
||||||
|
persistedQuery: {
|
||||||
|
version: 1,
|
||||||
|
sha256Hash: queryHash
|
||||||
|
}
|
||||||
|
},
|
||||||
|
variables: { id: '123' }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Future requests: Just send hash
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
- Reduced bandwidth (hash << full query)
|
||||||
|
- CDN caching of GET requests
|
||||||
|
- Query allowlisting (if configured)
|
||||||
|
|
||||||
|
### Field-Level Caching
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const resolvers = {
|
||||||
|
Query: {
|
||||||
|
user: async (parent, { id }, { cache }) => {
|
||||||
|
const cacheKey = `user:${id}`;
|
||||||
|
const cached = await cache.get(cacheKey);
|
||||||
|
if (cached) return JSON.parse(cached);
|
||||||
|
|
||||||
|
const user = await db.users.findOne(id);
|
||||||
|
await cache.set(cacheKey, JSON.stringify(user), { ttl: 300 });
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Subscriptions (Real-Time)
|
||||||
|
|
||||||
|
### Basic Subscription
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
type Subscription {
|
||||||
|
postAdded: Post!
|
||||||
|
commentAdded(postId: ID!): Comment!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mutation {
|
||||||
|
createPost(input: CreatePostInput!): Post!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation (Apollo Server)**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { PubSub } = require('graphql-subscriptions');
|
||||||
|
const pubsub = new PubSub();
|
||||||
|
|
||||||
|
const resolvers = {
|
||||||
|
Mutation: {
|
||||||
|
createPost: async (parent, { input }) => {
|
||||||
|
const post = await db.posts.create(input);
|
||||||
|
pubsub.publish('POST_ADDED', { postAdded: post });
|
||||||
|
return post;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Subscription: {
|
||||||
|
postAdded: {
|
||||||
|
subscribe: () => pubsub.asyncIterator(['POST_ADDED'])
|
||||||
|
},
|
||||||
|
commentAdded: {
|
||||||
|
subscribe: (parent, { postId }) =>
|
||||||
|
pubsub.asyncIterator([`COMMENT_ADDED_${postId}`])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Client
|
||||||
|
subscription {
|
||||||
|
postAdded {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
author { name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scaling Subscriptions
|
||||||
|
|
||||||
|
**Problem**: In-memory PubSub doesn't work across servers
|
||||||
|
|
||||||
|
**Solution**: Redis PubSub
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { RedisPubSub } = require('graphql-redis-subscriptions');
|
||||||
|
const Redis = require('ioredis');
|
||||||
|
|
||||||
|
const pubsub = new RedisPubSub({
|
||||||
|
publisher: new Redis(),
|
||||||
|
subscriber: new Redis()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now works across multiple server instances
|
||||||
|
```
|
||||||
|
|
||||||
|
### Subscription Authorization
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const resolvers = {
|
||||||
|
Subscription: {
|
||||||
|
secretDataUpdated: {
|
||||||
|
subscribe: withFilter(
|
||||||
|
() => pubsub.asyncIterator(['SECRET_DATA']),
|
||||||
|
(payload, variables, context) => {
|
||||||
|
// Only admin users can subscribe
|
||||||
|
return context.user?.role === 'ADMIN';
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Federation (Distributed Schema)
|
||||||
|
|
||||||
|
**Split schema across multiple services**:
|
||||||
|
|
||||||
|
### User Service
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
# user-service schema
|
||||||
|
type User @key(fields: "id") {
|
||||||
|
id: ID!
|
||||||
|
email: String!
|
||||||
|
name: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
user(id: ID!): User
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Post Service
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
# post-service schema
|
||||||
|
extend type User @key(fields: "id") {
|
||||||
|
id: ID! @external
|
||||||
|
posts: [Post!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Post {
|
||||||
|
id: ID!
|
||||||
|
title: String!
|
||||||
|
content: String!
|
||||||
|
authorId: ID!
|
||||||
|
author: User!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gateway
|
||||||
|
|
||||||
|
Composes schemas and routes requests:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { ApolloGateway } = require('@apollo/gateway');
|
||||||
|
|
||||||
|
const gateway = new ApolloGateway({
|
||||||
|
serviceList: [
|
||||||
|
{ name: 'users', url: 'http://user-service:4001/graphql' },
|
||||||
|
{ name: 'posts', url: 'http://post-service:4002/graphql' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = new ApolloServer({
|
||||||
|
gateway,
|
||||||
|
subscriptions: false // Not yet supported in federation
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reference Resolver** (fetch extended fields):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// post-service resolvers
|
||||||
|
const resolvers = {
|
||||||
|
User: {
|
||||||
|
__resolveReference: async (user) => {
|
||||||
|
// Receive { __typename: 'User', id: '123' }
|
||||||
|
// Don't need to fetch user, just return it for field resolution
|
||||||
|
return user;
|
||||||
|
},
|
||||||
|
posts: async (user) => {
|
||||||
|
return db.posts.findMany({ where: { authorId: user.id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Patterns
|
||||||
|
|
||||||
|
### Query Depth Limiting
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const depthLimit = require('graphql-depth-limit');
|
||||||
|
|
||||||
|
const server = new ApolloServer({
|
||||||
|
validationRules: [depthLimit(7)] // Max 7 levels deep
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prevents: user { posts { author { posts { author { ... } } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Allowlisting (Production)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const allowedQueries = new Map([
|
||||||
|
['GetUser', 'query GetUser($id: ID!) { user(id: $id) { name } }'],
|
||||||
|
['ListPosts', 'query ListPosts { posts { title } }']
|
||||||
|
]);
|
||||||
|
|
||||||
|
const server = new ApolloServer({
|
||||||
|
validationRules: [
|
||||||
|
(context) => ({
|
||||||
|
Document(node) {
|
||||||
|
const queryName = node.definitions[0]?.name?.value;
|
||||||
|
if (!allowedQueries.has(queryName)) {
|
||||||
|
context.reportError(
|
||||||
|
new GraphQLError('Query not allowed')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rate Limiting (Field-Level)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { shield, rule, and } = require('graphql-shield');
|
||||||
|
|
||||||
|
const isRateLimited = rule({ cache: 'contextual' })(
|
||||||
|
async (parent, args, ctx, info) => {
|
||||||
|
const key = `rate:${ctx.user.id}:${info.fieldName}`;
|
||||||
|
const count = await redis.incr(key);
|
||||||
|
if (count === 1) {
|
||||||
|
await redis.expire(key, 60); // 1 minute window
|
||||||
|
}
|
||||||
|
return count <= 10; // 10 requests per minute
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const permissions = shield({
|
||||||
|
Query: {
|
||||||
|
expensiveQuery: isRateLimited
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schema Evolution
|
||||||
|
|
||||||
|
### Deprecation
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
type User {
|
||||||
|
id: ID!
|
||||||
|
username: String @deprecated(reason: "Use `name` instead")
|
||||||
|
name: String!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tooling shows warnings to clients**
|
||||||
|
|
||||||
|
### Breaking Changes (Avoid)
|
||||||
|
|
||||||
|
❌ **Breaking**:
|
||||||
|
- Removing fields
|
||||||
|
- Changing field types
|
||||||
|
- Making nullable → non-nullable
|
||||||
|
- Removing enum values
|
||||||
|
- Changing arguments
|
||||||
|
|
||||||
|
✅ **Non-breaking**:
|
||||||
|
- Adding fields
|
||||||
|
- Adding types
|
||||||
|
- Deprecating fields
|
||||||
|
- Making non-nullable → nullable
|
||||||
|
- Adding arguments with defaults
|
||||||
|
|
||||||
|
### Versioning Strategy
|
||||||
|
|
||||||
|
**Don't version schema** - evolve incrementally:
|
||||||
|
|
||||||
|
1. Add new field
|
||||||
|
2. Deprecate old field
|
||||||
|
3. Monitor usage
|
||||||
|
4. Remove old field in next major version (if removing)
|
||||||
|
|
||||||
|
## Testing Strategies
|
||||||
|
|
||||||
|
### Schema Validation
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { buildSchema, validateSchema } = require('graphql');
|
||||||
|
|
||||||
|
test('schema is valid', () => {
|
||||||
|
const schema = buildSchema(typeDefs);
|
||||||
|
const errors = validateSchema(schema);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resolver Testing
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const resolvers = require('./resolvers');
|
||||||
|
|
||||||
|
test('user resolver fetches user', async () => {
|
||||||
|
const mockDb = {
|
||||||
|
users: { findOne: jest.fn().mockResolvedValue({ id: '1', name: 'Alice' }) }
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await resolvers.Query.user(
|
||||||
|
null,
|
||||||
|
{ id: '1' },
|
||||||
|
{ db: mockDb, loaders: { user: mockDataLoader() } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: '1', name: 'Alice' });
|
||||||
|
expect(mockDb.users.findOne).toHaveBeenCalledWith('1');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Testing
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { ApolloServer } = require('apollo-server');
|
||||||
|
const { createTestClient } = require('apollo-server-testing');
|
||||||
|
|
||||||
|
const server = new ApolloServer({ typeDefs, resolvers });
|
||||||
|
const { query } = createTestClient(server);
|
||||||
|
|
||||||
|
test('GetUser query', async () => {
|
||||||
|
const GET_USER = gql`
|
||||||
|
query GetUser($id: ID!) {
|
||||||
|
user(id: $id) {
|
||||||
|
name
|
||||||
|
email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const res = await query({ query: GET_USER, variables: { id: '1' } });
|
||||||
|
|
||||||
|
expect(res.errors).toBeUndefined();
|
||||||
|
expect(res.data.user).toMatchObject({
|
||||||
|
name: 'Alice',
|
||||||
|
email: 'alice@example.com'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
| Anti-Pattern | Why Bad | Fix |
|
||||||
|
|--------------|---------|-----|
|
||||||
|
| **No DataLoader** | N+1 queries kill performance | Use DataLoader for all entity fetching |
|
||||||
|
| **Offset pagination** | Breaks with real-time data | Use cursor-based connections |
|
||||||
|
| **No query complexity** | DoS via deeply nested queries | Set depth/complexity limits |
|
||||||
|
| **Shared DataLoader instances** | Stale cache across requests | Create new loaders per request |
|
||||||
|
| **No error masking** | Leaks internal errors to clients | Mask in production, log internally |
|
||||||
|
| **mutations returning Boolean** | Can't extend response | Return object type |
|
||||||
|
| **Nullable IDs** | IDs should never be null | Use `ID!` not `ID` |
|
||||||
|
| **Over-fetching in resolvers** | Selecting * wastes bandwidth | Select only requested fields |
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
### 1. DataLoader Return Order
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ❌ WRONG - Returns in database order
|
||||||
|
const batchUsers = async (ids) => {
|
||||||
|
return await db.users.findMany({ where: { id: { in: ids } } });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ CORRECT - Returns in requested order
|
||||||
|
const batchUsers = async (ids) => {
|
||||||
|
const users = await db.users.findMany({ where: { id: { in: ids } } });
|
||||||
|
const userMap = new Map(users.map(u => [u.id, u]));
|
||||||
|
return ids.map(id => userMap.get(id));
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Mutations Returning Primitives
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
# ❌ BAD - Can't extend
|
||||||
|
type Mutation {
|
||||||
|
deletePost(id: ID!): Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
# ✅ GOOD - Extensible
|
||||||
|
type DeletePostPayload {
|
||||||
|
success: Boolean!
|
||||||
|
deletedPostId: ID
|
||||||
|
message: String
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mutation {
|
||||||
|
deletePost(id: ID!): DeletePostPayload!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. No Context in Subscriptions
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ❌ Missing auth context
|
||||||
|
const server = new ApolloServer({
|
||||||
|
subscriptions: {
|
||||||
|
onConnect: () => {
|
||||||
|
return {}; // No user context!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ Include auth
|
||||||
|
const server = new ApolloServer({
|
||||||
|
subscriptions: {
|
||||||
|
onConnect: (connectionParams) => {
|
||||||
|
const token = connectionParams.authToken;
|
||||||
|
const user = verifyToken(token);
|
||||||
|
return { user };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tooling Ecosystem
|
||||||
|
|
||||||
|
**Schema Management**:
|
||||||
|
- **Apollo Studio**: Schema registry, operation tracking, metrics
|
||||||
|
- **GraphQL Inspector**: Schema diffing, breaking change detection
|
||||||
|
- **Graphql-eslint**: Linting for schema and queries
|
||||||
|
|
||||||
|
**Code Generation**:
|
||||||
|
- **GraphQL Code Generator**: TypeScript types from schema
|
||||||
|
- **Apollo Codegen**: Client types for queries
|
||||||
|
|
||||||
|
**Development**:
|
||||||
|
- **GraphiQL**: In-browser IDE
|
||||||
|
- **Apollo Sandbox**: Modern GraphQL explorer
|
||||||
|
- **Altair**: Desktop GraphQL client
|
||||||
|
|
||||||
|
**Testing**:
|
||||||
|
- **EasyGraphQL Test**: Schema mocking
|
||||||
|
- **GraphQL Tools**: Schema stitching, mocking
|
||||||
|
|
||||||
|
## Cross-References
|
||||||
|
|
||||||
|
**Related skills**:
|
||||||
|
- **REST comparison** → `rest-api-design` (when to use each)
|
||||||
|
- **FastAPI implementation** → `fastapi-development` (Strawberry, Graphene)
|
||||||
|
- **Express implementation** → `express-development` (Apollo Server, GraphQL Yoga)
|
||||||
|
- **Microservices** → `microservices-architecture` (use with Federation)
|
||||||
|
- **Security** → `ordis-security-architect` (OWASP API Security)
|
||||||
|
- **Testing** → `api-testing` (integration testing strategies)
|
||||||
|
- **Authentication** → `api-authentication` (JWT, OAuth2 with GraphQL)
|
||||||
|
|
||||||
|
## Further Reading
|
||||||
|
|
||||||
|
- **GraphQL Spec**: https://spec.graphql.org/
|
||||||
|
- **Apollo Docs**: Federation, caching, tooling
|
||||||
|
- **Relay Spec**: Connection specification
|
||||||
|
- **DataLoader GitHub**: facebook/dataloader
|
||||||
|
- **Production Ready GraphQL**: Book by Marc-André Giroux
|
||||||
993
skills/using-web-backend/message-queues.md
Normal file
993
skills/using-web-backend/message-queues.md
Normal file
@@ -0,0 +1,993 @@
|
|||||||
|
|
||||||
|
# Message Queues
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**Message queue specialist covering technology selection, reliability patterns, ordering guarantees, schema evolution, and production operations.**
|
||||||
|
|
||||||
|
**Core principle**: Message queues decouple producers from consumers, enabling async processing, load leveling, and resilience - but require careful design for reliability, ordering, monitoring, and operational excellence.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Use when encountering:
|
||||||
|
|
||||||
|
- **Technology selection**: RabbitMQ vs Kafka vs SQS vs SNS
|
||||||
|
- **Reliability**: Guaranteed delivery, acknowledgments, retries, DLQ
|
||||||
|
- **Ordering**: Partition keys, FIFO queues, ordered processing
|
||||||
|
- **Scaling**: Consumer groups, parallelism, backpressure
|
||||||
|
- **Schema evolution**: Message versioning, Avro, Protobuf
|
||||||
|
- **Monitoring**: Lag tracking, alerting, distributed tracing
|
||||||
|
- **Advanced patterns**: Outbox, saga, CQRS, event sourcing
|
||||||
|
- **Security**: Encryption, IAM, Kafka authentication
|
||||||
|
- **Testing**: Local testing, chaos engineering, load testing
|
||||||
|
|
||||||
|
**Do NOT use for**:
|
||||||
|
- Request/response APIs → Use REST or GraphQL instead
|
||||||
|
- Strong consistency required → Use database transactions
|
||||||
|
- Real-time streaming analytics → See if streaming-specific skill exists
|
||||||
|
|
||||||
|
## Technology Selection Matrix
|
||||||
|
|
||||||
|
| Factor | RabbitMQ | Apache Kafka | AWS SQS | AWS SNS |
|
||||||
|
|--------|----------|--------------|---------|---------|
|
||||||
|
| **Use Case** | Task queues, routing | Event streaming, logs | Simple queues | Pub/sub fanout |
|
||||||
|
| **Throughput** | 10k-50k msg/s | 100k+ msg/s | 3k msg/s (std), 300 msg/s (FIFO) | 100k+ msg/s |
|
||||||
|
| **Ordering** | Queue-level | Partition-level (strong) | FIFO queues only | None |
|
||||||
|
| **Persistence** | Durable queues | Log-based (default) | Managed | Ephemeral (SNS → SQS for durability) |
|
||||||
|
| **Retention** | Until consumed | Days to weeks | 4 days (std), 14 days max | None (delivery only) |
|
||||||
|
| **Routing** | Exchanges (topic, fanout, headers) | Topics only | None | Topic-based filtering |
|
||||||
|
| **Message size** | Up to 128 MB | Up to 1 MB (configurable) | 256 KB | 256 KB |
|
||||||
|
| **Ops complexity** | Medium (clustering) | High (partitions, replication) | Low (managed) | Low (managed) |
|
||||||
|
| **Cost** | EC2 self-hosted | Self-hosted or MSK | Pay-per-request | Pay-per-request |
|
||||||
|
|
||||||
|
### Decision Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
Are you on AWS and need simple async processing?
|
||||||
|
→ Yes → **AWS SQS** (start simple)
|
||||||
|
→ No → Continue...
|
||||||
|
|
||||||
|
Do you need event replay or stream processing?
|
||||||
|
→ Yes → **Kafka** (log-based, replayable)
|
||||||
|
→ No → Continue...
|
||||||
|
|
||||||
|
Do you need complex routing (topic exchange, headers)?
|
||||||
|
→ Yes → **RabbitMQ** (rich exchange types)
|
||||||
|
→ No → Continue...
|
||||||
|
|
||||||
|
Do you need pub/sub fanout to multiple subscribers?
|
||||||
|
→ Yes → **SNS** (or Kafka topics with multiple consumer groups)
|
||||||
|
→ No → **SQS** or **RabbitMQ** for task queues
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration Path
|
||||||
|
|
||||||
|
| Current State | Next Step | Why |
|
||||||
|
|---------------|-----------|-----|
|
||||||
|
| No queue | Start with SQS (if AWS) or RabbitMQ | Lowest operational complexity |
|
||||||
|
| SQS → 1k+ msg/s | Consider Kafka or sharded SQS | SQS throttles at 3k msg/s |
|
||||||
|
| RabbitMQ → Event sourcing needed | Migrate to Kafka | Kafka's log retention enables replay |
|
||||||
|
| Kafka → Simple task queue | Consider RabbitMQ or SQS | Kafka is overkill for simple queues |
|
||||||
|
|
||||||
|
## Reliability Patterns
|
||||||
|
|
||||||
|
### Acknowledgment Modes
|
||||||
|
|
||||||
|
| Mode | When Ack Sent | Reliability | Performance | Use Case |
|
||||||
|
|------|---------------|-------------|-------------|----------|
|
||||||
|
| **Auto-ack** | On receive | Low (lost on crash) | High | Logs, analytics, best-effort |
|
||||||
|
| **Manual ack (after processing)** | After success | High (at-least-once) | Medium | Standard production pattern |
|
||||||
|
| **Transactional** | In transaction | Highest (exactly-once) | Low | Financial, critical data |
|
||||||
|
|
||||||
|
### At-Least-Once Delivery Pattern
|
||||||
|
|
||||||
|
**SQS**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# WRONG: Delete before processing
|
||||||
|
message = sqs.receive_message(QueueUrl=queue_url)['Messages'][0]
|
||||||
|
sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=message['ReceiptHandle'])
|
||||||
|
process(message['Body']) # ❌ If this fails, message is lost
|
||||||
|
|
||||||
|
# CORRECT: Process, then delete
|
||||||
|
message = sqs.receive_message(
|
||||||
|
QueueUrl=queue_url,
|
||||||
|
VisibilityTimeout=300 # 5 minutes to process
|
||||||
|
)['Messages'][0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
process(json.loads(message['Body']))
|
||||||
|
sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=message['ReceiptHandle'])
|
||||||
|
except Exception as e:
|
||||||
|
# Message becomes visible again after timeout
|
||||||
|
logger.error(f"Processing failed, will retry: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kafka**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# WRONG: Auto-commit before processing
|
||||||
|
consumer = KafkaConsumer(
|
||||||
|
'orders',
|
||||||
|
enable_auto_commit=True, # ❌ Commits offset before processing
|
||||||
|
auto_commit_interval_ms=5000
|
||||||
|
)
|
||||||
|
|
||||||
|
for msg in consumer:
|
||||||
|
process(msg.value) # Crash here = message lost
|
||||||
|
|
||||||
|
# CORRECT: Manual commit after processing
|
||||||
|
consumer = KafkaConsumer(
|
||||||
|
'orders',
|
||||||
|
enable_auto_commit=False
|
||||||
|
)
|
||||||
|
|
||||||
|
for msg in consumer:
|
||||||
|
try:
|
||||||
|
process(msg.value)
|
||||||
|
consumer.commit() # ✓ Commit only after success
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Processing failed, will retry: {e}")
|
||||||
|
# Don't commit - message will be reprocessed
|
||||||
|
```
|
||||||
|
|
||||||
|
**RabbitMQ**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pika
|
||||||
|
|
||||||
|
connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
|
||||||
|
channel = connection.channel()
|
||||||
|
|
||||||
|
def callback(ch, method, properties, body):
|
||||||
|
try:
|
||||||
|
process(json.loads(body))
|
||||||
|
ch.basic_ack(delivery_tag=method.delivery_tag) # ✓ Ack after success
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Processing failed: {e}")
|
||||||
|
ch.basic_nack(delivery_tag=method.delivery_tag, requeue=True) # Requeue
|
||||||
|
|
||||||
|
channel.basic_consume(
|
||||||
|
queue='orders',
|
||||||
|
on_message_callback=callback,
|
||||||
|
auto_ack=False # ✓ Manual acknowledgment
|
||||||
|
)
|
||||||
|
|
||||||
|
channel.start_consuming()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Idempotency (Critical for At-Least-Once)
|
||||||
|
|
||||||
|
Since at-least-once delivery guarantees duplicates, **all processing must be idempotent**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Pattern 1: Database unique constraint
|
||||||
|
def process_order(order_id, data):
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO orders (id, user_id, amount, created_at) "
|
||||||
|
"VALUES (%s, %s, %s, NOW()) "
|
||||||
|
"ON CONFLICT (id) DO NOTHING", # Idempotent
|
||||||
|
(order_id, data['user_id'], data['amount'])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pattern 2: Distributed lock (Redis)
|
||||||
|
def process_order_with_lock(order_id, data):
|
||||||
|
lock_key = f"lock:order:{order_id}"
|
||||||
|
|
||||||
|
# Try to acquire lock (60s TTL)
|
||||||
|
if not redis.set(lock_key, "1", nx=True, ex=60):
|
||||||
|
logger.info(f"Order {order_id} already being processed")
|
||||||
|
return # Duplicate, skip
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Process order
|
||||||
|
create_order(data)
|
||||||
|
charge_payment(data['amount'])
|
||||||
|
finally:
|
||||||
|
redis.delete(lock_key)
|
||||||
|
|
||||||
|
# Pattern 3: Idempotency key table
|
||||||
|
def process_with_idempotency_key(message_id, data):
|
||||||
|
with db.transaction():
|
||||||
|
# Check if already processed
|
||||||
|
result = db.execute(
|
||||||
|
"SELECT 1 FROM processed_messages WHERE message_id = %s FOR UPDATE",
|
||||||
|
(message_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
return # Already processed
|
||||||
|
|
||||||
|
# Process + record atomically
|
||||||
|
process_order(data)
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO processed_messages (message_id, processed_at) VALUES (%s, NOW())",
|
||||||
|
(message_id,)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ordering Guarantees
|
||||||
|
|
||||||
|
### Kafka: Partition-Level Ordering
|
||||||
|
|
||||||
|
**Kafka guarantees ordering within a partition**, not across partitions.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from kafka import KafkaProducer
|
||||||
|
|
||||||
|
producer = KafkaProducer(
|
||||||
|
bootstrap_servers=['kafka:9092'],
|
||||||
|
key_serializer=str.encode,
|
||||||
|
value_serializer=lambda v: json.dumps(v).encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
# ✓ Partition key ensures ordering
|
||||||
|
def publish_order_event(user_id, event_type, data):
|
||||||
|
producer.send(
|
||||||
|
'orders',
|
||||||
|
key=str(user_id), # All user_id events go to same partition
|
||||||
|
value={
|
||||||
|
'event_type': event_type,
|
||||||
|
'user_id': user_id,
|
||||||
|
'data': data,
|
||||||
|
'timestamp': time.time()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# User 123's events all go to partition 2 → strict ordering
|
||||||
|
publish_order_event(123, 'order_placed', {...})
|
||||||
|
publish_order_event(123, 'payment_processed', {...})
|
||||||
|
publish_order_event(123, 'shipped', {...})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Partition count determines max parallelism**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Topic: orders (4 partitions)
|
||||||
|
Consumer group: order-processors
|
||||||
|
|
||||||
|
2 consumers → Each processes 2 partitions
|
||||||
|
4 consumers → Each processes 1 partition (max parallelism)
|
||||||
|
5 consumers → 1 consumer idle (wasted)
|
||||||
|
|
||||||
|
Rule: partition_count >= max_consumers_needed
|
||||||
|
```
|
||||||
|
|
||||||
|
### SQS FIFO: MessageGroupId Ordering
|
||||||
|
|
||||||
|
```python
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
sqs = boto3.client('sqs')
|
||||||
|
|
||||||
|
# FIFO queue guarantees ordering per MessageGroupId
|
||||||
|
sqs.send_message(
|
||||||
|
QueueUrl='orders.fifo',
|
||||||
|
MessageBody=json.dumps(event),
|
||||||
|
MessageGroupId=f"user-{user_id}", # Like Kafka partition key
|
||||||
|
MessageDeduplicationId=f"{event_id}-{timestamp}" # Prevent duplicates
|
||||||
|
)
|
||||||
|
|
||||||
|
# Throughput limit: 300 msg/s per MessageGroupId
|
||||||
|
# Workaround: Use multiple MessageGroupIds if possible
|
||||||
|
```
|
||||||
|
|
||||||
|
### RabbitMQ: Single Consumer Ordering
|
||||||
|
|
||||||
|
```python
|
||||||
|
# RabbitMQ guarantees ordering if single consumer
|
||||||
|
channel.basic_qos(prefetch_count=1) # Process one at a time
|
||||||
|
|
||||||
|
channel.basic_consume(
|
||||||
|
queue='orders',
|
||||||
|
on_message_callback=callback,
|
||||||
|
auto_ack=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Multiple consumers break ordering unless using consistent hashing
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dead Letter Queues (DLQ)
|
||||||
|
|
||||||
|
### Retry Strategy with Exponential Backoff
|
||||||
|
|
||||||
|
**SQS with DLQ**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Infrastructure setup
|
||||||
|
main_queue = sqs.create_queue(
|
||||||
|
QueueName='orders',
|
||||||
|
Attributes={
|
||||||
|
'RedrivePolicy': json.dumps({
|
||||||
|
'deadLetterTargetArn': dlq_arn,
|
||||||
|
'maxReceiveCount': '3' # After 3 failures → DLQ
|
||||||
|
}),
|
||||||
|
'VisibilityTimeout': '300'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Consumer with retry logic
|
||||||
|
def process_with_retry(message):
|
||||||
|
attempt = int(message.attributes.get('ApproximateReceiveCount', 0))
|
||||||
|
|
||||||
|
try:
|
||||||
|
process_order(json.loads(message.body))
|
||||||
|
message.delete()
|
||||||
|
|
||||||
|
except RetriableError as e:
|
||||||
|
# Exponential backoff: 10s, 20s, 40s, 80s, ...
|
||||||
|
backoff = min(300, 2 ** attempt * 10)
|
||||||
|
message.change_visibility(VisibilityTimeout=backoff)
|
||||||
|
logger.warning(f"Retriable error (attempt {attempt}), retry in {backoff}s")
|
||||||
|
|
||||||
|
except PermanentError as e:
|
||||||
|
# Send to DLQ immediately
|
||||||
|
logger.error(f"Permanent error: {e}")
|
||||||
|
send_to_dlq(message, error=str(e))
|
||||||
|
message.delete()
|
||||||
|
|
||||||
|
# Error classification
|
||||||
|
class RetriableError(Exception):
|
||||||
|
"""Network timeout, rate limit, DB unavailable"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class PermanentError(Exception):
|
||||||
|
"""Invalid data, missing field, business rule violation"""
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kafka DLQ Pattern**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from kafka import KafkaConsumer, KafkaProducer
|
||||||
|
|
||||||
|
consumer = KafkaConsumer('orders', group_id='processor')
|
||||||
|
dlq_producer = KafkaProducer(bootstrap_servers=['kafka:9092'])
|
||||||
|
|
||||||
|
def process_with_dlq(message):
|
||||||
|
retry_count = message.headers.get('retry_count', 0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
process_order(message.value)
|
||||||
|
consumer.commit()
|
||||||
|
|
||||||
|
except RetriableError as e:
|
||||||
|
if retry_count < 3:
|
||||||
|
# Send to retry topic with delay
|
||||||
|
delay_minutes = 2 ** retry_count # 1min, 2min, 4min
|
||||||
|
retry_producer.send(
|
||||||
|
f'orders-retry-{delay_minutes}min',
|
||||||
|
value=message.value,
|
||||||
|
headers={'retry_count': retry_count + 1}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Max retries → DLQ
|
||||||
|
dlq_producer.send(
|
||||||
|
'orders-dlq',
|
||||||
|
value=message.value,
|
||||||
|
headers={'error': str(e), 'retry_count': retry_count}
|
||||||
|
)
|
||||||
|
consumer.commit() # Don't reprocess from main topic
|
||||||
|
|
||||||
|
except PermanentError as e:
|
||||||
|
# Immediate DLQ
|
||||||
|
dlq_producer.send('orders-dlq', value=message.value, headers={'error': str(e)})
|
||||||
|
consumer.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
### DLQ Monitoring & Recovery
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Alert on DLQ depth
|
||||||
|
def check_dlq_depth():
|
||||||
|
attrs = sqs.get_queue_attributes(
|
||||||
|
QueueUrl=dlq_url,
|
||||||
|
AttributeNames=['ApproximateNumberOfMessages']
|
||||||
|
)
|
||||||
|
depth = int(attrs['Attributes']['ApproximateNumberOfMessages'])
|
||||||
|
|
||||||
|
if depth > 10:
|
||||||
|
alert(f"DLQ has {depth} messages - investigate!")
|
||||||
|
|
||||||
|
# Manual recovery
|
||||||
|
def replay_from_dlq():
|
||||||
|
"""Fix root cause, then replay"""
|
||||||
|
messages = dlq.receive_messages(MaxNumberOfMessages=10)
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
data = json.loads(msg.body)
|
||||||
|
|
||||||
|
# Fix data issue
|
||||||
|
if 'customer_email' not in data:
|
||||||
|
data['customer_email'] = lookup_email(data['user_id'])
|
||||||
|
|
||||||
|
# Replay to main queue
|
||||||
|
main_queue.send_message(MessageBody=json.dumps(data))
|
||||||
|
msg.delete()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Message Schema Evolution
|
||||||
|
|
||||||
|
### Versioning Strategies
|
||||||
|
|
||||||
|
**Pattern 1: Version field in message**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# v1 message
|
||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"order_id": "123",
|
||||||
|
"amount": 99.99
|
||||||
|
}
|
||||||
|
|
||||||
|
# v2 message (added currency)
|
||||||
|
{
|
||||||
|
"version": "2.0",
|
||||||
|
"order_id": "123",
|
||||||
|
"amount": 99.99,
|
||||||
|
"currency": "USD"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Consumer handles both versions
|
||||||
|
def process_order(message):
|
||||||
|
if message['version'] == "1.0":
|
||||||
|
amount = message['amount']
|
||||||
|
currency = "USD" # Default for v1
|
||||||
|
elif message['version'] == "2.0":
|
||||||
|
amount = message['amount']
|
||||||
|
currency = message['currency']
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported version: {message['version']}")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern 2: Apache Avro (Kafka best practice)**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from confluent_kafka import avro
|
||||||
|
from confluent_kafka.avro import AvroProducer, AvroConsumer
|
||||||
|
|
||||||
|
# Define schema
|
||||||
|
value_schema = avro.loads('''
|
||||||
|
{
|
||||||
|
"type": "record",
|
||||||
|
"name": "Order",
|
||||||
|
"fields": [
|
||||||
|
{"name": "order_id", "type": "string"},
|
||||||
|
{"name": "amount", "type": "double"},
|
||||||
|
{"name": "currency", "type": "string", "default": "USD"} # Backward compatible
|
||||||
|
]
|
||||||
|
}
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Producer
|
||||||
|
producer = AvroProducer({
|
||||||
|
'bootstrap.servers': 'kafka:9092',
|
||||||
|
'schema.registry.url': 'http://schema-registry:8081'
|
||||||
|
}, default_value_schema=value_schema)
|
||||||
|
|
||||||
|
producer.produce(topic='orders', value={
|
||||||
|
'order_id': '123',
|
||||||
|
'amount': 99.99,
|
||||||
|
'currency': 'USD'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Consumer automatically validates schema
|
||||||
|
consumer = AvroConsumer({
|
||||||
|
'bootstrap.servers': 'kafka:9092',
|
||||||
|
'group.id': 'processor',
|
||||||
|
'schema.registry.url': 'http://schema-registry:8081'
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Avro Schema Evolution Rules**:
|
||||||
|
|
||||||
|
| Change | Compatible? | Notes |
|
||||||
|
|--------|-------------|-------|
|
||||||
|
| Add field with default | ✓ Backward compatible | Old consumers ignore new field |
|
||||||
|
| Remove field | ✓ Forward compatible | New consumers must handle missing field |
|
||||||
|
| Rename field | ❌ Breaking | Requires migration |
|
||||||
|
| Change field type | ❌ Breaking | Requires new topic or migration |
|
||||||
|
|
||||||
|
**Pattern 3: Protobuf (alternative to Avro)**:
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
message Order {
|
||||||
|
string order_id = 1;
|
||||||
|
double amount = 2;
|
||||||
|
string currency = 3; // New field, backward compatible
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schema Registry (Kafka)
|
||||||
|
|
||||||
|
```
|
||||||
|
Producer → Schema Registry (validate) → Kafka
|
||||||
|
Consumer → Kafka → Schema Registry (deserialize)
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
- Centralized schema management
|
||||||
|
- Automatic validation
|
||||||
|
- Schema evolution enforcement
|
||||||
|
- Type safety
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring & Observability
|
||||||
|
|
||||||
|
### Key Metrics
|
||||||
|
|
||||||
|
| Metric | Alert Threshold | Why It Matters |
|
||||||
|
|--------|----------------|----------------|
|
||||||
|
| **Queue depth** | > 1000 (or 5min processing time) | Consumers can't keep up |
|
||||||
|
| **Consumer lag** (Kafka) | > 100k messages or > 5 min | Consumers falling behind |
|
||||||
|
| **DLQ depth** | > 10 | Messages failing repeatedly |
|
||||||
|
| **Processing time p99** | > 5 seconds | Slow processing blocks queue |
|
||||||
|
| **Error rate** | > 5% | Widespread failures |
|
||||||
|
| **Redelivery rate** | > 10% | Idempotency issues or transient errors |
|
||||||
|
|
||||||
|
### Consumer Lag Monitoring (Kafka)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from kafka import KafkaAdminClient, TopicPartition
|
||||||
|
|
||||||
|
admin = KafkaAdminClient(bootstrap_servers=['kafka:9092'])
|
||||||
|
|
||||||
|
def check_consumer_lag(group_id, topic):
|
||||||
|
# Get committed offsets
|
||||||
|
committed = admin.list_consumer_group_offsets(group_id)
|
||||||
|
|
||||||
|
# Get latest offsets (highwater mark)
|
||||||
|
consumer = KafkaConsumer(bootstrap_servers=['kafka:9092'])
|
||||||
|
partitions = [TopicPartition(topic, p) for p in range(partition_count)]
|
||||||
|
latest = consumer.end_offsets(partitions)
|
||||||
|
|
||||||
|
# Calculate lag
|
||||||
|
total_lag = 0
|
||||||
|
for partition in partitions:
|
||||||
|
committed_offset = committed[partition].offset
|
||||||
|
latest_offset = latest[partition]
|
||||||
|
lag = latest_offset - committed_offset
|
||||||
|
total_lag += lag
|
||||||
|
|
||||||
|
if lag > 10000:
|
||||||
|
alert(f"Partition {partition.partition} lag: {lag}")
|
||||||
|
|
||||||
|
return total_lag
|
||||||
|
|
||||||
|
# Alert if total lag > 100k
|
||||||
|
if check_consumer_lag('order-processor', 'orders') > 100000:
|
||||||
|
alert("Consumer lag critical!")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Distributed Tracing Across Queues
|
||||||
|
|
||||||
|
```python
|
||||||
|
from opentelemetry import trace
|
||||||
|
from opentelemetry.propagate import inject, extract
|
||||||
|
|
||||||
|
tracer = trace.get_tracer(__name__)
|
||||||
|
|
||||||
|
# Producer: Inject trace context
|
||||||
|
def publish_with_trace(topic, message):
|
||||||
|
with tracer.start_as_current_span("publish-order") as span:
|
||||||
|
headers = {}
|
||||||
|
inject(headers) # Inject trace context into headers
|
||||||
|
|
||||||
|
producer.send(
|
||||||
|
topic,
|
||||||
|
value=message,
|
||||||
|
headers=list(headers.items())
|
||||||
|
)
|
||||||
|
|
||||||
|
# Consumer: Extract trace context
|
||||||
|
def consume_with_trace(message):
|
||||||
|
context = extract(dict(message.headers))
|
||||||
|
|
||||||
|
with tracer.start_as_current_span("process-order", context=context) as span:
|
||||||
|
process_order(message.value)
|
||||||
|
span.set_attribute("order.id", message.value['order_id'])
|
||||||
|
|
||||||
|
# Trace spans: API → Producer → Queue → Consumer → DB
|
||||||
|
# Shows end-to-end latency including queue wait time
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backpressure & Circuit Breakers
|
||||||
|
|
||||||
|
### Rate Limiting Consumers
|
||||||
|
|
||||||
|
```python
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
class RateLimitedConsumer:
|
||||||
|
def __init__(self, max_per_second=100):
|
||||||
|
self.max_per_second = max_per_second
|
||||||
|
self.requests = deque()
|
||||||
|
|
||||||
|
def consume(self, message):
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# Remove requests older than 1 second
|
||||||
|
while self.requests and self.requests[0] < now - 1:
|
||||||
|
self.requests.popleft()
|
||||||
|
|
||||||
|
# Check rate limit
|
||||||
|
if len(self.requests) >= self.max_per_second:
|
||||||
|
sleep_time = 1 - (now - self.requests[0])
|
||||||
|
time.sleep(sleep_time)
|
||||||
|
|
||||||
|
self.requests.append(time.time())
|
||||||
|
process(message)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Circuit Breaker for Downstream Dependencies
|
||||||
|
|
||||||
|
```python
|
||||||
|
from circuitbreaker import circuit
|
||||||
|
|
||||||
|
@circuit(failure_threshold=5, recovery_timeout=60)
|
||||||
|
def call_payment_service(order_id, amount):
|
||||||
|
response = requests.post(
|
||||||
|
'https://payment-service/charge',
|
||||||
|
json={'order_id': order_id, 'amount': amount},
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code >= 500:
|
||||||
|
raise ServiceUnavailableError()
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def process_order(message):
|
||||||
|
try:
|
||||||
|
result = call_payment_service(message['order_id'], message['amount'])
|
||||||
|
# ... continue processing
|
||||||
|
except CircuitBreakerError:
|
||||||
|
# Circuit open - don't overwhelm failing service
|
||||||
|
logger.warning("Payment service circuit open, requeueing message")
|
||||||
|
raise RetriableError("Circuit breaker open")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Patterns
|
||||||
|
|
||||||
|
### Outbox Pattern (Reliable Publishing)
|
||||||
|
|
||||||
|
**Problem**: How to atomically update database AND publish message?
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ❌ WRONG: Dual write (can fail between DB and queue)
|
||||||
|
def create_order(data):
|
||||||
|
db.execute("INSERT INTO orders (...) VALUES (...)")
|
||||||
|
producer.send('orders', data) # ❌ If this fails, DB updated but no event
|
||||||
|
|
||||||
|
# ✓ CORRECT: Outbox pattern
|
||||||
|
def create_order_with_outbox(data):
|
||||||
|
with db.transaction():
|
||||||
|
# 1. Insert order
|
||||||
|
db.execute("INSERT INTO orders (id, user_id, amount) VALUES (%s, %s, %s)",
|
||||||
|
(data['id'], data['user_id'], data['amount']))
|
||||||
|
|
||||||
|
# 2. Insert into outbox (same transaction)
|
||||||
|
db.execute("INSERT INTO outbox (event_type, payload) VALUES (%s, %s)",
|
||||||
|
('order.created', json.dumps(data)))
|
||||||
|
|
||||||
|
# Separate process reads outbox and publishes
|
||||||
|
|
||||||
|
# Outbox processor (separate worker)
|
||||||
|
def process_outbox():
|
||||||
|
while True:
|
||||||
|
events = db.execute("SELECT * FROM outbox WHERE published_at IS NULL LIMIT 10")
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
try:
|
||||||
|
producer.send(event['event_type'], json.loads(event['payload']))
|
||||||
|
db.execute("UPDATE outbox SET published_at = NOW() WHERE id = %s", (event['id'],))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to publish event {event['id']}: {e}")
|
||||||
|
# Will retry on next iteration
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Saga Pattern (Distributed Transactions)
|
||||||
|
|
||||||
|
See `microservices-architecture` skill for full saga patterns (choreography vs orchestration).
|
||||||
|
|
||||||
|
**Quick reference for message-based saga**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Order saga coordinator publishes commands
|
||||||
|
def create_order_saga(order_data):
|
||||||
|
saga_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Step 1: Reserve inventory
|
||||||
|
producer.send('inventory-commands', {
|
||||||
|
'command': 'reserve',
|
||||||
|
'saga_id': saga_id,
|
||||||
|
'order_id': order_data['order_id'],
|
||||||
|
'items': order_data['items']
|
||||||
|
})
|
||||||
|
|
||||||
|
# Inventory service responds on 'inventory-events'
|
||||||
|
# If success → proceed to step 2
|
||||||
|
# If failure → compensate (cancel order)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
### Message Encryption
|
||||||
|
|
||||||
|
**SQS**: Server-side encryption (SSE) with KMS
|
||||||
|
|
||||||
|
```python
|
||||||
|
sqs.create_queue(
|
||||||
|
QueueName='orders-encrypted',
|
||||||
|
Attributes={
|
||||||
|
'KmsMasterKeyId': 'alias/my-key', # AWS KMS
|
||||||
|
'KmsDataKeyReusePeriodSeconds': '300'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kafka**: Encryption in transit + at rest
|
||||||
|
|
||||||
|
```python
|
||||||
|
# SSL/TLS for in-transit encryption
|
||||||
|
producer = KafkaProducer(
|
||||||
|
bootstrap_servers=['kafka:9093'],
|
||||||
|
security_protocol='SSL',
|
||||||
|
ssl_cafile='/path/to/ca-cert',
|
||||||
|
ssl_certfile='/path/to/client-cert',
|
||||||
|
ssl_keyfile='/path/to/client-key'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Encryption at rest (Kafka broker config)
|
||||||
|
# log.dirs=/encrypted-volume # Use encrypted EBS volumes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication & Authorization
|
||||||
|
|
||||||
|
**SQS**: IAM policies
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": {"AWS": "arn:aws:iam::123456789012:role/OrderService"},
|
||||||
|
"Action": ["sqs:SendMessage"],
|
||||||
|
"Resource": "arn:aws:sqs:us-east-1:123456789012:orders"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kafka**: SASL/SCRAM authentication
|
||||||
|
|
||||||
|
```python
|
||||||
|
producer = KafkaProducer(
|
||||||
|
bootstrap_servers=['kafka:9093'],
|
||||||
|
security_protocol='SASL_SSL',
|
||||||
|
sasl_mechanism='SCRAM-SHA-512',
|
||||||
|
sasl_plain_username='order-service',
|
||||||
|
sasl_plain_password='secret'
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kafka ACLs** (authorization):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Grant order-service permission to write to orders topic
|
||||||
|
kafka-acls --add \
|
||||||
|
--allow-principal User:order-service \
|
||||||
|
--operation Write \
|
||||||
|
--topic orders
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Strategies
|
||||||
|
|
||||||
|
### Local Testing
|
||||||
|
|
||||||
|
**LocalStack for SQS/SNS**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# docker-compose.yml
|
||||||
|
services:
|
||||||
|
localstack:
|
||||||
|
image: localstack/localstack
|
||||||
|
environment:
|
||||||
|
- SERVICES=sqs,sns
|
||||||
|
|
||||||
|
# Test code
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
sqs = boto3.client(
|
||||||
|
'sqs',
|
||||||
|
endpoint_url='http://localhost:4566', # LocalStack
|
||||||
|
region_name='us-east-1'
|
||||||
|
)
|
||||||
|
|
||||||
|
queue_url = sqs.create_queue(QueueName='test-orders')['QueueUrl']
|
||||||
|
sqs.send_message(QueueUrl=queue_url, MessageBody='test')
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kafka in Docker**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
services:
|
||||||
|
zookeeper:
|
||||||
|
image: confluentinc/cp-zookeeper:latest
|
||||||
|
environment:
|
||||||
|
ZOOKEEPER_CLIENT_PORT: 2181
|
||||||
|
|
||||||
|
kafka:
|
||||||
|
image: confluentinc/cp-kafka:latest
|
||||||
|
ports:
|
||||||
|
- "9092:9092"
|
||||||
|
environment:
|
||||||
|
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
|
||||||
|
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Testing
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from testcontainers.kafka import KafkaContainer
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def kafka():
|
||||||
|
with KafkaContainer() as kafka:
|
||||||
|
yield kafka.get_bootstrap_server()
|
||||||
|
|
||||||
|
def test_order_processing(kafka):
|
||||||
|
producer = KafkaProducer(bootstrap_servers=kafka)
|
||||||
|
consumer = KafkaConsumer('orders', bootstrap_servers=kafka, auto_offset_reset='earliest')
|
||||||
|
|
||||||
|
# Publish message
|
||||||
|
producer.send('orders', value=b'{"order_id": "123"}')
|
||||||
|
producer.flush()
|
||||||
|
|
||||||
|
# Consume and verify
|
||||||
|
message = next(consumer)
|
||||||
|
assert json.loads(message.value)['order_id'] == '123'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chaos Engineering
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Test consumer failure recovery
|
||||||
|
def test_consumer_crash_recovery():
|
||||||
|
# Start consumer
|
||||||
|
consumer_process = subprocess.Popen(['python', 'consumer.py'])
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# Publish message
|
||||||
|
producer.send('orders', value=test_order)
|
||||||
|
producer.flush()
|
||||||
|
|
||||||
|
# Kill consumer mid-processing
|
||||||
|
consumer_process.kill()
|
||||||
|
|
||||||
|
# Restart consumer
|
||||||
|
consumer_process = subprocess.Popen(['python', 'consumer.py'])
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
# Verify message was reprocessed (idempotency!)
|
||||||
|
assert db.execute("SELECT COUNT(*) FROM orders WHERE id = %s", (test_order['id'],))[0] == 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
| Anti-Pattern | Why Bad | Fix |
|
||||||
|
|--------------|---------|-----|
|
||||||
|
| **Auto-ack before processing** | Messages lost on crash | Manual ack after processing |
|
||||||
|
| **No idempotency** | Duplicates cause data corruption | Unique constraints, locks, or idempotency keys |
|
||||||
|
| **No DLQ** | Poison messages block queue | Configure DLQ with maxReceiveCount |
|
||||||
|
| **No monitoring** | Can't detect consumer lag or failures | Monitor lag, depth, error rate |
|
||||||
|
| **Synchronous message processing** | Low throughput | Batch processing, parallel consumers |
|
||||||
|
| **Large messages** | Exceeds queue limits, slow transfer | Store in S3, send reference in message |
|
||||||
|
| **No schema versioning** | Breaking changes break consumers | Use Avro/Protobuf with schema registry |
|
||||||
|
| **Shared consumer instances** | Race conditions, duplicate processing | Use consumer groups (Kafka) or visibility timeout (SQS) |
|
||||||
|
|
||||||
|
## Technology-Specific Patterns
|
||||||
|
|
||||||
|
### RabbitMQ Exchanges
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Topic exchange for routing
|
||||||
|
channel.exchange_declare(exchange='orders', exchange_type='topic')
|
||||||
|
|
||||||
|
# Bind queues with patterns
|
||||||
|
channel.queue_bind(exchange='orders', queue='us-orders', routing_key='order.us.*')
|
||||||
|
channel.queue_bind(exchange='orders', queue='eu-orders', routing_key='order.eu.*')
|
||||||
|
|
||||||
|
# Publish with routing key
|
||||||
|
channel.basic_publish(
|
||||||
|
exchange='orders',
|
||||||
|
routing_key='order.us.california', # Goes to us-orders queue
|
||||||
|
body=json.dumps(order)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fanout exchange for pub/sub
|
||||||
|
channel.exchange_declare(exchange='analytics', exchange_type='fanout')
|
||||||
|
# All bound queues receive every message
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kafka Connect (Data Integration)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "mysql-source",
|
||||||
|
"config": {
|
||||||
|
"connector.class": "io.confluent.connect.jdbc.JdbcSourceConnector",
|
||||||
|
"connection.url": "jdbc:mysql://localhost:3306/mydb",
|
||||||
|
"table.whitelist": "orders",
|
||||||
|
"mode": "incrementing",
|
||||||
|
"incrementing.column.name": "id",
|
||||||
|
"topic.prefix": "mysql-"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use cases**:
|
||||||
|
- Stream DB changes to Kafka (CDC)
|
||||||
|
- Sink Kafka to Elasticsearch, S3, databases
|
||||||
|
- No custom code needed for common integrations
|
||||||
|
|
||||||
|
## Batching Optimizations
|
||||||
|
|
||||||
|
### Batch Size Tuning
|
||||||
|
|
||||||
|
```python
|
||||||
|
# SQS batch receiving (up to 10 messages)
|
||||||
|
messages = sqs.receive_messages(
|
||||||
|
QueueUrl=queue_url,
|
||||||
|
MaxNumberOfMessages=10, # Fetch 10 at once
|
||||||
|
WaitTimeSeconds=20 # Long polling (reduces empty receives)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process in parallel
|
||||||
|
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||||
|
futures = [executor.submit(process, msg) for msg in messages]
|
||||||
|
for future in futures:
|
||||||
|
future.result()
|
||||||
|
|
||||||
|
# Kafka batch consuming
|
||||||
|
consumer = KafkaConsumer(
|
||||||
|
'orders',
|
||||||
|
max_poll_records=500, # Fetch 500 messages per poll
|
||||||
|
fetch_min_bytes=1024 # Wait for at least 1KB before returning
|
||||||
|
)
|
||||||
|
|
||||||
|
for messages in consumer:
|
||||||
|
batch_process(messages) # Process 500 at once
|
||||||
|
```
|
||||||
|
|
||||||
|
**Batch size tradeoffs**:
|
||||||
|
|
||||||
|
| Batch Size | Throughput | Latency | Memory |
|
||||||
|
|------------|------------|---------|--------|
|
||||||
|
| 1 | Low | Low | Low |
|
||||||
|
| 10-100 | Medium | Medium | Medium |
|
||||||
|
| 500+ | High | High | High |
|
||||||
|
|
||||||
|
**Recommendation**: Start with 10-100, increase for higher throughput if latency allows.
|
||||||
|
|
||||||
|
## Cross-References
|
||||||
|
|
||||||
|
**Related skills**:
|
||||||
|
- **Microservices communication** → `microservices-architecture` (saga, event-driven)
|
||||||
|
- **FastAPI async** → `fastapi-development` (consuming queues in FastAPI)
|
||||||
|
- **REST vs async** → `rest-api-design` (when to use queues vs HTTP)
|
||||||
|
- **Security** → `ordis-security-architect` (encryption, IAM, compliance)
|
||||||
|
- **Testing** → `api-testing` (integration testing strategies)
|
||||||
|
|
||||||
|
## Further Reading
|
||||||
|
|
||||||
|
- **Enterprise Integration Patterns** by Gregor Hohpe (message patterns)
|
||||||
|
- **Designing Data-Intensive Applications** by Martin Kleppmann (Kafka internals)
|
||||||
|
- **RabbitMQ in Action** by Alvaro Videla
|
||||||
|
- **Kafka: The Definitive Guide** by Neha Narkhede
|
||||||
|
- **AWS SQS Best Practices**: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-best-practices.html
|
||||||
592
skills/using-web-backend/microservices-architecture.md
Normal file
592
skills/using-web-backend/microservices-architecture.md
Normal file
@@ -0,0 +1,592 @@
|
|||||||
|
|
||||||
|
# Microservices Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**Microservices architecture specialist covering service boundaries, communication patterns, data consistency, and operational concerns.**
|
||||||
|
|
||||||
|
**Core principle**: Microservices decompose applications into independently deployable services organized around business capabilities - enabling team autonomy and technology diversity at the cost of operational complexity and distributed system challenges.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Use when encountering:
|
||||||
|
|
||||||
|
- **Service boundaries**: Defining service scope, applying domain-driven design
|
||||||
|
- **Monolith decomposition**: Strategies for splitting existing systems
|
||||||
|
- **Data consistency**: Sagas, event sourcing, eventual consistency patterns
|
||||||
|
- **Communication**: Sync (REST/gRPC) vs async (events/messages)
|
||||||
|
- **API gateways**: Routing, authentication, rate limiting
|
||||||
|
- **Service discovery**: Registry patterns, DNS, configuration
|
||||||
|
- **Resilience**: Circuit breakers, retries, timeouts, bulkheads
|
||||||
|
- **Observability**: Distributed tracing, logging aggregation, metrics
|
||||||
|
- **Deployment**: Containers, orchestration, blue-green deployments
|
||||||
|
|
||||||
|
**Do NOT use for**:
|
||||||
|
- Monolithic architectures (microservices aren't always better)
|
||||||
|
- Single-team projects < 5 services (overhead exceeds benefits)
|
||||||
|
- Simple CRUD applications (microservices add unnecessary complexity)
|
||||||
|
|
||||||
|
## When NOT to Use Microservices
|
||||||
|
|
||||||
|
**Stay monolithic if**:
|
||||||
|
- Team < 10 engineers
|
||||||
|
- Domain is not well understood yet
|
||||||
|
- Strong consistency required everywhere
|
||||||
|
- Network latency is critical
|
||||||
|
- You can't invest in observability/DevOps infrastructure
|
||||||
|
|
||||||
|
**Microservices require**: Mature DevOps, monitoring, distributed systems expertise, organizational support.
|
||||||
|
|
||||||
|
## Service Boundary Patterns (Domain-Driven Design)
|
||||||
|
|
||||||
|
### 1. Bounded Contexts
|
||||||
|
|
||||||
|
**Pattern: One microservice = One bounded context**
|
||||||
|
|
||||||
|
```
|
||||||
|
❌ Too fine-grained (anemic services):
|
||||||
|
- UserService (just CRUD)
|
||||||
|
- OrderService (just CRUD)
|
||||||
|
- PaymentService (just CRUD)
|
||||||
|
|
||||||
|
✅ Business capability alignment:
|
||||||
|
- CustomerManagementService (user profiles, preferences, history)
|
||||||
|
- OrderFulfillmentService (order lifecycle, inventory, shipping)
|
||||||
|
- PaymentProcessingService (payment, billing, invoicing, refunds)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Identifying boundaries**:
|
||||||
|
1. **Ubiquitous language** - Different terms for same concept = different contexts
|
||||||
|
2. **Change patterns** - Services that change together should stay together
|
||||||
|
3. **Team ownership** - One team should own one service
|
||||||
|
4. **Data autonomy** - Each service owns its data, no shared databases
|
||||||
|
|
||||||
|
### 2. Strategic DDD Patterns
|
||||||
|
|
||||||
|
| Pattern | Use When | Example |
|
||||||
|
|---------|----------|---------|
|
||||||
|
| **Separate Ways** | Contexts are independent | Analytics service, main app service |
|
||||||
|
| **Partnership** | Teams must collaborate closely | Order + Inventory services |
|
||||||
|
| **Customer-Supplier** | Upstream/downstream relationship | Payment gateway (upstream) → Order service |
|
||||||
|
| **Conformist** | Accept upstream model as-is | Third-party API integration |
|
||||||
|
| **Anti-Corruption Layer** | Isolate from legacy/external systems | ACL between new microservices and legacy monolith |
|
||||||
|
|
||||||
|
### 3. Service Sizing Guidelines
|
||||||
|
|
||||||
|
**Too small (Nanoservices)**:
|
||||||
|
- Excessive network calls
|
||||||
|
- Distributed monolith
|
||||||
|
- Coordination overhead exceeds benefits
|
||||||
|
|
||||||
|
**Too large (Minimonoliths)**:
|
||||||
|
- Multiple teams modifying same service
|
||||||
|
- Mixed deployment frequencies
|
||||||
|
- Tight coupling re-emerges
|
||||||
|
|
||||||
|
**Right size indicators**:
|
||||||
|
- Single team can own it
|
||||||
|
- Deployable independently
|
||||||
|
- Changes don't ripple to other services
|
||||||
|
- Clear business capability
|
||||||
|
- 100-10,000 LOC (highly variable)
|
||||||
|
|
||||||
|
## Communication Patterns
|
||||||
|
|
||||||
|
### Synchronous Communication
|
||||||
|
|
||||||
|
**REST APIs**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Order service calling Payment service
|
||||||
|
async def create_order(order: Order):
|
||||||
|
# Synchronous REST call
|
||||||
|
payment = await payment_service.charge(
|
||||||
|
amount=order.total,
|
||||||
|
customer_id=order.customer_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if payment.status == "success":
|
||||||
|
order.status = "confirmed"
|
||||||
|
await db.save(order)
|
||||||
|
return order
|
||||||
|
else:
|
||||||
|
raise PaymentFailedException()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**: Simple, request-response, easy to debug
|
||||||
|
**Cons**: Tight coupling, availability dependency, latency cascades
|
||||||
|
|
||||||
|
**gRPC**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Proto definition
|
||||||
|
service OrderService {
|
||||||
|
rpc CreateOrder (OrderRequest) returns (OrderResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
# Implementation
|
||||||
|
class OrderServicer(order_pb2_grpc.OrderServiceServicer):
|
||||||
|
async def CreateOrder(self, request, context):
|
||||||
|
# Type-safe, efficient binary protocol
|
||||||
|
payment = await payment_stub.Charge(
|
||||||
|
PaymentRequest(amount=request.total)
|
||||||
|
)
|
||||||
|
return OrderResponse(order_id=order.id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**: Type-safe, efficient, streaming support
|
||||||
|
**Cons**: HTTP/2 required, less human-readable, proto dependencies
|
||||||
|
|
||||||
|
### Asynchronous Communication
|
||||||
|
|
||||||
|
**Event-Driven (Pub/Sub)**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Order service publishes event
|
||||||
|
await event_bus.publish("order.created", {
|
||||||
|
"order_id": order.id,
|
||||||
|
"customer_id": customer.id,
|
||||||
|
"total": order.total
|
||||||
|
})
|
||||||
|
|
||||||
|
# Inventory service subscribes
|
||||||
|
@event_bus.subscribe("order.created")
|
||||||
|
async def reserve_inventory(event):
|
||||||
|
await inventory.reserve(event["order_id"])
|
||||||
|
await event_bus.publish("inventory.reserved", {...})
|
||||||
|
|
||||||
|
# Notification service subscribes
|
||||||
|
@event_bus.subscribe("order.created")
|
||||||
|
async def send_confirmation(event):
|
||||||
|
await email.send_order_confirmation(event)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**: Loose coupling, services independent, scalable
|
||||||
|
**Cons**: Eventual consistency, harder to trace, ordering challenges
|
||||||
|
|
||||||
|
**Message Queues (Point-to-Point)**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Producer
|
||||||
|
await queue.send("payment-processing", {
|
||||||
|
"order_id": order.id,
|
||||||
|
"amount": order.total
|
||||||
|
})
|
||||||
|
|
||||||
|
# Consumer
|
||||||
|
@queue.consumer("payment-processing")
|
||||||
|
async def process_payment(message):
|
||||||
|
result = await payment_gateway.charge(message["amount"])
|
||||||
|
if result.success:
|
||||||
|
await message.ack()
|
||||||
|
else:
|
||||||
|
await message.nack(requeue=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**: Guaranteed delivery, work distribution, retry handling
|
||||||
|
**Cons**: Queue becomes bottleneck, requires message broker
|
||||||
|
|
||||||
|
### Communication Pattern Decision Matrix
|
||||||
|
|
||||||
|
| Scenario | Pattern | Why |
|
||||||
|
|----------|---------|-----|
|
||||||
|
| User-facing request/response | Sync (REST/gRPC) | Low latency, immediate feedback |
|
||||||
|
| Background processing | Async (queue) | Don't block user, retry support |
|
||||||
|
| Cross-service notifications | Async (pub/sub) | Loose coupling, multiple consumers |
|
||||||
|
| Real-time updates | WebSocket/SSE | Bidirectional, streaming |
|
||||||
|
| Data replication | Event sourcing | Audit trail, rebuild state |
|
||||||
|
| High throughput | Async (messaging) | Buffer spikes, backpressure |
|
||||||
|
|
||||||
|
## Data Consistency Patterns
|
||||||
|
|
||||||
|
### 1. Saga Pattern (Distributed Transactions)
|
||||||
|
|
||||||
|
**Choreography (Event-Driven)**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Order Service
|
||||||
|
async def create_order(order):
|
||||||
|
order.status = "pending"
|
||||||
|
await db.save(order)
|
||||||
|
await events.publish("order.created", order)
|
||||||
|
|
||||||
|
# Payment Service
|
||||||
|
@events.subscribe("order.created")
|
||||||
|
async def handle_order(event):
|
||||||
|
try:
|
||||||
|
await charge_customer(event["total"])
|
||||||
|
await events.publish("payment.completed", event)
|
||||||
|
except PaymentError:
|
||||||
|
await events.publish("payment.failed", event)
|
||||||
|
|
||||||
|
# Inventory Service
|
||||||
|
@events.subscribe("payment.completed")
|
||||||
|
async def reserve_items(event):
|
||||||
|
try:
|
||||||
|
await reserve(event["items"])
|
||||||
|
await events.publish("inventory.reserved", event)
|
||||||
|
except InventoryError:
|
||||||
|
await events.publish("inventory.failed", event)
|
||||||
|
|
||||||
|
# Order Service (Compensation)
|
||||||
|
@events.subscribe("payment.failed")
|
||||||
|
async def cancel_order(event):
|
||||||
|
order = await db.get(event["order_id"])
|
||||||
|
order.status = "cancelled"
|
||||||
|
await db.save(order)
|
||||||
|
|
||||||
|
@events.subscribe("inventory.failed")
|
||||||
|
async def refund_payment(event):
|
||||||
|
await payment.refund(event["order_id"])
|
||||||
|
await cancel_order(event)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Orchestration (Coordinator)**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class OrderSaga:
|
||||||
|
def __init__(self, order):
|
||||||
|
self.order = order
|
||||||
|
self.completed_steps = []
|
||||||
|
|
||||||
|
async def execute(self):
|
||||||
|
try:
|
||||||
|
# Step 1: Reserve inventory
|
||||||
|
await self.reserve_inventory()
|
||||||
|
self.completed_steps.append("inventory")
|
||||||
|
|
||||||
|
# Step 2: Process payment
|
||||||
|
await self.process_payment()
|
||||||
|
self.completed_steps.append("payment")
|
||||||
|
|
||||||
|
# Step 3: Confirm order
|
||||||
|
await self.confirm_order()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Compensate in reverse order
|
||||||
|
await self.compensate()
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def compensate(self):
|
||||||
|
for step in reversed(self.completed_steps):
|
||||||
|
if step == "inventory":
|
||||||
|
await inventory_service.release(self.order.id)
|
||||||
|
elif step == "payment":
|
||||||
|
await payment_service.refund(self.order.id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Choreography vs Orchestration**:
|
||||||
|
|
||||||
|
| Aspect | Choreography | Orchestration |
|
||||||
|
|--------|--------------|---------------|
|
||||||
|
| Coordination | Decentralized (events) | Centralized (orchestrator) |
|
||||||
|
| Coupling | Loose | Tight to orchestrator |
|
||||||
|
| Complexity | Distributed across services | Concentrated in orchestrator |
|
||||||
|
| Tracing | Harder (follow events) | Easier (single coordinator) |
|
||||||
|
| Failure handling | Implicit (event handlers) | Explicit (orchestrator logic) |
|
||||||
|
| Best for | Simple workflows | Complex workflows |
|
||||||
|
|
||||||
|
### 2. Event Sourcing
|
||||||
|
|
||||||
|
**Pattern: Store events, not state**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Traditional approach (storing state)
|
||||||
|
class Order:
|
||||||
|
id: int
|
||||||
|
status: str # "pending" → "confirmed" → "shipped"
|
||||||
|
total: float
|
||||||
|
|
||||||
|
# Event sourcing (storing events)
|
||||||
|
class OrderCreated(Event):
|
||||||
|
order_id: int
|
||||||
|
total: float
|
||||||
|
|
||||||
|
class OrderConfirmed(Event):
|
||||||
|
order_id: int
|
||||||
|
|
||||||
|
class OrderShipped(Event):
|
||||||
|
order_id: int
|
||||||
|
|
||||||
|
# Rebuild state from events
|
||||||
|
def rebuild_order(order_id):
|
||||||
|
events = event_store.get_events(order_id)
|
||||||
|
order = Order()
|
||||||
|
for event in events:
|
||||||
|
order.apply(event) # Apply each event to rebuild state
|
||||||
|
return order
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**: Complete audit trail, time travel, event replay
|
||||||
|
**Cons**: Complexity, eventual consistency, schema evolution challenges
|
||||||
|
|
||||||
|
### 3. CQRS (Command Query Responsibility Segregation)
|
||||||
|
|
||||||
|
**Separate read and write models**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Write model (commands)
|
||||||
|
class CreateOrder:
|
||||||
|
def execute(self, data):
|
||||||
|
order = Order(**data)
|
||||||
|
await db.save(order)
|
||||||
|
await event_bus.publish("order.created", order)
|
||||||
|
|
||||||
|
# Read model (projections)
|
||||||
|
class OrderReadModel:
|
||||||
|
# Denormalized for fast reads
|
||||||
|
def __init__(self):
|
||||||
|
self.cache = {}
|
||||||
|
|
||||||
|
@event_bus.subscribe("order.created")
|
||||||
|
async def on_order_created(self, event):
|
||||||
|
self.cache[event["order_id"]] = {
|
||||||
|
"id": event["order_id"],
|
||||||
|
"customer_name": await get_customer_name(event["customer_id"]),
|
||||||
|
"status": "pending",
|
||||||
|
"total": event["total"]
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_order(self, order_id):
|
||||||
|
return self.cache.get(order_id) # Fast read, no joins
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use when**: Read/write patterns differ significantly (e.g., analytics dashboards)
|
||||||
|
|
||||||
|
## Resilience Patterns
|
||||||
|
|
||||||
|
### 1. Circuit Breaker
|
||||||
|
|
||||||
|
```python
|
||||||
|
from circuitbreaker import circuit
|
||||||
|
|
||||||
|
@circuit(failure_threshold=5, recovery_timeout=60)
|
||||||
|
async def call_payment_service(amount):
|
||||||
|
response = await http.post("http://payment-service/charge", json={"amount": amount})
|
||||||
|
if response.status >= 500:
|
||||||
|
raise PaymentServiceError()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
# Circuit states:
|
||||||
|
# CLOSED → normal operation
|
||||||
|
# OPEN → fails fast after threshold
|
||||||
|
# HALF_OPEN → test if service recovered
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Retry with Exponential Backoff
|
||||||
|
|
||||||
|
```python
|
||||||
|
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||||
|
|
||||||
|
@retry(
|
||||||
|
stop=stop_after_attempt(3),
|
||||||
|
wait=wait_exponential(multiplier=1, min=2, max=10)
|
||||||
|
)
|
||||||
|
async def call_with_retry(url):
|
||||||
|
return await http.get(url)
|
||||||
|
|
||||||
|
# Retries: 2s → 4s → 8s
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Timeout
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def call_with_timeout(url):
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(
|
||||||
|
http.get(url),
|
||||||
|
timeout=5.0 # 5 second timeout
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return {"error": "Service timeout"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Bulkhead
|
||||||
|
|
||||||
|
**Isolate resources to prevent cascade failures**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Separate thread pools for different services
|
||||||
|
payment_pool = ThreadPoolExecutor(max_workers=10)
|
||||||
|
inventory_pool = ThreadPoolExecutor(max_workers=5)
|
||||||
|
|
||||||
|
async def call_payment():
|
||||||
|
return await asyncio.get_event_loop().run_in_executor(
|
||||||
|
payment_pool,
|
||||||
|
payment_service.call
|
||||||
|
)
|
||||||
|
|
||||||
|
# If payment service is slow, it only exhausts payment_pool,
|
||||||
|
# inventory calls still work
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Gateway Pattern
|
||||||
|
|
||||||
|
**Centralized entry point for client requests**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Client → API Gateway → [Order, Payment, Inventory services]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Responsibilities**:
|
||||||
|
- Routing requests to services
|
||||||
|
- Authentication/authorization
|
||||||
|
- Rate limiting
|
||||||
|
- Request/response transformation
|
||||||
|
- Caching
|
||||||
|
- Logging/monitoring
|
||||||
|
|
||||||
|
**Example (Kong, AWS API Gateway, Nginx)**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# API Gateway config
|
||||||
|
routes:
|
||||||
|
- path: /orders
|
||||||
|
service: order-service
|
||||||
|
auth: jwt
|
||||||
|
ratelimit: 100/minute
|
||||||
|
|
||||||
|
- path: /payments
|
||||||
|
service: payment-service
|
||||||
|
auth: oauth2
|
||||||
|
ratelimit: 50/minute
|
||||||
|
```
|
||||||
|
|
||||||
|
**Backend for Frontend (BFF) Pattern**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Web Client → Web BFF → Services
|
||||||
|
Mobile App → Mobile BFF → Services
|
||||||
|
```
|
||||||
|
|
||||||
|
Each client type has optimized gateway.
|
||||||
|
|
||||||
|
## Service Discovery
|
||||||
|
|
||||||
|
### 1. Client-Side Discovery
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Service registry (Consul, Eureka)
|
||||||
|
registry = ServiceRegistry("http://consul:8500")
|
||||||
|
|
||||||
|
# Client looks up service
|
||||||
|
instances = registry.get_instances("payment-service")
|
||||||
|
instance = load_balancer.choose(instances)
|
||||||
|
response = await http.get(f"http://{instance.host}:{instance.port}/charge")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Server-Side Discovery (Load Balancer)
|
||||||
|
|
||||||
|
```
|
||||||
|
Client → Load Balancer → [Service Instance 1, Instance 2, Instance 3]
|
||||||
|
```
|
||||||
|
|
||||||
|
**DNS-based**: Kubernetes services, AWS ELB
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
### Distributed Tracing
|
||||||
|
|
||||||
|
```python
|
||||||
|
from opentelemetry import trace
|
||||||
|
|
||||||
|
tracer = trace.get_tracer(__name__)
|
||||||
|
|
||||||
|
async def create_order(order):
|
||||||
|
with tracer.start_as_current_span("create-order") as span:
|
||||||
|
span.set_attribute("order.id", order.id)
|
||||||
|
span.set_attribute("order.total", order.total)
|
||||||
|
|
||||||
|
# Trace propagates to payment service
|
||||||
|
payment = await payment_service.charge(
|
||||||
|
amount=order.total,
|
||||||
|
trace_context=span.context
|
||||||
|
)
|
||||||
|
|
||||||
|
span.add_event("payment-completed")
|
||||||
|
return order
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tools**: Jaeger, Zipkin, AWS X-Ray, Datadog APM
|
||||||
|
|
||||||
|
### Log Aggregation
|
||||||
|
|
||||||
|
**Structured logging with correlation IDs**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
async def handle_request(request):
|
||||||
|
correlation_id = request.headers.get("X-Correlation-ID") or str(uuid.uuid4())
|
||||||
|
|
||||||
|
logger.info("Processing request", extra={
|
||||||
|
"correlation_id": correlation_id,
|
||||||
|
"service": "order-service",
|
||||||
|
"user_id": request.user_id
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tools**: ELK stack (Elasticsearch, Logstash, Kibana), Splunk, Datadog
|
||||||
|
|
||||||
|
## Monolith Decomposition Strategies
|
||||||
|
|
||||||
|
### 1. Strangler Fig Pattern
|
||||||
|
|
||||||
|
**Gradually replace monolith with microservices**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1: Monolith handles everything
|
||||||
|
Phase 2: Extract service, proxy some requests to it
|
||||||
|
Phase 3: More services extracted, proxy more requests
|
||||||
|
Phase 4: Monolith retired
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Branch by Abstraction
|
||||||
|
|
||||||
|
1. Create abstraction layer in monolith
|
||||||
|
2. Implement new service
|
||||||
|
3. Gradually migrate code behind abstraction
|
||||||
|
4. Remove old implementation
|
||||||
|
5. Extract as microservice
|
||||||
|
|
||||||
|
### 3. Extract by Bounded Context
|
||||||
|
|
||||||
|
Priority order:
|
||||||
|
1. Services with clear boundaries (authentication, payments)
|
||||||
|
2. Services changing frequently
|
||||||
|
3. Services with different scaling needs
|
||||||
|
4. Services with technology mismatches (e.g., Java monolith, Python ML service)
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
| Anti-Pattern | Why Bad | Fix |
|
||||||
|
|--------------|---------|-----|
|
||||||
|
| **Distributed Monolith** | Services share database, deploy together | One DB per service, independent deployment |
|
||||||
|
| **Nanoservices** | Too fine-grained, excessive network calls | Merge related services, follow DDD |
|
||||||
|
| **Shared Database** | Tight coupling, schema changes break multiple services | Database per service |
|
||||||
|
| **Synchronous Chains** | A→B→C→D, latency adds up, cascading failures | Async events, parallelize where possible |
|
||||||
|
| **Chatty Services** | N+1 calls, excessive network overhead | Batch APIs, caching, coarser boundaries |
|
||||||
|
| **No Circuit Breakers** | Cascading failures bring down system | Circuit breakers + timeouts + retries |
|
||||||
|
| **No Distributed Tracing** | Impossible to debug cross-service issues | OpenTelemetry, correlation IDs |
|
||||||
|
|
||||||
|
## Cross-References
|
||||||
|
|
||||||
|
**Related skills**:
|
||||||
|
- **Message queues** → `message-queues` (RabbitMQ, Kafka patterns)
|
||||||
|
- **REST APIs** → `rest-api-design` (service interface design)
|
||||||
|
- **gRPC** → Check if gRPC skill exists
|
||||||
|
- **Security** → `ordis-security-architect` (service-to-service auth, zero trust)
|
||||||
|
- **Database** → `database-integration` (per-service databases, migrations)
|
||||||
|
- **Testing** → `api-testing` (contract testing, integration testing)
|
||||||
|
|
||||||
|
## Further Reading
|
||||||
|
|
||||||
|
- **Building Microservices** by Sam Newman
|
||||||
|
- **Domain-Driven Design** by Eric Evans
|
||||||
|
- **Release It!** by Michael Nygard (resilience patterns)
|
||||||
|
- **Microservices Patterns** by Chris Richardson
|
||||||
523
skills/using-web-backend/rest-api-design.md
Normal file
523
skills/using-web-backend/rest-api-design.md
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
|
||||||
|
# REST API Design
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**REST API design specialist covering resource modeling, HTTP semantics, versioning, pagination, and API evolution.**
|
||||||
|
|
||||||
|
**Core principle**: REST is an architectural style based on resources, HTTP semantics, and stateless communication. Good REST API design makes resources discoverable, operations predictable, and evolution manageable.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Use when encountering:
|
||||||
|
|
||||||
|
- **Resource modeling**: Designing URL structures, choosing singular vs plural, handling relationships
|
||||||
|
- **HTTP methods**: GET, POST, PUT, PATCH, DELETE semantics and idempotency
|
||||||
|
- **Status codes**: Choosing correct 2xx, 4xx, 5xx codes
|
||||||
|
- **Versioning**: URI vs header versioning, managing API evolution
|
||||||
|
- **Pagination**: Offset, cursor, or page-based pagination strategies
|
||||||
|
- **Filtering/sorting**: Query parameter design for collections
|
||||||
|
- **Error responses**: Standardized error formats
|
||||||
|
- **HATEOAS**: Hypermedia-driven APIs and discoverability
|
||||||
|
|
||||||
|
**Do NOT use for**:
|
||||||
|
- GraphQL API design → `graphql-api-design`
|
||||||
|
- Framework-specific implementation → `fastapi-development`, `django-development`, `express-development`
|
||||||
|
- Authentication patterns → `api-authentication`
|
||||||
|
|
||||||
|
## Quick Reference - HTTP Methods
|
||||||
|
|
||||||
|
| Method | Semantics | Idempotent? | Safe? | Request Body | Response Body |
|
||||||
|
|--------|-----------|-------------|-------|--------------|---------------|
|
||||||
|
| GET | Retrieve resource | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes |
|
||||||
|
| POST | Create resource | ❌ No | ❌ No | ✅ Yes | ✅ Yes |
|
||||||
|
| PUT | Replace resource | ✅ Yes | ❌ No | ✅ Yes | ✅ Optional |
|
||||||
|
| PATCH | Partial update | ❌ No* | ❌ No | ✅ Yes | ✅ Optional |
|
||||||
|
| DELETE | Remove resource | ✅ Yes | ❌ No | ❌ Optional | ✅ Optional |
|
||||||
|
| HEAD | Retrieve headers | ✅ Yes | ✅ Yes | ❌ No | ❌ No |
|
||||||
|
| OPTIONS | Supported methods | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes |
|
||||||
|
|
||||||
|
*PATCH can be designed to be idempotent but often isn't
|
||||||
|
|
||||||
|
## Quick Reference - Status Codes
|
||||||
|
|
||||||
|
| Code | Meaning | Use When |
|
||||||
|
|------|---------|----------|
|
||||||
|
| 200 OK | Success | GET, PUT, PATCH succeeded with response body |
|
||||||
|
| 201 Created | Resource created | POST created new resource |
|
||||||
|
| 202 Accepted | Async processing | Request accepted, processing continues async |
|
||||||
|
| 204 No Content | Success, no body | DELETE succeeded, PUT/PATCH succeeded without response |
|
||||||
|
| 400 Bad Request | Invalid input | Validation failed, malformed request |
|
||||||
|
| 401 Unauthorized | Authentication failed | Missing or invalid credentials |
|
||||||
|
| 403 Forbidden | Authorization failed | User authenticated but lacks permission |
|
||||||
|
| 404 Not Found | Resource missing | Resource doesn't exist |
|
||||||
|
| 409 Conflict | State conflict | Resource already exists, version conflict |
|
||||||
|
| 422 Unprocessable Entity | Semantic error | Valid syntax but business logic failed |
|
||||||
|
| 429 Too Many Requests | Rate limited | User exceeded rate limit |
|
||||||
|
| 500 Internal Server Error | Server error | Unexpected server failure |
|
||||||
|
| 503 Service Unavailable | Temporary outage | Maintenance, overload |
|
||||||
|
|
||||||
|
## Resource Modeling Patterns
|
||||||
|
|
||||||
|
### 1. URL Structure
|
||||||
|
|
||||||
|
**✅ Good patterns**:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /users # List users
|
||||||
|
POST /users # Create user
|
||||||
|
GET /users/{id} # Get specific user
|
||||||
|
PUT /users/{id} # Replace user
|
||||||
|
PATCH /users/{id} # Update user
|
||||||
|
DELETE /users/{id} # Delete user
|
||||||
|
|
||||||
|
GET /users/{id}/orders # User's orders (nested resource)
|
||||||
|
POST /users/{id}/orders # Create order for user
|
||||||
|
GET /orders/{id} # Get specific order (top-level for direct access)
|
||||||
|
|
||||||
|
GET /search/users?q=john # Search endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
**❌ Anti-patterns**:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /getUsers # Verb in URL (use HTTP method instead)
|
||||||
|
POST /users/create # Redundant verb
|
||||||
|
GET /users/123/delete # DELETE operation via GET
|
||||||
|
POST /api?action=createUser # RPC-style, not REST
|
||||||
|
GET /users/{id}/orders/{id} # Ambiguous - which {id}?
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Singular vs Plural
|
||||||
|
|
||||||
|
**Convention: Use plural for collections, even for single-item endpoints**
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ /users/{id} # Consistent plural
|
||||||
|
✅ /orders/{id} # Consistent plural
|
||||||
|
|
||||||
|
❌ /user/{id} # Inconsistent singular
|
||||||
|
❌ /users/{id}/order/{id} # Mixed singular/plural
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exception**: Non-countable resources can be singular
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ /me # Current user context
|
||||||
|
✅ /config # Application config (single resource)
|
||||||
|
✅ /health # Health check endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Nested Resources vs Top-Level
|
||||||
|
|
||||||
|
**Nested when showing relationship**:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /users/{userId}/orders # "Orders belonging to this user"
|
||||||
|
POST /users/{userId}/orders # "Create order for this user"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Top-level when resource has independent identity**:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /orders/{orderId} # Direct access to order
|
||||||
|
DELETE /orders/{orderId} # Delete order directly
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guidelines**:
|
||||||
|
- Nest ≤ 2 levels deep (`/users/{id}/orders/{id}` is max)
|
||||||
|
- Provide top-level access for resources that exist independently
|
||||||
|
- Use query parameters for filtering instead of deep nesting
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ GET /orders?userId=123 # Better than /users/123/orders/{id}
|
||||||
|
❌ GET /users/{id}/orders/{id}/items/{id} # Too deep
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pagination Patterns
|
||||||
|
|
||||||
|
### Offset Pagination
|
||||||
|
|
||||||
|
**Good for**: Small datasets, page numbers, SQL databases
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /users?limit=20&offset=40
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"data": [...],
|
||||||
|
"pagination": {
|
||||||
|
"limit": 20,
|
||||||
|
"offset": 40,
|
||||||
|
"total": 1000,
|
||||||
|
"hasMore": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**: Simple, allows jumping to any page
|
||||||
|
**Cons**: Performance degrades with large offsets, inconsistent with concurrent modifications
|
||||||
|
|
||||||
|
### Cursor Pagination
|
||||||
|
|
||||||
|
**Good for**: Large datasets, real-time data, NoSQL databases
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /users?limit=20&after=eyJpZCI6MTIzfQ
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"data": [...],
|
||||||
|
"pagination": {
|
||||||
|
"nextCursor": "eyJpZCI6MTQzfQ",
|
||||||
|
"hasMore": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**: Consistent results, efficient for large datasets
|
||||||
|
**Cons**: Can't jump to arbitrary page, cursors are opaque
|
||||||
|
|
||||||
|
### Page-Based Pagination
|
||||||
|
|
||||||
|
**Good for**: UIs with page numbers
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /users?page=3&pageSize=20
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"data": [...],
|
||||||
|
"pagination": {
|
||||||
|
"page": 3,
|
||||||
|
"pageSize": 20,
|
||||||
|
"totalPages": 50,
|
||||||
|
"totalCount": 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Choice matrix**:
|
||||||
|
|
||||||
|
| Use Case | Pattern |
|
||||||
|
|----------|---------|
|
||||||
|
| Admin dashboards, small datasets | Offset or Page |
|
||||||
|
| Infinite scroll feeds | Cursor |
|
||||||
|
| Real-time data (chat, notifications) | Cursor |
|
||||||
|
| Need page numbers in UI | Page |
|
||||||
|
| Large datasets (millions of rows) | Cursor |
|
||||||
|
|
||||||
|
## Filtering and Sorting
|
||||||
|
|
||||||
|
### Query Parameter Conventions
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /users?status=active&role=admin # Simple filtering
|
||||||
|
GET /users?createdAfter=2024-01-01 # Date filtering
|
||||||
|
GET /users?search=john # Full-text search
|
||||||
|
GET /users?sort=createdAt&order=desc # Sorting
|
||||||
|
GET /users?sort=-createdAt # Alternative: prefix for descending
|
||||||
|
GET /users?fields=id,name,email # Sparse fieldsets
|
||||||
|
GET /users?include=orders,profile # Relationship inclusion
|
||||||
|
```
|
||||||
|
|
||||||
|
### Advanced Filtering Patterns
|
||||||
|
|
||||||
|
**LHS Brackets (Rails-style)**:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /users?filter[status]=active&filter[role]=admin
|
||||||
|
```
|
||||||
|
|
||||||
|
**RHS Colon (JSON API style)**:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /users?filter=status:active,role:admin
|
||||||
|
```
|
||||||
|
|
||||||
|
**Comparison operators**:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /products?price[gte]=100&price[lte]=500 # Price between 100-500
|
||||||
|
GET /users?createdAt[gt]=2024-01-01 # Created after date
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Versioning Strategies
|
||||||
|
|
||||||
|
### 1. URI Versioning
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /v1/users
|
||||||
|
GET /v2/users
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**: Explicit, easy to route, clear in logs
|
||||||
|
**Cons**: Violates REST principles (resource identity changes), URL proliferation
|
||||||
|
|
||||||
|
**Best for**: Public APIs, major breaking changes
|
||||||
|
|
||||||
|
### 2. Header Versioning
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /users
|
||||||
|
Accept: application/vnd.myapi.v2+json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**: Clean URLs, follows REST principles
|
||||||
|
**Cons**: Less visible, harder to test in browser
|
||||||
|
|
||||||
|
**Best for**: Internal APIs, clients with header control
|
||||||
|
|
||||||
|
### 3. Query Parameter Versioning
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /users?version=2
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**: Easy to test, optional (can default to latest)
|
||||||
|
**Cons**: Pollutes query parameters, not semantic
|
||||||
|
|
||||||
|
**Best for**: Minor version variants, opt-in features
|
||||||
|
|
||||||
|
### Version Deprecation Process
|
||||||
|
|
||||||
|
1. **Announce**: Document deprecation timeline (6-12 months recommended)
|
||||||
|
2. **Warn**: Add `Deprecated` header to responses
|
||||||
|
3. **Sunset**: Add `Sunset` header with end date (RFC 8594)
|
||||||
|
4. **Migrate**: Provide migration guides and tooling
|
||||||
|
5. **Remove**: After sunset date, return 410 Gone
|
||||||
|
|
||||||
|
```
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Deprecated: true
|
||||||
|
Sunset: Sat, 31 Dec 2024 23:59:59 GMT
|
||||||
|
Link: </v2/users>; rel="successor-version"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Response Format
|
||||||
|
|
||||||
|
**Standard JSON error format**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "VALIDATION_ERROR",
|
||||||
|
"message": "One or more fields failed validation",
|
||||||
|
"details": [
|
||||||
|
{
|
||||||
|
"field": "email",
|
||||||
|
"message": "Invalid email format",
|
||||||
|
"code": "INVALID_FORMAT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "age",
|
||||||
|
"message": "Must be at least 18",
|
||||||
|
"code": "OUT_OF_RANGE"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestId": "req_abc123",
|
||||||
|
"timestamp": "2024-11-14T10:30:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem Details (RFC 7807)**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "https://api.example.com/errors/validation-error",
|
||||||
|
"title": "Validation Error",
|
||||||
|
"status": 400,
|
||||||
|
"detail": "The request body contains invalid data",
|
||||||
|
"instance": "/users",
|
||||||
|
"invalid-params": [
|
||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"reason": "Invalid email format"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## HATEOAS (Hypermedia)
|
||||||
|
|
||||||
|
**Level 3 REST includes hypermedia links**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"name": "John Doe",
|
||||||
|
"status": "active",
|
||||||
|
"_links": {
|
||||||
|
"self": { "href": "/users/123" },
|
||||||
|
"orders": { "href": "/users/123/orders" },
|
||||||
|
"deactivate": {
|
||||||
|
"href": "/users/123/deactivate",
|
||||||
|
"method": "POST"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
- Self-documenting API
|
||||||
|
- Clients discover available actions
|
||||||
|
- Server controls workflow
|
||||||
|
- Reduces client-server coupling
|
||||||
|
|
||||||
|
**Tradeoffs**:
|
||||||
|
- Increased response size
|
||||||
|
- Complexity for simple APIs
|
||||||
|
- Limited client library support
|
||||||
|
|
||||||
|
**When to use**: Complex workflows, long-lived APIs, discoverability requirements
|
||||||
|
|
||||||
|
## Idempotency Keys
|
||||||
|
|
||||||
|
**For POST operations that should be safely retryable**:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /orders
|
||||||
|
Idempotency-Key: key_abc123xyz
|
||||||
|
|
||||||
|
{
|
||||||
|
"items": [...],
|
||||||
|
"total": 99.99
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Server behavior**:
|
||||||
|
1. First request with key → Process and store result
|
||||||
|
2. Duplicate request with same key → Return stored result (do not reprocess)
|
||||||
|
3. Different request with same key → Return 409 Conflict
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.post("/orders")
|
||||||
|
def create_order(order: Order, idempotency_key: str = Header(None)):
|
||||||
|
if idempotency_key:
|
||||||
|
# Check if key was used before
|
||||||
|
cached = redis.get(f"idempotency:{idempotency_key}")
|
||||||
|
if cached:
|
||||||
|
return JSONResponse(content=cached, status_code=200)
|
||||||
|
|
||||||
|
# Process order
|
||||||
|
result = process_order(order)
|
||||||
|
|
||||||
|
if idempotency_key:
|
||||||
|
# Cache result for 24 hours
|
||||||
|
redis.setex(f"idempotency:{idempotency_key}", 86400, result)
|
||||||
|
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Evolution Patterns
|
||||||
|
|
||||||
|
### Adding Fields (Non-Breaking)
|
||||||
|
|
||||||
|
**✅ Safe changes**:
|
||||||
|
- Add optional request fields
|
||||||
|
- Add response fields
|
||||||
|
- Add new endpoints
|
||||||
|
- Add new query parameters
|
||||||
|
|
||||||
|
**Client requirements**: Ignore unknown fields
|
||||||
|
|
||||||
|
### Removing Fields (Breaking)
|
||||||
|
|
||||||
|
**Strategies**:
|
||||||
|
1. **Deprecation period**: Mark field as deprecated, remove in next major version
|
||||||
|
2. **Versioning**: Create v2 without field
|
||||||
|
3. **Optional → Required**: Never safe, always breaking
|
||||||
|
|
||||||
|
### Changing Field Types (Breaking)
|
||||||
|
|
||||||
|
**❌ Breaking**:
|
||||||
|
- String → Number
|
||||||
|
- Number → String
|
||||||
|
- Boolean → String
|
||||||
|
- Flat → Nested object
|
||||||
|
|
||||||
|
**✅ Non-breaking**:
|
||||||
|
- Number → String (if client coerces)
|
||||||
|
- Adding nullability (required → optional)
|
||||||
|
|
||||||
|
**Strategy**: Add new field with correct type, deprecate old field
|
||||||
|
|
||||||
|
## Richardson Maturity Model
|
||||||
|
|
||||||
|
| Level | Description | Example |
|
||||||
|
|-------|-------------|---------|
|
||||||
|
| 0 | POX (Plain Old XML) | Single endpoint, all operations via POST |
|
||||||
|
| 1 | Resources | Multiple endpoints, still using POST for everything |
|
||||||
|
| 2 | HTTP Verbs | Proper HTTP methods (GET, POST, PUT, DELETE) |
|
||||||
|
| 3 | Hypermedia (HATEOAS) | Responses include links to related resources |
|
||||||
|
|
||||||
|
**Most APIs target Level 2** (HTTP verbs + status codes).
|
||||||
|
**Level 3 is optional** but valuable for complex domains.
|
||||||
|
|
||||||
|
## Common Anti-Patterns
|
||||||
|
|
||||||
|
| Anti-Pattern | Why Bad | Fix |
|
||||||
|
|--------------|---------|-----|
|
||||||
|
| Verbs in URLs (`/createUser`) | Not RESTful, redundant with HTTP methods | Use POST /users |
|
||||||
|
| GET with side effects | Violates HTTP semantics, not safe | Use POST/PUT/DELETE |
|
||||||
|
| POST for everything | Loses HTTP semantics, not idempotent | Use appropriate method |
|
||||||
|
| 200 for errors | Breaks HTTP contract | Use correct 4xx/5xx codes |
|
||||||
|
| Deeply nested URLs | Hard to navigate, brittle | Max 2 levels, use query params |
|
||||||
|
| Binary response flags | Unclear semantics | Use proper HTTP status codes |
|
||||||
|
| Timestamps without timezone | Ambiguous | Use ISO 8601 with timezone |
|
||||||
|
| Pagination without total | Can't show "Page X of Y" | Include total count or hasMore |
|
||||||
|
|
||||||
|
## Best Practices Checklist
|
||||||
|
|
||||||
|
**Resource Design**:
|
||||||
|
- [ ] Resources are nouns, not verbs
|
||||||
|
- [ ] Plural names for collections
|
||||||
|
- [ ] Max 2 levels of nesting
|
||||||
|
- [ ] Consistent naming conventions (snake_case or camelCase)
|
||||||
|
|
||||||
|
**HTTP Semantics**:
|
||||||
|
- [ ] Correct HTTP methods for operations
|
||||||
|
- [ ] Proper status codes (not just 200/500)
|
||||||
|
- [ ] Idempotent operations are actually idempotent
|
||||||
|
- [ ] GET/HEAD have no side effects
|
||||||
|
|
||||||
|
**API Evolution**:
|
||||||
|
- [ ] Versioning strategy defined
|
||||||
|
- [ ] Backward compatibility maintained within version
|
||||||
|
- [ ] Deprecation headers for sunset features
|
||||||
|
- [ ] Migration guides for breaking changes
|
||||||
|
|
||||||
|
**Error Handling**:
|
||||||
|
- [ ] Consistent error response format
|
||||||
|
- [ ] Detailed field-level validation errors
|
||||||
|
- [ ] Request IDs for tracing
|
||||||
|
- [ ] Human-readable error messages
|
||||||
|
|
||||||
|
**Performance**:
|
||||||
|
- [ ] Pagination for large collections
|
||||||
|
- [ ] ETags for caching
|
||||||
|
- [ ] Gzip compression enabled
|
||||||
|
- [ ] Rate limiting implemented
|
||||||
|
|
||||||
|
## Cross-References
|
||||||
|
|
||||||
|
**Related skills**:
|
||||||
|
- **GraphQL alternative** → `graphql-api-design`
|
||||||
|
- **FastAPI implementation** → `fastapi-development`
|
||||||
|
- **Django implementation** → `django-development`
|
||||||
|
- **Express implementation** → `express-development`
|
||||||
|
- **Authentication** → `api-authentication`
|
||||||
|
- **API testing** → `api-testing`
|
||||||
|
- **API documentation** → `api-documentation` or `muna-technical-writer`
|
||||||
|
- **Security** → `ordis-security-architect` (OWASP API Security)
|
||||||
|
|
||||||
|
## Further Reading
|
||||||
|
|
||||||
|
- **REST Dissertation**: Roy Fielding's original thesis
|
||||||
|
- **RFC 7807**: Problem Details for HTTP APIs
|
||||||
|
- **RFC 8594**: Sunset HTTP Header
|
||||||
|
- **JSON:API**: Opinionated REST specification
|
||||||
|
- **OpenAPI 3.0**: API documentation standard
|
||||||
Reference in New Issue
Block a user