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
This commit is contained in:
Khoa (Revenovich) Tran Gia
2026-04-14 15:06:56 +07:00
commit e02db3ddde
10 changed files with 2817 additions and 0 deletions

View File

@@ -0,0 +1,288 @@
# 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