Skip to content

Commit 4de8361

Browse files
authored
Merge pull request #294 from spoo-me/fix/health-response-model
fix: document the /health response schema in OpenAPI
2 parents 25bd42d + 95e2dcc commit 4de8361

7 files changed

Lines changed: 134 additions & 21 deletions

File tree

.github/workflows/tests.yaml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,31 @@ permissions:
2020
contents: read
2121

2222
jobs:
23+
spec-drift:
24+
runs-on: ubuntu-latest
25+
steps:
26+
- name: Checkout
27+
uses: actions/checkout@v7
28+
29+
- name: Set up Python
30+
uses: actions/setup-python@v7
31+
with:
32+
python-version: "3.13"
33+
34+
- name: Install uv
35+
uses: astral-sh/setup-uv@v7
36+
37+
- name: Install dependencies
38+
run: uv sync --group dev
39+
40+
- name: Regenerate openapi.json and diff against the committed file
41+
run: |
42+
make openapi
43+
git diff --exit-code openapi.json || {
44+
echo "::error::openapi.json is stale — run 'make openapi' and commit the result."
45+
exit 1
46+
}
47+
2348
test:
2449
runs-on: ubuntu-latest
2550
strategy:

Makefile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,15 @@ docker-up: ## Start full stack (MongoDB + Redis + app)
2727
docker-down: ## Stop full stack
2828
docker-compose down
2929

30+
# Pinned env so the artifact is reproducible: the servers block comes from
31+
# APP_URL, and settings refuse to load without MONGODB_URI. Writing through a
32+
# temp file keeps a failed export from truncating the committed spec.
3033
openapi: ## Export OpenAPI spec to openapi.json
34+
MONGODB_URI="mongodb://localhost:27017/" APP_URL="https://spoo.me" \
3135
uv run python -c \
3236
"from app import create_app; import json; app = create_app(); \
33-
print(json.dumps(app.openapi(), indent=2))" > openapi.json
37+
print(json.dumps(app.openapi(), indent=2))" > openapi.json.tmp
38+
mv openapi.json.tmp openapi.json
3439

3540
docs: ## Open API docs in browser (requires running server)
3641
open http://localhost:8000/docs

