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
289 lines
10 KiB
Markdown
289 lines
10 KiB
Markdown
# 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
|
||
|
||
`<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 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
|