org.eel.kitchen.jsonschema.ref.JsonPointer.java Source code

Java tutorial

Introduction

Here is the source code for org.eel.kitchen.jsonschema.ref.JsonPointer.java

Source

/*
 * Copyright (c) 2012, Francis Galiegue <fgaliegue@gmail.com>
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the Lesser GNU General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * Lesser GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package org.eel.kitchen.jsonschema.ref;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.MissingNode;
import com.google.common.base.CharMatcher;
import com.google.common.collect.BiMap;
import com.google.common.collect.ImmutableBiMap;
import com.google.common.collect.ImmutableList;
import org.eel.kitchen.jsonschema.main.JsonSchemaException;
import org.eel.kitchen.jsonschema.report.Domain;
import org.eel.kitchen.jsonschema.report.Message;

import java.util.List;

/**
 * Implementation of IETF JSON Pointer draft, version 3
 *
 * <p><a href="http://tools.ietf.org/id/draft-ietf-appsawg-json-pointer-03.txt">
 * JSON Pointer</a> is an IETF draft defining a way to address paths within JSON
 * documents. Paths apply to container objects, ie arrays or nodes. For objects,
 * path elements are property names. For arrays, they are the index in the array
 * (starting from 0).</p>
 *
 * <p>The general syntax is {@code #/path/elements/here}. A path element is
 * referred to as a "reference token" in the specification.</p>
 *
 * <p>The difficulty with JSON Pointer is that any JSON String is valid as an
 * object's property name. These are all valid:</p>
 *
 * <ul>
 *     <li>{@code ""} -- the empty string;</li>
 *     <li>{@code "/"};</li>
 *     <li>{@code "0"};</li>
 *     <li>{@code "-1"};</li>
 *     <li>{@code "."}, {@code ".."}, {@code "../.."}.</li>
 * </ul>
 *
 * <p>The latter example is the reason why a JSON Pointer is <b>always</b>
 * absolute.</p>
 *
 * <p>All instances of this class are thread safe and immutable.</p>
 *
 */

public final class JsonPointer extends JsonFragment {
    private static final CharMatcher SLASH = CharMatcher.is('/');
    private static final CharMatcher ESCAPE_CHAR = CharMatcher.is('~');

    private static final BiMap<Character, Character> ESCAPE_REPLACEMENT_MAP = new ImmutableBiMap.Builder<Character, Character>()
            .put('0', '~').put('1', '/').build();

    private static final CharMatcher ESCAPED = CharMatcher.anyOf("01");
    private static final CharMatcher SPECIAL = CharMatcher.anyOf("~/");

    /**
     * The list of individual elements in the pointer.
     */
    private final List<String> elements;

    /**
     * Constructor
     *
     * @param input The input string, guaranteed not to be JSON encoded
     * @throws JsonSchemaException Illegal JSON Pointer
     */
    public JsonPointer(final String input) throws JsonSchemaException {
        super(input);
        final ImmutableList.Builder<String> builder = ImmutableList.builder();
        decode(input, builder);

        elements = builder.build();
    }

    private JsonPointer(final String fullPointer, final List<String> elements) {
        super(fullPointer);
        this.elements = elements;
    }

    /**
     * Append a path element to this pointer. Returns a new instance.
     *
     * @param element the element to append
     * @return a new instance with the element appended
     */
    public JsonPointer append(final String element) {
        final List<String> newElements = new ImmutableList.Builder<String>().addAll(elements).add(element).build();

        return new JsonPointer(asString + '/' + refTokenEncode(element), newElements);
    }

    /**
     * Append an array index to this pointer. Returns a new instance.
     *
     * <p>Note that the index validity is NOT checked for (ie,
     * you can append {@code -1} if you want to -- don't do that)</p>
     *
     * @param index the index to add
     * @return a new instance with the index appended
     */
    public JsonPointer append(final int index) {
        return append(Integer.toString(index));
    }

    @Override
    public JsonNode resolve(final JsonNode node) {
        JsonNode ret = node;

        for (final String pathElement : elements) {
            if (!ret.isContainerNode())
                return MissingNode.getInstance();
            if (ret.isObject())
                ret = ret.path(pathElement);
            else
                try {
                    ret = ret.path(Integer.parseInt(pathElement));
                } catch (NumberFormatException ignored) {
                    return MissingNode.getInstance();
                }
            if (ret.isMissingNode())
                break;
        }

        return ret;
    }

