Files
netneutrality/.junie/plans/javalin-webapi-refactor.md
T
anthorob 01bfe22d22 Migrate HTTP server to Javalin and remove legacy handlers
Replaced the existing HttpServer implementation and associated handler classes (`AbstractHandler`, `ActionHandler`, `GuildHandler`, `RootHandler`) with a Javalin-based routing system. Improved modularity by introducing `ActionRoutes` and `GuildRoutes` for route definitions, and centralized JSON mapping using `GsonJsonMapper`. Simplified caching via the new `ResponseCache` utility, updated `WebModule` for cleaner timeout handling, and extended test coverage for API endpoints.
2026-09-23 12:07:38 -04:00

188 lines
11 KiB
Markdown

---
sessionId: session-260923-111902-5uwc
---
# Requirements
### Overview & Goals
Replace the hand-rolled `com.sun.net.httpserver` stack in `me.bobert.netneutrality.http.server` with Javalin, so the bot's embedded API needs far fewer custom classes and gains correct status codes, JSON handling and error responses. Additionally simplify the outbound `WebModule` HTTP client.
### Scope
**In scope**
- `WebApi` becomes a thin Javalin-based `Lifecycle` component (same priority 70, same `NETNEUTRALITY_API_PORT` setting).
- Delete `AbstractHandler`, `RootHandler`, `ActionHandler`, `GuildHandler`; their logic moves into small route-registration units.
- Standardise the API layer on **Gson** — remove `org.json` (`JSONObject`/`JSONArray`) usage from the HTTP server package.
- Correct HTTP semantics: 404 for unknown guilds/paths, 405 for wrong methods, 500 on exceptions, UTF-8 safe bodies.
- Simplify `WebModule` (outbound OkHttp wrapper): extract the static cache, remove duplicated/unused members.
**Out of scope**
- Changing the public URL shape of existing endpoints (`/`, `/actions/*`, `/guilds/*`) — paths and payload keys stay backwards compatible.
- Authentication/authorisation for the API.
- Migrating `org.json` usage elsewhere in the project (e.g. task classes using `WebModule.getAsObject()`).
### Functional Requirements
- `GET /` → HTML liveness page (unchanged text).
- `GET /actions/status` → `{"health":...,"ping":...}`.
- `POST /actions/shutdown` → runs the shutdown hook and exits (accept `GET` too for compatibility if desired).
- `GET /actions/settings/current/{guildId}` → `{"status":"ok","settings":{...}}`, or 404 + `{"status":"error"}` for an unknown guild.
- `POST /actions/settings/reload/{guildId}` → reloads the guild setting cache, `{"status":"true"}`; 404 for an unknown guild.
- `GET /guilds/owner` → array of `{guild_id, owner_id}`.
- `GET /guilds/channels/{guildId}` and `GET /guilds/roles/{guildId}` → arrays of id/name objects; 404 for an unknown guild.
- Requests arriving before JDA is ready must return 503 rather than throwing an NPE.
### Non-Functional Requirements
- Startup/shutdown stays deterministic through `ApplicationContext` — `stop()` must release the port.
- Shaded jar must still run (`maven-shade-plugin`); Javalin/Jetty service files need merging.
- No behavioural regression for existing consumers of the endpoints.
# Technical Design
### Current Implementation
- `WebApi.java` (92 lines) implements `Lifecycle` (priority 70), creates an `HttpServer`, registers three contexts and a fixed pool built from `BaseThreadFactory("web-api")`. Constructed in `App.java:154`.
- `AbstractHandler` offers `sendStringResponse`/`sendJsonResponse`; both hardcode status 200 and use `str.length()` instead of byte length (breaks non-ASCII).
- `ActionHandler` / `GuildHandler` route via `if (path.startsWith(...))` chains and parse ids with `getRequestURI().toString().split("/")`; they mix Gson and `org.json`, never check the HTTP method, and call `jda.getGuildById()` twice in places.
- `WebModule` is an unrelated *outbound* OkHttp wrapper with a builder, a static LRU cache and a `CachedResponse` record; it builds a brand-new `OkHttpClient` per request and has three redundant timeout setters plus unused `elapsedTime`/`errorMessage` accessors.
### Key Decisions
1. **Javalin** (6.x, JDK 17+ / Java 25 compatible) as the embedded server — removes routing, method matching, error mapping and content-type handling code.
2. **Gson only** in the HTTP layer: register a `GsonJsonMapper` on the Javalin config so `ctx.json(obj)` works with plain records/maps; `org.json` disappears from `http/server`.
3. **Route groups instead of handler classes**: `ActionRoutes` and `GuildRoutes` expose a single `static void register(Javalin app, deps...)`, so `AbstractHandler`, `RootHandler`, `ActionHandler`, `GuildHandler` are all deleted (4 classes → 2 tiny registrars).
4. **Guild lookup guard**: one private helper resolves `{guildId}` → `Guild` or throws `NotFoundResponse`, replacing repeated null checks.
5. **WebModule**: keep the builder API used by callers, but move the cache into a package-private `ResponseCache`, share a single static `OkHttpClient` and derive per-request timeouts with `newBuilder()`.
### Proposed Changes
**`http/server/WebApi.java`**
```java
public class WebApi implements Lifecycle {
private Javalin app;
@Override public int priority() { return 70; }
@Override public void start() {
int port = Integer.parseInt(settings.get("NETNEUTRALITY_API_PORT", "9000"));
app = Javalin.create(cfg -> cfg.jsonMapper(new GsonJsonMapper()));
app.get("/", ctx -> ctx.html(LIVENESS_HTML));
ActionRoutes.register(app, jdaSupplier, guildServices, shutdownHook);
GuildRoutes.register(app, jdaSupplier);
app.exception(Exception.class, (e, ctx) -> { log.error(...); ctx.status(500).json(Map.of("status","error")); });
app.start(port);
}
@Override public void stop() { if (app != null) { app.stop(); app = null; } }
}
```
**`http/server/GuildRoutes.java`** — `/guilds/owner`, `/guilds/channels/{guildId}`, `/guilds/roles/{guildId}` mapped to `List<Map<String,String>>` and returned via `ctx.json(...)`.
**`http/server/ActionRoutes.java`** — `/actions/status`, `/actions/shutdown`, `/actions/settings/current/{guildId}`, `/actions/settings/reload/{guildId}`.
**`http/server/GsonJsonMapper.java`** — ~15 lines implementing Javalin's `JsonMapper` over a shared `Gson`.
**`http/WebModule.java`** — shared client, cache extracted, redundant setters/accessors dropped; public builder methods used by callers remain.
### Data Models / Contracts
Small records replace ad-hoc JSON building, serialized by Gson with the existing key names:
```java
record GuildOwner(String guild_id, String owner_id) {}
record NamedEntity(String channel_id, String channel_name) {}
record StatusResponse(String health, String ping) {}
```
### File Structure
```
src/main/java/me/bobert/netneutrality/http/
WebModule.java (modified)
server/WebApi.java (rewritten, Javalin)
server/GsonJsonMapper.java (new)
server/ActionRoutes.java (new)
server/GuildRoutes.java (new)
server/AbstractHandler.java (deleted)
server/RootHandler.java (deleted)
server/ActionHandler.java (deleted)
server/GuildHandler.java (deleted)
pom.xml (+ io.javalin:javalin, shade transformer)
```
### Architecture Diagram
```mermaid
graph LR
App-->ApplicationContext
ApplicationContext-->WebApi
WebApi-->Javalin
WebApi-->GsonJsonMapper
Javalin-->ActionRoutes
Javalin-->GuildRoutes
ActionRoutes-->GuildServiceProvider
GuildRoutes-->JDA
```
### Risks
- **Shaded jar**: Jetty/Javalin rely on `META-INF/services`; the shade plugin needs `ServicesResourceTransformer`, otherwise startup fails only in the packaged build.
- **Jar size / SLF4J binding**: Jetty logs through SLF4J; project already has log4j + slf4j — check for duplicate bindings.
- **JDA not ready**: `jdaSupplier.get()` may be null; guard with a 503 mapping.
- `System.exit(0)` in the shutdown route must run after the response is flushed.
# Testing
### Validation Approach
Build with `./mvnw -q verify` (checkstyle included), then start the app/`WebApi` on an ephemeral port and exercise every endpoint with `curl`, comparing payload shape against the pre-refactor behaviour.
### Key Scenarios
- `GET /` returns 200 and the HTML liveness text.
- `GET /actions/status` returns 200 `application/json` with `health` and `ping`.
- `GET /guilds/owner` returns a JSON array with `guild_id`/`owner_id` keys.
- `GET /guilds/channels/{id}` and `/guilds/roles/{id}` return arrays with `channel_id`/`channel_name`.
- `GET /actions/settings/current/{id}` returns `{"status":"ok","settings":{...}}`.
### Edge Cases
- Unknown guild id → 404 with `{"status":"error"}` (previously 200).
- Unknown path → 404 (previously echoed the path with 200).
- Wrong method on a route → 405.
- Non-ASCII content in settings values round-trips correctly (old `str.length()` bug).
- `Lifecycle.stop()` frees the port — a second `start()` on the same port succeeds.
### Test Changes
- Add a lightweight JUnit test starting `WebApi` on port 0 with a mocked `Supplier<JDA>` and `GuildServiceProvider`, hitting the routes with Javalin's test client or `java.net.http.HttpClient`.
- Add a `WebModule` test covering cache hit/expiry after the cache extraction.
# Delivery Steps
### ✓ Step 1: Add Javalin dependency and Gson JSON mapper
The project builds with Javalin available and a Gson-backed JSON mapper ready for use.
- Add `io.javalin:javalin` (6.x) to `pom.xml` dependencies.
- Add `ServicesResourceTransformer` to the `maven-shade-plugin` configuration so Jetty/Javalin service files survive shading.
- Create `http/server/GsonJsonMapper.java` implementing Javalin's `JsonMapper` over a shared `Gson` instance.
- Verify `./mvnw -q package` succeeds and the shaded jar still starts.
### ✓ Step 2: Rewrite WebApi as a Javalin Lifecycle component
`WebApi` starts and stops a Javalin server while keeping its Lifecycle contract and the root endpoint.
- Replace the `HttpServer`/`ExecutorService` fields with a `Javalin app` field.
- Build the instance in `start()` with the `GsonJsonMapper`, reading `NETNEUTRALITY_API_PORT` from `SettingsProvider` as today.
- Register `GET /` returning the liveness HTML and delete `RootHandler.java`.
- Add global `exception` and `error` handlers returning 500/404 JSON, plus a 503 guard when `jdaSupplier.get()` is null.
- Implement `stop()` via `app.stop()`; keep `priority() == 70` and the `App.java` construction site unchanged.
### ✓ Step 3: Migrate guild endpoints to GuildRoutes
All `/guilds/*` endpoints are served by Javalin route lambdas with proper status codes and Gson serialization.
- Create `http/server/GuildRoutes.register(Javalin, Supplier<JDA>)`.
- Map `GET /guilds/owner`, `GET /guilds/channels/{guildId}`, `GET /guilds/roles/{guildId}` using path params instead of `split("/")`.
- Return `List<record>` payloads via `ctx.json(...)`, preserving the existing `guild_id`/`owner_id`/`channel_id`/`channel_name` keys.
- Resolve guilds through a shared helper that throws `NotFoundResponse` for unknown ids (single `getGuildById` call).
- Delete `GuildHandler.java`.
### ✓ Step 4: Migrate action endpoints to ActionRoutes
All `/actions/*` endpoints run on Javalin, with `org.json` fully removed from the HTTP server package.
- Create `http/server/ActionRoutes.register(Javalin, Supplier<JDA>, GuildServiceProvider, Runnable)`.
- Map `/actions/status`, `/actions/shutdown`, `/actions/settings/current/{guildId}`, `/actions/settings/reload/{guildId}`.
- Replace `JSONObject`/`JSONArray` with Gson-serialized maps/records keeping the same response keys.
- Ensure the shutdown route flushes its response before running the hook and exiting.
- Delete `ActionHandler.java` and `AbstractHandler.java`, and add endpoint tests starting `WebApi` on port 0 with mocked JDA/guild services.
### ✓ Step 5: Simplify the outbound WebModule client
`WebModule` keeps its builder API but sheds duplicated state and its inlined cache.
- Extract the static LRU cache and `CachedResponse` into a package-private `ResponseCache` class in `http/`.
- Share a single static `OkHttpClient` and derive per-request timeouts with `newBuilder()` instead of constructing a client per build.
- Collapse the redundant `timeout`/`connectTimeout`/`readTimeout`/`writeTimeout` setters and remove unused accessors (`getErrorMessage`, `elapsedTime` exposure) after checking call sites.
- Add a unit test covering cache hit, expiry and POST bypass; verify existing `WebModule` callers still compile.