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

10 KiB
Raw Blame History

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):

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:

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

# 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:

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)
# 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)

# 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)

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

# 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

@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