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.
This commit is contained in:
1 parent
35fc415d91
commit
a8b353ea73
19 files changed
+947
-49
No files matched your search
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -43,6 +43,11 @@
|
||||
<artifactId>hibernate-processor</artifactId>
|
||||
<version>7.1.9.Final</version>
|
||||
</path>
|
||||
<path>
|
||||
<groupId>io.javalin.community.openapi</groupId>
|
||||
<artifactId>openapi-annotation-processor</artifactId>
|
||||
<version>7.2.3</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
@@ -185,6 +190,27 @@
|
||||
<version>7.2.3</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.javalin.community.openapi</groupId>
|
||||
<artifactId>openapi-specification</artifactId>
|
||||
<version>7.2.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.javalin.community.openapi</groupId>
|
||||
<artifactId>javalin-openapi-plugin</artifactId>
|
||||
<version>7.2.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.javalin.community.openapi</groupId>
|
||||
<artifactId>openapi-generator</artifactId>
|
||||
<version>7.2.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.javalin.community.openapi</groupId>
|
||||
<artifactId>javalin-swagger-plugin</artifactId>
|
||||
<version>7.2.3</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.hibernate.orm</groupId>
|
||||
<artifactId>hibernate-core</artifactId>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<OGuildSetting> 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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<JDA> 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<String, String> 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<JDA> 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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<String> PUBLIC_PREFIXES = List.of("/openapi", "/swagger", "/webjars");
|
||||
private static final List<String> 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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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 <T> the resolved entity type
|
||||
*/
|
||||
public static <T> void resolve(Context ctx, Supplier<T> cached, Supplier<RestAction<T>> restAction, Function<T, Object> 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;
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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<JDA> 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<JDA> 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<String, Object> guildSummary(Guild guild) {
|
||||
Map<String, Object> 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<JDA> 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<JDA> jdaSupplier, Context ctx) {
|
||||
Guild guild = guild(jdaSupplier, ctx);
|
||||
|
||||
CacheOrFetch.<Member>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<JDA> 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<JDA> 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<JDA> jdaSupplier, Context ctx) {
|
||||
Guild guild = guild(jdaSupplier, ctx);
|
||||
String userId = ctx.pathParam("userId");
|
||||
|
||||
CacheOrFetch.<Member>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<JDA> jdaSupplier, Context ctx) {
|
||||
Guild guild = guild(jdaSupplier, ctx);
|
||||
String userId = ctx.pathParam("userId");
|
||||
|
||||
CacheOrFetch.<Member>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<String, Object> memberSummary(Member member) {
|
||||
Map<String, Object> 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<String, Object> memberRolesSummary(Guild guild, Member member) {
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
summary.put("guild_id", guild.getId());
|
||||
summary.put("user_id", member.getId());
|
||||
List<Map<String, String>> 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<JDA> 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<JDA> 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<String, Object> voiceChannelSummary(VoiceChannel channel) {
|
||||
Map<String, Object> 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<String, String> voiceMemberSummary(Member member) {
|
||||
Map<String, String> summary = new LinkedHashMap<>();
|
||||
summary.put("user_id", member.getId());
|
||||
summary.put("username", member.getUser().getName());
|
||||
summary.put("nickname", member.getNickname());
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<JDA> 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<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
|
||||
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
|
||||
Map<String, String> 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<JDA> 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<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
|
||||
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
|
||||
guildServices.get(guild.getId()).getGuildSettingService().reloadSettingsCache();
|
||||
|
||||
ctx.json(Map.of("status", true));
|
||||
}
|
||||
}
|
||||
@@ -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<JDA> 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<JDA> 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<JDA> 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;
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE `guilds`
|
||||
ADD COLUMN `guild_name` varchar(255) DEFAULT NULL,
|
||||
ADD COLUMN `guild_icon_hash` varchar(255) DEFAULT NULL;
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<String>> restAction = mock(Supplier.class);
|
||||
|
||||
CacheOrFetch.<String>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<String> restAction = mock(RestAction.class);
|
||||
when(restAction.submit()).thenReturn(CompletableFuture.completedFuture("fetched"));
|
||||
|
||||
CacheOrFetch.<String>resolve(ctx, () -> null, () -> restAction, s -> s);
|
||||
|
||||
ArgumentCaptor<Supplier<CompletableFuture<?>>> captor = ArgumentCaptor.forClass(Supplier.class);
|
||||
verify(ctx).future(captor.capture());
|
||||
captor.getValue().get();
|
||||
|
||||
verify(restAction).submit();
|
||||
verify(ctx).json("fetched");
|
||||
}
|
||||
}
|
||||
@@ -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<String> response = get("/");
|
||||
void rootReturnsLivenessHtmlWithoutApiKey() throws Exception {
|
||||
HttpResponse<String> response = getWithoutKey("/");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertTrue(response.body().contains("Server start success"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestsWithoutApiKeyAreRejected() throws Exception {
|
||||
HttpResponse<String> 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<String> 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<String> 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<String> 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<String> 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<String> response = get("/settings/catalog");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertTrue(response.body().contains("\"name\":\"MUSIC_ROLE\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void currentSettingsAreReturnedForKnownGuild() throws Exception {
|
||||
HttpResponse<String> response = get("/actions/settings/current/" + GUILD_ID);
|
||||
HttpResponse<String> 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<String> 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<String> 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<String> getWithoutKey(String path) throws Exception {
|
||||
HttpRequest request = HttpRequest.newBuilder(uri(path)).GET().build();
|
||||
return http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user