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:
1 parent
5f6265db01
commit
01bfe22d22
14 files changed
+779
-384
No files matched your search
@@ -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.
|
||||||
@@ -183,6 +183,12 @@
|
|||||||
<version>5.3.2</version>
|
<version>5.3.2</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.javalin</groupId>
|
||||||
|
<artifactId>javalin</artifactId>
|
||||||
|
<version>6.7.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.hibernate.orm</groupId>
|
<groupId>org.hibernate.orm</groupId>
|
||||||
<artifactId>hibernate-core</artifactId>
|
<artifactId>hibernate-core</artifactId>
|
||||||
@@ -219,6 +225,13 @@
|
|||||||
<groupId>org.gitlab4j</groupId>
|
<groupId>org.gitlab4j</groupId>
|
||||||
<artifactId>gitlab4j-api</artifactId>
|
<artifactId>gitlab4j-api</artifactId>
|
||||||
<version>6.2.0</version>
|
<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>
|
||||||
|
|
||||||
<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 com.google.gson.Gson;
|
||||||
import io.sentry.Breadcrumb;
|
import io.sentry.Breadcrumb;
|
||||||
import io.sentry.Sentry;
|
import io.sentry.Sentry;
|
||||||
import okhttp3.Headers;
|
import me.bobert.netneutrality.http.ResponseCache.CachedResponse;
|
||||||
import okhttp3.MediaType;
|
import okhttp3.MediaType;
|
||||||
import okhttp3.OkHttpClient;
|
import okhttp3.OkHttpClient;
|
||||||
import okhttp3.Request;
|
import okhttp3.Request;
|
||||||
@@ -21,11 +21,8 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
public class WebModule {
|
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 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 volatile String defaultVersion = "0.0.0";
|
||||||
private static final Logger log = LoggerFactory.getLogger(WebModule.class);
|
private static final Logger log = LoggerFactory.getLogger(WebModule.class);
|
||||||
|
|
||||||
public static void setDefaultVersion(String version) {
|
public static void setDefaultVersion(String version) {
|
||||||
defaultVersion = 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 Method method;
|
||||||
private final String url;
|
private final String url;
|
||||||
@@ -62,7 +52,7 @@ public class WebModule {
|
|||||||
this.url = builder.url;
|
this.url = builder.url;
|
||||||
this.client = builder.client;
|
this.client = builder.client;
|
||||||
this.headers = builder.headers;
|
this.headers = builder.headers;
|
||||||
this.cacheExpirationMillis = builder.timeUnit.toMillis(builder.timeout);
|
this.cacheExpirationMillis = builder.timeUnit.toMillis(builder.cacheTimeout);
|
||||||
this.request = builder.request;
|
this.request = builder.request;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +64,7 @@ public class WebModule {
|
|||||||
* @param url the URL of the cached response to invalidate
|
* @param url the URL of the cached response to invalidate
|
||||||
*/
|
*/
|
||||||
public static void invalidateCache(String url) {
|
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.
|
* POST requests do not support caching.
|
||||||
*
|
*
|
||||||
* @return the {@code WebModule} instance, allowing further interaction with the response
|
* @return the {@code WebModule} instance, allowing further interaction with the response
|
||||||
* @throws RuntimeException if the request fails
|
|
||||||
*/
|
*/
|
||||||
public WebModule request() {
|
public WebModule request() {
|
||||||
String cacheKey = createCacheKey(method, url, headers);
|
String cacheKey = createCacheKey(method, url, headers);
|
||||||
|
|
||||||
if (method == Method.GET && cacheKey != null) {
|
if (cacheKey != null) {
|
||||||
CachedResponse cachedResponse = cache.get(cacheKey);
|
CachedResponse cached = ResponseCache.get(cacheKey).orElse(null);
|
||||||
|
|
||||||
if (cachedResponse != null && !cachedResponse.isExpired()) {
|
if (cached != null) {
|
||||||
log.debug("Retrieving cached response: {}", cacheKey);
|
log.debug("Retrieving cached response: {}", cacheKey);
|
||||||
this.cachedResponse = cachedResponse;
|
this.cachedResponse = cached;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
long startTime = System.currentTimeMillis();
|
|
||||||
try (Response response = client.newCall(request).execute()) {
|
try (Response response = client.newCall(request).execute()) {
|
||||||
long elapsedTime = System.currentTimeMillis() - startTime;
|
|
||||||
Sentry.addBreadcrumb(Breadcrumb.http(url, method.name(), response.code()));
|
Sentry.addBreadcrumb(Breadcrumb.http(url, method.name(), response.code()));
|
||||||
String body = response.body().string();
|
|
||||||
|
|
||||||
this.cachedResponse = new CachedResponse(
|
this.cachedResponse = new CachedResponse(
|
||||||
response.code(),
|
response.code(),
|
||||||
response.headers(),
|
response.body().string(),
|
||||||
elapsedTime,
|
System.currentTimeMillis() + cacheExpirationMillis
|
||||||
body,
|
|
||||||
System.currentTimeMillis() + cacheExpirationMillis,
|
|
||||||
null
|
|
||||||
);
|
);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
long elapsedTime = System.currentTimeMillis() - startTime;
|
|
||||||
log.error("Request failed: {}", e.getMessage(), e);
|
log.error("Request failed: {}", e.getMessage(), e);
|
||||||
Sentry.captureException(e);
|
Sentry.captureException(e);
|
||||||
|
|
||||||
this.cachedResponse = new CachedResponse(
|
this.cachedResponse = new CachedResponse(
|
||||||
0, // Status code 0 indicates a failure
|
0, // Status code 0 indicates a failure
|
||||||
new Headers.Builder().build(),
|
|
||||||
elapsedTime,
|
|
||||||
null,
|
null,
|
||||||
System.currentTimeMillis() + cacheExpirationMillis,
|
System.currentTimeMillis() + cacheExpirationMillis
|
||||||
e.getMessage()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method == Method.GET && cacheKey != null) {
|
if (cacheKey != null) {
|
||||||
cache.put(cacheKey, this.cachedResponse);
|
ResponseCache.put(cacheKey, this.cachedResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String createCacheKey(Method method, String url, Map<String, String> headers) {
|
private String createCacheKey(Method method, String url, Map<String, String> headers) {
|
||||||
if (method == Method.POST) {
|
if (method != Method.GET) {
|
||||||
return null; // no cache for post requests
|
return null; // only GET requests are cached
|
||||||
}
|
}
|
||||||
|
|
||||||
StringBuilder keyBuilder = new StringBuilder(method.name()).append(":").append(url);
|
StringBuilder keyBuilder = new StringBuilder(method.name()).append(":").append(url);
|
||||||
@@ -176,12 +155,9 @@ public class WebModule {
|
|||||||
private final String url;
|
private final String url;
|
||||||
private final String version;
|
private final String version;
|
||||||
private Method method = Method.GET;
|
private Method method = Method.GET;
|
||||||
private long timeout = 5;
|
private long cacheTimeout = 5;
|
||||||
private TimeUnit timeUnit = TimeUnit.MINUTES;
|
private TimeUnit timeUnit = TimeUnit.MINUTES;
|
||||||
private OkHttpClient client;
|
private OkHttpClient client = CLIENT;
|
||||||
private long connectTimeout;
|
|
||||||
private long readTimeout;
|
|
||||||
private long writeTimeout;
|
|
||||||
private final Map<String, String> headers = new HashMap<>();
|
private final Map<String, String> headers = new HashMap<>();
|
||||||
private final Map<String, String> params = 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
|
* @return the current {@code Builder} instance for chaining
|
||||||
*/
|
*/
|
||||||
public Builder cache(long timeout, TimeUnit timeUnit) {
|
public Builder cache(long timeout, TimeUnit timeUnit) {
|
||||||
this.timeout = timeout;
|
this.cacheTimeout = timeout;
|
||||||
this.timeUnit = timeUnit;
|
this.timeUnit = timeUnit;
|
||||||
return this;
|
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) {
|
public Builder timeout(long timeout, TimeUnit timeUnit) {
|
||||||
this.connectTimeout = timeUnit.toMillis(timeout);
|
this.client = CLIENT.newBuilder()
|
||||||
this.readTimeout = timeUnit.toMillis(timeout);
|
.connectTimeout(timeout, timeUnit)
|
||||||
this.writeTimeout = timeUnit.toMillis(timeout);
|
.readTimeout(timeout, timeUnit)
|
||||||
return this;
|
.writeTimeout(timeout, timeUnit)
|
||||||
}
|
.build();
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,30 +266,9 @@ public class WebModule {
|
|||||||
* @return a new {@code WebModule} instance
|
* @return a new {@code WebModule} instance
|
||||||
*/
|
*/
|
||||||
public WebModule build() {
|
public WebModule build() {
|
||||||
this.client = new OkHttpClient.Builder()
|
|
||||||
.connectTimeout(connectTimeout, TimeUnit.MILLISECONDS)
|
|
||||||
.writeTimeout(writeTimeout, TimeUnit.MILLISECONDS)
|
|
||||||
.readTimeout(readTimeout, TimeUnit.MILLISECONDS)
|
|
||||||
.build();
|
|
||||||
this.request = createRequest();
|
this.request = createRequest();
|
||||||
|
|
||||||
return new WebModule(this);
|
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
|
* 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.bootstrap.Lifecycle;
|
||||||
import me.bobert.netneutrality.core.thread.BaseThreadFactory;
|
|
||||||
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
||||||
import me.bobert.netneutrality.utils.SettingsProvider;
|
import me.bobert.netneutrality.utils.SettingsProvider;
|
||||||
import net.dv8tion.jda.api.JDA;
|
import net.dv8tion.jda.api.JDA;
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.util.Map;
|
||||||
import java.net.InetSocketAddress;
|
|
||||||
import java.util.concurrent.ExecutorService;
|
|
||||||
import java.util.concurrent.Executors;
|
|
||||||
import java.util.function.IntSupplier;
|
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,13 +24,13 @@ import java.util.function.Supplier;
|
|||||||
public class WebApi implements Lifecycle {
|
public class WebApi implements Lifecycle {
|
||||||
|
|
||||||
private static final Logger logger = LogManager.getLogger(WebApi.class);
|
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 SettingsProvider settings;
|
||||||
private final GuildServiceProvider guildServices;
|
private final GuildServiceProvider guildServices;
|
||||||
private final Supplier<JDA> jdaSupplier;
|
private final Supplier<JDA> jdaSupplier;
|
||||||
private final Runnable shutdownHook;
|
private final Runnable shutdownHook;
|
||||||
private HttpServer server;
|
private Javalin app;
|
||||||
private ExecutorService executor;
|
|
||||||
|
|
||||||
public WebApi(
|
public WebApi(
|
||||||
SettingsProvider settings,
|
SettingsProvider settings,
|
||||||
@@ -59,33 +55,36 @@ public class WebApi implements Lifecycle {
|
|||||||
int port = Integer.parseInt(settings.get("NETNEUTRALITY_API_PORT", "9000"));
|
int port = Integer.parseInt(settings.get("NETNEUTRALITY_API_PORT", "9000"));
|
||||||
logger.info("Starting web server...");
|
logger.info("Starting web server...");
|
||||||
|
|
||||||
try {
|
app = Javalin.create(config -> {
|
||||||
InetSocketAddress sockAddr = new InetSocketAddress(port);
|
config.jsonMapper(new GsonJsonMapper());
|
||||||
server = HttpServer.create(sockAddr, 0);
|
config.showJavalinBanner = false;
|
||||||
} catch (IOException e) {
|
});
|
||||||
logger.error("Failed to create HTTP server", e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
server.createContext("/", new RootHandler());
|
app.get("/", ctx -> ctx.html(LIVENESS_HTML));
|
||||||
server.createContext("/actions", new ActionHandler(jdaSupplier, guildServices, shutdownHook));
|
GuildRoutes.register(app, jdaSupplier);
|
||||||
server.createContext("/guilds", new GuildHandler(jdaSupplier));
|
ActionRoutes.register(app, jdaSupplier, guildServices, shutdownHook);
|
||||||
executor = Executors.newFixedThreadPool(10, new BaseThreadFactory("web-api"));
|
|
||||||
server.setExecutor(executor);
|
app.exception(HttpResponseException.class, (e, ctx) ->
|
||||||
server.start();
|
ctx.status(e.getStatus()).json(Map.of("status", "error", "message", e.getMessage()))
|
||||||
logger.info("Web server started at [{}]", server.getAddress());
|
);
|
||||||
|
|
||||||
|
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
|
@Override
|
||||||
public void stop() {
|
public void stop() {
|
||||||
if (server != null) {
|
if (app != null) {
|
||||||
logger.info("Stopping web server...");
|
logger.info("Stopping web server...");
|
||||||
server.stop(0);
|
app.stop();
|
||||||
server = null;
|
app = null;
|
||||||
}
|
|
||||||
if (executor != null) {
|
|
||||||
executor.shutdownNow();
|
|
||||||
executor = 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user