From: Robert Varga Date: Fri, 22 Oct 2021 18:26:08 +0000 (+0200) Subject: Introduce FieldsParameter X-Git-Tag: v2.0.6~35 X-Git-Url: https://git.opendaylight.org/gerrit/gitweb?a=commitdiff_plain;h=d7dd4861ce51eebae8423eda4197bd66a34254c3;p=netconf.git Introduce FieldsParameter 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 --- 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 index 0000000000..e8290ad871 --- /dev/null +++ b/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/FieldsParameter.java @@ -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 + * RFC8040 section 4.8.3. + */ +@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 path; + private final ImmutableList subSelectors; + + NodeSelector(final ImmutableList path, final ImmutableList subSelectors) { + this.path = requireNonNull(path); + this.subSelectors = requireNonNull(subSelectors); + checkArgument(!path.isEmpty(), "At least path segment is required"); + } + + NodeSelector(final ImmutableList 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 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 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 nodeSelectors; + + FieldsParameter(final ImmutableList 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 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 index 0000000000..1f98e429be --- /dev/null +++ b/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/FieldsParameterParser.java @@ -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: + *
+ *    fields-expr = path "(" fields-expr ")" / path ";" fields-expr / path
+ *    path = api-identifier [ "/" path ]
+ * 
+ * To make some sense of this, let's express the same constructs in a more powerful ANTLR4 grammar. + * + *

+ * {@code path} is a rather simple + *

+ *    path = api-identifier ("/" api-identifier)*
+ * 
+ * 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. + * + *

+ * {@code fields-expr} can be rewritten as three distinct possibilities: + *

+ *    fields-expr : path "(" fields-expr ")"
+ *                | path ";" fields-expr
+ *                | path
+ * 
+ * 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 + *
+ *   fields         : node-selectors EOF
+ *   node-selectors : node-selector (";" node-selector)*
+ *   node-selector  : path sub-selectors?
+ *   sub-selectors  : "(" node-selectors ")"
+ *   path           : api-identifier ("/" api-identifier)*
+ * 
+ * + *

+ * 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 parsers; + + @NonNull FieldsParameter parse(final String str) throws ParseException { + final var nodeSelectors = ImmutableList.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 path = new ArrayList<>(4); + + // Not that common: lazily instantiated + private List selectors; + + int fillFrom(final String str, final int offset) throws ParseException { + return parsePathStepFirst(str, offset); + } + + @NonNull NodeSelector collectAndReset() { + final ImmutableList collectedPath = ImmutableList.copyOf(path); + path.clear(); + + final ImmutableList 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 index 0000000000..3627eec129 --- /dev/null +++ b/restconf/restconf-nb-rfc8040/src/test/java/org/opendaylight/restconf/nb/rfc8040/FieldsParameterTest.java @@ -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 assertValidFields(final String str) { + try { + return FieldsParameter.parse(str).nodeSelectors(); + } catch (ParseException e) { + throw new AssertionError(e); + } + } +}