Files
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

139 lines
5.4 KiB
Markdown

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