<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>
--- /dev/null
+/*
+ * 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);
+ }
+}
--- /dev/null
+/*
+ * 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;
+ }
+}
--- /dev/null
+/*
+ * 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
--- /dev/null
+/*
+ * 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);
+ }
+ }
+}