Add rfc8040.ApiPath 03/97503/34
authorRobert Varga <robert.varga@pantheon.tech>
Thu, 16 Sep 2021 02:09:00 +0000 (04:09 +0200)
committerTomas Cere <tomas.cere@pantheon.tech>
Wed, 6 Oct 2021 11:53:12 +0000 (11:53 +0000)
Our YangInstanceIdentifierSerializer is a rather complex piece of
machinery, which is a maintenance burden and cannot be modified easily.

In order to deal with it, we will split parsing into two steps:

1) structural tokenization, which splits the input string into a
   representation of 'api-path' ABNF producation.

2) semantic binding to an EffectiveModelContext, which reconciles the
   structure with the context, producing YangInstanceIdentifier in the
   process.

This patch deals with item 1) by introducing an ApiPath class, which
can be constructed from a string.

JIRA: NETCONF-631
Change-Id: I44c8ad1308b27ee459f95f8617f54c8537ea9c65
Signed-off-by: Robert Varga <robert.varga@pantheon.tech>
restconf/restconf-nb-rfc8040/pom.xml
restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/ApiPath.java [new file with mode: 0644]
restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/ApiPathParser.java [new file with mode: 0644]
restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/Utf8Buffer.java [new file with mode: 0644]
restconf/restconf-nb-rfc8040/src/test/java/org/opendaylight/restconf/nb/rfc8040/ApiPathTest.java [new file with mode: 0644]

index 224f9ac1ca0a02c5c57201ce786c1fd0a9927403..7d9c7f9a42cdf148d0800a04b062a6d9883e2d7e 100644 (file)
       <artifactId>netconf-dom-api</artifactId>
     </dependency>
 
+    <dependency>
+      <groupId>org.opendaylight.odlparent</groupId>
+      <artifactId>logging-markers</artifactId>
+    </dependency>
+
     <dependency>
       <groupId>org.opendaylight.yangtools</groupId>
       <artifactId>yang-data-api</artifactId>
