From 01bfe22d2277015a8ce3b66acfaccd65b30ccbbc Mon Sep 17 00:00:00 2001 From: anthorob Date: Wed, 23 Sep 2026 12:07:38 -0400 Subject: [PATCH] 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. --- .junie/plans/javalin-webapi-refactor.md | 188 ++++++++++++++++++ pom.xml | 13 ++ .../netneutrality/http/ResponseCache.java | 65 ++++++ .../bobert/netneutrality/http/WebModule.java | 132 +++--------- .../http/server/AbstractHandler.java | 33 --- .../http/server/ActionHandler.java | 97 --------- .../http/server/ActionRoutes.java | 82 ++++++++ .../http/server/GsonJsonMapper.java | 33 +++ .../http/server/GuildHandler.java | 100 ---------- .../http/server/GuildRoutes.java | 79 ++++++++ .../http/server/RootHandler.java | 22 -- .../netneutrality/http/server/WebApi.java | 59 +++--- .../netneutrality/http/WebModuleTest.java | 88 ++++++++ .../netneutrality/http/server/WebApiTest.java | 172 ++++++++++++++++ 14 files changed, 779 insertions(+), 384 deletions(-) create mode 100644 .junie/plans/javalin-webapi-refactor.md create mode 100644 src/main/java/me/bobert/netneutrality/http/ResponseCache.java delete mode 100644 src/main/java/me/bobert/netneutrality/http/server/AbstractHandler.java delete mode 100644 src/main/java/me/bobert/netneutrality/http/server/ActionHandler.java create mode 100644 src/main/java/me/bobert/netneutrality/http/server/ActionRoutes.java create mode 100644 src/main/java/me/bobert/netneutrality/http/server/GsonJsonMapper.java delete mode 100644 src/main/java/me/bobert/netneutrality/http/server/GuildHandler.java create mode 100644 src/main/java/me/bobert/netneutrality/http/server/GuildRoutes.java delete mode 100644 src/main/java/me/bobert/netneutrality/http/server/RootHandler.java create mode 100644 src/test/java/me/bobert/netneutrality/http/WebModuleTest.java create mode 100644 src/test/java/me/bobert/netneutrality/http/server/WebApiTest.java diff --git a/.junie/plans/javalin-webapi-refactor.md b/.junie/plans/javalin-webapi-refactor.md new file mode 100644 index 0000000..18849e4 --- /dev/null +++ b/.junie/plans/javalin-webapi-refactor.md @@ -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>` 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` 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)`. +- Map `GET /guilds/owner`, `GET /guilds/channels/{guildId}`, `GET /guilds/roles/{guildId}` using path params instead of `split("/")`. +- Return `List` 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, 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. \ No newline at end of file diff --git a/pom.xml b/pom.xml index e325414..1270255 100644 --- a/pom.xml +++ b/pom.xml @@ -183,6 +183,12 @@ 5.3.2 + + io.javalin + javalin + 6.7.0 + + org.hibernate.orm hibernate-core @@ -219,6 +225,13 @@ org.gitlab4j gitlab4j-api 6.2.0 + + + + jakarta.servlet + jakarta.servlet-api + + diff --git a/src/main/java/me/bobert/netneutrality/http/ResponseCache.java b/src/main/java/me/bobert/netneutrality/http/ResponseCache.java new file mode 100644 index 0000000..80cb0b0 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/http/ResponseCache.java @@ -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 entries = Collections.synchronizedMap( + new LinkedHashMap<>(100, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry 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 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 getBody() { + return Optional.ofNullable(body); + } + } +} diff --git a/src/main/java/me/bobert/netneutrality/http/WebModule.java b/src/main/java/me/bobert/netneutrality/http/WebModule.java index 35e1fef..6505694 100644 --- a/src/main/java/me/bobert/netneutrality/http/WebModule.java +++ b/src/main/java/me/bobert/netneutrality/http/WebModule.java @@ -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 cache = Collections.synchronizedMap( - new LinkedHashMap<>(100, 0.75f, true) { - @Override - protected boolean removeEldestEntry(Map.Entry 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 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 headers = new HashMap<>(); private final Map 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 getBody() { - return Optional.ofNullable(body); - } - - public Optional getErrorMessage() { - return Optional.ofNullable(errorMessage); - } - } } diff --git a/src/main/java/me/bobert/netneutrality/http/server/AbstractHandler.java b/src/main/java/me/bobert/netneutrality/http/server/AbstractHandler.java deleted file mode 100644 index 21f9300..0000000 --- a/src/main/java/me/bobert/netneutrality/http/server/AbstractHandler.java +++ /dev/null @@ -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(); - } -} diff --git a/src/main/java/me/bobert/netneutrality/http/server/ActionHandler.java b/src/main/java/me/bobert/netneutrality/http/server/ActionHandler.java deleted file mode 100644 index d4689b1..0000000 --- a/src/main/java/me/bobert/netneutrality/http/server/ActionHandler.java +++ /dev/null @@ -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 jdaSupplier; - private final GuildServiceProvider guildServices; - private final Runnable shutdownHook; - - public ActionHandler(Supplier 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 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 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 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); - - } -} diff --git a/src/main/java/me/bobert/netneutrality/http/server/ActionRoutes.java b/src/main/java/me/bobert/netneutrality/http/server/ActionRoutes.java new file mode 100644 index 0000000..48a6f1a --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/http/server/ActionRoutes.java @@ -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 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 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); + }); + } +} diff --git a/src/main/java/me/bobert/netneutrality/http/server/GsonJsonMapper.java b/src/main/java/me/bobert/netneutrality/http/server/GsonJsonMapper.java new file mode 100644 index 0000000..cbfa38e --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/http/server/GsonJsonMapper.java @@ -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 fromJsonString(@NotNull String json, @NotNull Type targetType) { + return GSON.fromJson(json, targetType); + } +} diff --git a/src/main/java/me/bobert/netneutrality/http/server/GuildHandler.java b/src/main/java/me/bobert/netneutrality/http/server/GuildHandler.java deleted file mode 100644 index d8d9e09..0000000 --- a/src/main/java/me/bobert/netneutrality/http/server/GuildHandler.java +++ /dev/null @@ -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 jdaSupplier; - - public GuildHandler(Supplier 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); - - } -} diff --git a/src/main/java/me/bobert/netneutrality/http/server/GuildRoutes.java b/src/main/java/me/bobert/netneutrality/http/server/GuildRoutes.java new file mode 100644 index 0000000..1b392b8 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/http/server/GuildRoutes.java @@ -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 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 ordered(String firstKey, String firstValue, String secondKey, String secondValue) { + Map 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 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 jdaSupplier, Context ctx) { + Guild guild = jda(jdaSupplier).getGuildById(ctx.pathParam("guildId")); + if (guild == null) { + throw new NotFoundResponse("Unknown guild"); + } + return guild; + } +} diff --git a/src/main/java/me/bobert/netneutrality/http/server/RootHandler.java b/src/main/java/me/bobert/netneutrality/http/server/RootHandler.java deleted file mode 100644 index 6d8a8d1..0000000 --- a/src/main/java/me/bobert/netneutrality/http/server/RootHandler.java +++ /dev/null @@ -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 = "

Server start success if you see this message

"; - - sendStringResponse(exchange, response); - } -} diff --git a/src/main/java/me/bobert/netneutrality/http/server/WebApi.java b/src/main/java/me/bobert/netneutrality/http/server/WebApi.java index 8db4dd3..88d0f74 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/WebApi.java +++ b/src/main/java/me/bobert/netneutrality/http/server/WebApi.java @@ -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 = "

Server start success if you see this message

"; private final SettingsProvider settings; private final GuildServiceProvider guildServices; private final Supplier 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; } } } diff --git a/src/test/java/me/bobert/netneutrality/http/WebModuleTest.java b/src/test/java/me/bobert/netneutrality/http/WebModuleTest.java new file mode 100644 index 0000000..0b0685f --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/http/WebModuleTest.java @@ -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(); + } +} diff --git a/src/test/java/me/bobert/netneutrality/http/server/WebApiTest.java b/src/test/java/me/bobert/netneutrality/http/server/WebApiTest.java new file mode 100644 index 0000000..43b8421 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/http/server/WebApiTest.java @@ -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 response = get("/"); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("Server start success")); + } + + @Test + void statusReturnsHealthAndPing() throws Exception { + HttpResponse 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 response = get("/guilds/owner"); + + assertEquals(200, response.statusCode()); + assertEquals("[{\"guild_id\":\"" + GUILD_ID + "\",\"owner_id\":\"42\"}]", response.body()); + } + + @Test + void currentSettingsAreReturnedForKnownGuild() throws Exception { + HttpResponse 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 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 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 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(); + } + } +}