# Implementation Plan: API Documentation Visibility Gate **Branch**: `012-api-docs-gate` | **Date**: 2026-05-07 | **Spec**: [spec.md](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) ```text 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 ```text 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 ```python 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 ```python _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: ```python 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 ```bash # 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 |