diff --git a/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/ApiPath.java b/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/ApiPath.java
new file mode 100644 (file)
index 0000000..7bcb557
--- /dev/null
@@ -0,0 +1,208 @@
+/*
+ * Copyright (c) 2021 PANTHEON.tech, s.r.o. and others.  All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.restconf.nb.rfc8040;
+
+import static com.google.common.base.Verify.verifyNotNull;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.annotations.Beta;
+import com.google.common.base.MoreObjects;
+import com.google.common.base.MoreObjects.ToStringHelper;
+import com.google.common.collect.ImmutableList;
+import java.text.ParseException;
+import javax.ws.rs.PathParam;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.common.errors.RestconfDocumentedException;
+import org.opendaylight.yangtools.concepts.Immutable;
+import org.opendaylight.yangtools.yang.common.ErrorTag;
+import org.opendaylight.yangtools.yang.common.ErrorType;
+import org.opendaylight.yangtools.yang.common.UnqualifiedQName;
+
+/**
+ * Intermediate representation of a parsed {@code api-path} string as defined in
+ * <a href="https://datatracker.ietf.org/doc/html/rfc8040#section-3.5.3.1">RFC section 3.5.3.1</a>. It models the
+ * path as a series of {@link Step}s.
+ */
+@Beta
+@NonNullByDefault
+public final class ApiPath implements Immutable {
+    /**
+     * A single step in an {@link ApiPath}.
+     */
+    public abstract static class Step implements Immutable {
+        private final @Nullable String module;
+        private final UnqualifiedQName identifier;
+
+        Step(final @Nullable String module, final String identifier) {
+            this.identifier = verifyNotNull(UnqualifiedQName.tryCreate(identifier), "Unexpected invalid identifier %s",
+                identifier);
+            this.module = module;
+        }
+
+        public UnqualifiedQName identifier() {
+            return identifier;
+        }
+
+        public @Nullable String module() {
+            return module;
+        }
+
+        @Override
+        public final String toString() {
+            return addToStringAttributes(MoreObjects.toStringHelper(this).omitNullValues()).toString();
+        }
+
+        ToStringHelper addToStringAttributes(final ToStringHelper helper) {
+            return helper.add("module", module).add("identifier", identifier);
+        }
+    }
+
+    /**
+     * An {@code api-identifier} step in a {@link ApiPath}.
+     */
+    public static final class ApiIdentifier extends Step {
+        ApiIdentifier(final @Nullable String module, final String identifier) {
+            super(module, identifier);
+        }
+    }
+
+    /**
+     * A {@code list-instance} step in a {@link ApiPath}.
+     */
+    public static final class ListInstance extends Step {
+        private final ImmutableList<String> keyValues;
+
+        ListInstance(final @Nullable String module, final String identifier, final ImmutableList<String> keyValues) {
+            super(module, identifier);
+            this.keyValues = requireNonNull(keyValues);
+        }
+
+        public ImmutableList<String> keyValues() {
+            return keyValues;
+        }
+
+        @Override
+        ToStringHelper addToStringAttributes(final ToStringHelper helper) {
+            return super.addToStringAttributes(helper).add("keyValues", keyValues);
+        }
+    }
+
+    private static final ApiPath EMPTY = new ApiPath(ImmutableList.of());
+
+    private final ImmutableList<Step> steps;
+
+    private ApiPath(final ImmutableList<Step> steps) {
+        this.steps = requireNonNull(steps);
+    }
+
+    /**
+     * Return an empty ApiPath.
+     *
+     * @return An empty ApiPath.
+     */
+    public static ApiPath empty() {
+        return EMPTY;
+    }
+
+    /**
+     * Parse an {@link ApiPath} from a raw Request URI fragment or another source. The string is expected to contain
+     * percent-encoded bytes. Any sequence of such bytes is interpreted as a {@code UTF-8}-encoded string. Invalid
+     * sequences are rejected.
+     *
+     * @param str Request URI part
+     * @return An {@link ApiPath}
+     * @throws NullPointerException if {@code str} is {@code null}
+     * @throws ParseException if the string cannot be parsed
+     */
+    public static ApiPath parse(final String str) throws ParseException {
+        return str.isEmpty() ? EMPTY : parseString(ApiPathParser.newStrict(), str);
+    }
+
+    /**
+     * Parse an {@link ApiPath} from a raw Request URI fragment. The string is expected to contain percent-encoded
+     * bytes. Any sequence of such bytes is interpreted as a {@code UTF-8}-encoded string. Invalid sequences are
+     * rejected, but consecutive slashes may be tolerated, depending on runtime configuration.
+     *
+     * @param str Request URI part
+     * @return An {@link ApiPath}
+     * @throws NullPointerException if {@code str} is {@code null}
+     * @throws ParseException if the string cannot be parsed
+     */
+    public static ApiPath parseUrl(final String str) throws ParseException {
+        return str.isEmpty() ? EMPTY : parseString(ApiPathParser.newUrl(), str);
+    }
+
+    /**
+     * Parse an {@link ApiPath} from a raw Request URI. The string is expected to contain percent-encoded bytes. Any
+     * sequence of such bytes is interpreted as a {@code UTF-8}-encoded string. Invalid sequences are rejected.
+     *
+     * @param str Request URI part
+     * @return An {@link ApiPath}
+     * @throws RestconfDocumentedException if the string cannot be parsed
+     * @see PathParam
+     */
+    public static ApiPath valueOf(final @Nullable String str) {
+        if (str == null) {
+            return EMPTY;
+        }
+
+        try {
+            return parseUrl(str);
+        } catch (ParseException e) {
+            throw new RestconfDocumentedException("Invalid path '" + str + "'", ErrorType.APPLICATION,
+                ErrorTag.MALFORMED_MESSAGE, e);
+        }
+    }
+
+    public ImmutableList<Step> steps() {
+        return steps;
+    }
+
+    public int indexOf(final String module, final String identifier) {
+        final var m = requireNonNull(module);
+        final var id = requireNonNull(identifier);
+        for (int i = 0, size = steps.size(); i < size; ++i) {
+            final var step = steps.get(i);
+            if (m.equals(step.module) && id.equals(step.identifier.getLocalName())) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    public ApiPath subPath(final int fromIndex) {
+        return subPath(fromIndex, steps.size());
+    }
+
+    public ApiPath subPath(final int fromIndex, final int toIndex) {
+        final var subList = steps.subList(fromIndex, toIndex);
+        if (subList == steps) {
+            return this;
+        } else if (subList.isEmpty()) {
+            return EMPTY;
+        } else {
+            return new ApiPath(subList);
+        }
+    }
+
+    @Override
+    public int hashCode() {
+        return steps.hashCode();
+    }
+
+    @Override
+    public boolean equals(final @Nullable Object obj) {
+        return obj == this || obj instanceof ApiPath && steps.equals(((ApiPath) obj).steps());
+    }
+
+    private static ApiPath parseString(final ApiPathParser parser, final String str) throws ParseException {
+        final var steps = parser.parseSteps(str);
+        return steps.isEmpty() ? EMPTY : new ApiPath(steps);
+    }
+}
diff --git a/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/ApiPathParser.java b/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/ApiPathParser.java
new file mode 100644 (file)
index 0000000..b6494f9
--- /dev/null
@@ -0,0 +1,335 @@
+/*
+ * Copyright (c) 2021 PANTHEON.tech, s.r.o. and others.  All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.restconf.nb.rfc8040;
+
+import static com.google.common.base.Verify.verify;
+import static com.google.common.base.Verify.verifyNotNull;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableList.Builder;
+import java.text.ParseException;
+import java.util.function.Supplier;
+import org.eclipse.jdt.annotation.NonNull;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.nb.rfc8040.ApiPath.ApiIdentifier;
+import org.opendaylight.restconf.nb.rfc8040.ApiPath.ListInstance;
+import org.opendaylight.restconf.nb.rfc8040.ApiPath.Step;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Parser for a sequence of {@link ApiPath}'s {@link Step}s.
+ */
+class ApiPathParser {
+    private static final Logger LOG = LoggerFactory.getLogger(ApiPathParser.class);
+
+    // FIXME: use these from YangNames
+    private static final CharMatcher IDENTIFIER_START =
+        CharMatcher.inRange('A', 'Z').or(CharMatcher.inRange('a', 'z').or(CharMatcher.is('_'))).precomputed();
+    private static final CharMatcher NOT_IDENTIFIER_PART =
+        IDENTIFIER_START.or(CharMatcher.inRange('0', '9')).or(CharMatcher.anyOf("-.")).negate().precomputed();
+
+    /**
+     * A lenient interpretation: '//' is '/', i.e. there is no segment.
+     */
+    private static final class Lenient extends ApiPathParser {
+        @Override
+        int parseStep(final String str, final int offset, final int limit) throws ParseException {
+            return offset == limit ? limit : super.parseStep(str, offset, limit);
+        }
+    }
+
+    /**
+     * A lenient interpretation: '//' is '/', i.e. there is no segment, but also log offenders
+     */
+    private static final class Logging extends ApiPathParser {
+        @FunctionalInterface
+        private interface LogMethod {
+            void logLeniency(String format, Object arg0, Object arg1);
+        }
+
+        private final LogMethod method;
+
+        Logging(final LogMethod method) {
+            this.method = requireNonNull(method);
+        }
+
+        @Override
+        int parseStep(final String str, final int offset, final int limit) throws ParseException {
+            if (offset == limit) {
+                method.logLeniency("Ignoring duplicate slash in '{}' at offset", str, offset);
+                return limit;
+            }
+            return super.parseStep(str, offset, limit);
+        }
+    }
+
+    private static final Supplier<@NonNull ApiPathParser> URL_FACTORY;
+
+    static {
+        // Select the correct parser implementation where consecutive slashes are concerned. We default to lenient
+        // interpretation and treat them as a single slash, but allow this to be overridden through a system property.
+        // FIXME: 3.0.0: make "reject" the default
+        final String prop = System.getProperty("org.opendaylight.restconf.url.consecutive-slashes", "allow");
+        final String treatment;
+        switch (prop) {
+            case "allow":
+                treatment = "are treated as a single slash";
+                URL_FACTORY = Lenient::new;
+                break;
+            case "debug":
+                treatment = "are treated as a single slash and will be logged";
+                URL_FACTORY = () -> new Logging(LOG::debug);
+                break;
+            case "warn":
+                treatment = "are treated as a single slash and will be warned about";
+                URL_FACTORY = () -> new Logging(LOG::warn);
+                break;
+            case "reject":
+                treatment = "will be rejected";
+                URL_FACTORY = ApiPathParser::new;
+                break;
+            default:
+                LOG.warn("Unknown property value '{}', assuming 'reject'", prop);
+                treatment = "will be rejected";
+                URL_FACTORY = ApiPathParser::new;
+        }
+
+        LOG.info("Consecutive slashes in REST URLs {}", treatment);
+    }
+
+    private final Builder<Step> steps = ImmutableList.builder();
+
+    /*
+     * State tracking for creating substrings:
+     *
+     * Usually we copy spans 'src', in which case subStart captures 'start' argument to String.substring(...).
+     * If we encounter a percent escape we need to interpret as part of the string, we start building the string in
+     * subBuilder -- in which case subStart is set to -1.
+     *
+     * Note that StringBuilder is lazily-instantiated, as we have no percents at all
+     */
+    private int subStart;
+    private StringBuilder subBuilder;
+
+    // Lazily-allocated when we need to decode UTF-8. Since we touch this only when we are not expecting
+    private Utf8Buffer buf;
+
+    // the offset of the character returned from last peekBasicLatin()
+    private int nextOffset;
+
+    private ApiPathParser() {
+        // Hidden on purpose
+    }
+
+    static @NonNull ApiPathParser newStrict() {
+        return new ApiPathParser();
+    }
+
+    static @NonNull ApiPathParser newUrl() {
+        return URL_FACTORY.get();
+    }
+
+    // Grammar:
+    //   steps : step ("/" step)*
+    final @NonNull ImmutableList<@NonNull Step> parseSteps(final String str) throws ParseException {
+        int idx = 0;
+
+        // First process while we are seeing a slash
+        while (true) {
+            final int slash = str.indexOf('/', idx);
+            if (slash != -1) {
+                final int next = parseStep(str, idx, slash);
+                verify(next == slash, "Unconsumed bytes: %s next %s limit", next, slash);
+                idx = next + 1;
+            } else {
+                break;
+            }
+        }
+
+        // Now process the tail of the string
+        final int length = str.length();
+        final int next = parseStep(str, idx, length);
+        verify(next == length, "Unconsumed trailing bytes: %s next %s limit", next, length);
+
+        return steps.build();
+    }
+
+    // Grammar:
+    //   step : identifier (":" identifier)? ("=" key-value ("," key-value)*)?
+    // Note: visible for subclasses
+    int parseStep(final String str, final int offset, final int limit) throws ParseException {
+        int idx = startIdentifier(str, offset, limit);
+        while (idx < limit) {
+            final char ch = peekBasicLatin(str, idx, limit);
+            if (ch == ':') {
+                return parseStep(endSub(str, idx), str, nextOffset, limit);
+            } else if (ch == '=') {
+                return parseStep(null, endSub(str, idx), str, nextOffset, limit);
+            }
+            idx = continueIdentifer(idx, ch);
+        }
+
+        steps.add(new ApiIdentifier(null, endSub(str, idx)));
+        return idx;
+    }
+
+    // Starting at second identifier
+    private int parseStep(final @Nullable String module, final String str, final int offset, final int limit)
+            throws ParseException {
+        int idx = startIdentifier(str, offset, limit);
+        while (idx < limit) {
+            final char ch = peekBasicLatin(str, idx, limit);
+            if (ch == '=') {
+                return parseStep(module, endSub(str, idx), str, nextOffset, limit);
+            }
+            idx = continueIdentifer(idx, ch);
+        }
+
+        steps.add(new ApiIdentifier(module, endSub(str, idx)));
+        return idx;
+    }
+
+    // Starting at first key-value
+    private int parseStep(final @Nullable String module, final @NonNull String identifier,
+            final String str, final int offset, final int limit) throws ParseException {
+        final var values = ImmutableList.<String>builder();
+
+        startSub(offset);
+        int idx = offset;
+        while (idx < limit) {
+            final char ch = str.charAt(idx);
+            if (ch == ',') {
+                values.add(endSub(str, idx));
+                startSub(++idx);
+            } else if (ch != '%') {
+                append(ch);
+                idx++;
+            } else {
+                // Save current string content and capture current index for reporting
+                final var sb = flushSub(str, idx);
+                final int errorOffset = idx;
+
+                var utf = buf;
+                if (utf == null) {
+                    buf = utf = new Utf8Buffer();
+                }
+
+                do {
+                    utf.appendByte(parsePercent(str, idx, limit));
+                    idx += 3;
+                } while (idx < limit && str.charAt(idx) == '%');
+
+                utf.flushTo(sb, errorOffset);
+            }
+        }
+
+        steps.add(new ListInstance(module, identifier, values.add(endSub(str, idx)).build()));
+        return idx;
+    }
+
+    private int startIdentifier(final String str, final int offset, final int limit) throws ParseException {
+        if (offset == limit) {
+            throw new ParseException("Identifier may not be empty", offset);
+        }
+
+        startSub(offset);
+        final char ch = peekBasicLatin(str, offset, limit);
+        if (!IDENTIFIER_START.matches(ch)) {
+            throw new ParseException("Expecting [a-zA-Z_], not '" + ch + "'", offset);
+        }
+        append(ch);
+        return nextOffset;
+    }
+
+    private int continueIdentifer(final int offset, final char ch) throws ParseException {
+        if (NOT_IDENTIFIER_PART.matches(ch)) {
+            throw new ParseException("Expecting [a-zA-Z_.-], not '" + ch + "'", offset);
+        }
+        append(ch);
+        return nextOffset;
+    }
+
+    // Assert current character comes from the Basic Latin block, i.e. 00-7F.
+    // Callers are expected to pick up 'nextIdx' to resume parsing at the next character
+    private char peekBasicLatin(final String str, final int offset, final int limit) throws ParseException {
+        final char ch = str.charAt(offset);
+        if (ch == '%') {
+            final byte b = parsePercent(str, offset, limit);
+            if (b < 0) {
+                throw new ParseException("Expecting %00-%7F, not " + str.substring(offset, limit), offset);
+            }
+
+            flushSub(str, offset);
+            nextOffset = offset + 3;
+            return (char) b;
+        }
+
+        if (ch < 0 || ch > 127) {
+            throw new ParseException("Unexpected character '" + ch + "'", offset);
+        }
+        nextOffset = offset + 1;
+        return ch;
+    }
+
+    private void startSub(final int offset) {
+        subStart = offset;
+    }
+
+    private void append(final char ch) {
+        // We are not reusing string, append the char, otherwise
+        if (subStart == -1) {
+            verifyNotNull(subBuilder).append(ch);
+        }
+    }
+
+    private @NonNull String endSub(final String str, final int end) {
+        return subStart != -1 ? str.substring(subStart, end) : verifyNotNull(subBuilder).toString();
+    }
+
+    private @NonNull StringBuilder flushSub(final String str, final int end) {
+        var sb = subBuilder;
+        if (sb == null) {
+            subBuilder = sb = new StringBuilder();
+        }
+        if (subStart != -1) {
+            sb.setLength(0);
+            sb.append(str, subStart, end);
+            subStart = -1;
+        }
+        return sb;
+    }
+
+    private static byte parsePercent(final String str, final int offset, final int limit) throws ParseException {
+        if (limit - offset < 3) {
+            throw new ParseException("Incomplete escape '" + str.substring(offset, limit) + "'", offset);
+        }
+        return (byte) (parseHex(str, offset + 1) << 4 | parseHex(str, offset + 2));
+    }
+
+    // FIXME: Replace with HexFormat.fromHexDigit(str.charAt(offset)) when we have JDK17+
+    private static int parseHex(final String str, final int offset) throws ParseException {
+        final char ch = str.charAt(offset);
+        if (ch >= '0' && ch <= '9') {
+            return ch - '0';
+        }
+
+        final int zero;
+        if (ch >= 'a' && ch <= 'f') {
+            zero = 'a';
+        } else if (ch >= 'A' && ch <= 'F') {
+            zero = 'A';
+        } else {
+            throw new ParseException("Invalid escape character '" + ch + "'", offset);
+        }
+
+        return ch - zero + 10;
+    }
+}
diff --git a/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/Utf8Buffer.java b/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/Utf8Buffer.java
new file mode 100644 (file)
index 0000000..871c4f7
--- /dev/null
@@ -0,0 +1,87 @@
+/*
+ * Copyright (c) 2021 PANTHEON.tech, s.r.o. and others.  All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.restconf.nb.rfc8040;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CharsetDecoder;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.text.ParseException;
+import org.eclipse.jdt.annotation.NonNull;
+import org.opendaylight.odlparent.logging.markers.Markers;
+import org.opendaylight.yangtools.concepts.Mutable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A buffer of bytes in lazily-allocated array, which can be appended with {@link #appendByte(int)}. Current contents
+ * can be transferred to a {@link StringBuilder} via {@link #flushTo(StringBuilder, int)}, which performs UTF-8
+ * character decoding.
+ */
+final class Utf8Buffer implements Mutable {
+    private static final Logger LOG = LoggerFactory.getLogger(Utf8Buffer.class);
+
+    private ByteArrayOutputStream bos;
+    private CharsetDecoder decoder;
+
+    void appendByte(final byte value) {
+        var buf = bos;
+        if (buf == null) {
+            bos = buf = new ByteArrayOutputStream(8);
+        }
+        buf.write(value);
+    }
+
+    void flushTo(final @NonNull StringBuilder sb, final int errorOffset) throws ParseException {
+        final var buf = bos;
+        if (buf != null && buf.size() != 0) {
+            flushTo(sb, buf, errorOffset);
+        }
+    }
+
+    // Split out to aid inlining
+    private void flushTo(final StringBuilder sb, final ByteArrayOutputStream buf, final int errorOffset)
+            throws ParseException {
+        final var bytes = buf.toByteArray();
+        buf.reset();
+
+        // Special case for a single ASCII character, side-steps decoder/bytebuf allocation
+        if (bytes.length == 1) {
+            final byte ch = bytes[0];
+            if (ch >= 0) {
+                sb.append((char) ch);
+                return;
+            }
+        }
+        try {
+            append(sb, ByteBuffer.wrap(bytes));
+        } catch (CharacterCodingException e) {
+            throw report(errorOffset, bytes, e);
+        }
+    }
+
+    private void append(final StringBuilder sb, final ByteBuffer bytes) throws CharacterCodingException {
+        var local = decoder;
+        if (local == null) {
+            decoder = local = StandardCharsets.UTF_8.newDecoder()
+                .onMalformedInput(CodingErrorAction.REPORT)
+                .onUnmappableCharacter(CodingErrorAction.REPORT);
+        }
+        sb.append(local.decode(bytes));
+    }
+
+    // Split out to silence checkstyle's failure to understand we cannot propagate the cause
+    private static ParseException report(final int errorOffset, final byte[] bytes,
+            final CharacterCodingException cause) {
+        final String str = new String(bytes, StandardCharsets.UTF_8);
+        LOG.debug(Markers.confidential(), "Rejecting invalid UTF-8 sequence '{}'", str, cause);
+        return new ParseException("Invalid UTF-8 sequence '" + str + "': " + cause.getMessage(), errorOffset);
+    }
+}
\ No newline at end of file
diff --git a/restconf/restconf-nb-rfc8040/src/test/java/org/opendaylight/restconf/nb/rfc8040/ApiPathTest.java b/restconf/restconf-nb-rfc8040/src/test/java/org/opendaylight/restconf/nb/rfc8040/ApiPathTest.java
new file mode 100644 (file)
index 0000000..0834d1b
--- /dev/null
@@ -0,0 +1,131 @@
+/*
+ * Copyright (c) 2021 PANTHEON.tech, s.r.o. and others.  All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.restconf.nb.rfc8040;
+
+import static org.hamcrest.CoreMatchers.instanceOf;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+
+import java.text.ParseException;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.Test;
+import org.opendaylight.restconf.nb.rfc8040.ApiPath.ApiIdentifier;
+import org.opendaylight.restconf.nb.rfc8040.ApiPath.ListInstance;
+import org.opendaylight.restconf.nb.rfc8040.ApiPath.Step;
+import org.opendaylight.yangtools.yang.common.UnqualifiedQName;
+
+public class ApiPathTest {
+    @Test
+    public void testNull() {
+        assertThrows(NullPointerException.class, () -> ApiPath.parse(null));
+    }
+
+    @Test
+    public void testEmpty() {
+        assertEquals(List.of(), parse("/"));
+    }
+
+    @Test
+    public void testSingleSlash() throws ParseException {
+        final var ex = assertThrows(ParseException.class, () -> ApiPath.parse("/"));
+        assertEquals("Identifier may not be empty", ex.getMessage());
+        assertEquals(0, ex.getErrorOffset());
+
+        assertEquals(ApiPath.empty(), ApiPath.parseUrl("/"));
+    }
+
+    @Test
+    public void testTrailingSlash() throws ParseException {
+        final var ex = assertThrows(ParseException.class, () -> ApiPath.parse("foo/"));
+        assertEquals("Identifier may not be empty", ex.getMessage());
+        assertEquals(4, ex.getErrorOffset());
+
+        final var path = ApiPath.parseUrl("foo/").steps();
+        assertEquals(1, path.size());
+        assertApiIdentifier(path.get(0), null, "foo");
+    }
+
+    @Test
+    public void testExample1() {
+        final var path = parse("/example-top:top/list1=key1,key2,key3/list2=key4,key5/X");
+        assertEquals(4, path.size());
+        assertApiIdentifier(path.get(0), "example-top", "top");
+        assertListInstance(path.get(1), null, "list1", "key1", "key2", "key3");
+        assertListInstance(path.get(2), null, "list2", "key4", "key5");
+        assertApiIdentifier(path.get(3), null, "X");
+    }
+
+    @Test
+    public void testExample2() {
+        final var path = parse("/example-top:top/Y=instance-value");
+        assertEquals(2, path.size());
+        assertApiIdentifier(path.get(0), "example-top", "top");
+        assertListInstance(path.get(1), null, "Y", "instance-value");
+    }
+
+    @Test
+    public void testExample3() {
+        final var path = parse("/example-top:top/list1=%2C%27\"%3A\"%20%2F,,foo");
+        assertEquals(2, path.size());
+        assertApiIdentifier(path.get(0), "example-top", "top");
+        assertListInstance(path.get(1), null, "list1", ",'\":\" /", "", "foo");
+    }
+
+    @Test
+    public void testEscapedColon() {
+        final var path = parse("/foo%3Afoo");
+        assertEquals(1, path.size());
+        assertApiIdentifier(path.get(0), "foo", "foo");
+    }
+
+    @Test
+    public void nonAsciiFirstIdentifier() {
+        final var ex = assertThrows(ParseException.class, () -> ApiPath.parse("a%80"));
+        assertEquals("Expecting %00-%7F, not %80", ex.getMessage());
+        assertEquals(1, ex.getErrorOffset());
+    }
+
+    @Test
+    public void nonAsciiSecondIdentifier() {
+        final var ex = assertThrows(ParseException.class, () -> ApiPath.parse("foo:a%80"));
+        assertEquals("Expecting %00-%7F, not %80", ex.getMessage());
+        assertEquals(5, ex.getErrorOffset());
+    }
+
+    @Test
+    public void testIllegalEscape() {
+        final var ex = assertThrows(ParseException.class, () -> ApiPath.parse("foo:foo=%41%FF%42%FF%43"));
+        assertEquals("Invalid UTF-8 sequence 'A�B�C': Input length = 1", ex.getMessage());
+        assertEquals(8, ex.getErrorOffset());
+    }
+
+    private static void assertApiIdentifier(final Step step, final String module, final String identifier) {
+        assertThat(step, instanceOf(ApiIdentifier.class));
+        assertEquals(module, step.module());
+        assertEquals(UnqualifiedQName.of(identifier), step.identifier());
+    }
+
+    private static void assertListInstance(final Step step, final String module, final String identifier,
+            final String... keyValues) {
+        assertThat(step, instanceOf(ListInstance.class));
+        assertEquals(module, step.module());
+        assertEquals(UnqualifiedQName.of(identifier), step.identifier());
+        assertEquals(Arrays.asList(keyValues), ((ListInstance) step).keyValues());
+    }
+
+    private static List<Step> parse(final String str) {
+        final String toParse = str.substring(1);
+        try {
+            return ApiPath.parse(toParse).steps();
+        } catch (ParseException e) {
+            throw new AssertionError("Failed to parse \"" + toParse + "\"", e);
+        }
+    }
+}