Files
gh-tachyon-beep-skillpacks-…/skills/using-web-backend/api-documentation.md
2025-11-30 08:59:27 +08:00

27 KiB

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:

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:

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:

# 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:

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

# .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

# 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:

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

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:

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:

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

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:

<!-- 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:

# 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:

# .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
# 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:

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:

<!-- 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:

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:

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:

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:

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:

// 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:

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 writingmuna-technical-writer (writing style, organization)
  • API designrest-api-design, graphql-api-design (design patterns)
  • API testingapi-testing (contract testing, examples)
  • Authenticationapi-authentication (auth flow documentation)

Further Reading