jdaSupplier, Context ctx) {
+ JDA jda = GuildRoutes.jda(jdaSupplier);
+ ctx.json(Map.of(
+ "health", String.valueOf(jda.getStatus()),
+ "ping", String.valueOf(jda.getGatewayPing())
+ ));
+ }
- ctx.json(Map.of("status", "ok", "settings", settings));
- });
-
- routes.post("/actions/settings/reload/{guildId}", ctx -> {
- Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
- guildServices.get(guild.getId()).getGuildSettingService().reloadSettingsCache();
-
- ctx.json(Map.of("status", "true"));
- });
+ @OpenApi(
+ path = "/actions/shutdown",
+ methods = HttpMethod.POST,
+ summary = "Gracefully shut the bot down",
+ tags = {"Actions"},
+ responses = @OpenApiResponse(status = "200")
+ )
+ private static void shutdown(Context ctx, Runnable shutdownHook) {
+ logger.info("Request to shutdown received");
+ ctx.result("Shutting down..");
+ scheduleShutdown(shutdownHook);
}
/**
diff --git a/src/main/java/me/bobert/netneutrality/http/server/ApiKeyFilter.java b/src/main/java/me/bobert/netneutrality/http/server/ApiKeyFilter.java
new file mode 100644
index 0000000..088b7e8
--- /dev/null
+++ b/src/main/java/me/bobert/netneutrality/http/server/ApiKeyFilter.java
@@ -0,0 +1,57 @@
+package me.bobert.netneutrality.http.server;
+
+/*
+ * Copyright (c) anthorob
+ *
+ * https://discord.gg/qUa6zNFZsd
+ */
+
+import io.javalin.http.Context;
+import io.javalin.http.Handler;
+import io.javalin.http.UnauthorizedResponse;
+import me.bobert.netneutrality.utils.SettingsProvider;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Checks the {@code X-API-Key} header against the configured {@code NETNEUTRALITY_API_KEY} value.
+ *
+ * Registered as a {@code before} filter on every route; public paths (liveness check, Swagger UI,
+ * OpenAPI JSON, and the Swagger UI's static webjar assets) are skipped so they stay reachable without a key.
+ */
+public final class ApiKeyFilter implements Handler {
+
+ private static final String HEADER = "X-API-Key";
+
+ private static final List PUBLIC_PREFIXES = List.of("/openapi", "/swagger", "/webjars");
+ private static final List PUBLIC_PATHS = List.of("/");
+
+ private final SettingsProvider settings;
+
+ public ApiKeyFilter(SettingsProvider settings) {
+ this.settings = Objects.requireNonNull(settings, "settings");
+ }
+
+ @Override
+ public void handle(Context ctx) {
+ if (isPublic(ctx.path())) {
+ return;
+ }
+
+ String expected = settings.get("NETNEUTRALITY_API_KEY");
+ String provided = ctx.header(HEADER);
+
+ if (expected == null || expected.isEmpty() || !expected.equals(provided)) {
+ throw new UnauthorizedResponse("Missing or invalid API key");
+ }
+ }
+
+ private static boolean isPublic(String path) {
+ if (PUBLIC_PATHS.contains(path)) {
+ return true;
+ }
+
+ return PUBLIC_PREFIXES.stream().anyMatch(path::startsWith);
+ }
+}
diff --git a/src/main/java/me/bobert/netneutrality/http/server/CacheOrFetch.java b/src/main/java/me/bobert/netneutrality/http/server/CacheOrFetch.java
new file mode 100644
index 0000000..5e02aa1
--- /dev/null
+++ b/src/main/java/me/bobert/netneutrality/http/server/CacheOrFetch.java
@@ -0,0 +1,51 @@
+package me.bobert.netneutrality.http.server;
+
+/*
+ * Copyright (c) anthorob
+ *
+ * https://discord.gg/qUa6zNFZsd
+ */
+
+import io.javalin.http.Context;
+import net.dv8tion.jda.api.requests.RestAction;
+
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+/**
+ * Resolves a JDA entity from the in-memory cache when possible, falling back to a REST call
+ * (resolved asynchronously through Javalin's {@link Context#future}) otherwise.
+ *
+ * JDA only keeps a short-lived, best-effort cache; this helper compensates for cache misses
+ * (evicted entries, not-yet-warmed guilds, etc.) without widening {@code MemberCachePolicy} or
+ * {@code CacheFlag}s.
+ */
+public final class CacheOrFetch {
+
+ private CacheOrFetch() {
+ }
+
+ /**
+ * Resolves a value, returning the cached one synchronously if present, otherwise falling back
+ * to the given REST action and responding once it completes.
+ *
+ * @param ctx the request context
+ * @param cached supplies the cached value, or {@code null} on a cache miss
+ * @param restAction supplies the REST fallback when the cache misses
+ * @param onResolved called with the resolved value (from cache or REST) to build the response
+ * @param the resolved entity type
+ */
+ public static void resolve(Context ctx, Supplier cached, Supplier> restAction, Function onResolved) {
+ T value = cached.get();
+
+ if (value != null) {
+ ctx.json(onResolved.apply(value));
+ return;
+ }
+
+ ctx.future(() -> restAction.get().submit().thenApply(resolved -> {
+ ctx.json(onResolved.apply(resolved));
+ return null;
+ }));
+ }
+}
diff --git a/src/main/java/me/bobert/netneutrality/http/server/GuildRoutes.java b/src/main/java/me/bobert/netneutrality/http/server/GuildRoutes.java
index 5ebf818..92108c6 100644
--- a/src/main/java/me/bobert/netneutrality/http/server/GuildRoutes.java
+++ b/src/main/java/me/bobert/netneutrality/http/server/GuildRoutes.java
@@ -10,10 +10,18 @@ import io.javalin.config.RoutesConfig;
import io.javalin.http.Context;
import io.javalin.http.NotFoundResponse;
import io.javalin.http.ServiceUnavailableResponse;
+import io.javalin.openapi.HttpMethod;
+import io.javalin.openapi.OpenApi;
+import io.javalin.openapi.OpenApiContent;
+import io.javalin.openapi.OpenApiResponse;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild;
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.entities.Role;
+import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
@@ -26,23 +34,282 @@ public final class GuildRoutes {
}
public static void register(RoutesConfig routes, Supplier jdaSupplier) {
- routes.get("/guilds/owner", ctx -> ctx.json(
+ routes.get("/guilds", ctx -> guilds(jdaSupplier, ctx));
+ routes.get("/guilds/owner", ctx -> owner(jdaSupplier, ctx));
+ routes.get("/guilds/owner/{guildId}", ctx -> ownerOf(jdaSupplier, ctx));
+ routes.get("/guilds/channels/{guildId}", ctx -> channels(jdaSupplier, ctx));
+ routes.get("/guilds/roles/{guildId}", ctx -> roles(jdaSupplier, ctx));
+ routes.get("/guilds/members/{guildId}/{userId}", ctx -> member(jdaSupplier, ctx));
+ routes.get("/guilds/members/{guildId}/{userId}/roles", ctx -> memberRoles(jdaSupplier, ctx));
+ routes.get("/guilds/voice-channels/{guildId}", ctx -> voiceChannels(jdaSupplier, ctx));
+ routes.get("/guilds/voice-channels/{guildId}/{channelId}", ctx -> voiceChannel(jdaSupplier, ctx));
+ }
+
+ @OpenApi(
+ path = "/guilds",
+ methods = HttpMethod.GET,
+ summary = "List every guild the bot is currently in",
+ tags = {"Guilds"},
+ responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = Map[].class))
+ )
+ private static void guilds(Supplier jdaSupplier, Context ctx) {
+ ctx.json(
+ jda(jdaSupplier).getGuilds().stream()
+ .map(GuildRoutes::guildSummary)
+ .toList()
+ );
+ }
+
+ /**
+ * Builds the {@code /guilds} entry for a guild, falling back to a REST fetch for the owner id
+ * when it is not present in the cache.
+ */
+ private static Map guildSummary(Guild guild) {
+ Map summary = new LinkedHashMap<>();
+ summary.put("guild_id", guild.getId());
+ summary.put("name", guild.getName());
+ summary.put("icon_url", guild.getIconUrl());
+ summary.put("member_count", guild.getMemberCount());
+ summary.put("owner_id", ownerId(guild));
+ return summary;
+ }
+
+ /**
+ * Resolves the owner id from cache when available, otherwise via a REST fallback.
+ */
+ private static String ownerId(Guild guild) {
+ Member owner = guild.getOwner();
+ if (owner != null) {
+ return owner.getId();
+ }
+ return guild.getOwnerId();
+ }
+
+ @OpenApi(
+ path = "/guilds/owner",
+ methods = HttpMethod.GET,
+ summary = "List every bot guild with its owner id",
+ tags = {"Guilds"},
+ responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = Map[].class))
+ )
+ private static void owner(Supplier jdaSupplier, Context ctx) {
+ ctx.json(
jda(jdaSupplier).getGuilds().stream()
.map(guild -> ordered("guild_id", guild.getId(), "owner_id", guild.getOwnerId()))
.toList()
- ));
+ );
+ }
- routes.get("/guilds/channels/{guildId}", ctx -> ctx.json(
+ @OpenApi(
+ path = "/guilds/owner/{guildId}",
+ methods = HttpMethod.GET,
+ summary = "Get a guild's owner, falling back to a REST fetch on a member cache miss",
+ tags = {"Guilds"},
+ pathParams = @io.javalin.openapi.OpenApiParam(name = "guildId", description = "The Discord guild id"),
+ responses = {
+ @OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class)),
+ @OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class))
+ }
+ )
+ private static void ownerOf(Supplier jdaSupplier, Context ctx) {
+ Guild guild = guild(jdaSupplier, ctx);
+
+ CacheOrFetch.resolve(
+ ctx,
+ guild::getOwner,
+ guild::retrieveOwner,
+ owner -> ordered("guild_id", guild.getId(), "owner_id", owner.getId())
+ );
+ }
+
+ @OpenApi(
+ path = "/guilds/channels/{guildId}",
+ methods = HttpMethod.GET,
+ summary = "List the text channels of a guild",
+ tags = {"Guilds"},
+ pathParams = @io.javalin.openapi.OpenApiParam(name = "guildId", description = "The Discord guild id"),
+ responses = {
+ @OpenApiResponse(status = "200", content = @OpenApiContent(from = Map[].class)),
+ @OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class))
+ }
+ )
+ private static void channels(Supplier jdaSupplier, Context ctx) {
+ ctx.json(
guild(jdaSupplier, ctx).getTextChannels().stream()
.map(channel -> ordered("channel_id", channel.getId(), "channel_name", channel.getName()))
.toList()
- ));
+ );
+ }
- routes.get("/guilds/roles/{guildId}", ctx -> ctx.json(
+ @OpenApi(
+ path = "/guilds/roles/{guildId}",
+ methods = HttpMethod.GET,
+ summary = "List the roles of a guild",
+ tags = {"Guilds"},
+ pathParams = @io.javalin.openapi.OpenApiParam(name = "guildId", description = "The Discord guild id"),
+ responses = {
+ @OpenApiResponse(status = "200", content = @OpenApiContent(from = Map[].class)),
+ @OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class))
+ }
+ )
+ private static void roles(Supplier jdaSupplier, Context ctx) {
+ ctx.json(
guild(jdaSupplier, ctx).getRoles().stream()
.map(role -> ordered("channel_id", role.getId(), "channel_name", role.getName()))
.toList()
- ));
+ );
+ }
+
+ @OpenApi(
+ path = "/guilds/members/{guildId}/{userId}",
+ methods = HttpMethod.GET,
+ summary = "Get a guild member, falling back to a REST fetch on a member cache miss",
+ tags = {"Guilds"},
+ pathParams = {
+ @io.javalin.openapi.OpenApiParam(name = "guildId", description = "The Discord guild id"),
+ @io.javalin.openapi.OpenApiParam(name = "userId", description = "The Discord user id")
+ },
+ responses = {
+ @OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class)),
+ @OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class))
+ }
+ )
+ private static void member(Supplier jdaSupplier, Context ctx) {
+ Guild guild = guild(jdaSupplier, ctx);
+ String userId = ctx.pathParam("userId");
+
+ CacheOrFetch.resolve(
+ ctx,
+ () -> guild.getMemberById(userId),
+ () -> guild.retrieveMemberById(userId),
+ GuildRoutes::memberSummary
+ );
+ }
+
+ @OpenApi(
+ path = "/guilds/members/{guildId}/{userId}/roles",
+ methods = HttpMethod.GET,
+ summary = "List the roles of a guild member, falling back to a REST fetch on a member cache miss",
+ tags = {"Guilds"},
+ pathParams = {
+ @io.javalin.openapi.OpenApiParam(name = "guildId", description = "The Discord guild id"),
+ @io.javalin.openapi.OpenApiParam(name = "userId", description = "The Discord user id")
+ },
+ responses = {
+ @OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class)),
+ @OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class))
+ }
+ )
+ private static void memberRoles(Supplier jdaSupplier, Context ctx) {
+ Guild guild = guild(jdaSupplier, ctx);
+ String userId = ctx.pathParam("userId");
+
+ CacheOrFetch.resolve(
+ ctx,
+ () -> guild.getMemberById(userId),
+ () -> guild.retrieveMemberById(userId),
+ member -> memberRolesSummary(guild, member)
+ );
+ }
+
+ /**
+ * Builds the {@code /guilds/members/{guildId}/{userId}} response for a resolved member.
+ */
+ private static Map memberSummary(Member member) {
+ Map summary = new LinkedHashMap<>();
+ summary.put("user_id", member.getId());
+ summary.put("username", member.getUser().getName());
+ summary.put("nickname", member.getNickname());
+ summary.put("role_ids", member.getRoles().stream().map(Role::getId).toList());
+ return summary;
+ }
+
+ /**
+ * Builds the {@code /guilds/members/{guildId}/{userId}/roles} response for a resolved member.
+ */
+ private static Map memberRolesSummary(Guild guild, Member member) {
+ Map summary = new LinkedHashMap<>();
+ summary.put("guild_id", guild.getId());
+ summary.put("user_id", member.getId());
+ List