openapi.json

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,19 @@
2828
"description": "Successful Response",
2929
"content": {
3030
"application/json": {
31-
"schema": {}
31+
"schema": {
32+
"$ref": "#/components/schemas/HealthResponse"
33+
}
34+
}
35+
}
36+
},
37+
"503": {
38+
"description": "Unhealthy \u2014 MongoDB is unreachable",
39+
"content": {
40+
"application/json": {
41+
"schema": {
42+
"$ref": "#/components/schemas/HealthResponse"
43+
}
3244
}
3345
}
3446
}
@@ -3762,7 +3774,7 @@
37623774
"anyOf": [
37633775
{
37643776
"type": "string",
3765-
"maxLength": 50
3777+
"maxLength": 1000
37663778
},
37673779
{
37683780
"type": "null"
@@ -5001,7 +5013,7 @@
50015013
"anyOf": [
50025014
{
50035015
"type": "string",
5004-
"maxLength": 50
5016+
"maxLength": 1000
50055017
},
50065018
{
50075019
"type": "null"
@@ -10949,6 +10961,58 @@
1094910961
"type": "object",
1095010962
"title": "HTTPValidationError"
1095110963
},
10964+
"HealthChecks": {
10965+
"properties": {
10966+
"mongodb": {
10967+
"type": "string",
10968+
"enum": [
10969+
"ok",
10970+
"error",
10971+
"not_configured"
10972+
],
10973+
"title": "Mongodb"
10974+
},
10975+
"redis": {
10976+
"type": "string",
10977+
"enum": [
10978+
"ok",
10979+
"error",
10980+
"not_configured"
10981+
],
10982+
"title": "Redis"
10983+
}
10984+
},
10985+
"type": "object",
10986+
"required": [
10987+
"mongodb",
10988+
"redis"
10989+
],
10990+
"title": "HealthChecks",
10991+
"description": "Individual service check statuses inside HealthResponse."
10992+
},
10993+
"HealthResponse": {
10994+
"properties": {
10995+
"status": {
10996+
"type": "string",
10997+
"title": "Status"
10998+
},
10999+
"version": {
11000+
"type": "string",
11001+
"title": "Version"
11002+
},
11003+
"checks": {
11004+
"$ref": "#/components/schemas/HealthChecks"
11005+
}
11006+
},
11007+
"type": "object",
11008+
"required": [
11009+
"status",
11010+
"version",
11011+
"checks"
11012+
],
11013+
"title": "HealthResponse",
11014+
"description": "Response body for GET /health."
11015+
},
1095211016
"LayoutResponse": {
1095311017
"properties": {
1095411018
"layout": {
@@ -14195,7 +14259,7 @@
1419514259
],
1419614260
"servers": [
1419714261
{
14198-
"url": "http://spoo.local:8000",
14262+
"url": "https://spoo.me",
1419914263
"description": "Production"
1420014264
}
1420114265
],

routes/health_routes.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99

1010
from __future__ import annotations
1111

12-
from fastapi import APIRouter, Request
13-
from fastapi.responses import JSONResponse
12+
from fastapi import APIRouter, Request, Response
1413

1514
from middleware.openapi import PUBLIC_SECURITY
15+
from schemas.dto.responses.common import HealthResponse
1616

1717
router = APIRouter(tags=["System"])
1818

@@ -22,8 +22,14 @@
2222
openapi_extra=PUBLIC_SECURITY,
2323
operation_id="healthCheck",
2424
summary="Health Check",
25+
responses={
26+
503: {
27+
"description": "Unhealthy — MongoDB is unreachable",
28+
"model": HealthResponse,
29+
}
30+
},
2531
)
26-
async def health_check(request: Request) -> JSONResponse:
32+
async def health_check(request: Request, response: Response) -> HealthResponse:
2733
"""Check the health of the application and its dependencies.
2834
2935
Pings MongoDB and Redis to determine overall system status:
@@ -63,12 +69,10 @@ async def health_check(request: Request) -> JSONResponse:
6369

6470
settings = getattr(request.app.state, "settings", None)
6571

66-
status_code = 503 if overall == "unhealthy" else 200
67-
return JSONResponse(
68-
status_code=status_code,
69-
content={
70-
"status": overall,
71-
"version": settings.app_version if settings else "dev",
72-
"checks": checks,
73-
},
72+
if overall == "unhealthy":
73+
response.status_code = 503
74+
return HealthResponse(
75+
status=overall,
76+
version=settings.app_version if settings else "dev",
77+
checks=checks,
7478
)

schemas/dto/responses/common.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from __future__ import annotations
1010

11-
from typing import Any
11+
from typing import Any, Literal
1212

1313
from schemas.dto.base import ResponseBase
1414

@@ -25,15 +25,16 @@ class ErrorResponse(ResponseBase):
2525
class HealthChecks(ResponseBase):
2626
"""Individual service check statuses inside HealthResponse."""
2727

28-
mongodb: str
29-
redis: str
28+
mongodb: Literal["ok", "error", "not_configured"]
29+
redis: Literal["ok", "error", "not_configured"]
3030

3131

3232
class HealthResponse(ResponseBase):
3333
"""Response body for GET /health."""
3434

3535
status: str
36-
checks: dict[str, str]
36+
version: str
37+
checks: HealthChecks
3738

3839

3940
class MessageResponse(ResponseBase):

tests/smoke/test_openapi.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ def test_openapi_has_expected_paths(smoke_client: TestClient) -> None:
6666
assert path in paths, f"Missing path: {path}"
6767

6868

69+
def test_openapi_health_has_response_schema(smoke_client: TestClient) -> None:
70+
"""GET /health should document its response body, including the 503 case."""
71+
data = smoke_client.get("/openapi.json").json()
72+
responses = data["paths"]["/health"]["get"]["responses"]
73+
for status in ("200", "503"):
74+
schema = responses[status]["content"]["application/json"]["schema"]
75+
assert schema["$ref"] == "#/components/schemas/HealthResponse"
76+
77+
6978
def test_openapi_has_components_schemas(smoke_client: TestClient) -> None:
7079
"""OpenAPI spec should have response schemas defined in components."""
7180
data = smoke_client.get("/openapi.json").json()

tests/unit/schemas/dto/test_common.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,12 @@ def test_without_optional_fields(self):
3131

3232
class TestHealthResponse:
3333
def test_serialization(self):
34-
r = HealthResponse(status="healthy", checks={"mongodb": "ok", "redis": "ok"})
34+
r = HealthResponse(
35+
status="healthy",
36+
version="1.0.0",
37+
checks={"mongodb": "ok", "redis": "ok"},
38+
)
3539
d = r.model_dump()
3640
assert d["status"] == "healthy"
41+
assert d["version"] == "1.0.0"
3742
assert d["checks"]["mongodb"] == "ok"

0 commit comments

Comments
 (0)