From e41d54e1ed616f061bf81dacaa9533629950d96a Mon Sep 17 00:00:00 2001 From: Etzelia Date: Fri, 7 Aug 2020 14:30:36 -0500 Subject: [PATCH] Initial Version Signed-off-by: Etzelia --- README.md | 15 ++- pom.xml | 15 +++ .../java/xyz/etztech/serverapi/ServerAPI.java | 86 ++++++++++++- .../java/xyz/etztech/serverapi/tps/TPS.java | 30 +++++ .../xyz/etztech/serverapi/web/GraphQL.java | 41 ++++++ .../xyz/etztech/serverapi/web/IProvider.java | 20 +++ .../java/xyz/etztech/serverapi/web/Web.java | 117 ++++++++++++++++++ .../etztech/serverapi/web/api/ErrorAPI.java | 19 +++ .../etztech/serverapi/web/api/PlayerAPI.java | 31 +++++ .../etztech/serverapi/web/api/QueryAPI.java | 58 +++++++++ .../xyz/etztech/serverapi/web/api/TPSAPI.java | 35 ++++++ .../etztech/serverapi/web/api/WorldAPI.java | 50 ++++++++ src/main/resources/config.yml | 5 + .../xyz/etztech/serverapi/MockProvider.java | 69 +++++++++++ .../xyz/etztech/serverapi/ServerRunner.java | 12 ++ 15 files changed, 601 insertions(+), 2 deletions(-) create mode 100644 src/main/java/xyz/etztech/serverapi/tps/TPS.java create mode 100644 src/main/java/xyz/etztech/serverapi/web/GraphQL.java create mode 100644 src/main/java/xyz/etztech/serverapi/web/IProvider.java create mode 100644 src/main/java/xyz/etztech/serverapi/web/Web.java create mode 100644 src/main/java/xyz/etztech/serverapi/web/api/ErrorAPI.java create mode 100644 src/main/java/xyz/etztech/serverapi/web/api/PlayerAPI.java create mode 100644 src/main/java/xyz/etztech/serverapi/web/api/QueryAPI.java create mode 100644 src/main/java/xyz/etztech/serverapi/web/api/TPSAPI.java create mode 100644 src/main/java/xyz/etztech/serverapi/web/api/WorldAPI.java create mode 100644 src/test/java/xyz/etztech/serverapi/MockProvider.java create mode 100644 src/test/java/xyz/etztech/serverapi/ServerRunner.java diff --git a/README.md b/README.md index de3b6a7..2cdeee1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,19 @@ # ServerAPI -TBD +An API for your Minecraft server! + +Use REST API or GraphQL. + +## Available endpoints + +All endpoints can optionally be password-secured via config. + +* bans +* players +* query +* tps +* worlds +* world (by name) ## License diff --git a/pom.xml b/pom.xml index a8fd9b8..4796725 100644 --- a/pom.xml +++ b/pom.xml @@ -44,6 +44,21 @@ javalin 3.9.1 + + io.javalin + javalin-graphql + 3.9.1 + + + org.slf4j + slf4j-simple + 1.7.30 + + + com.fasterxml.jackson.core + jackson-databind + 2.10.3 + diff --git a/src/main/java/xyz/etztech/serverapi/ServerAPI.java b/src/main/java/xyz/etztech/serverapi/ServerAPI.java index 44982f6..8317ea9 100644 --- a/src/main/java/xyz/etztech/serverapi/ServerAPI.java +++ b/src/main/java/xyz/etztech/serverapi/ServerAPI.java @@ -1,26 +1,59 @@ package xyz.etztech.serverapi; +import org.bukkit.OfflinePlayer; +import org.bukkit.World; +import org.bukkit.entity.Player; import org.bukkit.plugin.java.JavaPlugin; import xyz.etztech.serverapi.commands.MainCommand; +import xyz.etztech.serverapi.tps.TPS; +import xyz.etztech.serverapi.web.IProvider; +import xyz.etztech.serverapi.web.Web; +import xyz.etztech.serverapi.web.api.*; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; import java.util.logging.Logger; -public class ServerAPI extends JavaPlugin { +public class ServerAPI extends JavaPlugin implements IProvider { private static ServerAPI instance; + private static TPS tps; + private Web web = new Web(this); private final Logger log = Logger.getLogger( "Minecraft" ); + @Override public void onEnable() { instance = this; + web = new Web(this); saveDefaultConfig(); reloadConfig(); if (isEnabled()) { new MainCommand(this); + + tps = new TPS(); + getServer().getScheduler().scheduleSyncRepeatingTask(this, tps, 1000, 50); } } + @Override + public void onDisable() { + web.stop(); + } + + @Override + public void reloadConfig() { + super.reloadConfig(); + web.stop(); + web.start( + getConfig().getInt("port", 8080), + getConfig().getString("password", "") + ); + } + public void log(String message) { log.info( "[ServerAPI]: " + message ); } @@ -28,5 +61,56 @@ public class ServerAPI extends JavaPlugin { public static ServerAPI getInstance() { return instance; } + + public static TPS getTPS() { + return tps; + } + + @Override + public TPSAPI TPS() { + return new TPSAPI(tps.getHistory()); + } + + @Override + public WorldAPI world(String name) { + for (World world : getServer().getWorlds()) { + if (world.getName().equalsIgnoreCase(name)) { + return WorldAPI.fromMinecraft(world); + } + } + return new WorldAPI("unknown", 0, WorldAPI.WEATHER_UNKNOWN); + } + + @Override + public List worlds() { + List worldAPIS = new ArrayList<>(); + for (World world : getServer().getWorlds()) { + worldAPIS.add(WorldAPI.fromMinecraft(world)); + } + return worldAPIS; + } + + @Override + public Set players() { + Set players = new HashSet<>(); + for (Player player : getServer().getOnlinePlayers()) { + players.add(PlayerAPI.fromMinecraft(player)); + } + return players; + } + + @Override + public Set bans() { + Set players = new HashSet<>(); + for (OfflinePlayer player : getServer().getBannedPlayers()) { + players.add(PlayerAPI.fromMinecraft(player)); + } + return players; + } + + @Override + public QueryAPI query() { + return QueryAPI.fromMinecraft(getServer()); + } } diff --git a/src/main/java/xyz/etztech/serverapi/tps/TPS.java b/src/main/java/xyz/etztech/serverapi/tps/TPS.java new file mode 100644 index 0000000..4cba031 --- /dev/null +++ b/src/main/java/xyz/etztech/serverapi/tps/TPS.java @@ -0,0 +1,30 @@ +package xyz.etztech.serverapi.tps; + +import java.util.LinkedList; + +public class TPS implements Runnable { + private transient long lastPoll = System.nanoTime(); + private final LinkedList history = new LinkedList<>(); + + @Override + public void run() { + final long startTime = System.nanoTime(); + long timeSpent = (startTime - lastPoll) / 1000; + if (timeSpent == 0) { + timeSpent = 1; + } + if (history.size() > 10) { + history.remove(); + } + long tickInterval = 50; + float tps = tickInterval * 1000000.0f / timeSpent; + if (tps <= 21) { + history.add(tps); + } + lastPoll = startTime; + } + + public LinkedList getHistory() { + return history; + } +} diff --git a/src/main/java/xyz/etztech/serverapi/web/GraphQL.java b/src/main/java/xyz/etztech/serverapi/web/GraphQL.java new file mode 100644 index 0000000..87a25b5 --- /dev/null +++ b/src/main/java/xyz/etztech/serverapi/web/GraphQL.java @@ -0,0 +1,41 @@ +package xyz.etztech.serverapi.web; + +import com.expediagroup.graphql.annotations.GraphQLName; +import io.javalin.plugin.graphql.graphql.QueryGraphql; +import xyz.etztech.serverapi.web.api.PlayerAPI; +import xyz.etztech.serverapi.web.api.QueryAPI; +import xyz.etztech.serverapi.web.api.TPSAPI; +import xyz.etztech.serverapi.web.api.WorldAPI; + +public class GraphQL implements QueryGraphql { + private final IProvider provider; + + public GraphQL(IProvider provider) { + this.provider = provider; + } + + @GraphQLName("players") + public PlayerAPI[] getPlayers() { + return provider.players().toArray(new PlayerAPI[0]); + } + + @GraphQLName("query") + public QueryAPI getQuery() { + return provider.query(); + } + + @GraphQLName("world") + public WorldAPI getWorld(@GraphQLName("world") String name) { + return provider.world(name); + } + + @GraphQLName("worlds") + public WorldAPI[] getWorlds() { + return provider.worlds().toArray(new WorldAPI[0]); + } + + @GraphQLName("tps") + public TPSAPI getTps() { + return provider.TPS(); + } +} diff --git a/src/main/java/xyz/etztech/serverapi/web/IProvider.java b/src/main/java/xyz/etztech/serverapi/web/IProvider.java new file mode 100644 index 0000000..1dbbcc0 --- /dev/null +++ b/src/main/java/xyz/etztech/serverapi/web/IProvider.java @@ -0,0 +1,20 @@ +package xyz.etztech.serverapi.web; + +import xyz.etztech.serverapi.web.api.PlayerAPI; +import xyz.etztech.serverapi.web.api.QueryAPI; +import xyz.etztech.serverapi.web.api.TPSAPI; +import xyz.etztech.serverapi.web.api.WorldAPI; + +import java.util.List; +import java.util.Set; + +public interface IProvider { + Set bans(); + Set players(); + QueryAPI query(); + TPSAPI TPS(); + List worlds(); + WorldAPI world(String name); + + void log(String message); +} diff --git a/src/main/java/xyz/etztech/serverapi/web/Web.java b/src/main/java/xyz/etztech/serverapi/web/Web.java new file mode 100644 index 0000000..a1d8cc4 --- /dev/null +++ b/src/main/java/xyz/etztech/serverapi/web/Web.java @@ -0,0 +1,117 @@ +package xyz.etztech.serverapi.web; + +import io.javalin.Javalin; +import io.javalin.core.event.EventListener; +import io.javalin.core.plugin.Plugin; +import io.javalin.core.util.RouteOverviewPlugin; +import io.javalin.http.Context; +import io.javalin.http.UnauthorizedResponse; +import io.javalin.plugin.graphql.GraphQLOptions; +import io.javalin.plugin.graphql.GraphQLPlugin; +import io.javalin.plugin.json.JavalinJson; +import org.apache.commons.lang.exception.ExceptionUtils; +import org.slf4j.helpers.NOPLogger; +import xyz.etztech.serverapi.ServerAPI; +import xyz.etztech.serverapi.web.api.ErrorAPI; + +public class Web { + private final IProvider provider; + private String password; + private Javalin app; + + public Web(IProvider provider) { + this.provider = provider; + } + + public void start(int port, String password) { + Javalin.log = NOPLogger.NOP_LOGGER; + app = Javalin.create(config -> { + config.registerPlugin(graphql()); + config.registerPlugin(new RouteOverviewPlugin("/")); + config.enableCorsForAllOrigins(); + }).events(this::events).exception(Exception.class, this::exception); + + this.password = password; + if (!"".equals(password)) { + app.before(this::access); + } + + app.get("/bans", this::bans); + app.get("/players", this::players); + app.get("/query", this::query); + app.get("/tps", this::tps); + app.get("/worlds", this::worlds); + app.get("/worlds/:name", this::world); + + // What in the actual fuck... https://github.com/tipsy/javalin/issues/358 + ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + Thread.currentThread().setContextClassLoader(ServerAPI.class.getClassLoader()); + app.start(port); + Thread.currentThread().setContextClassLoader(classLoader); + } + + + public void stop() { + if (app != null) { + app.stop(); + } + } + + public void events(EventListener event) { + event.serverStarting(() -> provider.log("Starting API...")); + event.serverStarted(() -> provider.log("API is ready!")); + event.serverStartFailed(() -> provider.log("Could not start API...")); + event.serverStopping(() -> provider.log("Stopping API...")); + event.serverStopped(() -> provider.log("API is stopped!")); + } + + public void exception(Exception exception, Context ctx) { + provider.log(String.format("API Exception at %s:\n%s", + ctx.req.getRequestURI(), + ExceptionUtils.getStackTrace(exception))); + ctx.status(500); + ctx.json(new ErrorAPI(500, "Internal Error")); + } + + public void access(Context ctx) { + String pw = ctx.header("X-ServerAPI-Password"); + if (pw == null) { + pw = ctx.queryParam("password"); + } + + if (!password.equals(pw)) { + throw new UnauthorizedResponse(JavalinJson.toJson(new ErrorAPI(401, "Unauthorized"))); + } + } + + public Plugin graphql() { + GraphQLOptions options = new GraphQLOptions("/graphql", null) + .addPackage("xyz.etztech.serverapi.web.api") + .register(new GraphQL(provider)); + return new GraphQLPlugin(options); + } + + public void tps(Context ctx) { + ctx.json(provider.TPS()); + } + + public void worlds(Context ctx) { + ctx.json(provider.worlds()); + } + + public void world(Context ctx) { + ctx.json(provider.world(ctx.pathParam("name"))); + } + + public void players(Context ctx) { + ctx.json(provider.players()); + } + + public void bans(Context ctx) { + ctx.json(provider.bans()); + } + + public void query(Context ctx) { + ctx.json(provider.query()); + } +} diff --git a/src/main/java/xyz/etztech/serverapi/web/api/ErrorAPI.java b/src/main/java/xyz/etztech/serverapi/web/api/ErrorAPI.java new file mode 100644 index 0000000..3dc163a --- /dev/null +++ b/src/main/java/xyz/etztech/serverapi/web/api/ErrorAPI.java @@ -0,0 +1,19 @@ +package xyz.etztech.serverapi.web.api; + +public class ErrorAPI { + private final int status; + private final String message; + + public ErrorAPI(int status, String message) { + this.status = status; + this.message = message; + } + + public int getStatus() { + return status; + } + + public String getMessage() { + return message; + } +} diff --git a/src/main/java/xyz/etztech/serverapi/web/api/PlayerAPI.java b/src/main/java/xyz/etztech/serverapi/web/api/PlayerAPI.java new file mode 100644 index 0000000..0a3090f --- /dev/null +++ b/src/main/java/xyz/etztech/serverapi/web/api/PlayerAPI.java @@ -0,0 +1,31 @@ +package xyz.etztech.serverapi.web.api; + +import com.expediagroup.graphql.annotations.GraphQLDescription; +import com.expediagroup.graphql.annotations.GraphQLName; +import org.bukkit.OfflinePlayer; + +@GraphQLName("Player") +@GraphQLDescription("Player GraphQL") +public class PlayerAPI { + private final String name; + private final String uuid; + + public PlayerAPI(String name, String uuid) { + this.name = name; + this.uuid = uuid; + } + + @GraphQLName("name") + public String getName() { + return name; + } + + @GraphQLName("uuid") + public String getUUID() { + return uuid; + } + + public static PlayerAPI fromMinecraft(OfflinePlayer player) { + return new PlayerAPI(player.getName(), player.getUniqueId().toString()); + } +} diff --git a/src/main/java/xyz/etztech/serverapi/web/api/QueryAPI.java b/src/main/java/xyz/etztech/serverapi/web/api/QueryAPI.java new file mode 100644 index 0000000..b343e9f --- /dev/null +++ b/src/main/java/xyz/etztech/serverapi/web/api/QueryAPI.java @@ -0,0 +1,58 @@ +package xyz.etztech.serverapi.web.api; + +import com.expediagroup.graphql.annotations.GraphQLName; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.bukkit.Server; + +public class QueryAPI { + private final String type; + private final String version; + private final String motd; + @JsonProperty("current_players") + private final int currentPlayers; + @JsonProperty("max_players") + private final int maxPlayers; + + public QueryAPI(String type, String version, String motd, int currentPlayers, int maxPlayers) { + this.type = type; + this.version = version; + this.motd = motd; + this.currentPlayers = currentPlayers; + this.maxPlayers = maxPlayers; + } + + @GraphQLName("type") + public String getType() { + return type; + } + + @GraphQLName("version") + public String getVersion() { + return version; + } + + @GraphQLName("motd") + public String getMotd() { + return motd; + } + + @GraphQLName("current_players") + public Integer getCurrentPlayers() { + return currentPlayers; + } + + @GraphQLName("max_players") + public Integer getMaxPlayers() { + return maxPlayers; + } + + public static QueryAPI fromMinecraft(Server server) { + return new QueryAPI( + server.getName(), + server.getBukkitVersion().split("-")[0], // 1.x.x-R0.1-SNAPSHOT + server.getMotd(), + server.getOnlinePlayers().size(), + server.getMaxPlayers() + ); + } +} diff --git a/src/main/java/xyz/etztech/serverapi/web/api/TPSAPI.java b/src/main/java/xyz/etztech/serverapi/web/api/TPSAPI.java new file mode 100644 index 0000000..6cdec6b --- /dev/null +++ b/src/main/java/xyz/etztech/serverapi/web/api/TPSAPI.java @@ -0,0 +1,35 @@ +package xyz.etztech.serverapi.web.api; + +import com.expediagroup.graphql.annotations.GraphQLDescription; +import com.expediagroup.graphql.annotations.GraphQLName; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.LinkedList; + +@GraphQLName("TPS") +@GraphQLDescription("TPS GraphQL") +public class TPSAPI { + private final LinkedList history; + + public TPSAPI(LinkedList history) { + this.history = history; + } + + @GraphQLName("history") + public LinkedList getHistory() { + return history; + } + + @JsonProperty("average") + @GraphQLName("average") + public float getAverageTPS() { + float avg = 0; + if (history.size() == 0) return avg; + for (Float f : history) { + if (f != null) { + avg += f; + } + } + return avg / history.size(); + } +} diff --git a/src/main/java/xyz/etztech/serverapi/web/api/WorldAPI.java b/src/main/java/xyz/etztech/serverapi/web/api/WorldAPI.java new file mode 100644 index 0000000..46dd5bf --- /dev/null +++ b/src/main/java/xyz/etztech/serverapi/web/api/WorldAPI.java @@ -0,0 +1,50 @@ +package xyz.etztech.serverapi.web.api; + +import com.expediagroup.graphql.annotations.GraphQLDescription; +import com.expediagroup.graphql.annotations.GraphQLName; +import org.bukkit.World; + +@GraphQLName("World") +@GraphQLDescription("World GraphQL") +public class WorldAPI { + public static int WEATHER_UNKNOWN = -1; + public static int WEATHER_CLEAR = 0; + public static int WEATHER_STORM = 1; + public static int WEATHER_THUNDER = 2; + + private final String name; + private final long time; + private final int weather; + + public WorldAPI(String name, long time, int weather) { + this.name = name; + this.time = time; + this.weather = weather; + } + + @GraphQLName("name") + public String getName() { + return name; + } + + @GraphQLName("time") + public long getTime() { + return time; + } + + @GraphQLName("weather") + public int getWeather() { + return weather; + } + + public static WorldAPI fromMinecraft(World world) { + int state = WEATHER_CLEAR; + if (world.hasStorm()) { + state = WEATHER_STORM; + if (world.isThundering()) { + state = WEATHER_THUNDER; + } + } + return new WorldAPI(world.getName(), world.getFullTime(), state); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index e69de29..32a3443 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -0,0 +1,5 @@ +# The port to run on +port: 8080 + +# (Optional) password +password: '' \ No newline at end of file diff --git a/src/test/java/xyz/etztech/serverapi/MockProvider.java b/src/test/java/xyz/etztech/serverapi/MockProvider.java new file mode 100644 index 0000000..3cac058 --- /dev/null +++ b/src/test/java/xyz/etztech/serverapi/MockProvider.java @@ -0,0 +1,69 @@ +package xyz.etztech.serverapi; + +import xyz.etztech.serverapi.web.IProvider; +import xyz.etztech.serverapi.web.api.PlayerAPI; +import xyz.etztech.serverapi.web.api.QueryAPI; +import xyz.etztech.serverapi.web.api.TPSAPI; +import xyz.etztech.serverapi.web.api.WorldAPI; + +import java.util.*; + +public class MockProvider implements IProvider { + List worlds; + + public MockProvider() { + worlds = Arrays.asList( + new WorldAPI("overworld", 1000, WorldAPI.WEATHER_CLEAR), + new WorldAPI("nether", 1500, WorldAPI.WEATHER_STORM), + new WorldAPI("end", 2000, WorldAPI.WEATHER_THUNDER) + ); + } + + @Override + public TPSAPI TPS() { + return new TPSAPI(new LinkedList<>(Arrays.asList(20.0f, 15.0f, 10.0f, 18.0f, 20.0f))); + } + + @Override + public WorldAPI world(String name) { + for (WorldAPI mock : worlds) { + if (mock.getName().equalsIgnoreCase(name)) { + return mock; + } + } + return new WorldAPI("unknown", 0, WorldAPI.WEATHER_CLEAR); + } + + @Override + public List worlds() { + return worlds; + } + + @Override + public Set bans() { + return new HashSet<>(Arrays.asList( + new PlayerAPI("Badzelia", "bf0446a8-9695-4c41-aa4c-7ff45bfd1171"), + new PlayerAPI("LessThanZeroSD", "fe7e8413-2570-4588-9203-2b69ff188bc3"), + new PlayerAPI("Vakbuttzel", "7afbf663-2bf0-49ef-915f-22e81b298d17") + )); + } + + @Override + public Set players() { + return new HashSet<>(Arrays.asList( + new PlayerAPI("Etzelia", "bf0446a8-9695-4c41-aa4c-7ff45bfd1171"), + new PlayerAPI("Zero", "fe7e8413-2570-4588-9203-2b69ff188bc3"), + new PlayerAPI("Vak", "7afbf663-2bf0-49ef-915f-22e81b298d17") + )); + } + + @Override + public QueryAPI query() { + return new QueryAPI("Mock", "0.0.1", "Hello, world!", 0, 100); + } + + @Override + public void log(String message) { + System.out.println(message); + } +} diff --git a/src/test/java/xyz/etztech/serverapi/ServerRunner.java b/src/test/java/xyz/etztech/serverapi/ServerRunner.java new file mode 100644 index 0000000..e34587f --- /dev/null +++ b/src/test/java/xyz/etztech/serverapi/ServerRunner.java @@ -0,0 +1,12 @@ +package xyz.etztech.serverapi; + +import xyz.etztech.serverapi.web.Web; + +public class ServerRunner { + + public static void main(String[] args) { + Web web = new Web(new MockProvider()); + + web.start(8080, ""); + } +}