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