Compare commits
3 Commits
011-ui-pro
...
ce279e6121
| Author | SHA1 | Date | |
|---|---|---|---|
| ce279e6121 | |||
| b14508e4cf | |||
| 602648ef56 |
@@ -27,3 +27,7 @@ LOGIN_COOLDOWN_SECONDS=900
|
||||
# Comma-separated IPs/CIDRs of trusted upstream proxies (e.g. nginx ingress pod CIDR).
|
||||
# Leave empty when not behind a reverse proxy.
|
||||
LOGIN_TRUSTED_PROXY_IPS=
|
||||
|
||||
# API documentation endpoints (Swagger UI, ReDoc, OpenAPI schema)
|
||||
# Set to false in production to avoid exposing the API surface publicly.
|
||||
API_DOCS_ENABLED=true
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"feature_directory": "specs/011-ui-prod-dockerfile"
|
||||
"feature_directory": "specs/012-api-docs-gate"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- SPECKIT START -->
|
||||
For additional context about technologies to be used, project structure,
|
||||
shell commands, and other important information, read the current plan at
|
||||
`specs/011-ui-prod-dockerfile/plan.md`.
|
||||
`specs/012-api-docs-gate/plan.md`.
|
||||
<!-- SPECKIT END -->
|
||||
|
||||
1
Makefile
1
Makefile
@@ -4,6 +4,7 @@ test-unit:
|
||||
cd api && python -m pytest tests/unit/ -v
|
||||
|
||||
test-integration:
|
||||
docker compose -f docker-compose.test.yml build api-test
|
||||
docker compose -f docker-compose.test.yml run --rm api-test
|
||||
|
||||
build-prod:
|
||||
|
||||
@@ -12,6 +12,3 @@ dist/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
tests/
|
||||
alembic/
|
||||
alembic.ini
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
@@ -22,6 +23,19 @@ class Settings(BaseSettings):
|
||||
login_window_seconds: int = 300
|
||||
login_cooldown_seconds: int = 900
|
||||
login_trusted_proxy_ips: str = ""
|
||||
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
|
||||
|
||||
|
||||
@lru_cache
|
||||
|
||||
@@ -33,7 +33,16 @@ async def lifespan(application: FastAPI):
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
app = FastAPI(title="Reactbin API", version="1.0.0", lifespan=lifespan)
|
||||
_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,
|
||||
)
|
||||
|
||||
# Defaults so app.state is populated even when lifespan doesn't run (e.g. tests)
|
||||
app.state.login_rate_limiter = LoginRateLimiter()
|
||||
|
||||
48
api/tests/integration/test_docs_gate.py
Normal file
48
api/tests/integration/test_docs_gate.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import importlib
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
_BASE_ENV = {
|
||||
"DATABASE_URL": "postgresql+asyncpg://u:p@localhost/db",
|
||||
"JWT_SECRET_KEY": "test-secret",
|
||||
"OWNER_USERNAME": "admin",
|
||||
"OWNER_PASSWORD": "password",
|
||||
"S3_ENDPOINT_URL": "http://localhost:9000",
|
||||
"S3_BUCKET_NAME": "test-bucket",
|
||||
"S3_ACCESS_KEY_ID": "key",
|
||||
"S3_SECRET_ACCESS_KEY": "secret",
|
||||
}
|
||||
|
||||
|
||||
def _set_env(monkeypatch, extra=None):
|
||||
for k, v in {**_BASE_ENV, **(extra or {})}.items():
|
||||
monkeypatch.setenv(k, v)
|
||||
|
||||
|
||||
def test_docs_hidden_when_flag_disabled(monkeypatch):
|
||||
_set_env(monkeypatch, {"API_DOCS_ENABLED": "false"})
|
||||
get_settings.cache_clear()
|
||||
import app.main as m
|
||||
|
||||
importlib.reload(m)
|
||||
client = TestClient(m.app, raise_server_exceptions=False)
|
||||
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()
|
||||
|
||||
|
||||
def test_docs_visible_when_flag_enabled(monkeypatch):
|
||||
_set_env(monkeypatch, {"API_DOCS_ENABLED": "true"})
|
||||
get_settings.cache_clear()
|
||||
import app.main as m
|
||||
|
||||
importlib.reload(m)
|
||||
client = TestClient(m.app, raise_server_exceptions=False)
|
||||
assert client.get("/docs").status_code == 200
|
||||
assert client.get("/redoc").status_code == 200
|
||||
assert client.get("/openapi.json").status_code == 200
|
||||
get_settings.cache_clear()
|
||||
@@ -59,3 +59,39 @@ def test_settings_jwt_expiry_override(monkeypatch):
|
||||
|
||||
s = config_module.Settings()
|
||||
assert s.jwt_expiry_seconds == 3600
|
||||
|
||||
|
||||
def test_api_docs_enabled_default(monkeypatch):
|
||||
_apply_env(monkeypatch)
|
||||
|
||||
import importlib
|
||||
|
||||
import app.config as config_module
|
||||
importlib.reload(config_module)
|
||||
|
||||
s = config_module.Settings()
|
||||
assert s.api_docs_enabled is True
|
||||
|
||||
|
||||
def test_api_docs_enabled_false(monkeypatch):
|
||||
_apply_env(monkeypatch, {"API_DOCS_ENABLED": "false"})
|
||||
|
||||
import importlib
|
||||
|
||||
import app.config as config_module
|
||||
importlib.reload(config_module)
|
||||
|
||||
s = config_module.Settings()
|
||||
assert s.api_docs_enabled is False
|
||||
|
||||
|
||||
def test_api_docs_invalid_value_defaults_to_enabled(monkeypatch):
|
||||
_apply_env(monkeypatch, {"API_DOCS_ENABLED": "not-a-bool"})
|
||||
|
||||
import importlib
|
||||
|
||||
import app.config as config_module
|
||||
importlib.reload(config_module)
|
||||
|
||||
s = config_module.Settings()
|
||||
assert s.api_docs_enabled is True
|
||||
|
||||
34
specs/012-api-docs-gate/checklists/requirements.md
Normal file
34
specs/012-api-docs-gate/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: API Documentation Visibility Gate
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-05-07
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [X] No implementation details (languages, frameworks, APIs)
|
||||
- [X] Focused on user value and business needs
|
||||
- [X] Written for non-technical stakeholders
|
||||
- [X] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [X] No [NEEDS CLARIFICATION] markers remain
|
||||
- [X] Requirements are testable and unambiguous
|
||||
- [X] Success criteria are measurable
|
||||
- [X] Success criteria are technology-agnostic (no implementation details)
|
||||
- [X] All acceptance scenarios are defined
|
||||
- [X] Edge cases are identified
|
||||
- [X] Scope is clearly bounded
|
||||
- [X] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [X] All functional requirements have clear acceptance criteria
|
||||
- [X] User scenarios cover primary flows
|
||||
- [X] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [X] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass. Spec is ready for `/speckit-plan`.
|
||||
40
specs/012-api-docs-gate/contracts/docs-endpoints.md
Normal file
40
specs/012-api-docs-gate/contracts/docs-endpoints.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Contract: API Documentation Endpoints
|
||||
|
||||
These three endpoints exist in FastAPI by default. This feature makes their availability conditional on a runtime configuration flag.
|
||||
|
||||
## Affected Endpoints
|
||||
|
||||
| Endpoint | Default path | Purpose |
|
||||
|----------|-------------|---------|
|
||||
| Swagger UI | `GET /docs` | Interactive browser-based API documentation |
|
||||
| ReDoc UI | `GET /redoc` | Alternative read-only API documentation |
|
||||
| OpenAPI schema | `GET /openapi.json` | Raw JSON schema of the entire API surface |
|
||||
|
||||
## Behaviour by Flag State
|
||||
|
||||
### `API_DOCS_ENABLED=true` (default)
|
||||
|
||||
All three endpoints respond exactly as they did before this feature. No change.
|
||||
|
||||
| Endpoint | Response |
|
||||
|----------|----------|
|
||||
| `GET /docs` | `200 OK` — Swagger UI HTML |
|
||||
| `GET /redoc` | `200 OK` — ReDoc UI HTML |
|
||||
| `GET /openapi.json` | `200 OK` — OpenAPI schema JSON |
|
||||
|
||||
### `API_DOCS_ENABLED=false`
|
||||
|
||||
All three endpoints are unregistered. Requests fall through to the framework's default 404 handler.
|
||||
|
||||
| Endpoint | Response |
|
||||
|----------|----------|
|
||||
| `GET /docs` | `404 Not Found` |
|
||||
| `GET /redoc` | `404 Not Found` |
|
||||
| `GET /openapi.json` | `404 Not Found` |
|
||||
|
||||
## Invariants
|
||||
|
||||
- All other endpoints are unaffected in both flag states.
|
||||
- The `GET /api/v1/health` endpoint always returns `200 OK` regardless of the flag.
|
||||
- Internal OpenAPI schema generation (used for request/response validation) is not disabled — only the HTTP routes serving it are removed.
|
||||
- The flag is read once at application startup. A running process does not respond to live changes; a restart is required.
|
||||
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 |
|
||||
42
specs/012-api-docs-gate/quickstart.md
Normal file
42
specs/012-api-docs-gate/quickstart.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Quickstart: API Documentation Visibility Gate
|
||||
|
||||
## Verify docs are disabled
|
||||
|
||||
```bash
|
||||
# Start API with docs disabled
|
||||
API_DOCS_ENABLED=false uvicorn app.main:app --reload
|
||||
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/docs # → 404
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/redoc # → 404
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/openapi.json # → 404
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/api/v1/health # → 200
|
||||
```
|
||||
|
||||
## Verify docs are enabled (default)
|
||||
|
||||
```bash
|
||||
# Start API without the flag (or with it set to true)
|
||||
uvicorn app.main:app --reload
|
||||
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/docs # → 200
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/redoc # → 200
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/openapi.json # → 200
|
||||
```
|
||||
|
||||
## Integration test scenarios
|
||||
|
||||
### Scenario 1: flag disabled — all three docs endpoints return 404
|
||||
|
||||
Start a test client with `API_DOCS_ENABLED=false` injected into settings. Assert each of the three endpoint paths returns 404. Assert `/api/v1/health` returns 200.
|
||||
|
||||
### Scenario 2: flag enabled (default) — docs endpoints return 200
|
||||
|
||||
Start a test client without the flag (or with `API_DOCS_ENABLED=true`). Assert each of the three endpoint paths returns 200.
|
||||
|
||||
### Scenario 3: invalid flag value — app starts, docs enabled
|
||||
|
||||
Set `API_DOCS_ENABLED=not-a-bool`. The app must start without error. Docs must be accessible (safe fallback to enabled).
|
||||
|
||||
### Scenario 4: flag absent — docs enabled (backwards compatibility)
|
||||
|
||||
Start the app with no `API_DOCS_ENABLED` variable set. Assert docs endpoints return 200 — identical to pre-feature behaviour.
|
||||
36
specs/012-api-docs-gate/research.md
Normal file
36
specs/012-api-docs-gate/research.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Research: API Documentation Visibility Gate
|
||||
|
||||
## Decision 1: Env var name
|
||||
|
||||
**Decision**: `API_DOCS_ENABLED` (boolean, default `true`)
|
||||
**Rationale**: Consistent with the existing `API_BASE_URL` naming convention in the project. The positive-phrasing default (`true` = enabled) preserves backwards compatibility — existing deployments that don't set the variable get the same behaviour as today.
|
||||
**Alternatives considered**: `HIDE_API_DOCS=false` (negative phrasing) — inverted booleans are error-prone and confusing in `.env` files; `DOCS_ENABLED` — too generic, could collide with other tools in a multi-service env file.
|
||||
|
||||
## Decision 2: FastAPI docs suppression mechanism
|
||||
|
||||
**Decision**: Pass `docs_url=None`, `redoc_url=None`, `openapi_url=None` to the `FastAPI()` constructor when the flag is disabled.
|
||||
**Rationale**: This is the official FastAPI-supported mechanism. Setting these to `None` causes FastAPI to register no routes for those paths — requests to them fall through to the default 404 handler. The internal OpenAPI schema is still generated in memory (for request validation), but no HTTP route exposes it.
|
||||
**Alternatives considered**: Route-level middleware that intercepts and returns 404 — more complex, not the canonical approach; removing routers at runtime — impossible, routers are registered at import time.
|
||||
|
||||
## Decision 3: Settings read at module level
|
||||
|
||||
**Decision**: Read `get_settings()` once at module import time in `main.py` to configure the `FastAPI()` constructor.
|
||||
**Rationale**: `FastAPI()` is instantiated at module level; the docs URL parameters must be known at that point. `get_settings()` is already `@lru_cache` so calling it at module level is cheap and consistent with calling it again inside `lifespan`. Tests that need to change the flag must reload the module or override `get_settings`.
|
||||
**Alternatives considered**: Lazy initialisation of `app` inside a factory function — would require restructuring `main.py` and all imports; not worth the complexity for this change.
|
||||
|
||||
## Decision 4: Graceful fallback for invalid flag values (FR-007)
|
||||
|
||||
**Decision**: Add a `@field_validator('api_docs_enabled', mode='before')` in `Settings` that wraps Pydantic's bool coercion in a try/except and returns `True` on any `ValueError`.
|
||||
**Rationale**: Pydantic v2 raises `ValidationError` for unrecognised boolean strings (e.g., `API_DOCS_ENABLED=maybe`). FR-007 requires the app to start rather than fail. The validator intercepts the invalid value before Pydantic's own coercion and returns the safe default.
|
||||
**Alternatives considered**: Using `Optional[bool] = True` without a validator — Pydantic would still raise on invalid input; using `str` field with manual parsing — duplicates Pydantic's boolean parsing logic unnecessarily.
|
||||
|
||||
## Decision 5: Integration test approach
|
||||
|
||||
**Decision**: Test both enabled and disabled states by overriding `get_settings` in integration tests using `app.dependency_overrides`, or by constructing a local `FastAPI` app instance with the appropriate `docs_url`/`redoc_url`/`openapi_url` values.
|
||||
**Rationale**: The `app` in `app.main` is created at import time. Since the unit tests already use `monkeypatch` + `importlib.reload` for config changes, the integration tests for docs visibility can follow the same pattern — reload `app.main` with the env var set before importing `app`. Alternatively, test the URL routing behaviour directly by constructing a minimal test app.
|
||||
**Alternatives considered**: Patching `app.docs_url` after import — FastAPI does not re-register routes when these attributes are changed post-construction; no effect on routing.
|
||||
|
||||
## Decision 6: Production documentation
|
||||
|
||||
**Decision**: Update `.env.example` to include `API_DOCS_ENABLED=true` with a comment recommending `false` for production. No changes to `api/Dockerfile.prod` (env vars are supplied by the deployment environment, not the image).
|
||||
**Rationale**: The Dockerfile intentionally contains no runtime secrets or config. The `.env.example` is the canonical documentation for operators. A comment is sufficient; the production Dockerfile.prod already has no docs-related config.
|
||||
80
specs/012-api-docs-gate/spec.md
Normal file
80
specs/012-api-docs-gate/spec.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Feature Specification: API Documentation Visibility Gate
|
||||
|
||||
**Feature Branch**: `012-api-docs-gate`
|
||||
**Created**: 2026-05-07
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Add an environment variable flag to disable the FastAPI Swagger and ReDoc documentation endpoints (and the raw OpenAPI schema) in production. When disabled, all three endpoints return 404. When enabled (the default), behaviour is unchanged. The flag should be off by default in production and on by default in development."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Documentation Hidden in Production (Priority: P1)
|
||||
|
||||
An operator deploys the API to a production environment and wants to ensure that the interactive documentation UI and the raw API schema are not publicly reachable. Setting a configuration flag causes all three documentation endpoints to return "not found", as if they do not exist.
|
||||
|
||||
**Why this priority**: Exposing the full API schema and interactive console to anonymous users in production reveals the attack surface of the application. Hiding it is a low-effort, high-value hardening step.
|
||||
|
||||
**Independent Test**: Start the API with the flag set to disabled. Request each of the three documentation endpoints. All three must return 404.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the API is started with documentation disabled, **When** a client requests the interactive documentation UI, **Then** the response is 404 Not Found.
|
||||
2. **Given** the API is started with documentation disabled, **When** a client requests the alternative documentation UI, **Then** the response is 404 Not Found.
|
||||
3. **Given** the API is started with documentation disabled, **When** a client requests the raw OpenAPI schema endpoint, **Then** the response is 404 Not Found.
|
||||
4. **Given** the API is started with documentation disabled, **When** a client requests any other API endpoint (e.g., the health check), **Then** the response is unaffected — normal behaviour continues.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Documentation Available in Development (Priority: P2)
|
||||
|
||||
A developer runs the API locally without setting the flag. The documentation endpoints remain fully accessible — no change in behaviour from before this feature.
|
||||
|
||||
**Why this priority**: Developer productivity depends on the interactive docs being available during local development. The default must not break existing workflows.
|
||||
|
||||
**Independent Test**: Start the API without the flag set (or with it explicitly enabled). Request each of the three documentation endpoints. All three must respond successfully with their normal content.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the API is started without the flag set, **When** a client requests any documentation endpoint, **Then** the response is the same as it was before this feature was introduced.
|
||||
2. **Given** the API is started with the flag explicitly set to enabled, **When** a client requests any documentation endpoint, **Then** the response is the same as it was before this feature was introduced.
|
||||
3. **Given** the flag is changed from enabled to disabled (or vice versa), **When** the API is restarted, **Then** the new state takes effect immediately with no other changes required.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens if the flag is set to an unrecognised value (e.g., a typo)?
|
||||
- What happens if the flag is absent entirely — is the default enabled or disabled?
|
||||
- Does disabling documentation affect any other behaviour (e.g., internal schema generation used for validation)?
|
||||
- If a monitoring tool scrapes the schema endpoint for API drift detection, does disabling break it?
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST support a configuration flag that controls whether the API documentation endpoints are reachable.
|
||||
- **FR-002**: When the flag is set to disabled, all three documentation endpoints (interactive UI, alternative UI, and raw schema) MUST return 404 Not Found.
|
||||
- **FR-003**: When the flag is set to enabled, the behaviour of all three documentation endpoints MUST be identical to the behaviour before this feature was introduced.
|
||||
- **FR-004**: The flag MUST default to **enabled** when not explicitly set (preserving backwards compatibility for existing deployments).
|
||||
- **FR-005**: Disabling documentation MUST NOT affect any other API endpoint, including the health check, authentication, and all resource endpoints.
|
||||
- **FR-006**: The flag MUST be configurable via an environment variable without requiring a code change or rebuild.
|
||||
- **FR-007**: An unrecognised or missing flag value MUST fall back to the enabled default rather than causing a startup failure.
|
||||
- **FR-008**: The existing `.env.example` file MUST be updated to document the flag and its default value.
|
||||
- **FR-009**: The production environment configuration MUST set the flag to disabled by default.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: With the flag disabled, all three documentation endpoints return 404, confirmed by automated test.
|
||||
- **SC-002**: With the flag enabled (or absent), all three documentation endpoints respond successfully, confirmed by automated test.
|
||||
- **SC-003**: All existing tests continue to pass — zero regressions introduced.
|
||||
- **SC-004**: The flag takes effect on restart with no other intervention required.
|
||||
- **SC-005**: The `.env.example` file documents the flag so any developer setting up the project discovers it without reading source code.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- There are exactly three documentation-related endpoints to gate: the primary interactive UI, the alternative documentation UI, and the raw OpenAPI schema JSON. No other endpoints are affected.
|
||||
- The flag is read once at application startup; a running process does not need to respond to live changes.
|
||||
- Internal schema generation (used by the framework for request validation) is not affected by hiding the documentation endpoints — only the public-facing HTTP routes are removed.
|
||||
- The production Dockerfile (`api/Dockerfile.prod`) does not hardcode the flag; it is supplied via the deployment environment (docker-compose, Kubernetes secret, etc.).
|
||||
- "Off by default in production" means the recommended value for production is disabled, documented in `.env.example` and in the production docker-compose or deployment config; it does not mean the application auto-detects its environment.
|
||||
100
specs/012-api-docs-gate/tasks.md
Normal file
100
specs/012-api-docs-gate/tasks.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Tasks: API Documentation Visibility Gate
|
||||
|
||||
**Input**: Design documents from `specs/012-api-docs-gate/`
|
||||
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, contracts/docs-endpoints.md ✅, quickstart.md ✅
|
||||
|
||||
**Tests**: TDD is non-negotiable (§5.1). Failing tests are written before implementation code in each phase.
|
||||
|
||||
**Organization**: No setup or foundational phases — this feature modifies three existing files and adds one new test file. Phase 3 (US1) covers the disable path; Phase 4 (US2) verifies the enable/default path using the same implementation; Phase 5 polishes.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel with other [P] tasks in the same phase
|
||||
- **[Story]**: Which user story this task belongs to
|
||||
- Exact file paths included in every task description
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Documentation Hidden in Production (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: When `API_DOCS_ENABLED=false`, all three documentation endpoints (`/docs`, `/redoc`, `/openapi.json`) return 404. All other endpoints are unaffected.
|
||||
|
||||
**Independent Test**: `make test-unit` passes the new settings tests; `make test-integration` passes the new `test_docs_disabled` integration test.
|
||||
|
||||
### Tests for User Story 1 (TDD — write first, confirm failure before T003)
|
||||
|
||||
- [X] T001 [US1] Add three failing unit tests to `api/tests/unit/test_config.py` using the existing `_apply_env`/`_BASE_ENV` pattern:
|
||||
1. `test_api_docs_enabled_default` — call `Settings()` with `_BASE_ENV` only (no `API_DOCS_ENABLED`); assert `s.api_docs_enabled is True`
|
||||
2. `test_api_docs_enabled_false` — call `Settings()` with `_BASE_ENV` + `{"API_DOCS_ENABLED": "false"}`; assert `s.api_docs_enabled is False`
|
||||
3. `test_api_docs_invalid_value_defaults_to_enabled` — call `Settings()` with `_BASE_ENV` + `{"API_DOCS_ENABLED": "not-a-bool"}`; assert `s.api_docs_enabled is True` (graceful fallback, FR-007)
|
||||
All three tests fail before T003 because `api_docs_enabled` does not yet exist on `Settings`.
|
||||
|
||||
- [X] T002 [US1] Create `api/tests/integration/test_docs_gate.py` with two failing integration tests; the file MUST set up a minimal app client using `from starlette.testclient import TestClient` and the `importlib.reload` + `get_settings.cache_clear()` pattern shown in plan.md:
|
||||
1. `test_docs_hidden_when_flag_disabled(monkeypatch)` — set `API_DOCS_ENABLED=false` via monkeypatch + all required env vars (`DATABASE_URL`, `JWT_SECRET_KEY`, `OWNER_USERNAME`, `OWNER_PASSWORD`, `S3_ENDPOINT_URL`, `S3_BUCKET_NAME`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`); call `get_settings.cache_clear()`; `importlib.reload(app.main)`; create `TestClient(app.main.app)`; assert `/docs` → 404, `/redoc` → 404, `/openapi.json` → 404, `/api/v1/health` → 200; after test, call `get_settings.cache_clear()` again as cleanup
|
||||
2. `test_docs_visible_when_flag_enabled(monkeypatch)` — same setup but with `API_DOCS_ENABLED=true` (or omit it); assert `/docs` → 200, `/redoc` → 200, `/openapi.json` → 200
|
||||
Both tests fail before T003/T004 because `api_docs_enabled` does not exist on `Settings`.
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T003 [US1] Add `api_docs_enabled: bool = True` field and a `coerce_docs_enabled` field validator to the `Settings` class in `api/app/config.py`: the validator MUST use `mode='before'`, be a `@classmethod`, and wrap Pydantic bool coercion in a try/except that returns `True` on any exception (implements FR-007); import `field_validator` from `pydantic` at the top of the file; the field goes after the existing `login_trusted_proxy_ips` field.
|
||||
|
||||
- [X] T004 [US1] Update `api/app/main.py`: before the `app = FastAPI(...)` call, add `_settings = get_settings()`; add `docs_url="/docs" if _settings.api_docs_enabled else None`, `redoc_url="/redoc" if _settings.api_docs_enabled else None`, and `openapi_url="/openapi.json" if _settings.api_docs_enabled else None` as keyword arguments to the `FastAPI()` constructor; the existing module-level defaults for `app.state` (after the `app = FastAPI(...)` line) are unchanged.
|
||||
|
||||
- [X] T005 [US1] Verify TDD green for US1: run `cd api && python -m pytest tests/unit/ -v -k "docs"` and confirm all three new unit tests pass; then run `cd api && python -m pytest tests/unit/ -v` to confirm no regressions in the full 102-test unit suite.
|
||||
|
||||
**Checkpoint**: US1 is complete. With `API_DOCS_ENABLED=false` the three docs endpoints return 404; all other endpoints are unaffected.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Documentation Available in Development (Priority: P2)
|
||||
|
||||
**Goal**: Without the flag set (or with it set to `true`), docs endpoints behave identically to before this feature. Default is backwards compatible.
|
||||
|
||||
**Independent Test**: `make test-integration` — the `test_docs_visible_when_flag_enabled` test written in T002 passes, confirming the enabled/default path.
|
||||
|
||||
- [X] T006 [US2] Verify TDD green for US2: run `make test-integration` from `/workspace` and confirm all integration tests pass, including `test_docs_gate.py::test_docs_visible_when_flag_enabled` and the full existing suite (102 tests + 2 new = 104 total).
|
||||
|
||||
**Checkpoint**: Both user stories verified. Flag disabled → 404; flag enabled or absent → unchanged behaviour.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [X] T007 Add documentation for `API_DOCS_ENABLED` to `/workspace/.env.example`: insert a new section after the `LOGIN_TRUSTED_PROXY_IPS` block with a comment and `API_DOCS_ENABLED=true`; the comment MUST note that this should be set to `false` in production to avoid publicly exposing the API schema
|
||||
|
||||
- [X] T008 Run `ruff check api/app/config.py api/app/main.py api/tests/integration/test_docs_gate.py` from `/workspace/api` and fix any lint violations; then run `ruff check api/` to confirm the full API directory is clean
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
- T001 and T002 can run in parallel (different files, both TDD-red before implementation)
|
||||
- T003 must complete before T004 (main.py reads from config.py)
|
||||
- T005 after T003 and T004
|
||||
- T006 after T005
|
||||
- T007 and T008 can run in parallel (different files, after all tests pass)
|
||||
|
||||
### Execution Order Summary
|
||||
|
||||
```
|
||||
Step 1: T001 ∥ T002 (write failing tests — TDD red)
|
||||
Step 2: T003 (implement config.py — turns T001 green)
|
||||
Step 3: T004 (implement main.py — turns T002 green)
|
||||
Step 4: T005 (verify unit tests green)
|
||||
Step 5: T006 (verify integration tests green — regression gate)
|
||||
Step 6: T007 ∥ T008 (polish — .env.example + ruff)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP (US1 + US2 — one implementation covers both)
|
||||
|
||||
1. Write failing tests (T001, T002)
|
||||
2. Add `api_docs_enabled` to `config.py` (T003)
|
||||
3. Update `FastAPI()` constructor in `main.py` (T004)
|
||||
4. Verify all tests green (T005, T006)
|
||||
5. Polish (T007, T008)
|
||||
|
||||
US1 and US2 share the same implementation — the flag controls both paths. There is no separate implementation for US2; the default value of `true` is the entire implementation of US2.
|
||||
Reference in New Issue
Block a user