    /**
     * Initialize the object
     *
     * <p>We read the string sequentially, a slash, then a reference token,
     * then a slash, etc. Bail out if the string is malformed.</p>
     *
     * @param input Input string, guaranteed not to be JSON/URI encoded
     * @param builder the list builder
     * @throws JsonSchemaException the input is not a valid JSON Pointer
     */
    private static void decode(final String input, final ImmutableList.Builder<String> builder)
            throws JsonSchemaException {
        String cooked, raw;
        String victim = input;

        while (!victim.isEmpty()) {
            /*
             * Skip the /
             */
            if (!victim.startsWith("/")) {
                final Message.Builder msg = newMsg("reference token not preceeded by '/'");
                throw new JsonSchemaException(msg.build());
            }
            victim = victim.substring(1);

            /*
             * Grab the "cooked" reference token
             */
            cooked = getNextRefToken(victim);
            victim = victim.substring(cooked.length());

            /*
             * Decode it, push it in the elements list
             */
            raw = refTokenDecode(cooked);
            builder.add(raw);
        }
    }

    /**
     * Grab a (cooked) reference token from an input string
     *
     * <p>This method is only called from
     * {@link #decode(String, ImmutableList.Builder)}, after a delimiter
     * ({@code /}) has been swallowed up. The input string is therefore
     * guaranteed to start with a reference token, which may be empty.
     * </p>
     *
     * @param input the input string
     * @return the cooked reference token
     * @throws JsonSchemaException the string is malformed
     */
    private static String getNextRefToken(final String input) throws JsonSchemaException {
        final StringBuilder sb = new StringBuilder();

        final char[] array = input.toCharArray();

        /*
         * If we encounter a /, this is the end of the current token.
         *
         * If we encounter a ~, ensure that what follows is either 0 or 1.
         *
         * If we encounter any other character, append it.
         */

        boolean inEscape = false;

        for (final char c : array) {
            if (inEscape) {
                if (!ESCAPED.matches(c)) {
                    final Message.Builder msg = newMsg(
                            "bad escape sequence: " + "'~' not followed by a valid token")
                                    .addInfo("allowed", ESCAPE_REPLACEMENT_MAP.keySet())
                                    .addInfo("found", Character.valueOf(c));
                    throw new JsonSchemaException(msg.build());
                }
                sb.append(c);
                inEscape = false;
                continue;
            }
            if (SLASH.matches(c))
                break;
            if (ESCAPE_CHAR.matches(c))
                inEscape = true;
            sb.append(c);
        }

        if (inEscape)
            throw new JsonSchemaException(
                    newMsg("bad escape sequence: '~' " + "not followed by any token").build());
        return sb.toString();
    }

    /**
     * Turn a cooked reference token into a raw reference token
     *
     * <p>This means we replace all occurrences of {@code ~0} with {@code ~},
     * and all occurrences of {@code ~1} with {@code /}.</p>
     *
     * <p>It is called from {@link #decode}, in order to push a token into
     * {@link #elements}.</p>
     *
     * @param cooked the cooked token
     * @return the raw token
     */
    private static String refTokenDecode(final String cooked) {
        final StringBuilder sb = new StringBuilder(cooked.length());

        /*
         * Replace all occurrences of "~0" with "~", and all occurrences of
         * "~1" with "/".
         *
         * The input is guaranteed to be well formed.
         */

        final char[] array = cooked.toCharArray();

        boolean inEscape = false;

        for (final char c : array) {
            if (ESCAPE_CHAR.matches(c)) {
                inEscape = true;
                continue;
            }
            if (inEscape) {
                sb.append(ESCAPE_REPLACEMENT_MAP.get(c));
                inEscape = false;
            } else
                sb.append(c);
        }

        return sb.toString();
    }

    /**
     * Make a cooked reference token out of a raw element token
     *
     * @param raw the raw token
     * @return the cooked token
     */
    private static String refTokenEncode(final String raw) {
        final StringBuilder sb = new StringBuilder(raw.length());

        /*
         * Replace all occurrences of "~" with "~0" and all occurrences of "/"
         * with "~1".
         */
        final char[] array = raw.toCharArray();

        for (final char c : array)
            if (SPECIAL.matches(c))
                sb.append('~').append(ESCAPE_REPLACEMENT_MAP.inverse().get(c));
            else
                sb.append(c);

        return sb.toString();
    }

    private static Message.Builder newMsg(final String reason) {
        return Domain.REF_RESOLVING.newMessage().setKeyword("$ref").setMessage("illegal JSON Pointer")
                .addInfo("reason", reason);
    }
}