All Downloads are FREE. Search and download functionalities are using the official Maven repository.

personthecat.catlib.serialization.json.XjsUtils Maven / Gradle / Ivy

Go to download

Utilities for serialization, commands, noise generation, IO, and some new data types.

The newest version!
package personthecat.catlib.serialization.json;

import com.mojang.datafixers.util.Either;
import com.mojang.serialization.Codec;
import lombok.experimental.UtilityClass;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.lang3.mutable.MutableObject;
import org.jetbrains.annotations.Nullable;
import personthecat.catlib.command.arguments.PathArgument;
import personthecat.catlib.exception.JsonFormatException;
import personthecat.catlib.serialization.codec.XjsOps;
import personthecat.fresult.Result;
import personthecat.fresult.Void;
import xjs.comments.CommentType;
import xjs.core.*;
import xjs.exception.SyntaxException;
import xjs.serialization.JsonContext;
import xjs.serialization.writer.JsonWriterOptions;

import javax.annotation.CheckReturnValue;
import javax.annotation.ParametersAreNonnullByDefault;
import java.io.*;
import java.util.*;
import java.util.function.Consumer;
import java.util.function.Function;

import static java.util.Optional.empty;
import static personthecat.catlib.exception.Exceptions.jsonFormatEx;
import static personthecat.catlib.exception.Exceptions.unreachable;
import static personthecat.catlib.util.Shorthand.f;
import static personthecat.catlib.util.Shorthand.full;
import static personthecat.catlib.util.Shorthand.nullable;

