Introduce FieldsParameter 51/98051/14
authorRobert Varga <robert.varga@pantheon.tech>
Fri, 22 Oct 2021 18:26:08 +0000 (20:26 +0200)
committerRobert Varga <robert.varga@pantheon.tech>
Sat, 23 Oct 2021 18:20:49 +0000 (20:20 +0200)
The "fields" parameter is a rather simple structure. Parsing it requires
a bit of recursion, but there is certainly no arcanum involved. Create a
class to semantically represent this field and introduce a parser which
can create these by following RFC8040 grammar to the letter.

JIRA: NETCONF-820
Change-Id: I279c04c03a482e9155ff1594a69ffd2da7678df2
Signed-off-by: Robert Varga <robert.varga@pantheon.tech>
restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/FieldsParameter.java [new file with mode: 0644]
restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/FieldsParameterParser.java [new file with mode: 0644]
restconf/restconf-nb-rfc8040/src/test/java/org/opendaylight/restconf/nb/rfc8040/FieldsParameterTest.java [new file with mode: 0644]

diff --git a/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/FieldsParameter.java b/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/FieldsParameter.java
new file mode 100644 (file)
index 0000000..e8290ad
--- /dev/null
@@ -0,0 +1,106 @@
+/*
+ * 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.Preconditions.checkArgument;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.annotations.Beta;
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import java.text.ParseException;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.opendaylight.restconf.nb.rfc8040.ApiPath.ApiIdentifier;
+import org.opendaylight.yangtools.concepts.Immutable;
+
+/**
+ * This class represents a "fields" parameter as defined in
+ * <a href="https://datatracker.ietf.org/doc/html/rfc8040#section-4.8.3">RFC8040 section 4.8.3</a>.
+ */
+@Beta
+@NonNullByDefault
+public final class FieldsParameter implements Immutable {
+    /**
+     * A selector for a single node as identified by {@link #path()}. Individual child nodes are subject to further
+     * filtering based on {@link #subSelectors()}.
+     */
+    public static final class NodeSelector implements Immutable {
+        private final ImmutableList<ApiIdentifier> path;
+        private final ImmutableList<NodeSelector> subSelectors;
+
+        NodeSelector(final ImmutableList<ApiIdentifier> path, final ImmutableList<NodeSelector> subSelectors) {
+            this.path = requireNonNull(path);
+            this.subSelectors = requireNonNull(subSelectors);
+            checkArgument(!path.isEmpty(), "At least path segment is required");
+        }
+
+        NodeSelector(final ImmutableList<ApiIdentifier> path) {
+            this(path, ImmutableList.of());
+        }
+
+        /**
+         * Return the path to the selected node. Guaranteed to have at least one element.
+         *
+         * @return path to the selected node
+         */
+        public ImmutableList<ApiIdentifier> path() {
+            return path;
+        }
+
+        /**
+         * Selectors for single nodes which should be selected from the node found by interpreting {@link #path}. If
+         * there are no selectors, i.e. {@code subSelectors().isEmpty())}, all child nodes are meant to be selected.
+         *
+         * @return Selectors for nested nodes.
+         */
+        public ImmutableList<NodeSelector> subSelectors() {
+            return subSelectors;
+        }
+
+        @Override
+        public String toString() {
+            final var helper = MoreObjects.toStringHelper(this).add("path", path);
+            if (!subSelectors.isEmpty()) {
+                helper.add("subSelectors", subSelectors);
+            }
+            return helper.toString();
+        }
+    }
+
+    private final ImmutableList<NodeSelector> nodeSelectors;
+
+    FieldsParameter(final ImmutableList<NodeSelector> nodeSelectors) {
+        this.nodeSelectors = requireNonNull(nodeSelectors);
+        checkArgument(!nodeSelectors.isEmpty(), "At least one selector is required");
+    }
+
+    /**
+     * Parse a {@code fields} parameter.
+     *
+     * @param str Unescaped URL string
+     * @return The contents of parameter
+     * @throws ParseException if {@code str} does not represent a valid {@code fields} parameter.
+     */
+    public static FieldsParameter parse(final String str) throws ParseException {
+        return new FieldsParameterParser().parse(str);
+    }
+
+    /**
+     * Selectors for nodes which should be reported. Guaranteed to have at least one element.
+     *
+     * @return selectors for nodes to be reported
+     */
+    public ImmutableList<NodeSelector> nodeSelectors() {
+        return nodeSelectors;
+    }
+
+    @Override
+    public String toString() {
+        return MoreObjects.toStringHelper(this).add("nodeSelectors", nodeSelectors).toString();
+    }
+}
diff --git a/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/FieldsParameterParser.java b/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/FieldsParameterParser.java
new file mode 100644 (file)
index 0000000..1f98e42
--- /dev/null
@@ -0,0 +1,270 @@
+/*
+ * 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 com.google.common.collect.ImmutableList;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+import java.text.ParseException;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.List;
+import org.eclipse.jdt.annotation.NonNull;
+import org.opendaylight.restconf.nb.rfc8040.ApiPath.ApiIdentifier;
+import org.opendaylight.restconf.nb.rfc8040.FieldsParameter.NodeSelector;
+import org.opendaylight.yangtools.yang.common.YangNames;
+
+/**
+ * Stateful parser for {@link FieldsParameter}. This is not as hard as IETF's ABNF would lead you to believe. The
+ * original definition is:
+ * <pre>
+ *    fields-expr = path "(" fields-expr ")" / path ";" fields-expr / path
+ *    path = api-identifier [ "/" path ]
+ * </pre>
+ * To make some sense of this, let's express the same constructs in a more powerful ANTLR4 grammar.
+ *
+ * <p>
+ * {@code path} is a rather simple
+ * <pre>
+ *    path = api-identifier ("/" api-identifier)*
+ * </pre>
+ * which is to say a {@code path} is "a sequence of one or more api-identifiers, separated by slashes". This boils in
+ * turn down to a list {@link ApiIdentifier}s, which is guaranteed to have at least one item.
+ *
+ * <p>
+ * {@code fields-expr} can be rewritten as three distinct possibilities:
+ * <pre>
+ *    fields-expr : path "(" fields-expr ")"
+ *                | path ";" fields-expr
+ *                | path
+ * </pre>
+ * which makes it clear it is a recursive structure, where the parentheses part is sub-filters and ';' serves as
+ * concatenation. So let's rewrite that by folding the common part and use optional elements and introducing proper
+ * names for constructs
+ * <pre>
+ *   fields         : node-selectors EOF
+ *   node-selectors : node-selector (";" node-selector)*
+ *   node-selector  : path sub-selectors?
+ *   sub-selectors  : "(" node-selectors ")"
+ *   path           : api-identifier ("/" api-identifier)*
+ * </pre>
+ *
+ * <p>
+ * That ANTLR4 grammar dictates the layout of {@link FieldsParameter}. It also shows the parsing is recursive on
+ * {@code node-selectors}, which is what {@link #parse(String)} and
+ * {@link NodeSelectorParser#parseSubSelectors(String, int)} deal with.
+ */
+final class FieldsParameterParser {
+    // Lazily instantiated queue for reuse of parser when we encounter sub-selectors. We could just rely on JIT/GC
+    // dealing with allocation rate, but we should be ready to see malicious inputs. One example of that is
+    // multiple nested sub-selectors like "a(b(c(d)));e(f(g(h)));i(j(k(l)))" With this cache we end allocating only four
+    // parsers instead of ten.
+    private Deque<NodeSelectorParser> parsers;
+
+    @NonNull FieldsParameter parse(final String str) throws ParseException {
+        final var nodeSelectors = ImmutableList.<NodeSelector>builder();
+
+        int idx = 0;
+        final var parser = new NodeSelectorParser();
+        while (true) {
+            final int next = parser.fillFrom(str, idx);
+            nodeSelectors.add(parser.collectAndReset());
+
+            if (next == str.length()) {
+                // We have reached the end, we are done
+                return new FieldsParameter(nodeSelectors.build());
+            }
+
+            final char ch = str.charAt(next);
+            if (ch != ';') {
+                throw new ParseException("Expecting ';', not '" + ch + "'", next);
+            }
+            idx = next + 1;
+        }
+    }
+
+    @SuppressFBWarnings(value = "UPM_UNCALLED_PRIVATE_METHOD",
+        justification = "https://github.com/spotbugs/spotbugs/issues/811")
+    private @NonNull NodeSelectorParser getParser() {
+        final var local = parsers;
+        if (local != null) {
+            final var existing = local.poll();
+            if (existing != null) {
+                return existing;
+            }
+        }
+        return new NodeSelectorParser();
+    }
+
+    @SuppressFBWarnings(value = "UPM_UNCALLED_PRIVATE_METHOD",
+        justification = "https://github.com/spotbugs/spotbugs/issues/811")
+    private void putParser(final NodeSelectorParser parser) {
+        var local = parsers;
+        if (local == null) {
+            // Let's be conservative with memory allocation
+            parsers = local = new ArrayDeque<>(2);
+        }
+        local.push(parser);
+    }
+
+    @SuppressFBWarnings(value = "UPM_UNCALLED_PRIVATE_METHOD",
+        justification = "https://github.com/spotbugs/spotbugs/issues/811")
+    private static void expectIdentifierStart(final String str, final int offset) throws ParseException {
+        final char ch = charAt(str, offset);
+        if (!YangNames.IDENTIFIER_START.matches(ch)) {
+            throw new ParseException("Expecting [a-ZA-Z_], not '" + ch + "'", offset);
+        }
+    }
+
+    @SuppressFBWarnings(value = "UPM_UNCALLED_PRIVATE_METHOD",
+        justification = "https://github.com/spotbugs/spotbugs/issues/811")
+    private static char charAt(final String str, final int offset) throws ParseException {
+        if (str.length() == offset) {
+            throw new ParseException("Unexpected end of input", offset);
+        }
+        return str.charAt(offset);
+    }
+
+    // A note here: we could store 'str' either in this object, or FieldsParameterParser, but that makes it a bit
+    // removed via indirection. We are opting for explicit argument passing to ensure JIT sees it as a local variable
+    // along with offset.
+    private final class NodeSelectorParser {
+        private final List<ApiIdentifier> path = new ArrayList<>(4);
+
+        // Not that common: lazily instantiated
+        private List<NodeSelector> selectors;
+
+        int fillFrom(final String str, final int offset) throws ParseException {
+            return parsePathStepFirst(str, offset);
+        }
+
+        @NonNull NodeSelector collectAndReset() {
+            final ImmutableList<ApiIdentifier> collectedPath = ImmutableList.copyOf(path);
+            path.clear();
+
+            final ImmutableList<NodeSelector> collectedSelectors;
+            if (selectors != null && !selectors.isEmpty()) {
+                collectedSelectors = ImmutableList.copyOf(selectors);
+                selectors.clear();
+            } else {
+                collectedSelectors = ImmutableList.of();
+            }
+
+            return new NodeSelector(collectedPath, collectedSelectors);
+        }
+
+        // We are at the start of a step in path. We are dealing with the first part of
+        //   identifier (":" identifier)?
+        // but are mindful of the big picture
+        private int parsePathStepFirst(final String str, final int offset) throws ParseException {
+            expectIdentifierStart(str, offset);
+
+            int idx = offset + 1;
+            while (true) {
+                if (idx == str.length()) {
+                    path.add(new ApiIdentifier(null, str.substring(offset)));
+                    return idx;
+                }
+
+                final char ch = str.charAt(idx);
+                if (!YangNames.NOT_IDENTIFIER_PART.matches(ch)) {
+                    idx++;
+                    continue;
+                }
+
+                final String first = str.substring(offset, idx);
+                if (ch == ':') {
+                    // We have complete first identifier, now switch to parsing the second identifier
+                    return parsePathStepSecond(first, str, idx + 1);
+                }
+                path.add(new ApiIdentifier(null, first));
+
+                switch (ch) {
+                    case ';':
+                    case ')':
+                        // End of this selector, return
+                        return idx;
+                    case '/':
+                        // Process next step
+                        return parsePathStepFirst(str, idx + 1);
+                    case '(':
+                        // Process at least one sub-selector
+                        return parseSubSelectors(str, idx + 1);
+                    default:
+                        throw new ParseException("Expecting [a-zA-Z_.-/(:;], not '" + ch + "'", idx);
+                }
+            }
+        }
+
+        // We are at the second identifier of a step in path, we already have the first identifier from
+        //   identifier (":" identifier)?
+        // but are mindful of the big picture
+        private int parsePathStepSecond(final String module, final String str, final int offset) throws ParseException {
+            expectIdentifierStart(str, offset);
+
+            int idx = offset + 1;
+            while (true) {
+                if (idx == str.length()) {
+                    path.add(new ApiIdentifier(module, str.substring(offset)));
+                    return idx;
+                }
+
+                final char ch = str.charAt(idx);
+                if (!YangNames.NOT_IDENTIFIER_PART.matches(ch)) {
+                    idx++;
+                    continue;
+                }
+                path.add(new ApiIdentifier(module, str.substring(offset, idx)));
+
+                switch (ch) {
+                    case ';':
+                    case ')':
+                        // End of this selector, return
+                        return idx;
+                    case '/':
+                        // Process next step
+                        return parsePathStepFirst(str, idx + 1);
+                    case '(':
+                        // Process at least one sub-selector
+                        return parseSubSelectors(str, idx + 1);
+                    default:
+                        throw new ParseException("Expecting [a-zA-Z_.-/(:;], not '" + ch + "'", idx);
+                }
+            }
+        }
+
+        // We are dealing with sub-selectors here
+        private int parseSubSelectors(final String str, final int offset) throws ParseException {
+            var local = selectors;
+            if (local == null) {
+                selectors = local = new ArrayList<>(4);
+            }
+
+            int idx = offset;
+            final var parser = getParser();
+            while (true) {
+                final int next = parser.fillFrom(str, idx);
+                local.add(parser.collectAndReset());
+
+                final char ch = charAt(str, next);
+                switch (ch) {
+                    case ';':
+                        // Another sub-selector
+                        idx = next + 1;
+                        continue;
+                    case ')':
+                        // End of these sub-selectors, return the parser for reuse
+                        putParser(parser);
+                        return next + 1;
+                    default:
+                        throw new ParseException("Expecting [;)], not '" + ch + "'", next);
+                }
+            }
+        }
+    }
+}
diff --git a/restconf/restconf-nb-rfc8040/src/test/java/org/opendaylight/restconf/nb/rfc8040/FieldsParameterTest.java b/restconf/restconf-nb-rfc8040/src/test/java/org/opendaylight/restconf/nb/rfc8040/FieldsParameterTest.java
new file mode 100644 (file)
index 0000000..3627eec
--- /dev/null
@@ -0,0 +1,191 @@
+/*
+ * 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.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+
+import java.text.ParseException;
+import java.util.List;
+import org.junit.Test;
+import org.opendaylight.restconf.nb.rfc8040.ApiPath.ApiIdentifier;
+import org.opendaylight.restconf.nb.rfc8040.FieldsParameter.NodeSelector;
+
+public class FieldsParameterTest {
+    // https://datatracker.ietf.org/doc/html/rfc8040#section-4.8.3:
+    //    ";" is used to select multiple nodes.  For example, to retrieve only
+    //    the "genre" and "year" of an album, use "fields=genre;year".
+    @Test
+    public void testGenreYear() {
+        final var selectors = assertValidFields("genre;year");
+        assertEquals(2, selectors.size());
+
+        var selector = selectors.get(0);
+        assertEquals(List.of(new ApiIdentifier(null, "genre")), selector.path());
+        assertEquals(List.of(), selector.subSelectors());
+
+        selector = selectors.get(1);
+        assertEquals(List.of(new ApiIdentifier(null, "year")), selector.path());
+        assertEquals(List.of(), selector.subSelectors());
+    }
+
+    // https://datatracker.ietf.org/doc/html/rfc8040#section-4.8.3:
+    //    "/" is used in a path to retrieve a child node of a node.  For
+    //    example, to retrieve only the "label" of an album, use
+    //    "fields=admin/label".
+    @Test
+    public void testAdminLabel() throws ParseException {
+        final var selectors = assertValidFields("admin/label");
+        assertEquals(1, selectors.size());
+
+        final var selector = selectors.get(0);
+        assertEquals(List.of(new ApiIdentifier(null, "admin"), new ApiIdentifier(null, "label")), selector.path());
+        assertEquals(List.of(), selector.subSelectors());
+    }
+
+    // https://datatracker.ietf.org/doc/html/rfc8040#section-4.8.3:
+    //    For example, assume that the target resource is the "album" list.  To
+    //    retrieve only the "label" and "catalogue-number" of the "admin"
+    //    container within an album, use
+    //    "fields=admin(label;catalogue-number)".
+    @Test
+    public void testAdminLabelCatalogueNumber() throws ParseException {
+        final var selectors = assertValidFields("admin(label;catalogue-number)");
+        assertEquals(1, selectors.size());
+
+        final var selector = selectors.get(0);
+        assertEquals(List.of(new ApiIdentifier(null, "admin")), selector.path());
+
+        final var subSelectors = selector.subSelectors();
+        assertEquals(2, subSelectors.size());
+
+        var subSelector = subSelectors.get(0);
+        assertEquals(List.of(new ApiIdentifier(null, "label")), subSelector.path());
+        assertEquals(List.of(), subSelector.subSelectors());
+
+
+        subSelector = subSelectors.get(1);
+        assertEquals(List.of(new ApiIdentifier(null, "catalogue-number")), subSelector.path());
+        assertEquals(List.of(), subSelector.subSelectors());
+    }
+
+    // https://datatracker.ietf.org/doc/html/rfc8040#appendix-B.3.3:
+    //    In this example, the client is retrieving the datastore resource in
+    //    JSON format, but retrieving only the "modules-state/module" list, and
+    //    only the "name" and "revision" nodes from each list entry.  Note that
+    //    the top node returned by the server matches the target resource node
+    //    (which is "data" in this example).  The "module-set-id" leaf is not
+    //    returned because it is not selected in the fields expression.
+    //
+    //       GET /restconf/data?fields=ietf-yang-library:modules-state/\
+    //           module(name;revision) HTTP/1.1
+    @Test
+    public void testModulesModuleNameRevision() {
+        final var selectors = assertValidFields("ietf-yang-library:modules-state/module(name;revision)");
+        assertEquals(1, selectors.size());
+
+        final var selector = selectors.get(0);
+        assertEquals(
+            List.of(new ApiIdentifier("ietf-yang-library", "modules-state"), new ApiIdentifier(null, "module")),
+            selector.path());
+
+        final var subSelectors = selector.subSelectors();
+        assertEquals(2, subSelectors.size());
+
+        var subSelector = subSelectors.get(0);
+        assertEquals(List.of(new ApiIdentifier(null, "name")), subSelector.path());
+        assertEquals(List.of(), subSelector.subSelectors());
+
+        subSelector = subSelectors.get(1);
+        assertEquals(List.of(new ApiIdentifier(null, "revision")), subSelector.path());
+        assertEquals(List.of(), subSelector.subSelectors());
+    }
+
+    @Test
+    public void testModulesSimple() {
+        final var selectors = assertValidFields("ietf-yang-library:modules-state");
+        assertEquals(1, selectors.size());
+
+        final var selector = selectors.get(0);
+        assertEquals(List.of(new ApiIdentifier("ietf-yang-library", "modules-state")), selector.path());
+        assertEquals(List.of(), selector.subSelectors());
+    }
+
+    @Test
+    public void testUnqualifiedSubQualified() {
+        final var selectors = assertValidFields("a(b:c)");
+        assertEquals(1, selectors.size());
+
+        final var selector = selectors.get(0);
+        assertEquals(List.of(new ApiIdentifier(null, "a")), selector.path());
+
+        final var subSelectors = selector.subSelectors();
+        assertEquals(1, subSelectors.size());
+
+        final var subSelector = subSelectors.get(0);
+        assertEquals(List.of(new ApiIdentifier("b", "c")), subSelector.path());
+        assertEquals(List.of(), subSelector.subSelectors());
+    }
+
+    @Test
+    public void testQualifiedSubUnqualified() {
+        final var selectors = assertValidFields("a:b(c)");
+        assertEquals(1, selectors.size());
+
+        final var selector = selectors.get(0);
+        assertEquals(List.of(new ApiIdentifier("a", "b")), selector.path());
+
+        final var subSelectors = selector.subSelectors();
+        assertEquals(1, subSelectors.size());
+
+        final var subSelector = subSelectors.get(0);
+        assertEquals(List.of(new ApiIdentifier(null, "c")), subSelector.path());
+        assertEquals(List.of(), subSelector.subSelectors());
+    }
+
+    @Test
+    public void testDeepNesting() {
+        final var selectors = assertValidFields("a(b(c(d)));e(f(g(h)));i(j(k(l)))");
+        assertEquals(3, selectors.size());
+    }
+
+    @Test
+    public void testInvalidIdentifier() {
+        assertInvalidFields(".", "Expecting [a-ZA-Z_], not '.'", 0);
+        assertInvalidFields("a+", "Expecting [a-zA-Z_.-/(:;], not '+'", 1);
+        assertInvalidFields("a:.", "Expecting [a-ZA-Z_], not '.'", 2);
+        assertInvalidFields("a:b+", "Expecting [a-zA-Z_.-/(:;], not '+'", 3);
+        assertInvalidFields("a;)", "Expecting [a-ZA-Z_], not ')'", 2);
+    }
+
+    @Test
+    public void testUnexpectedEnds() {
+        assertInvalidFields("a;", "Unexpected end of input", 2);
+        assertInvalidFields("a(", "Unexpected end of input", 2);
+        assertInvalidFields("a(a", "Unexpected end of input", 3);
+    }
+
+    @Test
+    public void testUnexpectedRightParent() {
+        assertInvalidFields("a)", "Expecting ';', not ')'", 1);
+    }
+
+    private static void assertInvalidFields(final String str, final String message, final int errorOffset) {
+        final var ex = assertThrows(ParseException.class, () -> FieldsParameter.parse(str));
+        assertEquals(message, ex.getMessage());
+        assertEquals(errorOffset, ex.getErrorOffset());
+    }
+
+    private static List<NodeSelector> assertValidFields(final String str) {
+        try {
+            return FieldsParameter.parse(str).nodeSelectors();
+        } catch (ParseException e) {
+            throw new AssertionError(e);
+        }
+    }
+}