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, "");
+ }
+}