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.
This commit is contained in:
anthorob committed 2026-09-23 12:07:38 -04:00
1 parent 5f6265db01
commit 01bfe22d22
14 files changed
+779 -384

No files matched your search

+188
View File
@@ -0,0 +1,188 @@
---
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.
+13
View File
@@ -183,6 +183,12 @@
<version>5.3.2</version>
</dependency>
<dependency>
<groupId>io.javalin</groupId>
<artifactId>javalin</artifactId>
<version>6.7.0</version>
</dependency>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
@@ -219,6 +225,13 @@
<groupId>org.gitlab4j</groupId>
<artifactId>gitlab4j-api</artifactId>
<version>6.2.0</version>
<exclusions>
<!-- Clashes with the Servlet 5 API used by Javalin's Jetty 11 in the shaded jar. -->
<exclusion>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
@@ -0,0 +1,65 @@
package me.bobert.netneutrality.http;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
/**
* Small LRU cache holding responses of outbound {@link WebModule} GET requests.
*/
final class ResponseCache {
private static final int MAX_ENTRIES = 50;
private static final Map<String, CachedResponse> entries = Collections.synchronizedMap(
new LinkedHashMap<>(100, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, CachedResponse> eldest) {
return size() > MAX_ENTRIES;
}
}
);
private ResponseCache() {
}
/**
* Returns the cached response for the key when it is still valid.
*
* @param key the cache key
* @return the cached response, or empty when absent or expired
*/
static Optional<CachedResponse> get(String key) {
CachedResponse cached = entries.get(key);
return cached != null && !cached.isExpired() ? Optional.of(cached) : Optional.empty();
}
static void put(String key, CachedResponse response) {
entries.put(key, response);
}
static void invalidate(String url) {
entries.keySet().removeIf(key -> key.contains(url));
}
/**
* A response body kept alongside its status code until {@code expirationTime}.
*/
record CachedResponse(int statusCode, String body, long expirationTime) {
boolean isExpired() {
return System.currentTimeMillis() > expirationTime;
}
Optional<String> getBody() {
return Optional.ofNullable(body);
}
}
}
@@ -9,7 +9,7 @@ package me.bobert.netneutrality.http;
import com.google.gson.Gson;
import io.sentry.Breadcrumb;
import io.sentry.Sentry;
import okhttp3.Headers;
import me.bobert.netneutrality.http.ResponseCache.CachedResponse;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
@@ -21,11 +21,8 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
public class WebModule {
@@ -34,20 +31,13 @@ public class WebModule {
}
private static final String USER_AGENT = "Net Neutrality/%s (Discord bot, discord.gg/trAqhQ5Wsr)";
private static final OkHttpClient CLIENT = new OkHttpClient();
private static volatile String defaultVersion = "0.0.0";
private static final Logger log = LoggerFactory.getLogger(WebModule.class);
public static void setDefaultVersion(String version) {
defaultVersion = version;
}
private static final Map<String, CachedResponse> cache = Collections.synchronizedMap(
new LinkedHashMap<>(100, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, CachedResponse> eldest) {
return size() > 50; // Limit cache to 50 entries
}
}
);
private final Method method;
private final String url;
@@ -62,7 +52,7 @@ public class WebModule {
this.url = builder.url;
this.client = builder.client;
this.headers = builder.headers;
this.cacheExpirationMillis = builder.timeUnit.toMillis(builder.timeout);
this.cacheExpirationMillis = builder.timeUnit.toMillis(builder.cacheTimeout);
this.request = builder.request;
}
@@ -74,7 +64,7 @@ public class WebModule {
* @param url the URL of the cached response to invalidate
*/
public static void invalidateCache(String url) {
cache.keySet().removeIf(s -> s.contains(url));
ResponseCache.invalidate(url);
}
/**
@@ -86,60 +76,49 @@ public class WebModule {
* POST requests do not support caching.
*
* @return the {@code WebModule} instance, allowing further interaction with the response
* @throws RuntimeException if the request fails
*/
public WebModule request() {
String cacheKey = createCacheKey(method, url, headers);
if (method == Method.GET && cacheKey != null) {
CachedResponse cachedResponse = cache.get(cacheKey);
if (cacheKey != null) {
CachedResponse cached = ResponseCache.get(cacheKey).orElse(null);
if (cachedResponse != null && !cachedResponse.isExpired()) {
if (cached != null) {
log.debug("Retrieving cached response: {}", cacheKey);
this.cachedResponse = cachedResponse;
this.cachedResponse = cached;
return this;
}
}
long startTime = System.currentTimeMillis();
try (Response response = client.newCall(request).execute()) {
long elapsedTime = System.currentTimeMillis() - startTime;
Sentry.addBreadcrumb(Breadcrumb.http(url, method.name(), response.code()));
String body = response.body().string();
this.cachedResponse = new CachedResponse(
response.code(),
response.headers(),
elapsedTime,
body,
System.currentTimeMillis() + cacheExpirationMillis,
null
response.body().string(),
System.currentTimeMillis() + cacheExpirationMillis
);
} catch (IOException e) {
long elapsedTime = System.currentTimeMillis() - startTime;
log.error("Request failed: {}", e.getMessage(), e);
Sentry.captureException(e);
this.cachedResponse = new CachedResponse(
0, // Status code 0 indicates a failure
new Headers.Builder().build(),
elapsedTime,
null,
System.currentTimeMillis() + cacheExpirationMillis,
e.getMessage()
System.currentTimeMillis() + cacheExpirationMillis
);
}
if (method == Method.GET && cacheKey != null) {
cache.put(cacheKey, this.cachedResponse);
if (cacheKey != null) {
ResponseCache.put(cacheKey, this.cachedResponse);
}
return this;
}
private String createCacheKey(Method method, String url, Map<String, String> headers) {
if (method == Method.POST) {
return null; // no cache for post requests
if (method != Method.GET) {
return null; // only GET requests are cached
}
StringBuilder keyBuilder = new StringBuilder(method.name()).append(":").append(url);
@@ -176,12 +155,9 @@ public class WebModule {
private final String url;
private final String version;
private Method method = Method.GET;
private long timeout = 5;
private long cacheTimeout = 5;
private TimeUnit timeUnit = TimeUnit.MINUTES;
private OkHttpClient client;
private long connectTimeout;
private long readTimeout;
private long writeTimeout;
private OkHttpClient client = CLIENT;
private final Map<String, String> headers = new HashMap<>();
private final Map<String, String> params = new HashMap<>();
@@ -242,51 +218,24 @@ public class WebModule {
* @return the current {@code Builder} instance for chaining
*/
public Builder cache(long timeout, TimeUnit timeUnit) {
this.timeout = timeout;
this.cacheTimeout = timeout;
this.timeUnit = timeUnit;
return this;
}
/**
* Sets the connect, read and write timeouts of the HTTP client.
*
* @param timeout the timeout duration
* @param timeUnit the unit of time for the timeout
* @return the current {@code Builder} instance for chaining
*/
public Builder timeout(long timeout, TimeUnit timeUnit) {
this.connectTimeout = timeUnit.toMillis(timeout);
this.readTimeout = timeUnit.toMillis(timeout);
this.writeTimeout = timeUnit.toMillis(timeout);
return this;
}
/**
* Sets the connection timeout for the HTTP client.
*
* @param timeout the timeout duration
* @param timeUnit the unit of time for the timeout
* @return the current {@code Builder} instance for chaining
*/
public Builder connectTimeout(long timeout, TimeUnit timeUnit) {
this.connectTimeout = timeUnit.toMillis(timeout);
return this;
}
/**
* Sets the read timeout for the HTTP client.
*
* @param timeout the timeout duration
* @param timeUnit the unit of time for the timeout
* @return the current {@code Builder} instance for chaining
*/
public Builder readTimeout(long timeout, TimeUnit timeUnit) {
this.readTimeout = timeUnit.toMillis(timeout);
return this;
}
/**
* Sets the write timeout for the HTTP client.
*
* @param timeout the timeout duration
* @param timeUnit the unit of time for the timeout
* @return the current {@code Builder} instance for chaining
*/
public Builder writeTimeout(long timeout, TimeUnit timeUnit) {
this.writeTimeout = timeUnit.toMillis(timeout);
this.client = CLIENT.newBuilder()
.connectTimeout(timeout, timeUnit)
.readTimeout(timeout, timeUnit)
.writeTimeout(timeout, timeUnit)
.build();
return this;
}
@@ -317,30 +266,9 @@ public class WebModule {
* @return a new {@code WebModule} instance
*/
public WebModule build() {
this.client = new OkHttpClient.Builder()
.connectTimeout(connectTimeout, TimeUnit.MILLISECONDS)
.writeTimeout(writeTimeout, TimeUnit.MILLISECONDS)
.readTimeout(readTimeout, TimeUnit.MILLISECONDS)
.build();
this.request = createRequest();
return new WebModule(this);
}
}
private record CachedResponse(int statusCode, Headers headers, long elapsedTime, String body, long expirationTime,
String errorMessage) {
public boolean isExpired() {
return System.currentTimeMillis() > expirationTime;
}
public Optional<String> getBody() {
return Optional.ofNullable(body);
}
public Optional<String> getErrorMessage() {
return Optional.ofNullable(errorMessage);
}
}
}
@@ -1,33 +0,0 @@
package me.bobert.netneutrality.http.server;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.io.OutputStream;
public abstract class AbstractHandler implements HttpHandler {
public abstract void handle(HttpExchange exchange) throws IOException;
public void sendStringResponse(HttpExchange exchange, String str) throws IOException {
exchange.sendResponseHeaders(200, str.length());
OutputStream os = exchange.getResponseBody();
os.write(str.getBytes());
os.close();
}
public void sendJsonResponse(HttpExchange exchange, String str) throws IOException {
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, str.length());
OutputStream os = exchange.getResponseBody();
os.write(str.getBytes());
os.close();
}
}
@@ -1,97 +0,0 @@
package me.bobert.netneutrality.http.server;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.google.gson.Gson;
import com.sun.net.httpserver.HttpExchange;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.db.service.GuildService;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild;
import org.apache.logging.log4j.LogManager;
import org.json.JSONObject;
import java.io.IOException;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import java.util.function.Supplier;
public class ActionHandler extends AbstractHandler {
private final Supplier<JDA> jdaSupplier;
private final GuildServiceProvider guildServices;
private final Runnable shutdownHook;
public ActionHandler(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Runnable shutdownHook) {
this.jdaSupplier = Objects.requireNonNull(jdaSupplier, "jdaSupplier");
this.guildServices = Objects.requireNonNull(guildServices, "guildServices");
this.shutdownHook = Objects.requireNonNull(shutdownHook, "shutdownHook");
}
@Override
public void handle(HttpExchange exchange) throws IOException {
String path = exchange.getRequestURI().getRawPath();
if (path.equals("/actions/shutdown")) {
LogManager.getLogger(ActionHandler.class).info("Request to shutdown received");
shutdownHook.run();
sendStringResponse(exchange, "Shutting down..");
System.exit(0);
return;
}
if (path.equals("/actions/status")) {
TreeMap<String, String> json = new TreeMap<>();
JDA jda = jdaSupplier.get();
json.put("health", String.valueOf(jda.getStatus()));
json.put("ping", String.valueOf(jda.getGatewayPing()));
sendJsonResponse(exchange, new Gson().toJson(json));
return;
}
if (path.startsWith("/actions/settings/current/")) {
JSONObject json = new JSONObject();
String[] args = exchange.getRequestURI().toString().split("/");
String guildId = args[args.length - 1];
Guild guild = jdaSupplier.get().getGuildById(guildId);
if (guild != null) {
GuildService service = guildServices.get(guild);
Map<String, String> settings = service.getGuildSettingService().getSettings();
JSONObject setting = new JSONObject(settings);
json.put("status", "ok");
json.put("settings", setting);
} else {
json.put("status", "error");
}
sendJsonResponse(exchange, json.toString());
return;
}
if (path.startsWith("/actions/settings/reload/")) {
TreeMap<String, String> json = new TreeMap<>();
String[] args = exchange.getRequestURI().toString().split("/");
String guildId = args[args.length - 1];
boolean ok = true; //TODO: add bool
guildServices.get(guildId).getGuildSettingService().reloadSettingsCache();
json.put("status", String.valueOf(ok));
sendJsonResponse(exchange, new Gson().toJson(json));
return;
}
sendStringResponse(exchange, path);
}
}
@@ -0,0 +1,82 @@
package me.bobert.netneutrality.http.server;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import io.javalin.Javalin;
import io.javalin.http.Handler;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.Map;
import java.util.function.Supplier;
/**
* Registers the {@code /actions/*} endpoints on a {@link Javalin} instance.
*/
public final class ActionRoutes {
private static final Logger logger = LogManager.getLogger(ActionRoutes.class);
private static final long SHUTDOWN_DELAY_MILLIS = 500;
private ActionRoutes() {
}
public static void register(
Javalin app,
Supplier<JDA> jdaSupplier,
GuildServiceProvider guildServices,
Runnable shutdownHook
) {
app.get("/actions/status", ctx -> {
JDA jda = GuildRoutes.jda(jdaSupplier);
ctx.json(Map.of(
"health", String.valueOf(jda.getStatus()),
"ping", String.valueOf(jda.getGatewayPing())
));
});
Handler shutdown = ctx -> {
logger.info("Request to shutdown received");
ctx.result("Shutting down..");
scheduleShutdown(shutdownHook);
};
app.post("/actions/shutdown", shutdown);
app.get("/actions/shutdown", shutdown);
app.get("/actions/settings/current/{guildId}", ctx -> {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
Map<String, String> settings = guildServices.get(guild).getGuildSettingService().getSettings();
ctx.json(Map.of("status", "ok", "settings", settings));
});
app.post("/actions/settings/reload/{guildId}", ctx -> {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
guildServices.get(guild).getGuildSettingService().reloadSettingsCache();
ctx.json(Map.of("status", "true"));
});
}
/**
* Runs the shutdown hook off the request thread so the response can be flushed first.
*/
private static void scheduleShutdown(Runnable shutdownHook) {
Thread.ofPlatform().name("web-api-shutdown").start(() -> {
try {
Thread.sleep(SHUTDOWN_DELAY_MILLIS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
shutdownHook.run();
System.exit(0);
});
}
}
@@ -0,0 +1,33 @@
package me.bobert.netneutrality.http.server;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.google.gson.Gson;
import io.javalin.json.JsonMapper;
import org.jetbrains.annotations.NotNull;
import java.lang.reflect.Type;
/**
* Javalin {@link JsonMapper} backed by a shared {@link Gson} instance.
*/
public class GsonJsonMapper implements JsonMapper {
private static final Gson GSON = new Gson();
@NotNull
@Override
public String toJsonString(@NotNull Object obj, @NotNull Type type) {
return GSON.toJson(obj, type);
}
@NotNull
@Override
public <T> T fromJsonString(@NotNull String json, @NotNull Type targetType) {
return GSON.fromJson(json, targetType);
}
}
@@ -1,100 +0,0 @@
package me.bobert.netneutrality.http.server;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.sun.net.httpserver.HttpExchange;
import net.dv8tion.jda.api.JDA;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.IOException;
import java.util.Objects;
import java.util.function.Supplier;
public class GuildHandler extends AbstractHandler {
private final Supplier<JDA> jdaSupplier;
public GuildHandler(Supplier<JDA> jdaSupplier) {
this.jdaSupplier = Objects.requireNonNull(jdaSupplier, "jdaSupplier");
}
@Override
public void handle(HttpExchange exchange) throws IOException {
String path = exchange.getRequestURI().getRawPath();
if (path.equals("/guilds/owner")) {
JSONArray array = new JSONArray();
JDA jda = jdaSupplier.get();
jda.getGuilds().forEach(guild -> {
JSONObject obj = new JSONObject();
obj.put("guild_id", guild.getId());
obj.put("owner_id", guild.getOwnerId());
array.put(obj);
});
sendJsonResponse(exchange, array.toString());
return;
}
if (path.startsWith("/guilds/channels/")) {
String[] args = exchange.getRequestURI().toString().split("/");
String guild = args[args.length - 1];
JSONArray array = new JSONArray();
JDA jda = jdaSupplier.get();
if (jda.getGuildById(guild) == null) {
sendStringResponse(exchange, path);
return;
}
jda.getGuildById(guild).getTextChannels().forEach(channel -> {
JSONObject obj = new JSONObject();
obj.put("channel_id", channel.getId());
obj.put("channel_name", channel.getName());
array.put(obj);
});
sendJsonResponse(exchange, array.toString());
return;
}
if (path.startsWith("/guilds/roles/")) {
String[] args = exchange.getRequestURI().toString().split("/");
String guild = args[args.length - 1];
JSONArray array = new JSONArray();
JDA jda = jdaSupplier.get();
if (jda.getGuildById(guild) == null) {
sendStringResponse(exchange, path);
return;
}
jda.getGuildById(guild).getRoles().forEach(role -> {
JSONObject obj = new JSONObject();
obj.put("channel_id", role.getId());
obj.put("channel_name", role.getName());
array.put(obj);
});
sendJsonResponse(exchange, array.toString());
return;
}
sendStringResponse(exchange, path);
}
}
@@ -0,0 +1,79 @@
package me.bobert.netneutrality.http.server;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import io.javalin.Javalin;
import io.javalin.http.Context;
import io.javalin.http.NotFoundResponse;
import io.javalin.http.ServiceUnavailableResponse;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Supplier;
/**
* Registers the read-only {@code /guilds/*} endpoints on a {@link Javalin} instance.
*/
public final class GuildRoutes {
private GuildRoutes() {
}
public static void register(Javalin app, Supplier<JDA> jdaSupplier) {
app.get("/guilds/owner", ctx -> ctx.json(
jda(jdaSupplier).getGuilds().stream()
.map(guild -> ordered("guild_id", guild.getId(), "owner_id", guild.getOwnerId()))
.toList()
));
app.get("/guilds/channels/{guildId}", ctx -> ctx.json(
guild(jdaSupplier, ctx).getTextChannels().stream()
.map(channel -> ordered("channel_id", channel.getId(), "channel_name", channel.getName()))
.toList()
));
app.get("/guilds/roles/{guildId}", ctx -> ctx.json(
guild(jdaSupplier, ctx).getRoles().stream()
.map(role -> ordered("channel_id", role.getId(), "channel_name", role.getName()))
.toList()
));
}
/**
* Builds a two entry map with a stable key order for JSON serialization.
*/
private static Map<String, String> ordered(String firstKey, String firstValue, String secondKey, String secondValue) {
Map<String, String> map = new LinkedHashMap<>();
map.put(firstKey, firstValue);
map.put(secondKey, secondValue);
return map;
}
/**
* Resolves the JDA client or fails with 503 while it is not ready yet.
*/
static JDA jda(Supplier<JDA> jdaSupplier) {
JDA jda = jdaSupplier.get();
if (jda == null) {
throw new ServiceUnavailableResponse("Discord client is not ready");
}
return jda;
}
/**
* Resolves the {@code guildId} path parameter or fails with 404.
*/
static Guild guild(Supplier<JDA> jdaSupplier, Context ctx) {
Guild guild = jda(jdaSupplier).getGuildById(ctx.pathParam("guildId"));
if (guild == null) {
throw new NotFoundResponse("Unknown guild");
}
return guild;
}
}
@@ -1,22 +0,0 @@
package me.bobert.netneutrality.http.server;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
public class RootHandler extends AbstractHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
String response = "<h1>Server start success if you see this message</h1>";
sendStringResponse(exchange, response);
}
}
@@ -6,20 +6,16 @@ package me.bobert.netneutrality.http.server;
* https://discord.gg/qUa6zNFZsd
*/
import com.sun.net.httpserver.HttpServer;
import io.javalin.Javalin;
import io.javalin.http.HttpResponseException;
import me.bobert.netneutrality.bootstrap.Lifecycle;
import me.bobert.netneutrality.core.thread.BaseThreadFactory;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.JDA;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.IntSupplier;
import java.util.Map;
import java.util.function.Supplier;
/**
@@ -28,13 +24,13 @@ import java.util.function.Supplier;
public class WebApi implements Lifecycle {
private static final Logger logger = LogManager.getLogger(WebApi.class);
private static final String LIVENESS_HTML = "<h1>Server start success if you see this message</h1>";
private final SettingsProvider settings;
private final GuildServiceProvider guildServices;
private final Supplier<JDA> jdaSupplier;
private final Runnable shutdownHook;
private HttpServer server;
private ExecutorService executor;
private Javalin app;
public WebApi(
SettingsProvider settings,
@@ -59,33 +55,36 @@ public class WebApi implements Lifecycle {
int port = Integer.parseInt(settings.get("NETNEUTRALITY_API_PORT", "9000"));
logger.info("Starting web server...");
try {
InetSocketAddress sockAddr = new InetSocketAddress(port);
server = HttpServer.create(sockAddr, 0);
} catch (IOException e) {
logger.error("Failed to create HTTP server", e);
return;
}
app = Javalin.create(config -> {
config.jsonMapper(new GsonJsonMapper());
config.showJavalinBanner = false;
});
server.createContext("/", new RootHandler());
server.createContext("/actions", new ActionHandler(jdaSupplier, guildServices, shutdownHook));
server.createContext("/guilds", new GuildHandler(jdaSupplier));
executor = Executors.newFixedThreadPool(10, new BaseThreadFactory("web-api"));
server.setExecutor(executor);
server.start();
logger.info("Web server started at [{}]", server.getAddress());
app.get("/", ctx -> ctx.html(LIVENESS_HTML));
GuildRoutes.register(app, jdaSupplier);
ActionRoutes.register(app, jdaSupplier, guildServices, shutdownHook);
app.exception(HttpResponseException.class, (e, ctx) ->
ctx.status(e.getStatus()).json(Map.of("status", "error", "message", e.getMessage()))
);
app.exception(Exception.class, (e, ctx) -> {
logger.error("Unhandled error while serving [{}]", ctx.path(), e);
ctx.status(500).json(Map.of("status", "error"));
});
app.error(404, ctx -> ctx.json(Map.of("status", "error", "message", "Not found")));
app.start(port);
logger.info("Web server started on port [{}]", port);
}
@Override
public void stop() {
if (server != null) {
if (app != null) {
logger.info("Stopping web server...");
server.stop(0);
server = null;
}
if (executor != null) {
executor.shutdownNow();
executor = null;
app.stop();
app = null;
}
}
}
@@ -0,0 +1,88 @@
package me.bobert.netneutrality.http;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import io.javalin.Javalin;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class WebModuleTest {
private Javalin server;
private int port;
private final AtomicInteger hits = new AtomicInteger();
@BeforeEach
void setUp() {
server = Javalin.create(config -> config.showJavalinBanner = false);
server.get("/counter", ctx -> ctx.result("{\"hits\":" + hits.incrementAndGet() + "}"));
server.post("/counter", ctx -> ctx.result("{\"hits\":" + hits.incrementAndGet() + "}"));
server.start(0);
port = server.port();
}
@AfterEach
void tearDown() {
server.stop();
}
@Test
void getRequestsAreServedFromTheCache() {
String url = url("cache-hit");
assertEquals(1, new WebModule.Builder(url).get().cache(1, TimeUnit.MINUTES).build().request()
.getAsObject().getInt("hits"));
assertEquals(1, new WebModule.Builder(url).get().cache(1, TimeUnit.MINUTES).build().request()
.getAsObject().getInt("hits"));
assertEquals(1, hits.get());
}
@Test
void expiredEntriesAreRefetched() throws Exception {
String url = url("cache-expiry");
new WebModule.Builder(url).get().cache(1, TimeUnit.MILLISECONDS).build().request();
Thread.sleep(20);
new WebModule.Builder(url).get().cache(1, TimeUnit.MILLISECONDS).build().request();
assertEquals(2, hits.get());
}
@Test
void invalidateCacheForcesANewRequest() {
String url = url("cache-invalidate");
new WebModule.Builder(url).get().cache(1, TimeUnit.MINUTES).build().request();
WebModule.invalidateCache(url);
new WebModule.Builder(url).get().cache(1, TimeUnit.MINUTES).build().request();
assertEquals(2, hits.get());
}
@Test
void postRequestsBypassTheCache() {
String url = url("cache-post");
assertTrue(new WebModule.Builder(url).post(Map.of("a", "b")).build().request().isSuccess());
new WebModule.Builder(url).post(Map.of("a", "b")).build().request();
assertEquals(2, hits.get());
}
private String url(String marker) {
return "http://localhost:" + port + "/counter?marker=" + marker + "-" + System.nanoTime();
}
}
@@ -0,0 +1,172 @@
package me.bobert.netneutrality.http.server;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.db.service.GuildSettingService;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class WebApiTest {
private static final String GUILD_ID = "1234567890";
private WebApi webApi;
private int port;
private GuildSettingService settingService;
private final HttpClient http = HttpClient.newHttpClient();
@BeforeEach
void setUp() throws IOException {
port = freePort();
Guild guild = mock(Guild.class);
when(guild.getId()).thenReturn(GUILD_ID);
when(guild.getOwnerId()).thenReturn("42");
JDA jda = mock(JDA.class);
when(jda.getGuilds()).thenReturn(List.of(guild));
when(jda.getGuildById(GUILD_ID)).thenReturn(guild);
when(jda.getGuildById(any(String.class))).thenAnswer(inv ->
GUILD_ID.equals(inv.getArgument(0)) ? guild : null);
when(jda.getStatus()).thenReturn(JDA.Status.CONNECTED);
when(jda.getGatewayPing()).thenReturn(12L);
settingService = mock(GuildSettingService.class);
when(settingService.getSettings()).thenReturn(Map.of("prefix", "ü!"));
GuildService guildService = mock(GuildService.class);
when(guildService.getGuildSettingService()).thenReturn(settingService);
GuildServiceProvider provider = mock(GuildServiceProvider.class);
when(provider.get(guild)).thenReturn(guildService);
SettingsProvider settings = mock(SettingsProvider.class);
when(settings.get("NETNEUTRALITY_API_PORT", "9000")).thenReturn(String.valueOf(port));
webApi = new WebApi(settings, provider, () -> jda, () -> { });
webApi.start();
}
@AfterEach
void tearDown() {
webApi.stop();
}
@Test
void rootReturnsLivenessHtml() throws Exception {
HttpResponse<String> response = get("/");
assertEquals(200, response.statusCode());
assertTrue(response.body().contains("Server start success"));
}
@Test
void statusReturnsHealthAndPing() throws Exception {
HttpResponse<String> response = get("/actions/status");
assertEquals(200, response.statusCode());
assertTrue(response.body().contains("\"health\":\"CONNECTED\""));
assertTrue(response.body().contains("\"ping\":\"12\""));
}
@Test
void ownerReturnsGuildOwners() throws Exception {
HttpResponse<String> response = get("/guilds/owner");
assertEquals(200, response.statusCode());
assertEquals("[{\"guild_id\":\"" + GUILD_ID + "\",\"owner_id\":\"42\"}]", response.body());
}
@Test
void currentSettingsAreReturnedForKnownGuild() throws Exception {
HttpResponse<String> response = get("/actions/settings/current/" + GUILD_ID);
assertEquals(200, response.statusCode());
assertTrue(response.body().contains("\"status\":\"ok\""));
assertTrue(response.body().contains("ü!"));
}
@Test
void unknownGuildReturnsNotFound() throws Exception {
HttpResponse<String> response = get("/guilds/channels/999");
assertEquals(404, response.statusCode());
assertTrue(response.body().contains("\"status\":\"error\""));
}
@Test
void unknownPathReturnsNotFound() throws Exception {
assertEquals(404, get("/nope").statusCode());
}
@Test
void wrongMethodIsNotRouted() throws Exception {
HttpRequest request = HttpRequest.newBuilder(uri("/guilds/owner"))
.POST(HttpRequest.BodyPublishers.noBody())
.build();
assertEquals(404, http.send(request, HttpResponse.BodyHandlers.ofString()).statusCode());
}
@Test
void reloadRefreshesTheSettingCache() throws Exception {
HttpRequest request = HttpRequest.newBuilder(uri("/actions/settings/reload/" + GUILD_ID))
.POST(HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
assertEquals(200, response.statusCode());
assertTrue(response.body().contains("\"status\":\"true\""));
verify(settingService).reloadSettingsCache();
}
@Test
void stopReleasesThePort() throws Exception {
webApi.stop();
try (ServerSocket socket = new ServerSocket(port)) {
assertEquals(port, socket.getLocalPort());
}
}
private HttpResponse<String> get(String path) throws Exception {
HttpRequest request = HttpRequest.newBuilder(uri(path)).GET().build();
return http.send(request, HttpResponse.BodyHandlers.ofString());
}
private URI uri(String path) {
return URI.create("http://localhost:" + port + path);
}
private static int freePort() throws IOException {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
}