diff --git a/README.md b/README.md index a608cda0..797687e7 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ the [Example Code](https://github.com/HypixelDev/PublicAPI/tree/master/hypixel-a #### Hypixel Maven Repo ```xml + Hypixel https://repo.hypixel.net/repository/Hypixel/ diff --git a/hypixel-api-core/src/main/java/net/hypixel/api/HypixelAPI.java b/hypixel-api-core/src/main/java/net/hypixel/api/HypixelAPI.java index 1bd195e5..1d4508c1 100644 --- a/hypixel-api-core/src/main/java/net/hypixel/api/HypixelAPI.java +++ b/hypixel-api-core/src/main/java/net/hypixel/api/HypixelAPI.java @@ -1,12 +1,7 @@ package net.hypixel.api; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; import com.google.gson.JsonObject; import com.google.gson.JsonSyntaxException; -import net.hypixel.api.adapters.*; -import net.hypixel.api.data.type.GameType; -import net.hypixel.api.data.type.ServerType; import net.hypixel.api.exceptions.BadResponseException; import net.hypixel.api.exceptions.BadStatusCodeException; import net.hypixel.api.http.HTTPQueryParams; @@ -14,21 +9,14 @@ import net.hypixel.api.http.HypixelHttpResponse; import net.hypixel.api.reply.*; import net.hypixel.api.reply.skyblock.*; +import net.hypixel.api.util.PropertyFilter; import net.hypixel.api.util.ResourceType; +import net.hypixel.api.util.Utilities; -import java.time.ZonedDateTime; import java.util.UUID; import java.util.concurrent.CompletableFuture; public class HypixelAPI { - - private static final Gson GSON = new GsonBuilder() - .registerTypeAdapter(UUID.class, new UUIDTypeAdapter()) - .registerTypeAdapter(GameType.class, new GameTypeTypeAdapter()) - .registerTypeAdapter(ServerType.class, new ServerTypeTypeAdapter()) - .registerTypeAdapter(ZonedDateTime.class, new DateTimeTypeAdapter()) - .registerTypeAdapterFactory(new BoostersTypeAdapterFactory<>(BoostersReply.Booster.class)) - .create(); private static final String BASE_URL = "https://api.hypixel.net/"; private final HypixelHttpClient httpClient; @@ -81,6 +69,31 @@ public CompletableFuture getPlayerByUuid(String player) { ); } + /** + * Same as {@link #getPlayerByUuid(UUID)}, but the resulting player object will only contain + * properties explicitly included via a {@link PropertyFilter filter}. + */ + public CompletableFuture getPlayerByUuid(UUID player, PropertyFilter filter) { + return applyFilterFuture(getPlayerByUuid(player), filter); + } + + /** + * Same as {@link #getPlayerByUuid(String)}, but the resulting player object will only contain + * properties explicitly included via a {@link PropertyFilter filter}. + */ + public CompletableFuture getPlayerByUuid(String player, PropertyFilter filter) { + return applyFilterFuture(getPlayerByUuid(player), filter); + } + + /** + * Same as {@link #getPlayerByName(String)}, but the resulting player object will only contain + * properties explicitly included via a {@link PropertyFilter filter}. + */ + @Deprecated + public CompletableFuture getPlayerByName(String player, PropertyFilter filter) { + return applyFilterFuture(getPlayerByName(player), filter); + } + /** * @param player the minecraft username of the player. * @return {@link CompletableFuture} containing {@link PlayerReply} @@ -257,6 +270,16 @@ public CompletableFuture getSkyBlockBazaar() { return get(SkyBlockBazaarReply.class, "skyblock/bazaar"); } + /** + * Applies a {@code filter} to a player object when it is received in an API response. + */ + private CompletableFuture applyFilterFuture(CompletableFuture future, PropertyFilter filter) { + return future.thenApply(reply -> { + reply.getPlayer().filter(filter); + return reply; + }); + } + private CompletableFuture get(Class clazz, String request) { return get(clazz, request, null); } @@ -270,16 +293,16 @@ private CompletableFuture get(Class clazz, Strin .thenApply(this::checkResponse) .thenApply(response -> { if (clazz == ResourceReply.class) { - return checkReply((R) new ResourceReply(GSON.fromJson(response.getBody(), JsonObject.class))); + return checkReply((R) new ResourceReply(Utilities.GSON.fromJson(response.getBody(), JsonObject.class))); } - return checkReply(GSON.fromJson(response.getBody(), clazz)); + return checkReply(Utilities.GSON.fromJson(response.getBody(), clazz)); }); } private CompletableFuture requestResource(String resource) { return httpClient.makeRequest(BASE_URL + "resources/" + resource) .thenApply(this::checkResponse) - .thenApply(response -> checkReply(new ResourceReply(GSON.fromJson(response.getBody(), JsonObject.class)))); + .thenApply(response -> checkReply(new ResourceReply(Utilities.GSON.fromJson(response.getBody(), JsonObject.class)))); } /** @@ -292,7 +315,7 @@ private HypixelHttpResponse checkResponse(HypixelHttpResponse response) { String cause; try { - cause = GSON.fromJson(response.getBody(), JsonObject.class).get("cause").getAsString(); + cause = Utilities.GSON.fromJson(response.getBody(), JsonObject.class).get("cause").getAsString(); } catch (JsonSyntaxException ignored) { cause = "Unknown (body is not json)"; } diff --git a/hypixel-api-core/src/main/java/net/hypixel/api/adapters/PlayerTypeAdapter.java b/hypixel-api-core/src/main/java/net/hypixel/api/adapters/PlayerTypeAdapter.java new file mode 100644 index 00000000..b3f4320b --- /dev/null +++ b/hypixel-api-core/src/main/java/net/hypixel/api/adapters/PlayerTypeAdapter.java @@ -0,0 +1,35 @@ +package net.hypixel.api.adapters; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; +import net.hypixel.api.reply.PlayerReply.Player; + +import java.io.IOException; + +public class PlayerTypeAdapter extends TypeAdapter { + + private final TypeAdapter defaultAdapter; + + public PlayerTypeAdapter() { + defaultAdapter = new Gson().getAdapter(JsonElement.class); + } + + @Override + public void write(JsonWriter out, Player value) throws IOException { + defaultAdapter.write(out, value.getRaw()); + } + + @Override + public Player read(JsonReader in) throws IOException { + JsonToken type = in.peek(); + if (type == JsonToken.NULL) { + in.nextNull(); + return new Player(null); + } + return new Player(defaultAdapter.read(in)); + } +} diff --git a/hypixel-api-core/src/main/java/net/hypixel/api/adapters/UUIDTypeAdapter.java b/hypixel-api-core/src/main/java/net/hypixel/api/adapters/UUIDTypeAdapter.java index 63d66f8a..609e49f5 100644 --- a/hypixel-api-core/src/main/java/net/hypixel/api/adapters/UUIDTypeAdapter.java +++ b/hypixel-api-core/src/main/java/net/hypixel/api/adapters/UUIDTypeAdapter.java @@ -1,6 +1,7 @@ package net.hypixel.api.adapters; import com.google.gson.*; +import net.hypixel.api.util.Utilities; import java.lang.reflect.Type; import java.util.UUID; @@ -14,11 +15,6 @@ public JsonElement serialize(UUID src, Type typeOfSrc, JsonSerializationContext @Override public UUID deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { - String uuid = json.getAsString(); - if (uuid.contains("-")) { - return UUID.fromString(uuid); - } else { - return UUID.fromString(uuid.substring(0, 8) + "-" + uuid.substring(8, 12) + "-" + uuid.substring(12, 16) + "-" + uuid.substring(16, 20) + "-" + uuid.substring(20, 32)); - } + return Utilities.uuidFromString(json.getAsString()); } } diff --git a/hypixel-api-core/src/main/java/net/hypixel/api/reply/PlayerReply.java b/hypixel-api-core/src/main/java/net/hypixel/api/reply/PlayerReply.java index dc1327c0..93c1ea89 100644 --- a/hypixel-api-core/src/main/java/net/hypixel/api/reply/PlayerReply.java +++ b/hypixel-api-core/src/main/java/net/hypixel/api/reply/PlayerReply.java @@ -1,17 +1,29 @@ package net.hypixel.api.reply; +import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import com.google.gson.reflect.TypeToken; +import net.hypixel.api.HypixelAPI; +import net.hypixel.api.data.type.GameType; +import net.hypixel.api.pets.PetStats; +import net.hypixel.api.util.ILeveling; +import net.hypixel.api.util.UnstableHypixelObject; +import net.hypixel.api.util.Utilities; + +import java.lang.reflect.Type; +import java.time.ZonedDateTime; +import java.util.Map; +import java.util.UUID; public class PlayerReply extends AbstractReply { - private JsonElement player; - public JsonObject getPlayer() { - if (player == null || player.isJsonNull()) { - return null; - } else { - return player.getAsJsonObject(); - } + // Suppressed because this field is dynamically assigned by Gson using reflection. + @SuppressWarnings({"unused", "RedundantSuppression"}) + private Player player; + + public Player getPlayer() { + return player; } @Override @@ -20,4 +32,257 @@ public String toString() { "player=" + player + "} " + super.toString(); } + + /** + * Information and statistics for a player on the Hypixel network. + *


+ * If the player does not {@link #exists() exist}, methods may return unexpected results. + */ + public static class Player extends UnstableHypixelObject { + + private static final String DEFAULT_RANK = "NONE"; + + /** + * @param raw A JSON object representing a Hypixel player, as returned from the API. If this + * object is valid, it can be retrieved later via {@link #getRaw()}. + */ + public Player(JsonElement raw) { + super(raw); + } + + /** + * @return The player's Minecraft UUID, or {@code null} if the player does not {@link + * #exists() exist}. + */ + public UUID getUuid() { + String uuidStr = getStringProperty("uuid", null); + return uuidStr != null ? Utilities.uuidFromString(uuidStr) : null; + } + + /** + * @return The Minecraft username that the player had when they last connected to Hypixel. + * {@code null} if the player's name is unknown. + */ + public String getName() { + // Attempt to get their display name + String displayName = getStringProperty("displayname", null); + if (displayName != null) { + return displayName; + } + + // Fallback to their most recently-known alias + JsonArray knownAliases = getArrayProperty("knownAliases"); + if (knownAliases != null && knownAliases.size() > 0) { + return knownAliases.get(knownAliases.size() - 1).getAsString(); + } + + // Fallback to lowercase variants of their name + return getStringProperty("playername", getStringProperty("username", null)); + } + + /** + * @return The total amount of network experience earned by the player. + */ + public long getNetworkExp() { + long exp = getLongProperty("networkExp", 0); + exp += ILeveling.getTotalExpToFullLevel(getLongProperty("networkLevel", 0) + 1); + return exp; + } + + /** + * @return The player's precise network level, including their progress to the next level. + */ + public double getNetworkLevel() { + return ILeveling.getExactLevel(getNetworkExp()); + } + + /** + * @return The total amount of karma points earned by the player. + */ + public long getKarma() { + return getLongProperty("karma", 0); + } + + /** + * @return The date when the player first connected to Hypixel. Defaults to the unix epoch + * when unknown. + */ + public ZonedDateTime getFirstLoginDate() { + return getTimestamp("firstLogin"); + } + + /** + * @return The last known time when the player connected to the main Hypixel network. + * Defaults to the unix epoch when unknown. + */ + public ZonedDateTime getLastLoginDate() { + return getTimestamp("lastLogin"); + } + + /** + * @return The last known time when the player disconnected from the main Hypixel network. + * Defaults to the unix epoch when unknown. + */ + public ZonedDateTime getLastLogoutDate() { + return getTimestamp("lastLogout"); + } + + /** + * @return {@code true} if the player is currently connected to the Hypixel network. {@code + * false} otherwise, or if the player's online status is hidden in the API. + * @see HypixelAPI#getStatus(UUID) + * @deprecated The status endpoint ({@link HypixelAPI#getStatus(UUID)}) is + * recommended for checking a player's online status. + */ + @Deprecated + public boolean isOnline() { + return getLongProperty("lastLogin", 0) > getLongProperty("lastLogout", 0); + } + + /** + * @return The color of the player's "+"s if they have MVP+ or MVP++. If they do not have + * either rank, or if they have not selected a color, {@code RED} is returned as the + * default. + */ + public String getSelectedPlusColor() { + return getStringProperty("rankPlusColor", "RED"); + } + + /** + * Note, returned colors use the names seen in this + * table, in all uppercase. For example, {@code DARK_BLUE} and {@code GRAY}. + * + * @return The color of the player's name tag if they have MVP++. Defaults to {@code GOLD}. + */ + public String getSuperstarTagColor() { + return getStringProperty("monthlyRankColor", "GOLD"); + } + + /** + * Returns the most privileged network rank that the player has. + *


+ * Example: If...

    + *
  • A player's base rank is MVP+ ({@code MVP_PLUS})
  • + *
  • They have a subscription for MVP++ ({@code SUPERSTAR})
  • + *
  • They are a staff member with the moderator rank ({@code MODERATOR})
  • + *
+ * ...then this method will return {@code MODERATOR}, because it has the highest permission + * level of the three ranks. + * + * @return The most privileged network rank that the player has, or {@code NONE} if they do + * not have any. + * @apiNote Display prefixes are not considered, as they have no effect on permissions. + * Examples include "OWNER" and "MOJANG". + * @see "How + * do I get a player's rank prefix?" + */ + public String getHighestRank() { + if (hasRankInField("rank")) { + return getStringProperty("rank", DEFAULT_RANK); + + } else if (hasRankInField("monthlyPackageRank")) { + return getStringProperty("monthlyPackageRank", DEFAULT_RANK); + + } else if (hasRankInField("newPackageRank")) { + return getStringProperty("newPackageRank", DEFAULT_RANK); + + } else if (hasRankInField("packageRank")) { + return getStringProperty("packageRank", DEFAULT_RANK); + } + + return DEFAULT_RANK; + } + + /** + * @return {@code true} if the player has a network rank (e.g. {@code VIP}, {@code MVP++}, + * {@code MODERATOR}, etc). + * @apiNote Display prefixes are not considered, as they are technically not ranks. Examples + * include "OWNER" and "MOJANG". + */ + public boolean hasRank() { + return !getHighestRank().equals(DEFAULT_RANK); + } + + /** + * @return {@code true} if the player is a member of the Hypixel + * Build Team. Otherwise {@code false}. + */ + public boolean isOnBuildTeam() { + return getBooleanProperty("buildTeam", false) + || getBooleanProperty("buildTeamAdmin", false); + } + + /** + * @return The player's most recently played {@link GameType}, or {@code null} if it is + * unknown. + */ + public GameType getMostRecentGameType() { + try { + return GameType.valueOf(getStringProperty("mostRecentGameType", "")); + } catch (IllegalArgumentException ignored) { + return null; + } + } + + /** + * @return Information about the player's lobby pets, or {@code null} if they have none. + */ + public PetStats getPetStats() { + JsonObject petStats = getObjectProperty("petStats"); + if (petStats == null) { + return null; + } + + Type statsObjectType = new TypeToken>>() { + }.getType(); + return new PetStats(Utilities.GSON.fromJson(petStats, statsObjectType)); + } + + /** + * @return The last Minecraft version that the player used to connect to Hypixel, or {@code + * null} if it is unknown. + */ + public String getLastKnownMinecraftVersion() { + return getStringProperty("mcVersionRp", null); + } + + /** + * @return {@code true} if the player could be identified by the API. Otherwise {@code + * false}. + */ + public boolean exists() { + return getUuid() != null; + } + + @Override + public String toString() { + return exists() + ? "Player" + raw + : "Player{exists=false}"; + } + + /** + * Helper method for checking if a rank-related field contains a non-default rank. + * + * @param name The name/json-path of the field to check. + * @return Whether or not the field contains a non-default rank value. + * @implNote {@code false} if {@code null}, {@code NONE}, or {@code NORMAL} + */ + protected boolean hasRankInField(String name) { + String value = getStringProperty(name, DEFAULT_RANK); + return !value.isEmpty() && !value.equals("NONE") && !value.equals("NORMAL"); + } + + /** + * Helper method for deserializing unix timestamp fields, in milliseconds. + * + * @param name The name/json-path of the field to check. + * @return The date represented by the timestamp, or the unix epoch if the field cannot be + * found. + */ + protected ZonedDateTime getTimestamp(String name) { + long timestamp = getLongProperty(name, 0); + return Utilities.getDateTime(timestamp); + } + } } diff --git a/Java/src/main/java/net/hypixel/api/util/IGuildLeveling.java b/hypixel-api-core/src/main/java/net/hypixel/api/util/IGuildLeveling.java similarity index 100% rename from Java/src/main/java/net/hypixel/api/util/IGuildLeveling.java rename to hypixel-api-core/src/main/java/net/hypixel/api/util/IGuildLeveling.java diff --git a/hypixel-api-core/src/main/java/net/hypixel/api/util/PropertyFilter.java b/hypixel-api-core/src/main/java/net/hypixel/api/util/PropertyFilter.java new file mode 100644 index 00000000..6cb6f1e1 --- /dev/null +++ b/hypixel-api-core/src/main/java/net/hypixel/api/util/PropertyFilter.java @@ -0,0 +1,194 @@ +package net.hypixel.api.util; + +import java.util.HashSet; +import java.util.Iterator; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * A tool for trimming unneeded properties from data, especially to minimize their memory and + * storage consumption. Based on MongoDB projections. + *


+ * To use an inclusion filter, property names (or "keys") can be added via {@link + * #include(String...) include(...)} or the {@link #including(String...) including(...) + * constructor}. When an object is passed through the filter, any properties not explicitly named + * using the aforementioned methods will be removed from the object. If the object did not have an + * included property to begin with, it will not be created. + *


+ * Property names are referenced using dot-notation. See the documentation for {@link + * #include(String...) include(...)} for more details. + */ +public class PropertyFilter { + + /** + * Shorthand for constructing a new filter that only allows the {@code includedKeys} to pass + * through. See {@link #include(String...)} for the key syntax. + */ + public static PropertyFilter including(String... includedKeys) { + PropertyFilter filter = new PropertyFilter(); + filter.include(includedKeys); + return filter; + } + + // Only these keys are allowed in objects passed through. + protected final Set allowedKeys; + + public PropertyFilter() { + allowedKeys = new HashSet<>(); + } + + /** + * Allows properties with any of the provided {@code keys} to pass through the filter. To + * include nested properties, use dots ({@code .}) to separate each parent property from its + * child. If a property's name contains a dot literally, use a double-backslash to escape the + * dot. (e.g. {@code "key_with_literal_dot\\.in_it"} instead of {@code + * "key_with_literal_dot.in_it"}) + *

+     * Examples:
+     *     •{@code uuid}                - Keep the player's UUID when filtering.
+     *     •{@code stats}               - Keep all of the player's stats when filtering.
+     *     •{@code stats.SkyWars}       - Keep all of the player's SkyWars stats when filtering.
+     *     •{@code stats.SkyWars.coins} - Keep just the player's SkyWars coins when filtering.
+     * 
+ * If an added key conflicts with an existing one, the newer key takes precedence. + * + * @param keys Names of properties that will be allowed to pass through the filter (in + * dot-notation). + */ + public void include(String... keys) { + if (keys == null) { + throw new IllegalArgumentException("Cannot include null property keys"); + } + + // Check for key collisions. + for (String rawKey : keys) { + if (rawKey == null) { + throw new IllegalArgumentException("Cannot include null property keys"); + } + + PropertyKey key = new PropertyKey(rawKey); + boolean shouldAddKey = true; + Iterator existingKeys = allowedKeys.iterator(); + + while (existingKeys.hasNext()) { + PropertyKey existingKey = existingKeys.next(); + + // Ignore duplicate keys. + if (existingKey.equals(key)) { + shouldAddKey = false; + break; + } + + // Check if the new key collides with the existing key's scope. + if (key.isExtendedBy(existingKey)) { + // Replace & continue, since there can be multiple keys with narrower scopes. + existingKeys.remove(); + } else if (existingKey.isExtendedBy(key)) { + // Replace & break, since only 1 key should possibly have a wider scope. + existingKeys.remove(); + break; + } + } + + if (shouldAddKey) { + allowedKeys.add(key); + } + } + } + + /** + * Removes all of the provided keys from the filter, such that objects passed through the filter + * will not include properties with those keys. + *


+ * Attempting to remove a key that was already removed, or never {@link #include(String...) + * included} to begin with, will have no effect. + */ + public void remove(String... keys) { + if (keys == null) { + throw new IllegalArgumentException("Cannot remove null keys"); + } + + for (String key : keys) { + if (key == null) { + throw new IllegalArgumentException("Cannot remove null keys"); + } + allowedKeys.removeIf(existingKey -> existingKey.toString().equals(key)); + } + } + + /** + * @return A new set containing all property keys that can pass through the filter. + * @see #include(String...) + */ + public Set getIncluded() { + return allowedKeys.stream() + .map(PropertyKey::toString) + .collect(Collectors.toSet()); + } + + /** + * The key a property in an object, potentially one nested inside multiple other objects. + */ + protected static final class PropertyKey { + + // The key's full stringified form. Literal dots (.) should still have escape characters. + final String full; + + // Each "part" of the key, delimited by un-escaped dots in the `full` key. + final String[] tokens; + + PropertyKey(String full) { + if (full == null) { + throw new IllegalArgumentException("Property key cannot be null"); + } + this.full = full; + tokens = Utilities.tokenizeKey(full); + } + + /** + * @return {@code true} if the {@code other} key starts with all of this key's {@link + * #tokens} & has additional tokens at the end, otherwise {@code false}. + */ + boolean isExtendedBy(PropertyKey other) { + String otherFull = other.full; + int extensionIndex = full.length(); + + // (1) `other` cannot possibly be an extension if it has a shorter or equal length. + // (2) Check that the key continues immediately after extensionIndex. + // (3) Check that the dot (.) we found in (2) wasn't escaped. + // (4) Check that the other key starts with this entire key. + return otherFull.length() > full.length() + && otherFull.charAt(extensionIndex) == '.' + && otherFull.charAt(extensionIndex - 1) != '\\' + && otherFull.startsWith(full); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null) { + return false; + } + if (o instanceof PropertyKey) { + return full.equals(((PropertyKey) o).full); + } + if (o instanceof String) { + return full.equals(o); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(full); + } + + @Override + public String toString() { + return full; + } + } +} diff --git a/hypixel-api-core/src/main/java/net/hypixel/api/util/UnstableHypixelObject.java b/hypixel-api-core/src/main/java/net/hypixel/api/util/UnstableHypixelObject.java new file mode 100644 index 00000000..06e5f74f --- /dev/null +++ b/hypixel-api-core/src/main/java/net/hypixel/api/util/UnstableHypixelObject.java @@ -0,0 +1,265 @@ +package net.hypixel.api.util; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import net.hypixel.api.util.PropertyFilter.PropertyKey; + +import java.util.Map.Entry; + +/** + * An object returned from the Hypixel API that lacks a defined structure. + */ +public abstract class UnstableHypixelObject { + + protected final JsonObject raw; + + protected UnstableHypixelObject(JsonElement raw) { + this.raw = raw instanceof JsonObject + ? (JsonObject) raw + : new JsonObject(); + } + + /** + * @return The raw object returned by the Hypixel API; the source of any properties for the + * object + */ + public JsonObject getRaw() { + return raw; + } + + /** + * @param key Dot-notation path to the desired field + * @return {@code true} if the object has a value associated with the {@code key}, including if + * that value is {@link JsonNull}. Otherwise {@code false}. + * @see #getProperty(String) + */ + public boolean hasProperty(String key) { + return getProperty(key) != null; + } + + /** + * Strips the object of any properties that haven't explicitly been allowed via the filter's + * {@link PropertyFilter#include(String...) include()} method or the {@link + * PropertyFilter#including(String...) including()} constructor. + *


+ * The resulting object will (at most) only contain the properties returned by {@link + * PropertyFilter#getIncluded() filter#getIncluded()}. If the object does not already have any + * of the included keys, they will not be added. + * + * @throws IllegalArgumentException If the {@code filter} is {@code null}. + */ + public void filter(PropertyFilter filter) { + if (filter == null) { + throw new IllegalArgumentException("Cannot use a null filter"); + } else if (raw.entrySet().isEmpty()) { + // Ignore empty objects. + return; + } + + JsonObject temp = new JsonObject(); + for (PropertyKey key : filter.allowedKeys) { + JsonElement value = getProperty(key.toString()); + if (value == null) { + // Ignore null properties. + continue; + } + + // Create any required parents for the property, similar to File#mkdirs(). + JsonObject parent = temp; + String[] tokens = key.tokens; + for (int i = 0; i < tokens.length; i++) { + String token = tokens[i]; + String escapedToken = token.replace("\\.", "."); + + if (i < tokens.length - 1) { + + // Use the existing child object (if one exists). + JsonElement existingChild = parent.get(token); + if (existingChild instanceof JsonObject) { + parent = (JsonObject) existingChild; + continue; + } + + // Create a new child object if one doesn't exist. + JsonObject child = new JsonObject(); + parent.add(escapedToken, child); + parent = child; + } else { + // Set the final value of the property. + parent.add(escapedToken, value); + } + } + } + + // Replace the contents of the original object. + raw.entrySet().clear(); + for (Entry property : temp.entrySet()) { + raw.add(property.getKey(), property.getValue()); + } + } + + /** + * Get a String from the object + * + * @return The string value associated with the {@code key}, or {@code defaultValue} if the + * value does not exist or isn't a string + * @see #getProperty(String) + */ + public String getStringProperty(String key, String defaultValue) { + JsonElement value = getProperty(key); + if (value == null + || !value.isJsonPrimitive() + || !value.getAsJsonPrimitive().isString()) { + return defaultValue; + } + return value.getAsJsonPrimitive().getAsString(); + } + + /** + * Get a float from the object + * + * @return The float value associated with the {@code key}, or {@code defaultValue} if the value + * does not exist or isn't a float + * @see #getProperty(String) + */ + public float getFloatProperty(String key, float defaultValue) { + return getNumberProperty(key, defaultValue).floatValue(); + } + + /** + * Get a double from the object + * + * @return The double value associated with the {@code key}, or {@code defaultValue} if the + * value does not exist or isn't a double + * @see #getProperty(String) + */ + public double getDoubleProperty(String key, double defaultValue) { + return getNumberProperty(key, defaultValue).doubleValue(); + } + + /** + * Get a long from the object + * + * @return The long value associated with the {@code key}, or {@code defaultValue} if the value + * does not exist or isn't a long + * @see #getProperty(String) + */ + public long getLongProperty(String key, long defaultValue) { + return getNumberProperty(key, defaultValue).longValue(); + } + + /** + * Get an integer from the object + * + * @return The int value associated with the {@code key}, or {@code defaultValue} if the value + * does not exist or isn't an int + * @see #getProperty(String) + */ + public int getIntProperty(String key, int defaultValue) { + return getNumberProperty(key, defaultValue).intValue(); + } + + /** + * Get a Number property from the object + * + * @return The numeric value associated with the {@code key}, or {@code defaultValue} if the + * value does not exist or isn't a number + * @see #getProperty(String) + */ + public Number getNumberProperty(String key, Number defaultValue) { + JsonElement value = getProperty(key); + if (value == null + || !value.isJsonPrimitive() + || !value.getAsJsonPrimitive().isNumber()) { + return defaultValue; + } + return value.getAsJsonPrimitive().getAsNumber(); + } + + /** + * Get a boolean from the object + * + * @return The boolean value associated with the {@code key}, or {@code defaultValue} if the + * value does not exist or isn't a boolean + * @see #getProperty(String) + */ + public boolean getBooleanProperty(String key, boolean defaultValue) { + JsonElement value = getProperty(key); + if (value == null + || !value.isJsonPrimitive() + || !value.getAsJsonPrimitive().isBoolean()) { + return defaultValue; + } + return value.getAsJsonPrimitive().getAsBoolean(); + } + + /** + * Get a JsonArray property from the object + * + * @return The JSON array associated with the {@code key}, or an empty array if the value does + * not exist or isn't an array + * @see #getProperty(String) + */ + public JsonArray getArrayProperty(String key) { + JsonElement result = getProperty(key); + if (result == null || !result.isJsonArray()) { + return new JsonArray(); + } + return result.getAsJsonArray(); + } + + /** + * Get a JsonObject property from the object + * + * @return The JSON object associated with the {@code key}, or {@code null} if the value does + * not exist or isn't a JSON object + * @see #getProperty(String) + */ + public JsonObject getObjectProperty(String key) { + JsonElement result = getProperty(key); + if (result == null || !result.isJsonObject()) { + return null; + } + return result.getAsJsonObject(); + } + + /** + * Read a property from the object returned by the API + * + * @param key Dot-notation path to the desired field (e.g. {@code "stats.SkyWars.deaths"}) + * @return The value associated with the specified property, or {@code null} if no value is set + * for that property. + */ + public JsonElement getProperty(String key) { + if (key == null) { + throw new IllegalArgumentException("Property key cannot be null"); + } else if (key.isEmpty()) { + // Return root object if path is empty. + return raw; + } + + String[] tokens = Utilities.tokenizeKey(key); + + // Navigate the raw object until the end of the provided token list. + JsonObject parent = getRaw(); + for (int i = 0; i < tokens.length; i++) { + + JsonElement child = parent.get(tokens[i].replace("\\.", ".")); + if (i + 1 == tokens.length) { + // No more tokens; current child must be the output. + return child; + } + + // More tokens follow; child must be an object to continue. + if (child instanceof JsonObject) { + parent = child.getAsJsonObject(); + continue; + } + break; + } + + return null; + } +} diff --git a/hypixel-api-core/src/main/java/net/hypixel/api/util/Utilities.java b/hypixel-api-core/src/main/java/net/hypixel/api/util/Utilities.java index 3759ffe0..5aef0b19 100644 --- a/hypixel-api-core/src/main/java/net/hypixel/api/util/Utilities.java +++ b/hypixel-api-core/src/main/java/net/hypixel/api/util/Utilities.java @@ -1,12 +1,54 @@ package net.hypixel.api.util; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import net.hypixel.api.adapters.*; +import net.hypixel.api.data.type.GameType; +import net.hypixel.api.reply.BoostersReply; +import net.hypixel.api.reply.PlayerReply.Player; + import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; +import java.util.UUID; +import java.util.regex.Pattern; + +public final class Utilities { -public class Utilities { + private static final Pattern TOKEN_SPLITTER = Pattern.compile("(?(BoostersReply.Booster.class)) + .create(); public static ZonedDateTime getDateTime(long timeStamp) { return Instant.ofEpochMilli(timeStamp).atZone(ZoneId.of("America/New_York")); } + + /** + * Splits the input {@code key} into tokens, which are delimited by dots ({@code .}) that aren't + * preceded by a backslash ({@code \}). + */ + public static String[] tokenizeKey(String key) { + return TOKEN_SPLITTER.split(key); + } + + public static UUID uuidFromString(String uuidStr) { + if (!uuidStr.contains("-")) { + uuidStr = uuidStr.substring(0, 8) + "-" + + uuidStr.substring(8, 12) + "-" + + uuidStr.substring(12, 16) + "-" + + uuidStr.substring(16, 20) + "-" + + uuidStr.substring(20, 32); + + } + return UUID.fromString(uuidStr); + } + + private Utilities() { + throw new UnsupportedOperationException("Helper class should not be instantiated"); + } } diff --git a/hypixel-api-example/src/main/java/net/hypixel/api/example/GetPlayerExample.java b/hypixel-api-example/src/main/java/net/hypixel/api/example/GetPlayerExample.java index 22ba58af..3d5bd018 100644 --- a/hypixel-api-example/src/main/java/net/hypixel/api/example/GetPlayerExample.java +++ b/hypixel-api-example/src/main/java/net/hypixel/api/example/GetPlayerExample.java @@ -1,8 +1,125 @@ package net.hypixel.api.example; +import net.hypixel.api.HypixelAPI; +import net.hypixel.api.reply.PlayerReply; +import net.hypixel.api.reply.PlayerReply.Player; + +import java.util.UUID; +import java.util.concurrent.ExecutionException; + +/** + * A sample app for demonstrating how players can be fetched & used from the Hypixel API. + */ public class GetPlayerExample { + public static void main(String[] args) { - ExampleUtil.API.getPlayerByUuid(ExampleUtil.HYPIXEL).whenComplete(ExampleUtil.getTestConsumer()); - ExampleUtil.await(); + /* + * Make sure you have a HypixelAPI object set up. You can see how this is done by going to + * the ExampleUtil class. + * + * See the finally{} block below for how to shutdown this API once you're all done. + */ + HypixelAPI api = ExampleUtil.API; + + /* + * Skip to below the try/catch/finally block to see how this is used. + */ + PlayerReply apiReply; + + try { + UUID playerUuid = ExampleUtil.HYPIXEL; + /* + * Here, we store the response from the API in our variable. + * + * We call `.get()` at the end so that we can use the reply in the same thread. + * The downside is that the current thread freezes (or "blocks") until the API responds. + * If this is a problem for you, instead use: + * + * .whenComplete((apiReply, error) -> { + * // Do something with apiReply (in a different thread)... + * }); + * + * But for a simple command-line app like this one, `.get()` will do the job. + */ + apiReply = api.getPlayerByUuid(playerUuid).get(); + + } catch (ExecutionException e) { + System.err.println("Oh no, our API request failed!"); + + /* + * If an ExecutionException is thrown, it's typically because of an API error. + * Use `getCause()` to determine what the actual problem is. + */ + e.getCause().printStackTrace(); + return; + + } catch (InterruptedException e) { + // Shouldn't happen under normal circumstances. + System.err.println("Oh no, the player fetch thread was interrupted!"); + e.printStackTrace(); + Thread.currentThread().interrupt(); + return; + + } finally { + /* + * Once you're finished with all your requests, you can shutdown your HypixelAPI object. + * + * If your app is meant to run continuously, you probably don't want to do this until + * the app is stopped/closed. For this example though, we only need the one request. + */ + api.shutdown(); + } + + /* + * Now that we have the player, we can start to read their stats! (if they actually exist) + */ + Player player = apiReply.getPlayer(); + if (!player.exists()) { + System.err.println("Player not found!"); + + api.shutdown(); + return; + } + + /* + * The player class has some built-in getters, like for the player's name, rank, and UUID. + */ + System.out.println("Here are some of \"" + player.getName() + "\"'s stats!"); + System.out.println(); + System.out.println("UUID ----------> " + player.getUuid()); + System.out.println("Rank ----------> " + player.getHighestRank()); + System.out.println("On Build Team? > " + player.isOnBuildTeam()); + System.out.println("Exact Level ---> " + player.getNetworkLevel()); + System.out.println("Experience ----> " + player.getNetworkExp()); + System.out.println("Karma ---------> " + player.getKarma()); + System.out.println("MC Version ----> " + player.getLastKnownMinecraftVersion()); + System.out.println("Last Game Type > " + player.getMostRecentGameType()); + + /* + * If you want to find a stat that doesn't have a built-in method, you can use the + * `getProperty()` method. + * + * If you also know what type of property it is (like a string or number), you can use more + * specific methods, like `getStringProperty(...)`, `getIntProperty(...)`, and so on. + */ + System.out.println("Previous Names > " + player.getArrayProperty("knownAliases")); + + /* + * Some of the property methods also accept a default value, which gets returned if the + * field does not exist for the player. In this case, we return `0` as the default number of + * deaths. + * + * If a stat is a bit deeper in the player object, you can separate each layer of the path + * using dots, like below. + * - In our case, it's the equivalent of getting the player's "stats" object, then the + * "SkyWars" object inside that, and finally the "deaths" stat inside that. + */ + System.out.println("SkyWars Deaths > " + player.getIntProperty("stats.SkyWars.deaths", 0)); + + /* + * If you need the entire player JSON returned by the API, you can get it using the player's + * `.getRaw()` method. + */ + System.out.println("Raw JSON ------> " + player.getRaw()); } } diff --git a/hypixel-api-example/src/main/java/net/hypixel/api/example/skyblock/GetSkyBlockProfileExample.java b/hypixel-api-example/src/main/java/net/hypixel/api/example/skyblock/GetSkyBlockProfileExample.java index d96b0eb7..54913e4f 100644 --- a/hypixel-api-example/src/main/java/net/hypixel/api/example/skyblock/GetSkyBlockProfileExample.java +++ b/hypixel-api-example/src/main/java/net/hypixel/api/example/skyblock/GetSkyBlockProfileExample.java @@ -1,24 +1,60 @@ package net.hypixel.api.example.skyblock; import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import net.hypixel.api.example.ExampleUtil; +import net.hypixel.api.reply.skyblock.SkyBlockProfileReply; -import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.concurrent.CompletableFuture; public class GetSkyBlockProfileExample { + public static void main(String[] args) { - ExampleUtil.API.getPlayerByUuid(ExampleUtil.HYPIXEL).whenComplete((playerReply, throwable) -> { - if (throwable != null) { - throwable.printStackTrace(); + ExampleUtil.API.getPlayerByUuid(ExampleUtil.HYPIXEL).whenComplete((reply, error) -> { + if (error != null) { + error.printStackTrace(); System.exit(0); return; } - for (Map.Entry profileEntry : playerReply.getPlayer().getAsJsonObject("stats").getAsJsonObject("SkyBlock").getAsJsonObject("profiles").entrySet()) { - ExampleUtil.API.getSkyBlockProfile(profileEntry.getKey()).whenComplete(ExampleUtil.getTestConsumer()); - break; + // Get all of the player's profiles. + JsonObject profiles = reply.getPlayer().getObjectProperty("stats.SkyBlock.profiles"); + if (profiles == null || profiles.entrySet().isEmpty()) { + System.out.println("Player has no SkyBlock profiles"); + System.exit(0); + return; } + + // Request each profile from the API & print the reply. + Set> profileEntries = profiles.entrySet(); + CompletableFuture[] requests = new CompletableFuture[profileEntries.size()]; + int i = 0; + for (Entry profile : profileEntries) { + requests[i] = requestProfile(profile.getKey()); + i++; + } + + // Only exit once all requests are completed. + CompletableFuture.allOf(requests).whenComplete((ignored, profileError) -> { + if (profileError != null) { + profileError.printStackTrace(); + } + System.exit(0); + }); }); ExampleUtil.await(); } + + private static CompletableFuture requestProfile(String profileId) { + return ExampleUtil.API.getSkyBlockProfile(profileId).whenComplete((profileReply, ex) -> { + if (ex != null) { + ex.printStackTrace(); + return; + } + + System.out.println(profileReply); + }); + } } diff --git a/hypixel-api-transport-apache/README.md b/hypixel-api-transport-apache/README.md index ee6b4e31..15788e01 100644 --- a/hypixel-api-transport-apache/README.md +++ b/hypixel-api-transport-apache/README.md @@ -4,6 +4,7 @@ Hypixel Public API - Apache Transport ### Usage ```xml + net.hypixel hypixel-api-transport-apache diff --git a/hypixel-api-transport-reactor/README.md b/hypixel-api-transport-reactor/README.md index 45ce314e..8aa6c3ee 100644 --- a/hypixel-api-transport-reactor/README.md +++ b/hypixel-api-transport-reactor/README.md @@ -45,5 +45,5 @@ This transport depends on the following: * [Google Gson library - 2.8.6](https://mvnrepository.com/artifact/com.google.code.gson/gson) (for hypixel-api-core) * [Reactor Core 3.4.5](https://mvnrepository.com/artifact/io.projectreactor/reactor-core) (for reactor netty) * Reactor Netty [(project-reactor)](https://projectreactor.io/docs): - * [Netty Core 1.0.6](https://mvnrepository.com/artifact/io.projectreactor.netty/reactor-netty-core) - * [Netty Http 1.0.6](https://mvnrepository.com/artifact/io.projectreactor.netty/reactor-netty-http) \ No newline at end of file + * [Netty Core 1.0.6](https://mvnrepository.com/artifact/io.projectreactor.netty/reactor-netty-core) + * [Netty Http 1.0.6](https://mvnrepository.com/artifact/io.projectreactor.netty/reactor-netty-http) \ No newline at end of file diff --git a/hypixel-api-transport-unirest/README.md b/hypixel-api-transport-unirest/README.md index f8c85bb7..86dcae27 100644 --- a/hypixel-api-transport-unirest/README.md +++ b/hypixel-api-transport-unirest/README.md @@ -4,6 +4,7 @@ Hypixel Public API - Unirest Transport ### Usage ```xml + net.hypixel hypixel-api-transport-unirest