From a8b353ea73df746ae200c2f92f45e18efd5e8f6f Mon Sep 17 00:00:00 2001 From: anthorob Date: Tue, 29 Sep 2026 10:32:28 -0400 Subject: [PATCH] Add OpenAPI-based API documentation and new endpoints Integrated OpenAPI and Swagger plugins for comprehensive API documentation. Added new routes, including settings management (`/settings/*`), guild metadata endpoints (`/guilds/*`), and detailed action status handling. Updated database schema to support guild metadata persistence, revised command subcommand handling for better modularity, and enhanced API key-based authentication for all routes. --- .env.example | 1 + docs/web-api.md | 3 +- pom.xml | 26 ++ .../me/bobert/netneutrality/db/GSettings.java | 36 ++- .../netneutrality/db/entities/OGuild.java | 22 ++ .../db/service/GuildService.java | 19 ++ .../netneutrality/hooks/EventsHandler.java | 14 + .../http/server/ActionRoutes.java | 61 ++-- .../http/server/ApiKeyFilter.java | 57 ++++ .../http/server/CacheOrFetch.java | 51 ++++ .../http/server/GuildRoutes.java | 279 +++++++++++++++++- .../http/server/SettingsRoutes.java | 138 +++++++++ .../http/server/StatsRoutes.java | 83 ++++++ .../netneutrality/http/server/WebApi.java | 17 +- .../db/migration/V3__guild_metadata.sql | 3 + .../netneutrality/db/SessionManagerTest.java | 2 +- .../db/service/GuildServiceTest.java | 22 ++ .../http/server/CacheOrFetchTest.java | 54 ++++ .../netneutrality/http/server/WebApiTest.java | 108 ++++++- 19 files changed, 947 insertions(+), 49 deletions(-) create mode 100644 src/main/java/me/bobert/netneutrality/http/server/ApiKeyFilter.java create mode 100644 src/main/java/me/bobert/netneutrality/http/server/CacheOrFetch.java create mode 100644 src/main/java/me/bobert/netneutrality/http/server/SettingsRoutes.java create mode 100644 src/main/java/me/bobert/netneutrality/http/server/StatsRoutes.java create mode 100644 src/main/resources/db/migration/V3__guild_metadata.sql create mode 100644 src/test/java/me/bobert/netneutrality/http/server/CacheOrFetchTest.java diff --git a/.env.example b/.env.example index 054b8e8..9cde698 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ NETNEUTRALITY_BOT_PREFIX=$ NETNEUTRALITY_BOT_OWNER= NETNEUTRALITY_BOT_INVITE= NETNEUTRALITY_API_PORT=9000 +NETNEUTRALITY_API_KEY= MYSQL_HOST=127.0.0.1 MYSQL_PORT=3306 diff --git a/docs/web-api.md b/docs/web-api.md index 24bd5c0..76518da 100644 --- a/docs/web-api.md +++ b/docs/web-api.md @@ -33,7 +33,8 @@ a path prefix. |---|---|---| | `RootHandler` | `/` | Simple HTML "OK" landing response. | | `GuildHandler` | `/guilds` | `/guilds/owner` — guild ids + owner ids (JSON); `/guilds/channels/{guildId}` — text channels (JSON); `/guilds/roles/{guildId}` — roles (JSON). | -| `ActionHandler` | `/actions` | `/actions/status` — JDA health + gateway ping; `/actions/shutdown` — graceful shutdown; `/actions/settings/current/{guildId}` — current guild settings; `/actions/settings/reload/{guildId}` — invalidate & reload the settings cache. | +| `ActionHandler` | `/actions` | `/actions/status` — JDA health + gateway ping; `/actions/shutdown` — graceful shutdown. | +| `SettingsHandler` | `/settings` | `/settings/catalog` — every known setting with its default value and description; `/settings/current/{guildId}` — current guild settings; `/settings/reload/{guildId}` — invalidate & reload the settings cache. | The action/guild handlers read live state from the [`JDA`](architecture.md) instance and from the [persistence services](persistence.md#service-layer) (e.g. `GuildSettingService` for the settings diff --git a/pom.xml b/pom.xml index eb7c066..84c1778 100644 --- a/pom.xml +++ b/pom.xml @@ -43,6 +43,11 @@ hibernate-processor 7.1.9.Final + + io.javalin.community.openapi + openapi-annotation-processor + 7.2.3 + @@ -185,6 +190,27 @@ 7.2.3 + + io.javalin.community.openapi + openapi-specification + 7.2.3 + + + io.javalin.community.openapi + javalin-openapi-plugin + 7.2.3 + + + io.javalin.community.openapi + openapi-generator + 7.2.3 + + + io.javalin.community.openapi + javalin-swagger-plugin + 7.2.3 + + org.hibernate.orm hibernate-core diff --git a/src/main/java/me/bobert/netneutrality/db/GSettings.java b/src/main/java/me/bobert/netneutrality/db/GSettings.java index fead52b..32b0776 100644 --- a/src/main/java/me/bobert/netneutrality/db/GSettings.java +++ b/src/main/java/me/bobert/netneutrality/db/GSettings.java @@ -14,21 +14,41 @@ package me.bobert.netneutrality.db; * column and must never change for an existing setting. */ public enum GSettings { - /** Id of the role required to use music commands; empty means everyone may use them. */ - MUSIC_ROLE("MUSIC_ROLE", ""), + /** The role required to use music commands; empty means everyone may use them. */ + MUSIC_ROLE("MUSIC_ROLE", "", "The role required to use music commands; empty means everyone may use them"), /** Id of the only text channel music commands may be used in; empty means any channel. */ - MUSIC_CHANNEL("MUSIC_CHANNEL", ""), + MUSIC_CHANNEL("MUSIC_CHANNEL", "", "Id of the only text channel music commands may be used in; empty means any channel"), /** Default playback volume (0-100) applied when a music player is created. */ - MUSIC_VOLUME("MUSIC_VOLUME", "30"), + MUSIC_VOLUME("MUSIC_VOLUME", "30", "Default playback volume (0-100) applied when a music player is created"), /** Playlist size above which the bot asks for confirmation before queueing every track. */ - MUSIC_MAXPLAYLISTSIZE("MUSIC_MAXPLAYLISTSIZE", "20"); + MUSIC_MAXPLAYLISTSIZE("MUSIC_MAXPLAYLISTSIZE", "20", "Playlist size above which the bot asks for confirmation before queueing every track"), + /** Role that may skip/stop/adjust volume without needing to be the current DJ; empty means the built-in DJ rule only. */ + MUSIC_DJ_ROLE("MUSIC_DJ_ROLE", "", "Role that may control music playback without being the current DJ; empty means the built-in DJ rule only"), + /** Percentage (0-100) of connected listeners required to approve a vote-skip. */ + MUSIC_VOTE_SKIP_THRESHOLD("MUSIC_VOTE_SKIP_THRESHOLD", "50", "Percentage (0-100) of connected listeners required to approve a vote-skip"), + /** Minutes of voice channel inactivity before the bot automatically disconnects. */ + MUSIC_IDLE_TIMEOUT("MUSIC_IDLE_TIMEOUT", "10", "Minutes of voice channel inactivity before the bot automatically disconnects"), + /** Per-guild override of the bot's message command prefix; empty means the bot-wide default. */ + COMMAND_PREFIX("COMMAND_PREFIX", "", "Per-guild override of the bot's message command prefix; empty means the bot-wide default"), + /** Role granted admin permissions for admin-only commands, in addition to Discord's own permission checks. */ + ADMIN_ROLE("ADMIN_ROLE", "", "Role granted admin permissions for admin-only commands, in addition to Discord's own permission checks"), + /** Comma-separated list of command names disabled on this guild. */ + DISABLED_COMMANDS("DISABLED_COMMANDS", "", "Comma-separated list of command names disabled on this guild"), + /** Minimum severity of events sent to the log channel (e.g. ERROR, WARN, INFO). */ + LOG_LEVEL("LOG_LEVEL", "ERROR", "Minimum severity of events sent to the log channel (e.g. ERROR, WARN, INFO)"), + /** Preferred locale for the bot's replies on this guild. */ + LOCALE("LOCALE", "en", "Preferred locale for the bot's replies on this guild"), + /** Timezone used to display timestamps for this guild. */ + TIMEZONE("TIMEZONE", "UTC", "Timezone used to display timestamps for this guild"); private final String name; private final String defaultValue; + private final String description; - GSettings(String name, String defaultValue) { + GSettings(String name, String defaultValue, String description) { this.name = name; this.defaultValue = defaultValue; + this.description = description; } public String getName() { @@ -38,4 +58,8 @@ public enum GSettings { public String getDefaultValue() { return defaultValue; } + + public String getDescription() { + return description; + } } diff --git a/src/main/java/me/bobert/netneutrality/db/entities/OGuild.java b/src/main/java/me/bobert/netneutrality/db/entities/OGuild.java index de7b23c..e718d7e 100644 --- a/src/main/java/me/bobert/netneutrality/db/entities/OGuild.java +++ b/src/main/java/me/bobert/netneutrality/db/entities/OGuild.java @@ -34,6 +34,12 @@ public class OGuild { @Column(name = "log_channel") private String logChannel; + @Column(name = "guild_name") + private String guildName; + + @Column(name = "guild_icon_hash") + private String guildIconHash; + @OneToMany(mappedBy = "guild") private Set guildSettings = new LinkedHashSet<>(); @@ -111,6 +117,22 @@ public class OGuild { this.guildId = guildId; } + public String getGuildName() { + return guildName; + } + + public void setGuildName(String guildName) { + this.guildName = guildName; + } + + public String getGuildIconHash() { + return guildIconHash; + } + + public void setGuildIconHash(String guildIconHash) { + this.guildIconHash = guildIconHash; + } + /** * Identity is the database id: two instances are equal when they have the same non-null id, * regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves. diff --git a/src/main/java/me/bobert/netneutrality/db/service/GuildService.java b/src/main/java/me/bobert/netneutrality/db/service/GuildService.java index 43aea71..d7338f3 100644 --- a/src/main/java/me/bobert/netneutrality/db/service/GuildService.java +++ b/src/main/java/me/bobert/netneutrality/db/service/GuildService.java @@ -83,6 +83,25 @@ public class GuildService { this.oGuild = guildRepository.save(current); } + /** + * Mirrors the guild's name/icon into the database so they survive JDA cache eviction. Only + * writes when a value actually changed, to avoid needless updates on every event. + * + * @param name the current guild name + * @param iconHash the current guild icon hash, or {@code null} if the guild has no icon + */ + public void updateMetadata(String name, String iconHash) { + OGuild current = findOrCreateGuild(guildId); + + if (Objects.equals(current.getGuildName(), name) && Objects.equals(current.getGuildIconHash(), iconHash)) { + return; + } + + current.setGuildName(name); + current.setGuildIconHash(iconHash); + this.oGuild = guildRepository.save(current); + } + public void deleteGuild() { guildRepository.deleteById(guildId); blacklistService.removeGuildBlacklist(); diff --git a/src/main/java/me/bobert/netneutrality/hooks/EventsHandler.java b/src/main/java/me/bobert/netneutrality/hooks/EventsHandler.java index f981071..e0472b0 100644 --- a/src/main/java/me/bobert/netneutrality/hooks/EventsHandler.java +++ b/src/main/java/me/bobert/netneutrality/hooks/EventsHandler.java @@ -23,12 +23,15 @@ import me.bobert.netneutrality.db.service.GuildServiceProvider; import me.bobert.netneutrality.db.service.GuildService; import me.bobert.netneutrality.core.command.CommandRegistry; import me.bobert.netneutrality.core.tasks.TaskManager; +import me.bobert.netneutrality.db.GSettings; import me.bobert.netneutrality.utils.SettingsProvider; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.events.Event; import net.dv8tion.jda.api.events.GenericEvent; import net.dv8tion.jda.api.events.guild.GuildJoinEvent; import net.dv8tion.jda.api.events.guild.GuildLeaveEvent; +import net.dv8tion.jda.api.events.guild.update.GuildUpdateIconEvent; +import net.dv8tion.jda.api.events.guild.update.GuildUpdateNameEvent; import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; @@ -81,6 +84,12 @@ public class EventsHandler implements EventListener { Message message = event.getMessage(); String rawMessage = message.getContentDisplay().trim(); String prefix = settings.get("NETNEUTRALITY_BOT_PREFIX", "$"); + if (event.isFromGuild()) { + String guildPrefix = guildServices.get(event.getGuild().getId()).getGuildSettingService().getOrDefault(GSettings.COMMAND_PREFIX); + if (!guildPrefix.isEmpty()) { + prefix = guildPrefix; + } + } if (rawMessage.startsWith(prefix)) { return new MessageCommandContext(event, settings); } @@ -96,12 +105,17 @@ public class EventsHandler implements EventListener { addListener(MessageReactionAddEvent.class, EmojiTaskContext::new); addGenericListener(GuildJoinEvent.class, event -> { GuildService service = guildServices.get(event.getGuild().getId()); + service.updateMetadata(event.getGuild().getName(), event.getGuild().getIconId()); if (service.isDebugEnabled()) { LogHandler.sendMessageToDebugChannel(event.getGuild(), "Error logging enabled on this channel"); } }); addGenericListener(GuildLeaveEvent.class, event -> onGuildLeave(event.getGuild().getId())); + addGenericListener(GuildUpdateNameEvent.class, event -> + guildServices.get(event.getGuild().getId()).updateMetadata(event.getNewName(), event.getGuild().getIconId())); + addGenericListener(GuildUpdateIconEvent.class, event -> + guildServices.get(event.getGuild().getId()).updateMetadata(event.getGuild().getName(), event.getNewIconId())); } /** diff --git a/src/main/java/me/bobert/netneutrality/http/server/ActionRoutes.java b/src/main/java/me/bobert/netneutrality/http/server/ActionRoutes.java index 80ce212..bdd27fa 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/ActionRoutes.java +++ b/src/main/java/me/bobert/netneutrality/http/server/ActionRoutes.java @@ -7,10 +7,13 @@ package me.bobert.netneutrality.http.server; */ import io.javalin.config.RoutesConfig; +import io.javalin.http.Context; import io.javalin.http.Handler; -import me.bobert.netneutrality.db.service.GuildServiceProvider; +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 org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -31,38 +34,40 @@ public final class ActionRoutes { public static void register( RoutesConfig routes, Supplier jdaSupplier, - GuildServiceProvider guildServices, Runnable shutdownHook ) { - routes.get("/actions/status", ctx -> { - JDA jda = GuildRoutes.jda(jdaSupplier); - ctx.json(Map.of( - "health", String.valueOf(jda.getStatus()), - "ping", String.valueOf(jda.getGatewayPing()) - )); - }); + routes.get("/actions/status", ctx -> status(jdaSupplier, ctx)); - Handler shutdown = ctx -> { - logger.info("Request to shutdown received"); - ctx.result("Shutting down.."); - scheduleShutdown(shutdownHook); - }; + Handler shutdown = ctx -> shutdown(ctx, shutdownHook); routes.post("/actions/shutdown", shutdown); - routes.get("/actions/shutdown", shutdown); + } - routes.get("/actions/settings/current/{guildId}", ctx -> { - Guild guild = GuildRoutes.guild(jdaSupplier, ctx); - Map settings = guildServices.get(guild.getId()).getGuildSettingService().snapshot(); + @OpenApi( + path = "/actions/status", + methods = HttpMethod.GET, + summary = "Report the current Discord gateway health", + tags = {"Actions"}, + responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class)) + ) + private static void status(Supplier 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> roles = member.getRoles().stream() + .map(role -> ordered("role_id", role.getId(), "role_name", role.getName())) + .toList(); + summary.put("roles", roles); + return summary; + } + + @OpenApi( + path = "/guilds/voice-channels/{guildId}", + methods = HttpMethod.GET, + summary = "List the voice channels of a guild, including the members currently connected", + 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 voiceChannels(Supplier jdaSupplier, Context ctx) { + ctx.json( + guild(jdaSupplier, ctx).getVoiceChannels().stream() + .map(GuildRoutes::voiceChannelSummary) + .toList() + ); + } + + @OpenApi( + path = "/guilds/voice-channels/{guildId}/{channelId}", + methods = HttpMethod.GET, + summary = "Get a voice channel, including the members currently connected", + tags = {"Guilds"}, + pathParams = { + @io.javalin.openapi.OpenApiParam(name = "guildId", description = "The Discord guild id"), + @io.javalin.openapi.OpenApiParam(name = "channelId", description = "The Discord voice channel id") + }, + responses = { + @OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class)), + @OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class)) + } + ) + private static void voiceChannel(Supplier jdaSupplier, Context ctx) { + ctx.json(voiceChannelSummary(voiceChannel(guild(jdaSupplier, ctx), ctx))); + } + + /** + * Resolves the {@code channelId} path parameter as a voice channel of the given guild, or + * fails with 404. + */ + private static VoiceChannel voiceChannel(Guild guild, Context ctx) { + VoiceChannel channel = guild.getVoiceChannelById(ctx.pathParam("channelId")); + if (channel == null) { + throw new NotFoundResponse("Unknown voice channel"); + } + return channel; + } + + /** + * Builds the {@code /guilds/voice-channels} entry for a voice channel, including the members + * currently connected to it. + */ + private static Map voiceChannelSummary(VoiceChannel channel) { + Map summary = new LinkedHashMap<>(); + summary.put("channel_id", channel.getId()); + summary.put("channel_name", channel.getName()); + summary.put("user_limit", channel.getUserLimit()); + summary.put("member_count", channel.getMembers().size()); + summary.put("members", channel.getMembers().stream().map(GuildRoutes::voiceMemberSummary).toList()); + return summary; + } + + /** + * Builds a minimal member entry for the members connected to a voice channel. + */ + private static Map voiceMemberSummary(Member member) { + Map summary = new LinkedHashMap<>(); + summary.put("user_id", member.getId()); + summary.put("username", member.getUser().getName()); + summary.put("nickname", member.getNickname()); + return summary; } /** diff --git a/src/main/java/me/bobert/netneutrality/http/server/SettingsRoutes.java b/src/main/java/me/bobert/netneutrality/http/server/SettingsRoutes.java new file mode 100644 index 0000000..b4de6db --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/http/server/SettingsRoutes.java @@ -0,0 +1,138 @@ +package me.bobert.netneutrality.http.server; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import io.javalin.config.RoutesConfig; +import io.javalin.http.BadRequestResponse; +import io.javalin.http.Context; +import io.javalin.openapi.HttpMethod; +import io.javalin.openapi.OpenApi; +import io.javalin.openapi.OpenApiContent; +import io.javalin.openapi.OpenApiParam; +import io.javalin.openapi.OpenApiRequestBody; +import io.javalin.openapi.OpenApiResponse; +import me.bobert.netneutrality.db.GSettings; +import me.bobert.netneutrality.db.service.GuildServiceProvider; +import me.bobert.netneutrality.db.service.GuildSettingService; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.Guild; + +import java.util.Arrays; +import java.util.Map; +import java.util.function.Supplier; + +/** + * Registers the {@code /settings/*} endpoints on a {@link RoutesConfig} instance. + */ +public final class SettingsRoutes { + + private SettingsRoutes() { + } + + public static void register(RoutesConfig routes, Supplier jdaSupplier, GuildServiceProvider guildServices) { + routes.get("/settings/catalog", SettingsRoutes::settingsCatalog); + + routes.get("/settings/current/{guildId}", ctx -> settingsCurrent(jdaSupplier, guildServices, ctx)); + + routes.put("/settings/current/{guildId}/{name}", ctx -> settingsUpdate(jdaSupplier, guildServices, ctx)); + + routes.put("/settings/reload/{guildId}", ctx -> settingsReload(jdaSupplier, guildServices, ctx)); + } + + @OpenApi( + path = "/settings/catalog", + methods = HttpMethod.GET, + summary = "List every known per-guild setting with its default value and description", + tags = {"Settings"}, + responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = Map[].class)) + ) + private static void settingsCatalog(Context ctx) { + ctx.json( + Arrays.stream(GSettings.values()) + .map(setting -> Map.of( + "name", setting.getName(), + "default_value", setting.getDefaultValue(), + "description", setting.getDescription() + )) + .toList() + ); + } + + @OpenApi( + path = "/settings/current/{guildId}", + methods = HttpMethod.GET, + summary = "Get the current per-guild setting overrides", + tags = {"Settings"}, + pathParams = @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 settingsCurrent(Supplier jdaSupplier, GuildServiceProvider guildServices, Context ctx) { + Guild guild = GuildRoutes.guild(jdaSupplier, ctx); + Map settings = guildServices.get(guild.getId()).getGuildSettingService().snapshot(); + + ctx.json(Map.of("status", "ok", "settings", settings)); + } + + @OpenApi( + path = "/settings/current/{guildId}/{name}", + methods = HttpMethod.PUT, + summary = "Update (or reset) a per-guild setting", + tags = {"Settings"}, + pathParams = { + @OpenApiParam(name = "guildId", description = "The Discord guild id"), + @OpenApiParam(name = "name", description = "The setting name, as returned by /settings/catalog") + }, + requestBody = @OpenApiRequestBody(content = @OpenApiContent(from = Map.class)), + responses = { + @OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class)), + @OpenApiResponse(status = "400", content = @OpenApiContent(from = Map.class)), + @OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class)) + } + ) + private static void settingsUpdate(Supplier jdaSupplier, GuildServiceProvider guildServices, Context ctx) { + Guild guild = GuildRoutes.guild(jdaSupplier, ctx); + GSettings setting = setting(ctx.pathParam("name")); + String value = ctx.bodyAsClass(Map.class).get("value") instanceof String s ? s : null; + + GuildSettingService settingService = guildServices.get(guild.getId()).getGuildSettingService(); + settingService.set(setting, value); + + ctx.json(Map.of("status", "ok", "name", setting.getName(), "value", settingService.getOrDefault(setting))); + } + + /** + * Resolves the {@code name} path parameter to a known {@link GSettings} or fails with 400. + */ + private static GSettings setting(String name) { + try { + return GSettings.valueOf(name); + } catch (IllegalArgumentException e) { + throw new BadRequestResponse("Unknown setting: " + name); + } + } + + @OpenApi( + path = "/settings/reload/{guildId}", + methods = HttpMethod.PUT, + summary = "Reload a guild's cached settings from the database", + tags = {"Settings"}, + pathParams = @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 settingsReload(Supplier jdaSupplier, GuildServiceProvider guildServices, Context ctx) { + Guild guild = GuildRoutes.guild(jdaSupplier, ctx); + guildServices.get(guild.getId()).getGuildSettingService().reloadSettingsCache(); + + ctx.json(Map.of("status", true)); + } +} diff --git a/src/main/java/me/bobert/netneutrality/http/server/StatsRoutes.java b/src/main/java/me/bobert/netneutrality/http/server/StatsRoutes.java new file mode 100644 index 0000000..971d9a7 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/http/server/StatsRoutes.java @@ -0,0 +1,83 @@ +package me.bobert.netneutrality.http.server; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import io.javalin.config.RoutesConfig; +import io.javalin.http.Context; +import io.javalin.openapi.HttpMethod; +import io.javalin.openapi.OpenApi; +import io.javalin.openapi.OpenApiContent; +import io.javalin.openapi.OpenApiParam; +import io.javalin.openapi.OpenApiResponse; +import me.bobert.netneutrality.db.service.GuildService; +import me.bobert.netneutrality.db.service.GuildServiceProvider; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.Guild; + +import java.util.Map; +import java.util.function.Supplier; + +/** + * Registers the {@code /stats/*} endpoints on a {@link RoutesConfig} instance. + */ +public final class StatsRoutes { + + private StatsRoutes() { + } + + public static void register(RoutesConfig routes, Supplier jdaSupplier, GuildServiceProvider guildServices) { + routes.get("/stats/bot", ctx -> bot(jdaSupplier, ctx)); + routes.get("/stats/guilds/{guildId}", ctx -> guild(jdaSupplier, guildServices, ctx)); + } + + @OpenApi( + path = "/stats/bot", + methods = HttpMethod.GET, + summary = "Report bot-wide stats (guild count, gateway health, uptime)", + tags = {"Stats"}, + responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class)) + ) + private static void bot(Supplier jdaSupplier, Context ctx) { + JDA jda = GuildRoutes.jda(jdaSupplier); + + ctx.json(Map.of( + "guild_count", jda.getGuilds().size(), + "gateway_ping", jda.getGatewayPing(), + "status", String.valueOf(jda.getStatus()), + "uptime_seconds", uptimeSeconds() + )); + } + + @OpenApi( + path = "/stats/guilds/{guildId}", + methods = HttpMethod.GET, + summary = "Report per-guild stats (member/channel/role counts, debug flag, blacklist size)", + tags = {"Stats"}, + pathParams = @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 guild(Supplier jdaSupplier, GuildServiceProvider guildServices, Context ctx) { + Guild guild = GuildRoutes.guild(jdaSupplier, ctx); + GuildService guildService = guildServices.get(guild.getId()); + + ctx.json(Map.of( + "guild_id", guild.getId(), + "member_count", guild.getMemberCount(), + "channel_count", guild.getChannels().size(), + "role_count", guild.getRoles().size(), + "debug_enabled", guildService.isDebugEnabled(), + "blacklist_size", guildService.getBlacklistService().list().size() + )); + } + + private static long uptimeSeconds() { + return java.lang.management.ManagementFactory.getRuntimeMXBean().getUptime() / 1000; + } +} diff --git a/src/main/java/me/bobert/netneutrality/http/server/WebApi.java b/src/main/java/me/bobert/netneutrality/http/server/WebApi.java index 06f418f..dca3c9c 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/WebApi.java +++ b/src/main/java/me/bobert/netneutrality/http/server/WebApi.java @@ -8,6 +8,8 @@ package me.bobert.netneutrality.http.server; import io.javalin.Javalin; import io.javalin.http.HttpResponseException; +import io.javalin.openapi.plugin.OpenApiPlugin; +import io.javalin.openapi.plugin.swagger.SwaggerPlugin; import me.bobert.netneutrality.bootstrap.Lifecycle; import me.bobert.netneutrality.db.service.GuildServiceProvider; import me.bobert.netneutrality.utils.SettingsProvider; @@ -59,9 +61,22 @@ public class WebApi implements Lifecycle { app = Javalin.create(config -> { config.startup.showJavalinBanner = false; + config.registerPlugin(new OpenApiPlugin(pluginConfig -> pluginConfig.withDefinitionConfiguration( + (version, definition) -> definition + .info(info -> info.title("Net Neutrality API")) + .withApiKeyAuth("ApiKeyAuth", "X-API-Key") + .withGlobalSecurity("ApiKeyAuth") + ))); + config.registerPlugin(new SwaggerPlugin()); + + // API key gate for every route except the liveness check and the docs themselves. + config.routes.before(new ApiKeyFilter(settings)); + config.routes.get("/", ctx -> ctx.html(LIVENESS_HTML)); GuildRoutes.register(config.routes, jdaSupplier); - ActionRoutes.register(config.routes, jdaSupplier, guildServices, shutdownHook); + ActionRoutes.register(config.routes, jdaSupplier, shutdownHook); + SettingsRoutes.register(config.routes, jdaSupplier, guildServices); + StatsRoutes.register(config.routes, jdaSupplier, guildServices); config.routes.exception(HttpResponseException.class, (e, ctx) -> ctx.status(e.getStatus()).json(Map.of("status", "error", "message", e.getMessage())) diff --git a/src/main/resources/db/migration/V3__guild_metadata.sql b/src/main/resources/db/migration/V3__guild_metadata.sql new file mode 100644 index 0000000..cb4b273 --- /dev/null +++ b/src/main/resources/db/migration/V3__guild_metadata.sql @@ -0,0 +1,3 @@ +ALTER TABLE `guilds` + ADD COLUMN `guild_name` varchar(255) DEFAULT NULL, + ADD COLUMN `guild_icon_hash` varchar(255) DEFAULT NULL; diff --git a/src/test/java/me/bobert/netneutrality/db/SessionManagerTest.java b/src/test/java/me/bobert/netneutrality/db/SessionManagerTest.java index a3454c2..dbbd1c8 100644 --- a/src/test/java/me/bobert/netneutrality/db/SessionManagerTest.java +++ b/src/test/java/me/bobert/netneutrality/db/SessionManagerTest.java @@ -21,7 +21,7 @@ class SessionManagerTest extends MySqlTestBase { Long applied = sessions().getSessionFactory().fromSession(session -> session .createNativeQuery("SELECT COUNT(*) FROM flyway_schema_history WHERE success = 1", Long.class) .getSingleResult()); - assertEquals(2L, applied); + assertEquals(3L, applied); } @Test diff --git a/src/test/java/me/bobert/netneutrality/db/service/GuildServiceTest.java b/src/test/java/me/bobert/netneutrality/db/service/GuildServiceTest.java index b71b15c..3b34efc 100644 --- a/src/test/java/me/bobert/netneutrality/db/service/GuildServiceTest.java +++ b/src/test/java/me/bobert/netneutrality/db/service/GuildServiceTest.java @@ -56,6 +56,28 @@ class GuildServiceTest extends MySqlTestBase { assertFalse(guilds.findById("guild-1").orElseThrow().getDebug()); } + @Test + void updateMetadataPersistsNameAndIcon() { + GuildService service = newService("guild-1"); + + service.updateMetadata("My Guild", "icon-hash"); + + assertEquals("My Guild", guilds.findById("guild-1").orElseThrow().getGuildName()); + assertEquals("icon-hash", guilds.findById("guild-1").orElseThrow().getGuildIconHash()); + } + + @Test + void updateMetadataIsANoOpWhenNothingChanged() { + GuildService service = newService("guild-1"); + service.updateMetadata("My Guild", "icon-hash"); + + // Second call with the same values should not throw and should keep the same data. + service.updateMetadata("My Guild", "icon-hash"); + + assertEquals("My Guild", guilds.findById("guild-1").orElseThrow().getGuildName()); + assertEquals("icon-hash", guilds.findById("guild-1").orElseThrow().getGuildIconHash()); + } + @Test void writesRecreateDeletedGuild() { GuildService service = newService("guild-1"); diff --git a/src/test/java/me/bobert/netneutrality/http/server/CacheOrFetchTest.java b/src/test/java/me/bobert/netneutrality/http/server/CacheOrFetchTest.java new file mode 100644 index 0000000..b88f3a3 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/http/server/CacheOrFetchTest.java @@ -0,0 +1,54 @@ +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 org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class CacheOrFetchTest { + + @Test + @SuppressWarnings("unchecked") + void cacheHitResolvesSynchronouslyWithoutCallingRestAction() { + Context ctx = mock(Context.class); + Supplier> restAction = mock(Supplier.class); + + CacheOrFetch.resolve(ctx, () -> "cached", restAction, s -> s); + + verify(ctx).json("cached"); + verify(restAction, never()).get(); + verify(ctx, never()).future(any()); + } + + @Test + @SuppressWarnings("unchecked") + void cacheMissFallsBackToRestActionViaFuture() { + Context ctx = mock(Context.class); + RestAction restAction = mock(RestAction.class); + when(restAction.submit()).thenReturn(CompletableFuture.completedFuture("fetched")); + + CacheOrFetch.resolve(ctx, () -> null, () -> restAction, s -> s); + + ArgumentCaptor>> captor = ArgumentCaptor.forClass(Supplier.class); + verify(ctx).future(captor.capture()); + captor.getValue().get(); + + verify(restAction).submit(); + verify(ctx).json("fetched"); + } +} diff --git a/src/test/java/me/bobert/netneutrality/http/server/WebApiTest.java b/src/test/java/me/bobert/netneutrality/http/server/WebApiTest.java index 14aed1c..403e391 100644 --- a/src/test/java/me/bobert/netneutrality/http/server/WebApiTest.java +++ b/src/test/java/me/bobert/netneutrality/http/server/WebApiTest.java @@ -6,6 +6,7 @@ package me.bobert.netneutrality.http.server; * https://discord.gg/qUa6zNFZsd */ +import me.bobert.netneutrality.db.service.BlacklistService; import me.bobert.netneutrality.db.service.GuildService; import me.bobert.netneutrality.db.service.GuildServiceProvider; import me.bobert.netneutrality.db.service.GuildSettingService; @@ -35,6 +36,7 @@ import static org.mockito.Mockito.when; class WebApiTest { private static final String GUILD_ID = "1234567890"; + private static final String API_KEY = "test-api-key"; private WebApi webApi; private int port; @@ -48,6 +50,11 @@ class WebApiTest { Guild guild = mock(Guild.class); when(guild.getId()).thenReturn(GUILD_ID); when(guild.getOwnerId()).thenReturn("42"); + when(guild.getName()).thenReturn("My Guild"); + when(guild.getIconUrl()).thenReturn("https://example.com/icon.png"); + when(guild.getMemberCount()).thenReturn(7); + when(guild.getChannels()).thenReturn(List.of()); + when(guild.getRoles()).thenReturn(List.of()); JDA jda = mock(JDA.class); when(jda.getGuilds()).thenReturn(List.of(guild)); @@ -60,14 +67,20 @@ class WebApiTest { settingService = mock(GuildSettingService.class); when(settingService.snapshot()).thenReturn(Map.of("prefix", "ü!")); + BlacklistService blacklistService = mock(BlacklistService.class); + when(blacklistService.list()).thenReturn(List.of()); + GuildService guildService = mock(GuildService.class); when(guildService.getGuildSettingService()).thenReturn(settingService); + when(guildService.getBlacklistService()).thenReturn(blacklistService); + when(guildService.isDebugEnabled()).thenReturn(true); GuildServiceProvider provider = mock(GuildServiceProvider.class); when(provider.get(GUILD_ID)).thenReturn(guildService); SettingsProvider settings = mock(SettingsProvider.class); when(settings.get("NETNEUTRALITY_API_PORT", "9000")).thenReturn(String.valueOf(port)); + when(settings.get("NETNEUTRALITY_API_KEY")).thenReturn(API_KEY); webApi = new WebApi(settings, provider, () -> jda, () -> { }); webApi.start(); @@ -79,13 +92,39 @@ class WebApiTest { } @Test - void rootReturnsLivenessHtml() throws Exception { - HttpResponse response = get("/"); + void rootReturnsLivenessHtmlWithoutApiKey() throws Exception { + HttpResponse response = getWithoutKey("/"); assertEquals(200, response.statusCode()); assertTrue(response.body().contains("Server start success")); } + @Test + void requestsWithoutApiKeyAreRejected() throws Exception { + HttpResponse response = getWithoutKey("/actions/status"); + + assertEquals(401, response.statusCode()); + } + + @Test + void requestsWithWrongApiKeyAreRejected() throws Exception { + HttpRequest request = HttpRequest.newBuilder(uri("/actions/status")) + .header("X-API-Key", "wrong-key") + .GET() + .build(); + + assertEquals(401, http.send(request, HttpResponse.BodyHandlers.ofString()).statusCode()); + } + + @Test + void shutdownWithoutApiKeyIsRejected() throws Exception { + HttpRequest request = HttpRequest.newBuilder(uri("/actions/shutdown")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + assertEquals(401, http.send(request, HttpResponse.BodyHandlers.ofString()).statusCode()); + } + @Test void statusReturnsHealthAndPing() throws Exception { HttpResponse response = get("/actions/status"); @@ -103,9 +142,59 @@ class WebApiTest { assertEquals("[{\"guild_id\":\"" + GUILD_ID + "\",\"owner_id\":\"42\"}]", response.body()); } + @Test + void guildsReturnsEveryBotGuild() throws Exception { + HttpResponse response = get("/guilds"); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("\"guild_id\":\"" + GUILD_ID + "\"")); + assertTrue(response.body().contains("\"name\":\"My Guild\"")); + assertTrue(response.body().contains("\"icon_url\":\"https://example.com/icon.png\"")); + assertTrue(response.body().contains("\"member_count\":7")); + assertTrue(response.body().contains("\"owner_id\":\"42\"")); + } + + @Test + void guildsRequiresApiKey() throws Exception { + assertEquals(401, getWithoutKey("/guilds").statusCode()); + } + + @Test + void botStatsReturnsGuildCountAndGatewayInfo() throws Exception { + HttpResponse response = get("/stats/bot"); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("\"guild_count\":1")); + assertTrue(response.body().contains("\"status\":\"CONNECTED\"")); + assertTrue(response.body().contains("\"gateway_ping\":12")); + } + + @Test + void guildStatsReturnsCountsForKnownGuild() throws Exception { + HttpResponse response = get("/stats/guilds/" + GUILD_ID); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("\"member_count\":7")); + assertTrue(response.body().contains("\"debug_enabled\":true")); + assertTrue(response.body().contains("\"blacklist_size\":0")); + } + + @Test + void guildStatsForUnknownGuildReturnsNotFound() throws Exception { + assertEquals(404, get("/stats/guilds/999").statusCode()); + } + + @Test + void settingsCatalogListsEveryKnownSetting() throws Exception { + HttpResponse response = get("/settings/catalog"); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("\"name\":\"MUSIC_ROLE\"")); + } + @Test void currentSettingsAreReturnedForKnownGuild() throws Exception { - HttpResponse response = get("/actions/settings/current/" + GUILD_ID); + HttpResponse response = get("/settings/current/" + GUILD_ID); assertEquals(200, response.statusCode()); assertTrue(response.body().contains("\"status\":\"ok\"")); @@ -128,6 +217,7 @@ class WebApiTest { @Test void wrongMethodIsNotRouted() throws Exception { HttpRequest request = HttpRequest.newBuilder(uri("/guilds/owner")) + .header("X-API-Key", API_KEY) .POST(HttpRequest.BodyPublishers.noBody()) .build(); @@ -136,13 +226,14 @@ class WebApiTest { @Test void reloadRefreshesTheSettingCache() throws Exception { - HttpRequest request = HttpRequest.newBuilder(uri("/actions/settings/reload/" + GUILD_ID)) - .POST(HttpRequest.BodyPublishers.noBody()) + HttpRequest request = HttpRequest.newBuilder(uri("/settings/reload/" + GUILD_ID)) + .header("X-API-Key", API_KEY) + .PUT(HttpRequest.BodyPublishers.noBody()) .build(); HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); assertEquals(200, response.statusCode()); - assertTrue(response.body().contains("\"status\":\"true\"")); + assertTrue(response.body().contains("\"status\":true")); verify(settingService).reloadSettingsCache(); } @@ -156,6 +247,11 @@ class WebApiTest { } private HttpResponse get(String path) throws Exception { + HttpRequest request = HttpRequest.newBuilder(uri(path)).header("X-API-Key", API_KEY).GET().build(); + return http.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse getWithoutKey(String path) throws Exception { HttpRequest request = HttpRequest.newBuilder(uri(path)).GET().build(); return http.send(request, HttpResponse.BodyHandlers.ofString()); }