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>
139 lines
5.4 KiB
Markdown
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 |
|