JsonPointer.java

package io.github.bsonpatch;


import org.bson.BsonValue;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Implements RFC 6901 (JSON Pointer)
 *
 * <p>For full details, please refer to <a href="https://tools.ietf.org/html/rfc6901">RFC 6901</a>.
 *
 * <p></p>Generally, a JSON Pointer is a string representation of a path into a JSON document.
 * This class implements the RFC as closely as possible, and offers several helpers and
 * utility methods on top of it:
 *
 * <pre>
 *      // Parse, build or render a JSON pointer
 *      String path = "/a/0/b/1";
 *      JsonPointer ptr1 = JsonPointer.{@link #parse}(path);
 *      JsonPointer ptr2 = JsonPointer.{@link #ROOT}.append("a").append(0).append("b").append(1);
 *      assert(ptr1.equals(ptr2));
 *      assert(path.equals(ptr1.toString()));
 *      assert(path.equals(ptr2.toString()));
 *
 *      // Evaluate a JSON pointer against a live document
 *      BsonDocument doc = BsonDocument.parse("{\"foo\":[\"bar\", \"baz\"]}");
 *      BsonValue baz = JsonPointer.parse("/foo/1").{@link #evaluate(BsonValue) evaluate}(doc);
 *      assert(baz.asString().getValue().equals("baz")); 
 *      
 * </pre>
 *
 * <p>Instances of {@link JsonPointer} and its constituent {@link RefToken}s are <b>immutable</b>.
 *
 * @since 0.4.8
 */
public class JsonPointer implements Serializable {
    private final RefToken[] tokens;

    private static final long serialVersionUID = 7876822196894534620L;

    /** A JSON pointer representing the root node of a JSON document */
    public final static JsonPointer ROOT = new JsonPointer(new RefToken[] {});

    private JsonPointer(RefToken[] tokens) {
        this.tokens = tokens;
    }

    /**
     * Constructs a new pointer from a list of reference tokens.
     *
     * @param tokens The list of reference tokens from which to construct the new pointer. This list is not modified.
     */
    public JsonPointer(List<RefToken> tokens) {
        this.tokens = tokens.toArray(new RefToken[0]);
    }

    /**
     * Parses a valid string representation of a JSON Pointer.
     *
     * @param path The string representation to be parsed.
     * @return An instance of {@link JsonPointer} conforming to the specified string representation.
     * @throws IllegalArgumentException The specified JSON Pointer is invalid.
     */
    public static JsonPointer parse(String path) throws IllegalArgumentException {
        StringBuilder reftoken = null;
        List<RefToken> result = new ArrayList<RefToken>();

        for (int i = 0; i < path.length(); ++i) {
            char c = path.charAt(i);

            // Require leading slash
            if (i == 0) {
                if (c != '/') throw new IllegalArgumentException("Missing leading slash");
                reftoken = new StringBuilder();
                continue;
            }

            switch (c) {
                // Escape sequences
                case '~':
                    switch (path.charAt(++i)) {
                        case '0':
                        case '1':
                        case '2':
                            reftoken.append('~');
                            reftoken.append(path.charAt(i));
                            break;
                        default:
                            throw new IllegalArgumentException("Invalid escape sequence ~" + path.charAt(i) + " at index " + i);
                    }
                    break;

                // New reftoken
                case '/':
                    result.add(RefToken.parse(reftoken.toString()));
                    reftoken.setLength(0);
                    break;

                default:
                    reftoken.append(c);
                    break;
            }
        }

        if (reftoken == null)
            return ROOT;

        result.add(RefToken.parse(reftoken.toString()));
        return new JsonPointer(result);
    }

    /**
     * Indicates whether or not this instance points to the root of a JSON document.
     * @return {@code true} if this pointer represents the root node, {@code false} otherwise.
     */
    public boolean isRoot() {
        return tokens.length == 0;
    }

    /**
     * Creates a new JSON pointer to the specified field of the object referenced by this instance.
     *
     * @param field The desired field name, or any valid JSON Pointer reference token
     * @return The new {@link JsonPointer} instance.
     */
    JsonPointer append(String field) {
        RefToken[] newTokens = Arrays.copyOf(tokens, tokens.length + 1);
        newTokens[tokens.length] = new RefToken(field, null, null);
        return new JsonPointer(newTokens);
    }

    /**
     * Creates a new JSON pointer to an indexed element of the array referenced by this instance.
     *
     * @param index The desired index, or {@link #LAST_INDEX} to point past the end of the array.
     * @return The new {@link JsonPointer} instance.
     */
    JsonPointer append(int index) {
        RefToken[] newTokens = Arrays.copyOf(tokens, tokens.length + 1);
        newTokens[tokens.length] = new RefToken(Integer.toString(index), index, null);
        return new JsonPointer(newTokens);
    }

    /** Returns the number of reference tokens comprising this instance. */
    int size() {
        return tokens.length;
    }

    /**
     * Returns a string representation of this instance
     *
     * @return
     *  An <a href="https://tools.ietf.org/html/rfc6901#section-5">RFC 6901 compliant</a> string
     *  representation of this JSON pointer.
     */
    public String toString() {
        StringBuilder sb = new StringBuilder();
        for (RefToken token : tokens) {
            sb.append('/');
            sb.append(token);
        }
        return sb.toString();
    }

    /**
     * Decomposes this JSON pointer into its reference tokens.
     *
     * @return A list of {@link RefToken}s. Modifications to this list do not affect this instance.
     */
    public List<RefToken> decompose() {
        return Arrays.asList(tokens.clone());
    }

    /**
     * Retrieves the reference token at the specified index.
     *
     * @param index The desired reference token index.
     * @return The specified instance of {@link RefToken}.
     * @throws IndexOutOfBoundsException The specified index is illegal.
     */
    public RefToken get(int index) throws IndexOutOfBoundsException {
        if (index < 0 || index >= tokens.length) throw new IndexOutOfBoundsException("Illegal index: " + index);
        return tokens[index];
    }

    /**
     * Retrieves the last reference token for this JSON pointer.
     *
     * @return The last {@link RefToken} comprising this instance.
     * @throws IllegalStateException Last cannot be called on {@link #ROOT root} pointers.
     */
    public RefToken last() {
        if (isRoot()) throw new IllegalStateException("Root pointers contain no reference tokens");
        return tokens[tokens.length - 1];
    }

    /**
     * Creates a JSON pointer to the parent of the node represented by this instance.
     *
     * The parent of the {@link #ROOT root} pointer is the root pointer itself.
     *
     * @return A {@link JsonPointer} to the parent node.
     */
    public JsonPointer getParent() {
        return isRoot() ? this : new JsonPointer(Arrays.copyOf(tokens, tokens.length - 1));
    }

    private void error(int atToken, String message, BsonValue document) throws JsonPointerEvaluationException {
        throw new JsonPointerEvaluationException(
                message,
                new JsonPointer(Arrays.copyOf(tokens, atToken)),
                document);
    }

    /**
     * Takes a target document and resolves the node represented by this instance.
     *
     * The evaluation semantics are described in
     * <a href="https://tools.ietf.org/html/rfc6901#section-4">RFC 6901 sectino 4</a>.
     *
     * @param document The target document against which to evaluate the JSON pointer.
     * @return The {@link BsonValue} resolved by evaluating this JSON pointer.
     * @throws JsonPointerEvaluationException The pointer could not be evaluated.
     */
    public BsonValue evaluate(final BsonValue document) throws JsonPointerEvaluationException {
    	BsonValue current = document;

        for (int idx = 0; idx < tokens.length; ++idx) {
            final RefToken token = tokens[idx];

            if (current.isArray()) {
                if (token.isArrayIndex()) {
                    if (token.getIndex() == LAST_INDEX || token.getIndex() >= current.asArray().size())
                        error(idx, "Array index " + token + " is out of bounds", document);
                    current = current.asArray().get(token.getIndex());
                } else if (token.isArrayKeyRef()) {
                    KeyRef keyRef = token.getKeyRef();
                    BsonValue foundArrayNode = null;
                    for (int arrayIdx = 0; arrayIdx < current.asArray().size(); ++arrayIdx) {
                        BsonValue arrayNode = current.asArray().get(arrayIdx);
                        if (matches(keyRef, arrayNode)) {
                            foundArrayNode = arrayNode;
                            break;
                        }
                    }
                    if (foundArrayNode == null) {
                        error(idx, "Array has no matching object for key reference " + token, document);
                    }
                    current = foundArrayNode;
                } else {
                    error(idx, "Can't reference field \"" + token.getField() + "\" on array", document);
                }
            }
            else if (current.isDocument()) {
                if (!current.asDocument().containsKey(token.getField()))
                    error(idx,"Missing field \"" + token.getField() + "\"", document);
                current = current.asDocument().get(token.getField());
            }
            else
                error(idx, "Can't reference past scalar value", document);
        }

        return current;
    }

    private boolean matches(KeyRef keyRef, BsonValue arrayNode) {
        boolean matches = false;
        if (arrayNode.asDocument().containsKey(keyRef.key)) {
            BsonValue valueNode = arrayNode.asDocument().get(keyRef.key);
            if (valueNode.isString()) {
                matches = Objects.equals(keyRef.value, valueNode.asString().getValue());
            } else if (valueNode.isNumber() || valueNode.isBoolean()) {
                matches = Objects.equals(keyRef.value, valueNode.toString());
            }
        }
        return matches;
    }


    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        JsonPointer that = (JsonPointer) o;

        // Probably incorrect - comparing Object[] arrays with Arrays.equals
        return Arrays.equals(tokens, that.tokens);
    }

    @Override
    public int hashCode() {
        return Arrays.hashCode(tokens);
    }

    /** Represents a single JSON Pointer reference token. */
    static class RefToken implements Serializable
    {
        private final String decodedToken;
        private final Integer index;
        private final KeyRef keyRef;

        private static final long serialVersionUID = -8672427347472605093L;

        private RefToken(String decodedToken, Integer arrayIndex, KeyRef arrayKeyRef) {
            if (decodedToken == null) throw new IllegalArgumentException("Token can't be null");
            this.decodedToken = decodedToken;
            this.index = arrayIndex;
            this.keyRef = arrayKeyRef;
        }

        private static final Pattern DECODED_TILDA_PATTERN = Pattern.compile("~0");
        private static final Pattern DECODED_SLASH_PATTERN = Pattern.compile("~1");
        private static final Pattern DECODED_EQUALS_PATTERN = Pattern.compile("~2");

        private static String decodePath(Object object) {
            String path = object.toString(); // see http://tools.ietf.org/html/rfc6901#section-4
            path = DECODED_SLASH_PATTERN.matcher(path).replaceAll("/");
            path = DECODED_TILDA_PATTERN.matcher(path).replaceAll("~");
            return DECODED_EQUALS_PATTERN.matcher(path).replaceAll("=");
        }

        private static final Pattern ENCODED_TILDA_PATTERN = Pattern.compile("~");
        private static final Pattern ENCODED_SLASH_PATTERN = Pattern.compile("/");
        private static final Pattern ENCODED_EQUALS_PATTERN = Pattern.compile("=");

        private static String encodePath(Object object) {
            String path = object.toString(); // see http://tools.ietf.org/html/rfc6901#section-4
            path = ENCODED_TILDA_PATTERN.matcher(path).replaceAll("~0");
            path = ENCODED_SLASH_PATTERN.matcher(path).replaceAll("~1");
            return ENCODED_EQUALS_PATTERN.matcher(path).replaceAll("~2");
        }

        private static final Pattern VALID_ARRAY_IND = Pattern.compile("-|0|(?:[1-9][0-9]*)");

        private static final Pattern VALID_ARRAY_KEY_REF = Pattern.compile("([^=]+)=([^=]+)");

        public static RefToken parse(String rawToken) {
            if (rawToken == null) throw new IllegalArgumentException("Token can't be null");

            Integer index = null;
            Matcher indexMatcher = VALID_ARRAY_IND.matcher(rawToken);
            if (indexMatcher.matches()) {
                if (indexMatcher.group().equals("-")) {
                    index = LAST_INDEX;
                } else {
                    try {
                        int validInt = Integer.parseInt(indexMatcher.group());
                        index = validInt;
                    } catch (NumberFormatException ignore) {}
                }
            }

            KeyRef keyRef = null;
            Matcher arrayKeyRefMatcher = VALID_ARRAY_KEY_REF.matcher(rawToken);
            if (arrayKeyRefMatcher.matches()) {
                keyRef = new KeyRef(
                        decodePath(arrayKeyRefMatcher.group(1)),
                        decodePath(arrayKeyRefMatcher.group(2))
                );
            }
            return new RefToken(decodePath(rawToken), index, keyRef);
        }

        public boolean isArrayIndex() {
            return index != null;
        }

        public boolean isArrayKeyRef() {
            return keyRef != null;
        }

        public int getIndex() {
            if (!isArrayIndex()) throw new IllegalStateException("Object operation on array index target");
            return index;
        }

        public KeyRef getKeyRef() {
            if (!isArrayKeyRef()) throw new IllegalStateException("Object operation on array key ref target");
            return keyRef;
        }

        public String getField() {
            return decodedToken;
        }

        @Override
        public String toString() {
            if (isArrayKeyRef()) {
                return encodePath(keyRef.key) + "=" + encodePath(keyRef.value);
            } else {
                return encodePath(decodedToken);
            }
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;

            RefToken refToken = (RefToken) o;

            return decodedToken.equals(refToken.decodedToken);
        }

        @Override
        public int hashCode() {
            return decodedToken.hashCode();
        }
    }


    static class KeyRef implements Serializable {
        private String key;
        private String value;

        private static final long serialVersionUID = 6558265555055471373L;

        public KeyRef(String key, String value) {
            this.key = key;
            this.value = value;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;

            KeyRef keyRef = (KeyRef) o;

            return Objects.equals(key, keyRef.key) && Objects.equals(value, keyRef.value);
        }

        @Override
        public int hashCode() {
            return Objects.hash(key, value);
        }
    }

    /**
     * Represents an array index pointing past the end of the array.
     *
     * Such an index is represented by the JSON pointer reference token "{@code -}"; see
     * <a href="https://tools.ietf.org/html/rfc6901#section-4">RFC 6901 section 4</a> for
     * more details.
     */
    final static int LAST_INDEX = Integer.MIN_VALUE;
}