Skip to content

Commit 035b636

Browse files
authored
Merge pull request #19 from Accenture/feature/actuator-polish
Engine-parity host polish: index page, pretty JSON, engine error shape
2 parents 1888a48 + da60593 commit 035b636

5 files changed

Lines changed: 98 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
## 0.1.0 (unreleased)
44

5+
- Host polish for engine parity: `GET /` serves the engines' minimal index page linking
6+
the actuator endpoints (embedded - no static file service by design); actuator JSON
7+
responses are pretty-printed (the engines' default-serializer presentation); unknown
8+
paths and non-GET methods answer the engines' error shape
9+
`{"status": 404, "message": "Resource not found", "type": "error"}`.
510
- Sync bridge: `PostOffice.request_sync()` / `send_sync()` let a plain `def` handler
611
(the synchronous ecosystem - `requests`, NumPy/ML inference, database drivers) call
712
sibling or remote functions - the call runs on the host event loop while only the

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ Kubernetes probes and dashboards treat a Python app exactly like a Java or Rust
154154

155155
| Endpoint | Purpose |
156156
|----------|---------|
157+
| `GET /` | minimal index page linking the endpoints below |
157158
| `GET /info` | app identity, runtime, origin id, start time, uptime |
158159
| `GET /info/routes` | registered routes split by visibility, with instance counts |
159160
| `GET /env` | selected environment variables and configuration parameters |
@@ -175,6 +176,10 @@ async def health(headers: dict[str, str], _body: Body) -> Body:
175176
return "demo.service is running fine" # a non-200 reply marks it down
176177
```
177178

179+
JSON responses are pretty-printed — the engines' default-serializer presentation — and
180+
unknown paths answer the engines' error shape
181+
(`{"status": 404, "message": "Resource not found", "type": "error"}`).
182+
178183
Kubernetes wiring: point `livenessProbe` at `/livenessprobe` and `readinessProbe` at
179184
`/health`.
180185

src/mercury_composable/actuator.py

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ async def health(headers: dict[str, str], _body: Body) -> Body:
3939
from __future__ import annotations
4040

4141
import contextlib
42+
import json
4243
import os
4344
import platform as runtime_platform
4445
import re
@@ -62,6 +63,38 @@ async def health(headers: dict[str, str], _body: Body) -> Body:
6263
HEALTH_TIMEOUT_MS = 10000 # engine value for the type=health probe
6364
UNHEALTHY = "Unhealthy. Please check '/health' endpoint."
6465

66+
# The engines' minimal landing page (platform-core public/index.html style);
67+
# the wrappers embed it - no static file service by design.
68+
INDEX_HTML = """<!DOCTYPE html>
69+
<html>
70+
<body>
71+
72+
<h2>Welcome</h2>
73+
74+
<p><a href="/info">INFO endpoint</a></p>
75+
<p><a href="/info/routes">Service list</a></p>
76+
<p><a href="/env">Environment endpoint</a></p>
77+
<p><a href="/health">Health endpoint</a></p>
78+
<p><a href="/livenessprobe">Liveness probe</a></p>
79+
80+
</body>
81+
</html>"""
82+
83+
84+
def _pretty(payload: Any) -> str:
85+
"""The engines' default serializer presentation: pretty-printed JSON."""
86+
return json.dumps(payload, indent=2, ensure_ascii=False)
87+
88+
89+
def _json_response(payload: dict[str, Any], status: int = 200) -> web.Response:
90+
return web.json_response(payload, status=status, dumps=_pretty)
91+
92+
93+
def _error_response(status: int, message: str) -> web.Response:
94+
"""The engines' host-level error shape (SimpleHttpUtility signature)."""
95+
return _json_response({"status": status, "message": message, "type": "error"},
96+
status=status)
97+
6598
_SPLIT = re.compile(r"[,\s]+")
6699

67100
_origin: str | None = None
@@ -138,9 +171,14 @@ def _app_block(self) -> dict[str, Any]:
138171
"description": self.description}
139172

140173
async def handle(self, request: web.Request) -> web.Response:
141-
"""The single GET dispatcher (aiohttp handlers must be coroutines):
142-
/health awaits its dependency probes; the rest render synchronously."""
174+
"""The single dispatcher (aiohttp handlers must be coroutines):
175+
/health awaits its dependency probes; the rest render synchronously.
176+
Unknown paths and non-GET methods answer the engines' error shape."""
177+
if request.method != "GET":
178+
return _error_response(404, "Resource not found")
143179
match request.path:
180+
case "/":
181+
return web.Response(text=INDEX_HTML, content_type="text/html")
144182
case "/info":
145183
return self._info()
146184
case "/info/routes":
@@ -151,12 +189,12 @@ async def handle(self, request: web.Request) -> web.Response:
151189
return await self._health()
152190
case "/livenessprobe":
153191
return self._liveness_probe()
154-
case _: # the router only maps the five paths above here
155-
return web.Response(status=404, text="Not found")
192+
case _:
193+
return _error_response(404, "Resource not found")
156194

157195
def _info(self) -> web.Response:
158196
now = datetime.now(timezone.utc)
159-
return web.json_response({
197+
return _json_response({
160198
"app": self._app_block(),
161199
"runtime": {
162200
"language": "python",
@@ -174,7 +212,7 @@ def _routes(self) -> web.Response:
174212
for route, service in sorted(self.registry.routes().items()):
175213
target = private if service.private else public
176214
target[route] = service.instances
177-
return web.json_response({
215+
return _json_response({
178216
"app": self._app_block(),
179217
"routing": {"public": public, "private": private},
180218
})
@@ -185,7 +223,7 @@ def _env(self) -> web.Response:
185223
for name in _as_list(config.get("show.env.variables"))}
186224
properties = {name: config.get_property(name) or ""
187225
for name in _as_list(config.get("show.application.properties"))}
188-
return web.json_response({
226+
return _json_response({
189227
"app": self._app_block(),
190228
"env": {"environment": environment, "properties": properties},
191229
})
@@ -204,7 +242,7 @@ async def _health(self) -> web.Response:
204242
result["status"] = "UP" if up else "DOWN"
205243
result["origin"] = app_origin()
206244
result["name"] = self.app_name
207-
return web.json_response(result, status=200 if up else 400)
245+
return _json_response(result, status=200 if up else 400)
208246

209247
def _liveness_probe(self) -> web.Response:
210248
if self.healthy:

src/mercury_composable/server.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,13 @@ async def handle_event(self, request: web.Request) -> web.Response:
103103
def create_app(self) -> web.Application:
104104
app = web.Application(client_max_size=16 * 1024 * 1024)
105105
app.router.add_post("/api/event", self.handle_event)
106-
# the engines' actuator endpoints (see actuator.py)
107-
for path in ("/info", "/info/routes", "/env", "/health", "/livenessprobe"):
106+
# the engines' landing page + actuator endpoints (see actuator.py)
107+
for path in ("/", "/info", "/info/routes", "/env", "/health", "/livenessprobe"):
108108
app.router.add_get(path, self.actuator.handle)
109+
# any other path or method answers the engines' error shape, not
110+
# aiohttp's default text page (exact routes above win - they are
111+
# registered first)
112+
app.router.add_route("*", "/{unknown:.*}", self.actuator.handle)
109113
return app
110114

111115

tests/test_actuator.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,3 +196,39 @@ def test_origin_is_stable_and_engine_shaped():
196196
minted = app_origin()
197197
assert minted == app_origin() # minted once per process
198198
assert re.fullmatch(ORIGIN_SHAPE, minted)
199+
200+
async def test_index_page_lists_actuator_endpoints():
201+
async with (
202+
actuator_server(FunctionRegistry()) as url,
203+
aiohttp.ClientSession() as session,
204+
session.get(f"{url}/") as response,
205+
):
206+
assert response.status == 200
207+
assert response.content_type == "text/html"
208+
page = await response.text()
209+
for link in ("/info", "/info/routes", "/env", "/health", "/livenessprobe"):
210+
assert f'href="{link}"' in page
211+
212+
213+
async def test_unknown_path_answers_engine_error_shape():
214+
async with actuator_server(FunctionRegistry()) as url:
215+
status, body = await get_json(f"{url}/no/such/page")
216+
# non-GET on a known path is equally not a resource (engine semantics)
217+
async with aiohttp.ClientSession() as session, session.post(f"{url}/info") as response:
218+
post_status = response.status
219+
post_body = await response.json()
220+
assert status == 404
221+
assert body == {"status": 404, "message": "Resource not found", "type": "error"}
222+
assert post_status == 404
223+
assert post_body == {"status": 404, "message": "Resource not found", "type": "error"}
224+
225+
226+
async def test_json_responses_are_pretty_printed():
227+
# the engines' default serializer presentation (SimpleMapper pretty Gson)
228+
async with (
229+
actuator_server(FunctionRegistry()) as url,
230+
aiohttp.ClientSession() as session,
231+
session.get(f"{url}/info") as response,
232+
):
233+
text = await response.text()
234+
assert text.startswith('{\n "app": {\n')

0 commit comments

Comments
 (0)