# 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 ``` ## 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 {% if deprecated %}
⚠️ Deprecated: This documentation is for API v1, which will be sunset on June 1, 2025. Migrate to v2
{% 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/