diff --git a/src/example/app.py b/src/example/app.py index 7c72769..73f7cb3 100644 --- a/src/example/app.py +++ b/src/example/app.py @@ -1,6 +1,6 @@ """Application factory — wires dependencies and registers routes.""" -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse @@ -62,6 +62,12 @@ UpdateTagUseCase, ) +# OpenAPI / NENE2 PHP parity (FrameworkInfo in hideyukiMORI/NENE2). +_FRAMEWORK_NAME = "NENE2" +_FRAMEWORK_DESCRIPTION = "JSON APIs first, minimal server HTML, frontend ready, AI-readable." +# Matches nene2-js tools/compose-ft-evac.yaml default for local evac smoke. +_DEFAULT_MACHINE_API_KEYS = ["ft-evac-local-machine-api-key-32ch!!"] + type _Repos = tuple[ NoteRepositoryInterface, TagRepositoryInterface, @@ -143,6 +149,16 @@ def create_app(settings: AppSettings | None = None) -> FastAPI: ApiKeyAuthMiddleware, verifier=LocalTokenVerifier(cfg.api_keys), ) + if cfg.api_key_enabled and cfg.api_keys: + machine_keys = cfg.api_keys + else: + machine_keys = _DEFAULT_MACHINE_API_KEYS + app.add_middleware( + ApiKeyAuthMiddleware, + verifier=LocalTokenVerifier(machine_keys), + include_paths=["/machine/health"], + header_name="X-NENE2-API-Key", + ) # CORS must be outermost — register last so preflight OPTIONS is handled # before throttle, auth, or any other middleware runs. if cfg.cors_enabled: @@ -199,10 +215,31 @@ def create_app(settings: AppSettings | None = None) -> FastAPI: prefix="/examples", ) + @app.get("/", tags=["system"], summary="Framework smoke endpoint") + async def framework_smoke() -> JSONResponse: + return JSONResponse( + { + "name": _FRAMEWORK_NAME, + "description": _FRAMEWORK_DESCRIPTION, + "status": "ok", + } + ) + @app.get("/examples/ping", tags=["system"], summary="Example ping") async def example_ping() -> JSONResponse: return JSONResponse({"message": "pong", "status": "ok"}) + @app.get("/machine/health", tags=["system"], summary="Protected machine health endpoint") + async def machine_health(request: Request) -> JSONResponse: + credential_type = getattr(request.state, "nene2_auth_credential_type", "api_key") + return JSONResponse( + { + "status": "ok", + "service": _FRAMEWORK_NAME, + "credential_type": credential_type, + } + ) + db_health = DatabaseHealthCheck(db_executor) if db_executor else None @app.get("/health", tags=["system"], summary="Health check") diff --git a/src/nene2/auth/api_key.py b/src/nene2/auth/api_key.py index 795d72f..d43651a 100644 --- a/src/nene2/auth/api_key.py +++ b/src/nene2/auth/api_key.py @@ -84,4 +84,5 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) - 401, f"A valid {self._header_name} header is required.", ) + request.state.nene2_auth_credential_type = "api_key" return await call_next(request) diff --git a/tests/example/test_system_routes.py b/tests/example/test_system_routes.py new file mode 100644 index 0000000..3076e09 --- /dev/null +++ b/tests/example/test_system_routes.py @@ -0,0 +1,33 @@ +"""System route parity with NENE2 OpenAPI (GET /, /machine/health).""" + +from fastapi.testclient import TestClient + +from example.app import create_app +from nene2.config import AppSettings + +_MACHINE_KEY = "ft-evac-local-machine-api-key-32ch!!" + + +def test_framework_smoke_root() -> None: + client = TestClient(create_app(AppSettings(throttle_enabled=False))) + r = client.get("/") + assert r.status_code == 200 + body = r.json() + assert body["name"] == "NENE2" + assert body["status"] == "ok" + assert "description" in body + + +def test_machine_health_requires_api_key() -> None: + client = TestClient(create_app(AppSettings(throttle_enabled=False))) + assert client.get("/machine/health").status_code == 401 + + +def test_machine_health_with_api_key() -> None: + client = TestClient(create_app(AppSettings(throttle_enabled=False))) + r = client.get("/machine/health", headers={"X-NENE2-API-Key": _MACHINE_KEY}) + assert r.status_code == 200 + body = r.json() + assert body["status"] == "ok" + assert body["service"] == "NENE2" + assert body["credential_type"] == "api_key"