Migrate HTTP server to Javalin and remove legacy handlers

Replaced the existing HttpServer implementation and associated handler classes (`AbstractHandler`, `ActionHandler`, `GuildHandler`, `RootHandler`) with a Javalin-based routing system. Improved modularity by introducing `ActionRoutes` and `GuildRoutes` for route definitions, and centralized JSON mapping using `GsonJsonMapper`. Simplified caching via the new `ResponseCache` utility, updated `WebModule` for cleaner timeout handling, and extended test coverage for API endpoints.
This commit is contained in:
anthorob committed 2026-09-23 12:07:38 -04:00
1 parent 5f6265db01
commit 01bfe22d22
14 files changed
+779 -384

No files matched your search

@@ -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();
}
}
}