Initial commit
This commit is contained in:
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/
|
||||
Reference in New Issue
Block a user