Files
arma-server-web-manager/phases/phase-08-middleware-polish.md
Khoa (Revenovich) Tran Gia e02db3ddde feat: add Java→Python migration plan with 9 self-contained phase files
Converts Spring Boot 4.0.3 ARMA Server Web GUI to FastAPI/Python.
Each phase file is fully self-contained: lists Java source files to
read, output files to create, implementation patterns, REST endpoint
contracts, and a completion checklist. A future agent can execute any
single phase without rescanning the Java project.

Phases:
- 01: Foundation — SQLAlchemy models, Alembic, settings, base schemas
- 02: Auth & Users — JWT middleware, RBAC, user CRUD
- 03: CFG parser + server process — server.cfg round-trip, start/stop
- 04: Server settings — general/network/logging/security/difficulty
- 05: Mod management — mod CRUD, presets, settings, WebSocket progress
- 06: Steam integration — SteamCMD queue, Workshop API, python-a2s
- 07: Missions, CDLC, Discord, APScheduler jobs
- 08: Middleware & polish — global exception handler, SPA redirect, structlog
- 09: Testing — pytest-asyncio, respx, 80% coverage target
2026-04-14 15:06:56 +07:00

289 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 8 — Middleware + Polish
**Status**: PENDING
**Depends on**: Phases 17 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
`<JAVA_SRC>` = `E:\TestScript\ARMA-Server-Web-Gui\src\main\java\pl\bartlomiejstepien\armaserverwebgui\`
| File | What to extract |
|------|----------------|
| `<JAVA_SRC>application/ApiException.java` | Annotation fields: `code (ApiExceptionCode)`, `messageKey (str)`, `status (HttpStatus)` |
| `<JAVA_SRC>application/ApiExceptionCode.java` | **All enum values** — these become the `api_code` strings on Python exceptions |
| `<JAVA_SRC>application/ApiExceptionFilter.java` | Catch-all servlet filter: only handles `/api/**` paths; calls `ApiErrorResponseResolver.resolve()` |
| `<JAVA_SRC>application/ApiErrorResponseResolver.java` | Resolves throwable → `RestErrorResponse` using annotation metadata or falls back to 500 `SERVER_ERROR` |
| `<JAVA_SRC>application/frontend/FrontEndRedirectWebFilter.java` | Forward non-API, non-dotted paths to `/index.html` via `request.getRequestDispatcher` |
| `<JAVA_SRC>application/IpAddressMdcHttpFilter.java` | Adds `X-Forwarded-For` / remote addr to MDC logging context |
| `<JAVA_SRC>application/RateLimitWebFilter.java` | **Entire file is commented out** — rate limiting was never implemented; do not port |
| `<JAVA_SRC>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 37)
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