# Phase 8 — Middleware + Polish **Status**: PENDING **Depends on**: Phases 1–7 all complete **Next phase**: `phase-09-testing.md` --- ## Goal Global exception handling, SPA redirect middleware, structured logging, and final `src/main.py` wiring. No new domain logic. After this phase the app is production-equivalent to the Java version for all error cases and routing edge cases. --- ## Java Source Files to Read `` = `E:\TestScript\ARMA-Server-Web-Gui\src\main\java\pl\bartlomiejstepien\armaserverwebgui\` | File | What to extract | |------|----------------| | `application/ApiException.java` | Annotation fields: `code (ApiExceptionCode)`, `messageKey (str)`, `status (HttpStatus)` | | `application/ApiExceptionCode.java` | **All enum values** — these become the `api_code` strings on Python exceptions | | `application/ApiExceptionFilter.java` | Catch-all servlet filter: only handles `/api/**` paths; calls `ApiErrorResponseResolver.resolve()` | | `application/ApiErrorResponseResolver.java` | Resolves throwable → `RestErrorResponse` using annotation metadata or falls back to 500 `SERVER_ERROR` | | `application/frontend/FrontEndRedirectWebFilter.java` | Forward non-API, non-dotted paths to `/index.html` via `request.getRequestDispatcher` | | `application/IpAddressMdcHttpFilter.java` | Adds `X-Forwarded-For` / remote addr to MDC logging context | | `application/RateLimitWebFilter.java` | **Entire file is commented out** — rate limiting was never implemented; do not port | | `web/response/RestErrorResponse.java` | Fields: `code (str)`, `message (str)` — `status` int is used internally but NOT in JSON | --- ## Output Files to Create ``` src/application/middleware/__init__.py src/application/middleware/spa_redirect.py src/application/middleware/error_handler.py src/application/middleware/logging_middleware.py ``` Update `src/main.py` with final middleware stack and complete router list. --- ## Implementation Notes ### RestErrorResponse — only two fields in JSON The Java `RestErrorResponse.java` has an internal `status` int but it does NOT appear in the serialized JSON. The Angular frontend reads only `code` and `message`. The `RestErrorResponse` class was already defined in Phase 2 (`src/web/schemas/common.py`): ```python class RestErrorResponse(BaseSchema): code: str message: str ``` Do not add a `status` field to JSON output. ### Domain exception pattern (apply to ALL exception classes in Phases 3–7) Replace Java's `@ApiException` annotation with class-level attributes: ```python class ModFileAlreadyExistsException(Exception): api_code = "MOD_FILE_ALREADY_EXISTS" api_status = 409 api_message = "Mod file already exists." class ModIdAlreadyRegisteredException(Exception): api_code = "MOD_ID_ALREADY_REGISTERED" api_status = 409 api_message = "Mod with this workshop ID is already registered." class ModIdCannotBeZeroException(Exception): api_code = "MOD_ID_CANNOT_BE_ZERO" api_status = 400 api_message = "Mod workshop file ID cannot be zero." class NotManagedModNotFoundException(Exception): api_code = "NOT_MANAGED_MOD_NOT_FOUND" api_status = 404 api_message = "Not-managed mod not found." class MissingSteamApiKeyException(Exception): api_code = "MISSING_STEAM_API_KEY" api_status = 400 api_message = "Steam API key is not configured." class SteamCmdNotInstalled(Exception): api_code = "STEAMCMD_NOT_INSTALLED" api_status = 400 api_message = "SteamCMD is not installed or path is not set." ``` Read `ApiExceptionCode.java` for the **complete** enum — add every value as a matching Python exception class. ### Global exception handler ```python # src/application/middleware/error_handler.py from fastapi import Request from fastapi.responses import JSONResponse async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse: path = request.url.path if not path.startswith("/api"): # Non-API paths: StaticFiles or SPA redirect will handle it from fastapi.responses import Response return Response(status_code=404) code = getattr(exc, "api_code", "SERVER_ERROR") status = getattr(exc, "api_status", 500) message = getattr(exc, "api_message", "Internal server error.") if status >= 500: import logging logging.getLogger(__name__).error( "Unhandled exception on %s %s", request.method, path, exc_info=exc ) return JSONResponse( status_code=status, content={"code": code, "message": message}, ) ``` Register: ```python app.add_exception_handler(Exception, global_exception_handler) ``` ### SPA redirect middleware Java's `FrontEndRedirectWebFilter` forwards to `index.html` when the path: - Does NOT start with `/api` - Contains no dot (i.e. is not a static asset like `main.js` or `favicon.ico`) ```python # src/application/middleware/spa_redirect.py import re from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import FileResponse # Match paths that have no dot in the last segment (no file extension) _IS_SPA_ROUTE = re.compile(r"[^.]*$") class SPARedirectMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): path = request.url.path if (not path.startswith("/api") and not path.startswith("/ws") and _IS_SPA_ROUTE.fullmatch(path.split("/")[-1])): return FileResponse("static/index.html") return await call_next(request) ``` ### Request logging middleware (IP context) ```python # src/application/middleware/logging_middleware.py import structlog.contextvars from starlette.middleware.base import BaseHTTPMiddleware class RequestLoggingMiddleware(BaseHTTPMiddleware): async def dispatch(self, request, call_next): ip = request.headers.get("X-Forwarded-For", "") if not ip and request.client: ip = request.client.host structlog.contextvars.bind_contextvars(client_ip=ip, path=request.url.path) try: return await call_next(request) finally: structlog.contextvars.clear_contextvars() ``` ### Structlog configuration (add to top of src/main.py) ```python import logging, structlog structlog.configure( processors=[ structlog.contextvars.merge_contextvars, structlog.processors.add_log_level, structlog.processors.TimeStamper(fmt="iso"), structlog.dev.ConsoleRenderer(), ], wrapper_class=structlog.make_filtering_bound_logger(logging.INFO), logger_factory=structlog.PrintLoggerFactory(), ) ``` ### Final src/main.py — complete structure ```python # Middleware (registration order = outermost first in ASGI stack) app.add_middleware(RequestLoggingMiddleware) # Phase 8 app.add_middleware(JwtMiddleware) # Phase 2 app.add_middleware(SPARedirectMiddleware) # Phase 8 # Exception handler app.add_exception_handler(Exception, global_exception_handler) # All 17 routers app.include_router(auth_router) # Phase 2 /api/v1/auth app.include_router(user_router) # Phase 2 /api/v1/users app.include_router(status_router) # Phase 3 /api/v1/status app.include_router(general_router) # Phase 4 /api/v1/general app.include_router(network_router) # Phase 4 /api/v1/network app.include_router(logging_router) # Phase 4 /api/v1/logging app.include_router(security_router) # Phase 4 /api/v1/security app.include_router(difficulty_router) # Phase 4 /api/v1/difficulties app.include_router(mods_router) # Phase 5 /api/v1/mods app.include_router(mods_files_router) # Phase 5 /api/v1/mods-files app.include_router(mods_presets_router) # Phase 5 /api/v1/mods-presets app.include_router(mod_settings_router) # Phase 5 /api/v1/mods/settings app.include_router(workshop_router) # Phase 6 /api/v1/workshop app.include_router(steam_settings_router) # Phase 6 /api/v1/settings/steam app.include_router(mission_router) # Phase 7 /api/v1/missions app.include_router(mission_files_router) # Phase 7 /api/v1/missions-files app.include_router(cdlc_router) # Phase 7 /api/v1/cdlc # WebSocket (Phase 5) @app.websocket("/api/v1/ws/mod-install-status") async def mod_install_ws(websocket: WebSocket): ... # Actuator endpoints (permit-all, Phase 1) @app.get("/api/v1/actuator/health") async def health(): return {"status": "UP"} @app.get("/api/v1/actuator/info") async def info(): return {"application": {"name": "ASWG"}} # Static files — MUST be last app.mount("/", StaticFiles(directory="static", html=True), name="static") ``` ### Lifespan complete sequence ```python @asynccontextmanager async def lifespan(app: FastAPI): # 1. Run Alembic migrations to "head" alembic_cfg = Config("alembic.ini") command.upgrade(alembic_cfg, "head") # 2. Create default user (Phase 2) await create_default_user_if_absent() # 3. Start SteamCMD task loop (Phase 6) steam_task = asyncio.create_task(run_task_loop()) # 4. Start APScheduler + register all 6 jobs (Phase 7) scheduler.start() _register_all_jobs() # 5. Import vanilla missions on first run (Phase 7) await vanilla_missions_importer.run() yield # Teardown scheduler.shutdown(wait=False) steam_task.cancel() ``` --- ## Completion Checklist - [ ] Domain exceptions in all phases have `api_code`, `api_status`, `api_message` attrs - [ ] `POST /api/v1/mods-files` with wrong file type returns 409 `{"code":"...", "message":"..."}` - [ ] 500 errors return `{"code":"SERVER_ERROR", "message":"Internal server error."}` - [ ] Navigating browser to `/mods` returns `index.html` content - [ ] `/api/**` paths are NOT intercepted by SPA middleware - [ ] `static/main.js` (dotted path) is NOT redirected - [ ] Request log lines include `client_ip` and `path` fields - [ ] `GET /api/v1/actuator/health` works without JWT token - [ ] All 17 routers registered in correct order in `src/main.py` - [ ] Rate limiting: explicitly NOT implemented (matches Java source) ## Contract for Phase 9 Phase 9 imports: - `from src.main import app` (the FastAPI instance) - `from src.repository.base import SessionLocal, engine, Base` - All router and service classes for integration testing