Files
reactbin/specs/012-api-docs-gate/plan.md
agatha 602648ef56 Feat: Gate API docs endpoints behind API_DOCS_ENABLED env var
When API_DOCS_ENABLED=false, FastAPI registers no routes for /docs,
/redoc, or /openapi.json, returning 404 for all three. Default is true
for backwards compatibility. Invalid values fall back to true (FR-007).

Fix: Remove tests/ and alembic/ from api/.dockerignore so the test
Dockerfile (which uses COPY . .) includes the test suite; Dockerfile.prod
is unaffected as it only copies app/ explicitly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:40:48 +00:00

5.4 KiB

Implementation Plan: API Documentation Visibility Gate

Branch: 012-api-docs-gate | Date: 2026-05-07 | Spec: spec.md Input: Feature specification from specs/012-api-docs-gate/spec.md

Summary

Add API_DOCS_ENABLED (boolean, default true) to app/config.py. When false, pass docs_url=None, redoc_url=None, openapi_url=None to the FastAPI() constructor in app/main.py, making all three documentation routes return 404. A field validator provides graceful fallback for invalid flag values. Two new integration tests verify both flag states; the existing unit test suite is extended with two settings tests.

Technical Context

Language/Version: Python 3.12 Primary Dependencies: FastAPI (constructor params), pydantic-settings (field validator) Storage: None Testing: pytest unit (api/tests/unit/test_config.py), pytest + ASGI test client (api/tests/integration/test_docs_gate.py) Target Platform: API container (same as existing) Project Type: Web service configuration change Performance Goals: No measurable impact — one boolean read at startup Constraints: Default must be true (backwards compatible); invalid env var value must not crash startup; no other routes affected Scale/Scope: Three files changed (config.py, main.py, .env.example); one new test file; one existing test file extended

Constitution Check

Principle Requirement Status
§5.1 TDD Failing tests written before implementation Tasks order tests first
§5.2 Integration tests New integration tests follow existing pattern
§5.3 Tests next to code api/tests/unit/ and api/tests/integration/
§5.4 CI before done All tests pass before task marked done
§7.2 Env config Flag via environment variable, not hardcoded
§7.3 Linting ruff passes on all changed files Enforced in polish task
§2.6 No speculative abstraction One boolean field, no plugin system

No violations. All gates pass.

Project Structure

Documentation (this feature)

specs/012-api-docs-gate/
├── plan.md                    ← this file
├── research.md                ← 6 decisions
├── contracts/
│   └── docs-endpoints.md     ← behaviour contract for 3 affected endpoints
├── quickstart.md              ← 4 test scenarios
└── tasks.md                  ← generated by /speckit-tasks

Source Code Changes

api/
├── app/
│   ├── config.py              ← MODIFIED: add api_docs_enabled field + validator
│   └── main.py                ← MODIFIED: conditional docs_url/redoc_url/openapi_url
├── tests/
│   ├── unit/
│   │   └── test_config.py     ← MODIFIED: 2 new tests for api_docs_enabled
│   └── integration/
│       └── test_docs_gate.py  ← NEW: 2 integration tests (disabled + enabled)

.env.example                   ← MODIFIED: document API_DOCS_ENABLED

Implementation Design

app/config.py — new field with graceful fallback validator

from pydantic import field_validator

class Settings(BaseSettings):
    # ... existing fields ...
    api_docs_enabled: bool = True

    @field_validator('api_docs_enabled', mode='before')
    @classmethod
    def coerce_docs_enabled(cls, v):
        if isinstance(v, bool):
            return v
        try:
            from pydantic import TypeAdapter
            return TypeAdapter(bool).validate_python(v)
        except Exception:
            return True  # FR-007: invalid value → safe default (enabled)

app/main.py — conditional docs URLs

_settings = get_settings()

app = FastAPI(
    title="Reactbin API",
    version="1.0.0",
    lifespan=lifespan,
    docs_url="/docs" if _settings.api_docs_enabled else None,
    redoc_url="/redoc" if _settings.api_docs_enabled else None,
    openapi_url="/openapi.json" if _settings.api_docs_enabled else None,
)

Integration test pattern

The app object is constructed at module import time. Tests reload the module with the env var pre-set:

def test_docs_disabled(monkeypatch, _base_env):
    monkeypatch.setenv("API_DOCS_ENABLED", "false")
    from app.config import get_settings
    get_settings.cache_clear()
    import importlib, app.main as m
    importlib.reload(m)
    client = TestClient(m.app)
    assert client.get("/docs").status_code == 404
    assert client.get("/redoc").status_code == 404
    assert client.get("/openapi.json").status_code == 404
    assert client.get("/api/v1/health").status_code == 200

get_settings.cache_clear() is required before the reload so the new env var is picked up.

.env.example addition

# API documentation endpoints (Swagger UI, ReDoc, OpenAPI schema)
# Set to false in production to avoid exposing the API surface publicly.
API_DOCS_ENABLED=true

Dependencies & Risks

Item Risk Mitigation
@lru_cache on get_settings() Tests may pick up cached settings across reloads Always call get_settings.cache_clear() before reloading app.main in tests
Module-level get_settings() in main.py Import fails if required settings are absent (pre-existing behaviour) Not a new risk; same as today
openapi_url=None Disables HTTP route but not internal schema generation Intentional; request validation is unaffected