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