/**
 * A collection of convenience methods for interacting with XJS objects. Unlike
 * the original methods inside of {@link JsonObject}, most of the utilities in this
 * class return values wrapped in {@link Optional}, instead of null.
 * 

* In a future version of this library (via Exjson/xjs-core), JSON objects will * support returning {@link Optional} out of the box, as well as the options to * flatten arrays, support additional data types, and more. As a result, most * of these utilities will eventually be deprecated. *

*/ @Log4j2 @UtilityClass @SuppressWarnings("unused") @ParametersAreNonnullByDefault public class XjsUtils { /** * Reads a {@link JsonObject} from the given file. * * @param file The file containing the serialized JSON object. * @return The deserialized object, or else {@link Optional#empty}. */ public static Optional readJson(final File file) { return Result .define(FileNotFoundException.class, Result::WARN) .define(SyntaxException.class, e -> { throw jsonFormatEx(file.getPath(), e); }) .suppress(() -> Json.parse(file).asObject()) .get(); } /** * Reads a {@link JsonObject} from the given input stream. * * @param is The stream containing the serialized JSON object. * @return The deserialized object, or else {@link Optional#empty}. */ public static Optional readJson(final InputStream is) { return Result .define(IOException.class, Result::WARN) .define(SyntaxException.class, r -> { throw jsonFormatEx("Reading data"); }) .suppress(() -> Json.parse(is).asObject()) .get(); } /** * Variant of {@link #readJson(File)} which ignores syntax errors * and simply returns {@link Optional#empty} if any error occurs. * * @param file The file containing the serialized JSON object. * @return The deserialized object, or else {@link Optional#empty}. */ public static Optional readSuppressing(final File file) { return Result.suppress(() -> Json.parse(file).asObject()).get(Result::WARN); } /** * Variant of {@link #readSuppressing(File)} which reads directly * from an {@link InputStream}. * * @param is The data containing the serialized JSON object. * @return The deserialized object, or else {@link Optional#empty}. */ public static Optional readSuppressing(final InputStream is) { return Result.suppress(() -> Json.parse(is).asObject()).get(Result::WARN); } /** * Reads any JSON data from the given string contents. * * @param contents The raw JSON data being parsed. * @return The parsed JSON data, or else {@link Result#err} containing the exception. */ public static Result readValue(final String contents) { return Result.of(() -> Json.parse(contents)).ifErr(Result::IGNORE); } /** * Reads an object from the given data when provided a codec. * * @param codec Instructions for deserializing the data. * @param value The actual data being deserialized. * @param The type of object being returned. * @return The deserialized object, or else {@link Optional#empty}. */ public static Optional readOptional(final Codec codec, final JsonValue value) { return codec.parse(XjsOps.INSTANCE, value).result(); } /** * Reads an object from the given data, or else throws an exception. * * @param codec Instructions for deserializing the data. * @param value The actual data being deserialized. * @param The type of object being returned. * @return The deserialized object. */ public static T readThrowing(final Codec codec, final JsonValue value) { return codec.parse(XjsOps.INSTANCE, value).get().map(Function.identity(), partial -> { throw new JsonFormatException(partial.message()); }); } /** * Writes a regular {@link JsonObject} to the disk. The format of this output file * is automatically determined by its extension. *

* Any file extended with .json will be written in regular JSON * format. All other extensions will implicitly be treated as XJS. *

*

* No {@link IOException}s will be thrown by this method. Instead, they will be * logged and simply returned for the caller to optionally throw. *

*

* All other exceptions will be thrown by this method. *

* @param json The JSON data being serialized. * @param file The destination file containing these data. * @return A result which potentially contains an error. */ public static Result writeJson(final JsonObject json, final File file) { return Result.with(() -> new FileWriter(file), writer -> { json.write(file); }) .ifErr(e -> log.error("Writing file", e)); } /** * Writes the input value as JSON, returning {@link Optional#empty} if any errors * occur in the process. * * @param codec The codec responsible for the serialization. * @param a The data being serialized. * @param The type of data being serialized. * @return The serialized data, or else {@link Optional#empty}. */ public static Optional writeSuppressing(final Codec codec, final @Nullable A a) { if (a == null) return Optional.of(JsonLiteral.jsonNull()); return codec.encodeStart(XjsOps.INSTANCE, a).result(); } /** * Writes the input value as JSON, or else throwing an exception if any errors * occur in the process. * * @param codec The codec responsible for the serialization. * @param a The data being serialized. * @param The type of data being serialized. * @return The serialized data. */ public static JsonValue writeThrowing(final Codec codec, final @Nullable A a) { if (a == null) return JsonLiteral.jsonNull(); return codec.encodeStart(XjsOps.INSTANCE, a).result() .orElseThrow(() -> new JsonFormatException("Writing object: " + a)); } /** * Reads a file from the disk and updates it. *

* For example, *

*
{@code
     *   XJSTools.updateJson(file, json -> {
     *      json.set("hello", "world");
     *   });
     * }
*

* The output of this expression will be applied to the original file. *

* @param file the file containing JSON data. * @param f Instructions for updating the JSON data. * @return A result which potentially contains an error. */ @CheckReturnValue public static Result updateJson(final File file, final Consumer f) { // If #readJson returned empty, it's because the file didn't exist. final JsonObject json = readJson(file).orElseGet(JsonObject::new); f.accept(json); return writeJson(json, file); } /** * Gets the default formatting options, guaranteed to never print a `\r` character, * which Minecraft does not print correctly in-game. * * @return The default formatting options without \r. */ public static JsonWriterOptions noCr() { return JsonContext.getDefaultFormatting().setEol("\n"); } /** * Updates a single value in a JSON object based on a full, dotted path. *

* For example, *

*
     *   /update my_json path.to.field true
     * 
* @param json The JSON object containing this path. * @param path The output of a {@link PathArgument}. * @param value The updated value to set at this path. */ public static void setValueFromPath(final JsonObject json, final JsonPath path, @Nullable final JsonValue value) { if (path.isEmpty()) { return; } final Either lastVal = path.get(path.size() - 1); final JsonContainer parent = getLastContainer(json, path); // This will ideally be handled by XJS in the future. if (value != null && value.getLinesAbove() == -1 && condenseNewValue(path, parent)) { value.setLinesAbove(0); } setEither(parent, lastVal, value); } /** * Determines whether to format an incoming value as condensed. * * @param path The path to the value being set. * @param container The parent container for this new value. * @return true, if the value should be condensed. */ private static boolean condenseNewValue(final JsonPath path, final JsonContainer container) { if (container.isEmpty()) { return true; } final int s = path.size() == 1 && container.isObject() ? 1 : 0; for (int i = s; i < container.size(); i++) { if (container.getReference(i).getOnly().getLinesAbove() == 0) { return true; } } return false; } /** * Gets a single value in a JSON object based on a full, dotted path. * * @param json The JSON object containing this path. * @param path The output of a {@link PathArgument}. * @return The value at this location, or else {@link Optional#empty}. */ public static Optional getValueFromPath(final JsonObject json, final JsonPath path) { if (path.isEmpty()) { return empty(); } final Either lastVal = path.get(path.size() - 1); return getEither(getLastContainer(json, path), lastVal); } /** * Retrieves the last JsonObject or JsonArray represented by this path. *

* For example, a path of *

*
     *   object1.array2.object3.value4
     * 
*

* will return object3 when passed into this method. *

*

* If no object or array exists at this location, a new container will be created at this * location and returned by the method. *

* @param json The JSON object containing this path. * @param path The output of a {@link PathArgument}. * @return The value at this location, the original json, or else a new container. */ public static JsonContainer getLastContainer(final JsonObject json, final JsonPath path) { if (path.isEmpty()) { return json; } JsonContainer current = json; for (int i = 0; i < path.size() - 1; i++) { final Either val = path.get(i); final Either peek = path.get(i + 1); if (val.right().isPresent()) { // Index current = getOrTryNew(current.asArray(), val.right().get(), peek); } else if (peek.left().isPresent()) { // Key -> key -> object current = current.asObject() .getOptional(val.left().orElseThrow(), JsonValue::asObject) .orElseGet(Json::object); } else { // Key -> index -> array current = current.asObject() .getOptional(val.left().orElseThrow(), JsonValue::asArray) .orElseGet(Json::array); } } return current; } /** * Gets the index of the last available element in this path, or else -1. * *

For example, when given the following JSON object:

*
     *   a:{b:[]}
     * 
*

And the following path:

*
     *   a.b[0].c
     * 
*

An index of 1 (pointing to b) will be returned.

* * @param json The JSON object containing the data being inspected. * @param path The path to the expected data, which may or may not exist. * @return The index to the last matching element, or else -1. */ public static int getLastAvailable(final JsonObject json, final JsonPath path) { final MutableObject current = new MutableObject<>(json); int index = -1; for (final Either component : path) { component.ifLeft(key -> { final JsonValue value = current.getValue(); if (value.isObject()) { current.setValue(value.asObject().get(key)); } else { current.setValue(null); } }).ifRight(i -> { final JsonValue value = current.getValue(); if (value.isArray() && i < value.asArray().size()) { current.setValue(value.asArray().get(i)); } else { current.setValue(null); } }); if (current.getValue() == null) { return index; } index++; } return index; } /** * Attempts to resolve the closest matching path in the given JSON data. * *

Essentially, this method accepts the canonicalized path of an expected value for the * data being represented. It will account for the possibility that object arrays may be * expressed as singletons and return the actual path, should any be used.

* * @param json The object being inspected. * @param path The canonicalized path to the expected value * @return The actual path to the value, or else the canonical path. */ public static JsonPath getClosestMatch(final JsonObject json, final JsonPath path) { final MutableObject current = new MutableObject<>(json); final JsonPath.JsonPathBuilder builder = JsonPath.builder(); for (int i = 0; i < path.size(); i++) { path.get(i).ifLeft(key -> { JsonValue value = current.getValue(); while (value.isArray() && !value.asArray().isEmpty()) { builder.index(0); value = value.asArray().get(0); } if (value.isObject() && value.asObject().has(key)) { current.setValue(value.asObject().get(key)); builder.key(key); } else { current.setValue(null); } }).ifRight(index -> { final JsonValue value = current.getValue(); if (value.isArray() && value.asArray().size() > index) { current.setValue(value.asArray().get(index)); builder.index(index); } else if (!(value.isObject() && index == 0)) { current.setValue(null); } }); if (current.getValue() == null) { return builder.build().append(path, i); } } return builder.build(); } /** * Filters values from the given JSON object according to a list of expected paths. * * @param json The JSON object and source being transformed. * @param paths The paths expected to stay in the output. * @return A transformed object containing only the expected paths. */ public static JsonObject filter(final JsonObject json, final Collection paths) { return filter(json, paths, false); } /** * Filters values from the given JSON object according to a list of expected paths. * * @param json The JSON object and source being transformed. * @param paths The paths expected to stay in the output. * @param blacklist Whether to optionally blacklist these paths. * @return A transformed object containing only the expected paths. */ public static JsonObject filter(final JsonObject json, final Collection paths, final boolean blacklist) { final JsonObject clone = (JsonObject) json.deepCopy(); // Flag each path as used so anything else will get removed. paths.forEach(path -> path.getValue(clone)); return skip(clone, blacklist); } /** * Generates a new {@link JsonObject} containing only the values that were or were not * used in the original. * * @param json The original JSON object being transformed. * @param used true to skip used values, false to skip unused. * @return A new JSON object with these values trimmed out. */ public static JsonObject skip(final JsonObject json, final boolean used) { final JsonObject generated = (JsonObject) new JsonObject().setDefaultMetadata(json); final StringBuilder skipped = new StringBuilder(); for (final JsonObject.Member member : json) { final JsonValue value = member.getOnly(); final String name = member.getKey(); if (member.getReference().isAccessed() != used) { if (skipped.length() > 0) { value.prependComment("Skipped " + skipped); skipped.setLength(0); } if (value.isObject()) { generated.add(name, skip(value.asObject(), used)); } else if (value.isArray()) { generated.add(name, skip(value.asArray(), used)); } else { generated.add(name, value); } } else if (skipped.length() == 0) { skipped.append(name); } else { skipped.append(", ").append(name); } } if (skipped.length() > 0) { generated.prependComment(CommentType.INTERIOR, "Skipped " + skipped); } return generated; } /** * Generates a new {@link JsonArray} containing only the values that were or were not * used in the original. * * @param json The original JSON array being transformed. * @param used true to skip used values, false to skip unused. * @return A new JSON array with these values trimmed out. */ public static JsonArray skip(final JsonArray json, final boolean used) { final JsonArray generated = (JsonArray) new JsonArray().setDefaultMetadata(json); int lastIndex = 0; int index = 0; for (final JsonReference reference : json.references()) { final JsonValue value = reference.getOnly(); if (reference.isAccessed() != used) { if (index == lastIndex + 1) { value.prependComment("Skipped " + (index - 1)); } else if (index > lastIndex) { value.prependComment("Skipped " + lastIndex + " ~ " + (index - 1)); } if (value.isObject()) { generated.add(skip(value.asObject(), used)); } else if (value.isArray()) { generated.add(skip(value.asArray(), used)); } else { generated.add(value); } lastIndex = index + 1; } index++; } if (index == lastIndex + 1) { generated.prependComment(CommentType.INTERIOR, "Skipped " + (index - 1)); } else if (index > lastIndex) { generated.prependComment(CommentType.INTERIOR, "Skipped " + lastIndex + " ~ " + (index - 1)); } return generated; } /** * Retrieves a list of paths adjacent to the input path. This can be used to provide * command suggestions as the user is walking through this container. *

* For example, when given the following JSON object: *

*
     *   a: [
     *     {
     *       b: { b1: true }
     *       c: { c1: false }
     *     }
     *   ]
     * 
*

* and the following incomplete command: *

*
     *   /update my_json a[0]
     * 
*

* the following paths will be returned: *

*
    *
  • a[0].b
  • *
  • a[0].c
  • *
* @param json The JSON data containing these paths. * @param path The current output of a {@link PathArgument}. * @return A list of all adjacent paths. */ public static List getPaths(final JsonObject json, final JsonPath path) { final JsonValue container = Result.of(() -> getLastContainer(json, path)) .get(Result::WARN) .orElse(json); int end = path.size() - 1; if (end < 0) { return getNeighbors("", container); } final Optional v = getEither(container, path.get(end)) .filter(value -> value.isObject() || value.isArray()); if (v.isPresent()) { end++; // The full path is a valid container -> use it. } final String dir = JsonPath.serialize(path.subList(0, end)); return getNeighbors(dir, v.orElse(container)); } /** * Retrieves a list of paths in the given container. * * @param dir The path to this container, as a string. * @param container The {@link JsonObject} or {@link JsonArray} at this location. * @return A formatted list of all members at this location. */ private static List getNeighbors(final String dir, final JsonValue container) { final List neighbors = new ArrayList<>(); if (container.isObject()) { for (JsonObject.Member member : container.asObject()) { final String name = member.getKey(); neighbors.add(dir.isEmpty() ? name : f("{}.{}", dir, name)); } } else if (container.isArray()) { for (int i = 0; i < container.asArray().size(); i++) { neighbors.add(f("{}[{}]", dir, i)); } } return neighbors; } /** * Attempts to retrieve an object or an array. Creates a new one, if absent. * * @throws IndexOutOfBoundsException If index > array.size() * @param array The JSON array containing the researched data. * @param index The index of the data in the array. * @param type The path element at this index, indicating either a key or an index. * @return Either a JSON object or array, whichever is at this location. */ private static JsonContainer getOrTryNew(final JsonArray array, final int index, final Either type) { if (index == array.size()) { // The value must be added. type.ifLeft(s -> array.add(new JsonObject())) .ifRight(i -> array.add(new JsonArray())); } // if index >= newSize -> index out of bounds return array.get(index).asContainer(); } /** * Attempts to retrieve either an object or an array from a JSON container. *

* If this value is a string, it will be treated as a key. If the value is a * number, it will be treated as an index. *

* @param container Either a JSON object or array * @param either The accessor for the value at this location. */ private static Optional getEither(final JsonValue container, final Either either) { if (either.left().isPresent()) { return nullable(container.asObject().get(either.left().get())); } else if (either.right().isPresent()) { final JsonArray array = container.asArray(); final int index = either.right().get(); return index < array.size() ? full(array.get(index)) : empty(); } throw unreachable(); } /** * Attempts to set a value in a container which may either be an object or an array. * * @param container Either a JSON object or array. * @param either The accessor for this value, either a key or an index. * @param value The value to set at this location. */ private static void setEither(final JsonValue container, final Either either, @Nullable final JsonValue value) { if (either.left().isPresent()) { if (value == null) { container.asObject().remove(either.left().get()); } else if (value.hasComments()) { container.asObject().set(either.left().get(), value); } else { final String key = either.left().get(); final JsonObject object = container.asObject(); object.set(key, value); } } else if (either.right().isPresent()) { // Just to stop the linting. if (value == null) { container.asArray().remove(either.right().get()); } else if (value.hasComments()) { container.asArray().set(either.right().get(), value); } else { final int index = either.right().get(); final JsonArray array = container.asArray(); setOrAdd(array, index, value); } } } /** * Adds a value to an array by name. The value will be coerced into an array, if needed. *

* For example, when adding a string to the following JSON field: *

*
     *   field: hello
     * 
*

* the field will be updated as follows: *

*
     *   field: [
     *     hello
     *     world
     *   ]
     * 
* @param json The JSON object containing these data. * @param field The key for updating an array. * @param value The value being added to the array. * @return The original json passed in. */ public static JsonObject addToArray(final JsonObject json, final String field, final JsonValue value) { JsonValue array = json.get(field); if (array == null) { array = new JsonArray(); json.add(field, array); } else if (!array.isArray()) { array = new JsonArray().add(array); json.set(field, array); } array.asArray().add(value); return json; } /** * Sets the value at the given index, or else if index == array.size(), adds it. * * @param array The array being added into. * @param index The index of the value being set. * @param value The value being set. * @return array, for method chaining. * @throws IndexOutOfBoundsException If index < 0 || index > size */ public static JsonArray setOrAdd(final JsonArray array, final int index, final JsonValue value) { if (index == array.size()) { return array.add(value); } return array.set(index, value); } /** * Returns a list of {@link JsonObject}s from the given source. *

* Note that the values in this array will be coerced into {@link JsonObject}s. *

*

* These objects can be stored in any number of dimensions, but will be coerced * into a single dimensional array. For example, each of the following values will * yield single dimensional object arrays: *

*
    *
  • array: [{},{},{}]
  • *
  • array: [[{}],[[{}]]]
  • *
  • array: {}
  • *
* @param json The JSON parent containing the array. * @param field The field where this array is stored. * @return The JSON array in the form of a regular list. */ public static List getObjectArray(final JsonObject json, final String field) { final List array = new ArrayList<>(); json.getOptional(field).map(JsonValue::intoArray) .ifPresent(a -> flatten(array, a)); return array; } /** * Recursively flattens object arrays into a single dimension. * * @param array The list of JSON objects being accumulated into. * @param source The original JSON array data source. */ private static void flatten(final List array, final JsonArray source) { for (final JsonValue value: source) { if (value.isArray()) { flatten(array, value.asArray()); } else if (value.isObject()) { array.add(value.asObject()); } else { throw jsonFormatEx("Expected an array or object: {}", value); } } } /** * Variant of {@link #getObjectArray} which does not coerce values into objects. *

* Note that any non-object values in this array will not be returned. *

*

* For example, when given the following JSON array: *

*
     *   array: [{},{},true,[[{}]]]
     * 
*

* This array will be returned: *

*
     *   [{},{},{}]
     * 
* @param json The JSON object containing the array. * @param field The key where this array is stored. * @return A list of all {@link JsonObject}s at this location. */ public static List getRegularObjects(final JsonObject json, final String field) { final List list = new ArrayList<>(); final JsonArray array = json.getOptional(field) .map(JsonValue::intoArray) .orElseGet(JsonArray::new); flattenRegularObjects(list, array); return list; } /** * Variant of {@link #flatten} which does not coerce values into objects. * * @param array The list of JSON objects being accumulated into. * @param source The original JSON array data source. */ private static void flattenRegularObjects(final List array, final JsonArray source) { for (final JsonValue value: source) { if (value.isArray()) { flattenRegularObjects(array, value.asArray()); } else if (value.isObject()) { array.add(value.asObject()); } } } /** * Gets an array for the given key, or else adds a new array into the object and returns it. * * @param json The JSON object being inspected. * @param field The name of the array being queried. * @return The existing or new array. */ public static JsonArray getOrCreateArray(final JsonObject json, final String field) { if (json.get(field) instanceof JsonArray array) { return array; } final JsonArray array = Json.array(); json.set(field, array); return array; } /** * Gets and object for the given key, or else adds a new object into the container and returns it. * * @param json The JSON object being inspected. * @param field The name of the object being queried. * @return The existing or new object. */ public static JsonObject getOrCreateObject(final JsonObject json, final String field) { if (json.get(field) instanceof JsonObject object) { return object; } final JsonObject object = Json.object(); json.set(field, object); return object; } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy