diff options
Diffstat (limited to 'src/main')
| -rw-r--r-- | src/main/java/com/MylesAndMore/tumble/api/Metrics.java | 863 | 
1 files changed, 863 insertions, 0 deletions
diff --git a/src/main/java/com/MylesAndMore/tumble/api/Metrics.java b/src/main/java/com/MylesAndMore/tumble/api/Metrics.java new file mode 100644 index 0000000..b05119b --- /dev/null +++ b/src/main/java/com/MylesAndMore/tumble/api/Metrics.java @@ -0,0 +1,863 @@ +/* + * This Metrics class was auto-generated and can be copied into your project if you are + * not using a build tool like Gradle or Maven for dependency management. + * + * IMPORTANT: You are not allowed to modify this class, except changing the package. + * + * Unallowed modifications include but are not limited to: + *  - Remove the option for users to opt-out + *  - Change the frequency for data submission + *  - Obfuscate the code (every obfucator should allow you to make an exception for specific files) + *  - Reformat the code (if you use a linter, add an exception) + * + * Violations will result in a ban of your plugin and account from bStats. + */ +package com.MylesAndMore.tumble.api; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.lang.reflect.Method; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.logging.Level; +import java.util.stream.Collectors; +import java.util.zip.GZIPOutputStream; +import javax.net.ssl.HttpsURLConnection; +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.java.JavaPlugin; + +public class Metrics { + +    private final Plugin plugin; + +    private final MetricsBase metricsBase; + +    /** +     * Creates a new Metrics instance. +     * +     * @param plugin Your plugin instance. +     * @param serviceId The id of the service. It can be found at <a +     *     href="https://bstats.org/what-is-my-plugin-id">What is my plugin id?</a> +     */ +    public Metrics(JavaPlugin plugin, int serviceId) { +        this.plugin = plugin; +        // Get the config file +        File bStatsFolder = new File(plugin.getDataFolder().getParentFile(), "bStats"); +        File configFile = new File(bStatsFolder, "config.yml"); +        YamlConfiguration config = YamlConfiguration.loadConfiguration(configFile); +        if (!config.isSet("serverUuid")) { +            config.addDefault("enabled", true); +            config.addDefault("serverUuid", UUID.randomUUID().toString()); +            config.addDefault("logFailedRequests", false); +            config.addDefault("logSentData", false); +            config.addDefault("logResponseStatusText", false); +            // Inform the server owners about bStats +            config +                    .options() +                    .header( +                            "bStats (https://bStats.org) collects some basic information for plugin authors, like how\n" +                                    + "many people use their plugin and their total player count. It's recommended to keep bStats\n" +                                    + "enabled, but if you're not comfortable with this, you can turn this setting off. There is no\n" +                                    + "performance penalty associated with having metrics enabled, and data sent to bStats is fully\n" +                                    + "anonymous.") +                    .copyDefaults(true); +            try { +                config.save(configFile); +            } catch (IOException ignored) { +            } +        } +        // Load the data +        boolean enabled = config.getBoolean("enabled", true); +        String serverUUID = config.getString("serverUuid"); +        boolean logErrors = config.getBoolean("logFailedRequests", false); +        boolean logSentData = config.getBoolean("logSentData", false); +        boolean logResponseStatusText = config.getBoolean("logResponseStatusText", false); +        metricsBase = +                new MetricsBase( +                        "bukkit", +                        serverUUID, +                        serviceId, +                        enabled, +                        this::appendPlatformData, +                        this::appendServiceData, +                        submitDataTask -> Bukkit.getScheduler().runTask(plugin, submitDataTask), +                        plugin::isEnabled, +                        (message, error) -> this.plugin.getLogger().log(Level.WARNING, message, error), +                        (message) -> this.plugin.getLogger().log(Level.INFO, message), +                        logErrors, +                        logSentData, +                        logResponseStatusText); +    } + +    /** +     * Adds a custom chart. +     * +     * @param chart The chart to add. +     */ +    public void addCustomChart(CustomChart chart) { +        metricsBase.addCustomChart(chart); +    } + +    private void appendPlatformData(JsonObjectBuilder builder) { +        builder.appendField("playerAmount", getPlayerAmount()); +        builder.appendField("onlineMode", Bukkit.getOnlineMode() ? 1 : 0); +        builder.appendField("bukkitVersion", Bukkit.getVersion()); +        builder.appendField("bukkitName", Bukkit.getName()); +        builder.appendField("javaVersion", System.getProperty("java.version")); +        builder.appendField("osName", System.getProperty("os.name")); +        builder.appendField("osArch", System.getProperty("os.arch")); +        builder.appendField("osVersion", System.getProperty("os.version")); +        builder.appendField("coreCount", Runtime.getRuntime().availableProcessors()); +    } + +    private void appendServiceData(JsonObjectBuilder builder) { +        builder.appendField("pluginVersion", plugin.getDescription().getVersion()); +    } + +    private int getPlayerAmount() { +        try { +            // Around MC 1.8 the return type was changed from an array to a collection, +            // This fixes java.lang.NoSuchMethodError: +            // org.bukkit.Bukkit.getOnlinePlayers()Ljava/util/Collection; +            Method onlinePlayersMethod = Class.forName("org.bukkit.Server").getMethod("getOnlinePlayers"); +            return onlinePlayersMethod.getReturnType().equals(Collection.class) +                    ? ((Collection<?>) onlinePlayersMethod.invoke(Bukkit.getServer())).size() +                    : ((Player[]) onlinePlayersMethod.invoke(Bukkit.getServer())).length; +        } catch (Exception e) { +            // Just use the new method if the reflection failed +            return Bukkit.getOnlinePlayers().size(); +        } +    } + +    public static class MetricsBase { + +        /** The version of the Metrics class. */ +        public static final String METRICS_VERSION = "3.0.0"; + +        private static final ScheduledExecutorService scheduler = +                Executors.newScheduledThreadPool(1, task -> new Thread(task, "bStats-Metrics")); + +        private static final String REPORT_URL = "https://bStats.org/api/v2/data/%s"; + +        private final String platform; + +        private final String serverUuid; + +        private final int serviceId; + +        private final Consumer<JsonObjectBuilder> appendPlatformDataConsumer; + +        private final Consumer<JsonObjectBuilder> appendServiceDataConsumer; + +        private final Consumer<Runnable> submitTaskConsumer; + +        private final Supplier<Boolean> checkServiceEnabledSupplier; + +        private final BiConsumer<String, Throwable> errorLogger; + +        private final Consumer<String> infoLogger; + +        private final boolean logErrors; + +        private final boolean logSentData; + +        private final boolean logResponseStatusText; + +        private final Set<CustomChart> customCharts = new HashSet<>(); + +        private final boolean enabled; + +        /** +         * Creates a new MetricsBase class instance. +         * +         * @param platform The platform of the service. +         * @param serviceId The id of the service. +         * @param serverUuid The server uuid. +         * @param enabled Whether or not data sending is enabled. +         * @param appendPlatformDataConsumer A consumer that receives a {@code JsonObjectBuilder} and +         *     appends all platform-specific data. +         * @param appendServiceDataConsumer A consumer that receives a {@code JsonObjectBuilder} and +         *     appends all service-specific data. +         * @param submitTaskConsumer A consumer that takes a runnable with the submit task. This can be +         *     used to delegate the data collection to a another thread to prevent errors caused by +         *     concurrency. Can be {@code null}. +         * @param checkServiceEnabledSupplier A supplier to check if the service is still enabled. +         * @param errorLogger A consumer that accepts log message and an error. +         * @param infoLogger A consumer that accepts info log messages. +         * @param logErrors Whether or not errors should be logged. +         * @param logSentData Whether or not the sent data should be logged. +         * @param logResponseStatusText Whether or not the response status text should be logged. +         */ +        public MetricsBase( +                String platform, +                String serverUuid, +                int serviceId, +                boolean enabled, +                Consumer<JsonObjectBuilder> appendPlatformDataConsumer, +                Consumer<JsonObjectBuilder> appendServiceDataConsumer, +                Consumer<Runnable> submitTaskConsumer, +                Supplier<Boolean> checkServiceEnabledSupplier, +                BiConsumer<String, Throwable> errorLogger, +                Consumer<String> infoLogger, +                boolean logErrors, +                boolean logSentData, +                boolean logResponseStatusText) { +            this.platform = platform; +            this.serverUuid = serverUuid; +            this.serviceId = serviceId; +            this.enabled = enabled; +            this.appendPlatformDataConsumer = appendPlatformDataConsumer; +            this.appendServiceDataConsumer = appendServiceDataConsumer; +            this.submitTaskConsumer = submitTaskConsumer; +            this.checkServiceEnabledSupplier = checkServiceEnabledSupplier; +            this.errorLogger = errorLogger; +            this.infoLogger = infoLogger; +            this.logErrors = logErrors; +            this.logSentData = logSentData; +            this.logResponseStatusText = logResponseStatusText; +            checkRelocation(); +            if (enabled) { +                // WARNING: Removing the option to opt-out will get your plugin banned from bStats +                startSubmitting(); +            } +        } + +        public void addCustomChart(CustomChart chart) { +            this.customCharts.add(chart); +        } + +        private void startSubmitting() { +            final Runnable submitTask = +                    () -> { +                        if (!enabled || !checkServiceEnabledSupplier.get()) { +                            // Submitting data or service is disabled +                            scheduler.shutdown(); +                            return; +                        } +                        if (submitTaskConsumer != null) { +                            submitTaskConsumer.accept(this::submitData); +                        } else { +                            this.submitData(); +                        } +                    }; +            // Many servers tend to restart at a fixed time at xx:00 which causes an uneven distribution +            // of requests on the +            // bStats backend. To circumvent this problem, we introduce some randomness into the initial +            // and second delay. +            // WARNING: You must not modify and part of this Metrics class, including the submit delay or +            // frequency! +            // WARNING: Modifying this code will get your plugin banned on bStats. Just don't do it! +            long initialDelay = (long) (1000 * 60 * (3 + Math.random() * 3)); +            long secondDelay = (long) (1000 * 60 * (Math.random() * 30)); +            scheduler.schedule(submitTask, initialDelay, TimeUnit.MILLISECONDS); +            scheduler.scheduleAtFixedRate( +                    submitTask, initialDelay + secondDelay, 1000 * 60 * 30, TimeUnit.MILLISECONDS); +        } + +        private void submitData() { +            final JsonObjectBuilder baseJsonBuilder = new JsonObjectBuilder(); +            appendPlatformDataConsumer.accept(baseJsonBuilder); +            final JsonObjectBuilder serviceJsonBuilder = new JsonObjectBuilder(); +            appendServiceDataConsumer.accept(serviceJsonBuilder); +            JsonObjectBuilder.JsonObject[] chartData = +                    customCharts.stream() +                            .map(customChart -> customChart.getRequestJsonObject(errorLogger, logErrors)) +                            .filter(Objects::nonNull) +                            .toArray(JsonObjectBuilder.JsonObject[]::new); +            serviceJsonBuilder.appendField("id", serviceId); +            serviceJsonBuilder.appendField("customCharts", chartData); +            baseJsonBuilder.appendField("service", serviceJsonBuilder.build()); +            baseJsonBuilder.appendField("serverUUID", serverUuid); +            baseJsonBuilder.appendField("metricsVersion", METRICS_VERSION); +            JsonObjectBuilder.JsonObject data = baseJsonBuilder.build(); +            scheduler.execute( +                    () -> { +                        try { +                            // Send the data +                            sendData(data); +                        } catch (Exception e) { +                            // Something went wrong! :( +                            if (logErrors) { +                                errorLogger.accept("Could not submit bStats metrics data", e); +                            } +                        } +                    }); +        } + +        private void sendData(JsonObjectBuilder.JsonObject data) throws Exception { +            if (logSentData) { +                infoLogger.accept("Sent bStats metrics data: " + data.toString()); +            } +            String url = String.format(REPORT_URL, platform); +            HttpsURLConnection connection = (HttpsURLConnection) new URL(url).openConnection(); +            // Compress the data to save bandwidth +            byte[] compressedData = compress(data.toString()); +            connection.setRequestMethod("POST"); +            connection.addRequestProperty("Accept", "application/json"); +            connection.addRequestProperty("Connection", "close"); +            connection.addRequestProperty("Content-Encoding", "gzip"); +            connection.addRequestProperty("Content-Length", String.valueOf(compressedData.length)); +            connection.setRequestProperty("Content-Type", "application/json"); +            connection.setRequestProperty("User-Agent", "Metrics-Service/1"); +            connection.setDoOutput(true); +            try (DataOutputStream outputStream = new DataOutputStream(connection.getOutputStream())) { +                outputStream.write(compressedData); +            } +            StringBuilder builder = new StringBuilder(); +            try (BufferedReader bufferedReader = +                         new BufferedReader(new InputStreamReader(connection.getInputStream()))) { +                String line; +                while ((line = bufferedReader.readLine()) != null) { +                    builder.append(line); +                } +            } +            if (logResponseStatusText) { +                infoLogger.accept("Sent data to bStats and received response: " + builder); +            } +        } + +        /** Checks that the class was properly relocated. */ +        private void checkRelocation() { +            // You can use the property to disable the check in your test environment +            if (System.getProperty("bstats.relocatecheck") == null +                    || !System.getProperty("bstats.relocatecheck").equals("false")) { +                // Maven's Relocate is clever and changes strings, too. So we have to use this little +                // "trick" ... :D +                final String defaultPackage = +                        new String(new byte[] {'o', 'r', 'g', '.', 'b', 's', 't', 'a', 't', 's'}); +                final String examplePackage = +                        new String(new byte[] {'y', 'o', 'u', 'r', '.', 'p', 'a', 'c', 'k', 'a', 'g', 'e'}); +                // We want to make sure no one just copy & pastes the example and uses the wrong package +                // names +                if (MetricsBase.class.getPackage().getName().startsWith(defaultPackage) +                        || MetricsBase.class.getPackage().getName().startsWith(examplePackage)) { +                    throw new IllegalStateException("bStats Metrics class has not been relocated correctly!"); +                } +            } +        } + +        /** +         * Gzips the given string. +         * +         * @param str The string to gzip. +         * @return The gzipped string. +         */ +        private static byte[] compress(final String str) throws IOException { +            if (str == null) { +                return null; +            } +            ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); +            try (GZIPOutputStream gzip = new GZIPOutputStream(outputStream)) { +                gzip.write(str.getBytes(StandardCharsets.UTF_8)); +            } +            return outputStream.toByteArray(); +        } +    } + +    public static class DrilldownPie extends CustomChart { + +        private final Callable<Map<String, Map<String, Integer>>> callable; + +        /** +         * Class constructor. +         * +         * @param chartId The id of the chart. +         * @param callable The callable which is used to request the chart data. +         */ +        public DrilldownPie(String chartId, Callable<Map<String, Map<String, Integer>>> callable) { +            super(chartId); +            this.callable = callable; +        } + +        @Override +        public JsonObjectBuilder.JsonObject getChartData() throws Exception { +            JsonObjectBuilder valuesBuilder = new JsonObjectBuilder(); +            Map<String, Map<String, Integer>> map = callable.call(); +            if (map == null || map.isEmpty()) { +                // Null = skip the chart +                return null; +            } +            boolean reallyAllSkipped = true; +            for (Map.Entry<String, Map<String, Integer>> entryValues : map.entrySet()) { +                JsonObjectBuilder valueBuilder = new JsonObjectBuilder(); +                boolean allSkipped = true; +                for (Map.Entry<String, Integer> valueEntry : map.get(entryValues.getKey()).entrySet()) { +                    valueBuilder.appendField(valueEntry.getKey(), valueEntry.getValue()); +                    allSkipped = false; +                } +                if (!allSkipped) { +                    reallyAllSkipped = false; +                    valuesBuilder.appendField(entryValues.getKey(), valueBuilder.build()); +                } +            } +            if (reallyAllSkipped) { +                // Null = skip the chart +                return null; +            } +            return new JsonObjectBuilder().appendField("values", valuesBuilder.build()).build(); +        } +    } + +    public static class AdvancedPie extends CustomChart { + +        private final Callable<Map<String, Integer>> callable; + +        /** +         * Class constructor. +         * +         * @param chartId The id of the chart. +         * @param callable The callable which is used to request the chart data. +         */ +        public AdvancedPie(String chartId, Callable<Map<String, Integer>> callable) { +            super(chartId); +            this.callable = callable; +        } + +        @Override +        protected JsonObjectBuilder.JsonObject getChartData() throws Exception { +            JsonObjectBuilder valuesBuilder = new JsonObjectBuilder(); +            Map<String, Integer> map = callable.call(); +            if (map == null || map.isEmpty()) { +                // Null = skip the chart +                return null; +            } +            boolean allSkipped = true; +            for (Map.Entry<String, Integer> entry : map.entrySet()) { +                if (entry.getValue() == 0) { +                    // Skip this invalid +                    continue; +                } +                allSkipped = false; +                valuesBuilder.appendField(entry.getKey(), entry.getValue()); +            } +            if (allSkipped) { +                // Null = skip the chart +                return null; +            } +            return new JsonObjectBuilder().appendField("values", valuesBuilder.build()).build(); +        } +    } + +    public static class MultiLineChart extends CustomChart { + +        private final Callable<Map<String, Integer>> callable; + +        /** +         * Class constructor. +         * +         * @param chartId The id of the chart. +         * @param callable The callable which is used to request the chart data. +         */ +        public MultiLineChart(String chartId, Callable<Map<String, Integer>> callable) { +            super(chartId); +            this.callable = callable; +        } + +        @Override +        protected JsonObjectBuilder.JsonObject getChartData() throws Exception { +            JsonObjectBuilder valuesBuilder = new JsonObjectBuilder(); +            Map<String, Integer> map = callable.call(); +            if (map == null || map.isEmpty()) { +                // Null = skip the chart +                return null; +            } +            boolean allSkipped = true; +            for (Map.Entry<String, Integer> entry : map.entrySet()) { +                if (entry.getValue() == 0) { +                    // Skip this invalid +                    continue; +                } +                allSkipped = false; +                valuesBuilder.appendField(entry.getKey(), entry.getValue()); +            } +            if (allSkipped) { +                // Null = skip the chart +                return null; +            } +            return new JsonObjectBuilder().appendField("values", valuesBuilder.build()).build(); +        } +    } + +    public static class SimpleBarChart extends CustomChart { + +        private final Callable<Map<String, Integer>> callable; + +        /** +         * Class constructor. +         * +         * @param chartId The id of the chart. +         * @param callable The callable which is used to request the chart data. +         */ +        public SimpleBarChart(String chartId, Callable<Map<String, Integer>> callable) { +            super(chartId); +            this.callable = callable; +        } + +        @Override +        protected JsonObjectBuilder.JsonObject getChartData() throws Exception { +            JsonObjectBuilder valuesBuilder = new JsonObjectBuilder(); +            Map<String, Integer> map = callable.call(); +            if (map == null || map.isEmpty()) { +                // Null = skip the chart +                return null; +            } +            for (Map.Entry<String, Integer> entry : map.entrySet()) { +                valuesBuilder.appendField(entry.getKey(), new int[] {entry.getValue()}); +            } +            return new JsonObjectBuilder().appendField("values", valuesBuilder.build()).build(); +        } +    } + +    public abstract static class CustomChart { + +        private final String chartId; + +        protected CustomChart(String chartId) { +            if (chartId == null) { +                throw new IllegalArgumentException("chartId must not be null"); +            } +            this.chartId = chartId; +        } + +        public JsonObjectBuilder.JsonObject getRequestJsonObject( +                BiConsumer<String, Throwable> errorLogger, boolean logErrors) { +            JsonObjectBuilder builder = new JsonObjectBuilder(); +            builder.appendField("chartId", chartId); +            try { +                JsonObjectBuilder.JsonObject data = getChartData(); +                if (data == null) { +                    // If the data is null we don't send the chart. +                    return null; +                } +                builder.appendField("data", data); +            } catch (Throwable t) { +                if (logErrors) { +                    errorLogger.accept("Failed to get data for custom chart with id " + chartId, t); +                } +                return null; +            } +            return builder.build(); +        } + +        protected abstract JsonObjectBuilder.JsonObject getChartData() throws Exception; +    } + +    public static class SimplePie extends CustomChart { + +        private final Callable<String> callable; + +        /** +         * Class constructor. +         * +         * @param chartId The id of the chart. +         * @param callable The callable which is used to request the chart data. +         */ +        public SimplePie(String chartId, Callable<String> callable) { +            super(chartId); +            this.callable = callable; +        } + +        @Override +        protected JsonObjectBuilder.JsonObject getChartData() throws Exception { +            String value = callable.call(); +            if (value == null || value.isEmpty()) { +                // Null = skip the chart +                return null; +            } +            return new JsonObjectBuilder().appendField("value", value).build(); +        } +    } + +    public static class AdvancedBarChart extends CustomChart { + +        private final Callable<Map<String, int[]>> callable; + +        /** +         * Class constructor. +         * +         * @param chartId The id of the chart. +         * @param callable The callable which is used to request the chart data. +         */ +        public AdvancedBarChart(String chartId, Callable<Map<String, int[]>> callable) { +            super(chartId); +            this.callable = callable; +        } + +        @Override +        protected JsonObjectBuilder.JsonObject getChartData() throws Exception { +            JsonObjectBuilder valuesBuilder = new JsonObjectBuilder(); +            Map<String, int[]> map = callable.call(); +            if (map == null || map.isEmpty()) { +                // Null = skip the chart +                return null; +            } +            boolean allSkipped = true; +            for (Map.Entry<String, int[]> entry : map.entrySet()) { +                if (entry.getValue().length == 0) { +                    // Skip this invalid +                    continue; +                } +                allSkipped = false; +                valuesBuilder.appendField(entry.getKey(), entry.getValue()); +            } +            if (allSkipped) { +                // Null = skip the chart +                return null; +            } +            return new JsonObjectBuilder().appendField("values", valuesBuilder.build()).build(); +        } +    } + +    public static class SingleLineChart extends CustomChart { + +        private final Callable<Integer> callable; + +        /** +         * Class constructor. +         * +         * @param chartId The id of the chart. +         * @param callable The callable which is used to request the chart data. +         */ +        public SingleLineChart(String chartId, Callable<Integer> callable) { +            super(chartId); +            this.callable = callable; +        } + +        @Override +        protected JsonObjectBuilder.JsonObject getChartData() throws Exception { +            int value = callable.call(); +            if (value == 0) { +                // Null = skip the chart +                return null; +            } +            return new JsonObjectBuilder().appendField("value", value).build(); +        } +    } + +    /** +     * An extremely simple JSON builder. +     * +     * <p>While this class is neither feature-rich nor the most performant one, it's sufficient enough +     * for its use-case. +     */ +    public static class JsonObjectBuilder { + +        private StringBuilder builder = new StringBuilder(); + +        private boolean hasAtLeastOneField = false; + +        public JsonObjectBuilder() { +            builder.append("{"); +        } + +        /** +         * Appends a null field to the JSON. +         * +         * @param key The key of the field. +         * @return A reference to this object. +         */ +        public JsonObjectBuilder appendNull(String key) { +            appendFieldUnescaped(key, "null"); +            return this; +        } + +        /** +         * Appends a string field to the JSON. +         * +         * @param key The key of the field. +         * @param value The value of the field. +         * @return A reference to this object. +         */ +        public JsonObjectBuilder appendField(String key, String value) { +            if (value == null) { +                throw new IllegalArgumentException("JSON value must not be null"); +            } +            appendFieldUnescaped(key, "\"" + escape(value) + "\""); +            return this; +        } + +        /** +         * Appends an integer field to the JSON. +         * +         * @param key The key of the field. +         * @param value The value of the field. +         * @return A reference to this object. +         */ +        public JsonObjectBuilder appendField(String key, int value) { +            appendFieldUnescaped(key, String.valueOf(value)); +            return this; +        } + +        /** +         * Appends an object to the JSON. +         * +         * @param key The key of the field. +         * @param object The object. +         * @return A reference to this object. +         */ +        public JsonObjectBuilder appendField(String key, JsonObject object) { +            if (object == null) { +                throw new IllegalArgumentException("JSON object must not be null"); +            } +            appendFieldUnescaped(key, object.toString()); +            return this; +        } + +        /** +         * Appends a string array to the JSON. +         * +         * @param key The key of the field. +         * @param values The string array. +         * @return A reference to this object. +         */ +        public JsonObjectBuilder appendField(String key, String[] values) { +            if (values == null) { +                throw new IllegalArgumentException("JSON values must not be null"); +            } +            String escapedValues = +                    Arrays.stream(values) +                            .map(value -> "\"" + escape(value) + "\"") +                            .collect(Collectors.joining(",")); +            appendFieldUnescaped(key, "[" + escapedValues + "]"); +            return this; +        } + +        /** +         * Appends an integer array to the JSON. +         * +         * @param key The key of the field. +         * @param values The integer array. +         * @return A reference to this object. +         */ +        public JsonObjectBuilder appendField(String key, int[] values) { +            if (values == null) { +                throw new IllegalArgumentException("JSON values must not be null"); +            } +            String escapedValues = +                    Arrays.stream(values).mapToObj(String::valueOf).collect(Collectors.joining(",")); +            appendFieldUnescaped(key, "[" + escapedValues + "]"); +            return this; +        } + +        /** +         * Appends an object array to the JSON. +         * +         * @param key The key of the field. +         * @param values The integer array. +         * @return A reference to this object. +         */ +        public JsonObjectBuilder appendField(String key, JsonObject[] values) { +            if (values == null) { +                throw new IllegalArgumentException("JSON values must not be null"); +            } +            String escapedValues = +                    Arrays.stream(values).map(JsonObject::toString).collect(Collectors.joining(",")); +            appendFieldUnescaped(key, "[" + escapedValues + "]"); +            return this; +        } + +        /** +         * Appends a field to the object. +         * +         * @param key The key of the field. +         * @param escapedValue The escaped value of the field. +         */ +        private void appendFieldUnescaped(String key, String escapedValue) { +            if (builder == null) { +                throw new IllegalStateException("JSON has already been built"); +            } +            if (key == null) { +                throw new IllegalArgumentException("JSON key must not be null"); +            } +            if (hasAtLeastOneField) { +                builder.append(","); +            } +            builder.append("\"").append(escape(key)).append("\":").append(escapedValue); +            hasAtLeastOneField = true; +        } + +        /** +         * Builds the JSON string and invalidates this builder. +         * +         * @return The built JSON string. +         */ +        public JsonObject build() { +            if (builder == null) { +                throw new IllegalStateException("JSON has already been built"); +            } +            JsonObject object = new JsonObject(builder.append("}").toString()); +            builder = null; +            return object; +        } + +        /** +         * Escapes the given string like stated in https://www.ietf.org/rfc/rfc4627.txt. +         * +         * <p>This method escapes only the necessary characters '"', '\'. and '\u0000' - '\u001F'. +         * Compact escapes are not used (e.g., '\n' is escaped as "\u000a" and not as "\n"). +         * +         * @param value The value to escape. +         * @return The escaped value. +         */ +        private static String escape(String value) { +            final StringBuilder builder = new StringBuilder(); +            for (int i = 0; i < value.length(); i++) { +                char c = value.charAt(i); +                if (c == '"') { +                    builder.append("\\\""); +                } else if (c == '\\') { +                    builder.append("\\\\"); +                } else if (c <= '\u000F') { +                    builder.append("\\u000").append(Integer.toHexString(c)); +                } else if (c <= '\u001F') { +                    builder.append("\\u00").append(Integer.toHexString(c)); +                } else { +                    builder.append(c); +                } +            } +            return builder.toString(); +        } + +        /** +         * A super simple representation of a JSON object. +         * +         * <p>This class only exists to make methods of the {@link JsonObjectBuilder} type-safe and not +         * allow a raw string inputs for methods like {@link JsonObjectBuilder#appendField(String, +         * JsonObject)}. +         */ +        public static class JsonObject { + +            private final String value; + +            private JsonObject(String value) { +                this.value = value; +            } + +            @Override +            public String toString() { +                return value; +            } +        } +    } +}  | 
