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>
This commit is contained in:
138
specs/012-api-docs-gate/plan.md
Normal file
138
specs/012-api-docs-gate/plan.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# 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 |
|
||||
Reference in New Issue
Block a user