Properly encapsulate PatchBody normalization 54/111254/2
authorRobert Varga <robert.varga@pantheon.tech>
Thu, 4 Apr 2024 02:54:28 +0000 (04:54 +0200)
committerRobert Varga <robert.varga@pantheon.tech>
Thu, 4 Apr 2024 03:03:04 +0000 (05:03 +0200)
Every PatchBody needs to be interpreted in terms of a ResourceContext,
which is capable of resolving sub-resources.

Structuring the code this way disconnects PatchBody from server.spi
contract. The prerequisite wiring is then provided through a
restconf.server.spi.DefaultResourceContext based a Data path and
ApiPathNormalizer.

This makes the semantics crispy-clear, allowing us to ditch a few
re-lookups which are no longer necessary.

JIRA: NETCONF-1288
Change-Id: I37d4903d1cc1bbb8eb3e8db3ac08c2142f661c88
Signed-off-by: Robert Varga <robert.varga@pantheon.tech>
restconf/restconf-nb/src/main/java/org/opendaylight/restconf/nb/rfc8040/rests/transactions/RestconfStrategy.java
restconf/restconf-nb/src/main/java/org/opendaylight/restconf/server/api/JsonPatchBody.java
restconf/restconf-nb/src/main/java/org/opendaylight/restconf/server/api/PatchBody.java
restconf/restconf-nb/src/main/java/org/opendaylight/restconf/server/api/XmlPatchBody.java
restconf/restconf-nb/src/main/java/org/opendaylight/restconf/server/spi/ApiPathNormalizer.java
restconf/restconf-nb/src/main/java/org/opendaylight/restconf/server/spi/DefaultResourceContext.java [new file with mode: 0644]
restconf/restconf-nb/src/test/java/org/opendaylight/restconf/nb/rfc8040/databind/AbstractPatchBodyTest.java

index 5ad5a2748f8b57059289bef385e0124a8f300c7b..48bd554633175dd5301af92983a3615b9280c696 100644 (file)
@@ -92,6 +92,7 @@ import org.opendaylight.restconf.server.api.PatchBody;
 import org.opendaylight.restconf.server.api.ResourceBody;
 import org.opendaylight.restconf.server.spi.ApiPathCanonizer;
 import org.opendaylight.restconf.server.spi.ApiPathNormalizer;
+import org.opendaylight.restconf.server.spi.DefaultResourceContext;
 import org.opendaylight.restconf.server.spi.OperationInput;
 import org.opendaylight.restconf.server.spi.RpcImplementation;
 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.netconf.with.defaults.rev110601.WithDefaultsMode;
@@ -602,7 +603,7 @@ public abstract class RestconfStrategy {
 
         final PatchContext patch;
         try {
-            patch = body.toPatchContext(path);
+            patch = body.toPatchContext(new DefaultResourceContext(path));
         } catch (IOException e) {
             LOG.debug("Error parsing YANG Patch input", e);
             return RestconfFuture.failed(new RestconfDocumentedException("Error parsing input: " + e.getMessage(),
index cc568bd4a02f7664c0cf48617541e9ee2d3e0c99..0efd3b6687817dc75490ca942d0c4f2e9301efa5 100644 (file)
@@ -32,6 +32,7 @@ import org.opendaylight.yangtools.yang.common.ErrorType;
 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier;
 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeIdentifierWithPredicates;
 import org.opendaylight.yangtools.yang.data.api.schema.NormalizedNode;
+import org.opendaylight.yangtools.yang.data.codec.gson.JSONCodecFactory;
 import org.opendaylight.yangtools.yang.data.codec.gson.JsonParserStream;
 import org.opendaylight.yangtools.yang.data.impl.schema.ImmutableNormalizedNodeStreamWriter;
 import org.opendaylight.yangtools.yang.data.impl.schema.NormalizationResultHolder;
@@ -44,16 +45,16 @@ public final class JsonPatchBody extends PatchBody {
     }
 
     @Override
-    PatchContext toPatchContext(final DatabindPath.Data path, final InputStream inputStream) throws IOException {
+    PatchContext toPatchContext(final ResourceContext resource, final InputStream inputStream) throws IOException {
         try (var jsonReader = new JsonReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
             final var patchId = new AtomicReference<String>();
-            final var resultList = read(jsonReader, path, patchId);
+            final var resultList = read(jsonReader, resource, patchId);
             // Note: patchId side-effect of above
             return new PatchContext(patchId.get(), resultList);
         }
     }
 
-    private static ImmutableList<PatchEntity> read(final JsonReader in, final DatabindPath.Data path,
+    private static ImmutableList<PatchEntity> read(final JsonReader in, final @NonNull ResourceContext resource,
             final AtomicReference<String> patchId) throws IOException {
         final var edits = ImmutableList.<PatchEntity>builder();
         final var edit = new PatchEdit();
@@ -79,7 +80,7 @@ public final class JsonPatchBody extends PatchBody {
                 case END_DOCUMENT:
                     break;
                 case NAME:
-                    parseByName(in.nextName(), edit, in, path, edits, patchId);
+                    parseByName(in.nextName(), edit, in, resource, edits, patchId);
                     break;
                 case END_OBJECT:
                     in.endObject();
@@ -98,7 +99,7 @@ public final class JsonPatchBody extends PatchBody {
 
     // Switch value of parsed JsonToken.NAME and read edit definition or patch id
     private static void parseByName(final @NonNull String name, final @NonNull PatchEdit edit,
-            final @NonNull JsonReader in, final DatabindPath.@NonNull Data path,
+            final @NonNull JsonReader in, final @NonNull ResourceContext resource,
             final @NonNull Builder<PatchEntity> resultCollection, final @NonNull AtomicReference<String> patchId)
                 throws IOException {
         switch (name) {
@@ -107,14 +108,14 @@ public final class JsonPatchBody extends PatchBody {
                     in.beginArray();
 
                     while (in.hasNext()) {
-                        readEditDefinition(edit, in, path);
+                        readEditDefinition(edit, in, resource);
                         resultCollection.add(prepareEditOperation(edit));
                         edit.clear();
                     }
 
                     in.endArray();
                 } else {
-                    readEditDefinition(edit, in, path);
+                    readEditDefinition(edit, in, resource);
                     resultCollection.add(prepareEditOperation(edit));
                     edit.clear();
                 }
@@ -130,10 +131,12 @@ public final class JsonPatchBody extends PatchBody {
 
     // Read one patch edit object from JSON input
     private static void readEditDefinition(final @NonNull PatchEdit edit, final @NonNull JsonReader in,
-            final DatabindPath.@NonNull Data path) throws IOException {
+            final @NonNull ResourceContext resource) throws IOException {
         String deferredValue = null;
         in.beginObject();
 
+        final var codecs = resource.path.databind().jsonCodecs();
+
         while (in.hasNext()) {
             final String editDefinition = in.nextName();
             switch (editDefinition) {
@@ -145,8 +148,9 @@ public final class JsonPatchBody extends PatchBody {
                     break;
                 case "target":
                     // target can be specified completely in request URI
-                    edit.setTarget(parsePatchTarget(path, in.nextString()));
-                    final var stack = path.databind().schemaTree().enterPath(edit.getTarget()).orElseThrow().stack();
+                    final var target = parsePatchTarget(resource, in.nextString());
+                    edit.setTarget(target.instance());
+                    final var stack = target.inference().toSchemaInferenceStack();
                     if (!stack.isEmpty()) {
                         stack.exit();
                     }
@@ -167,7 +171,7 @@ public final class JsonPatchBody extends PatchBody {
                         deferredValue = readValueNode(in);
                     } else {
                         // We have a target schema node, reuse this reader without buffering the value.
-                        edit.setData(readEditData(in, edit.getTargetSchemaNode(), path.databind()));
+                        edit.setData(readEditData(in, edit.getTargetSchemaNode(), codecs));
                     }
                     break;
                 default:
@@ -181,7 +185,7 @@ public final class JsonPatchBody extends PatchBody {
         if (deferredValue != null) {
             // read saved data to normalized node when target schema is already known
             edit.setData(readEditData(new JsonReader(new StringReader(deferredValue)), edit.getTargetSchemaNode(),
-                path.databind()));
+                codecs));
         }
     }
 
@@ -285,10 +289,10 @@ public final class JsonPatchBody extends PatchBody {
      * @return NormalizedNode representing data
      */
     private static NormalizedNode readEditData(final @NonNull JsonReader in, final @NonNull Inference targetSchemaNode,
-            final @NonNull DatabindContext databind) {
+            final @NonNull JSONCodecFactory codecs) {
         final var resultHolder = new NormalizationResultHolder();
         final var writer = ImmutableNormalizedNodeStreamWriter.from(resultHolder);
-        JsonParserStream.create(writer, databind.jsonCodecs(), targetSchemaNode).parse(in);
+        JsonParserStream.create(writer, codecs, targetSchemaNode).parse(in);
         return resultHolder.getResult().data();
     }
 
index 0820d5b373b355a4e1c4826855abe20f55b41366..b7466b676e675de17b547bc8a010025ac117c688 100644 (file)
@@ -7,37 +7,61 @@
  */
 package org.opendaylight.restconf.server.api;
 
+import static java.util.Objects.requireNonNull;
+
 import java.io.IOException;
 import java.io.InputStream;
 import java.text.ParseException;
 import org.eclipse.jdt.annotation.NonNull;
+import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.opendaylight.restconf.api.ApiPath;
 import org.opendaylight.restconf.common.errors.RestconfDocumentedException;
 import org.opendaylight.restconf.common.patch.PatchContext;
-import org.opendaylight.restconf.server.spi.ApiPathNormalizer;
+import org.opendaylight.restconf.server.api.DatabindPath.Data;
 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.yang.patch.rev170222.yang.patch.yang.patch.Edit.Operation;
+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.data.api.YangInstanceIdentifier;
 
 /**
  * A YANG Patch body.
  */
 public abstract sealed class PatchBody extends AbstractBody permits JsonPatchBody, XmlPatchBody {
+    /**
+     * Resource context needed to completely resolve a {@link PatchBody}.
+     */
+    @NonNullByDefault
+    public abstract static class ResourceContext implements Immutable {
+        protected final Data path;
+
+        protected ResourceContext(final Data path) {
+            this.path = requireNonNull(path);
+        }
+
+        /**
+         * Return a {@link ResourceContext} for a sub-resource identified by an {@link ApiPath}.
+         *
+         * @param apiPath sub-resource
+         * @return A {@link ResourceContext}
+         * @throws RestconfDocumentedException if the sub-resource cannot be resolved
+         */
+        protected abstract ResourceContext resolveRelative(ApiPath apiPath);
+    }
+
     PatchBody(final InputStream inputStream) {
         super(inputStream);
     }
 
-    public final @NonNull PatchContext toPatchContext(final DatabindPath.@NonNull Data path) throws IOException {
+    public final @NonNull PatchContext toPatchContext(final @NonNull ResourceContext resource) throws IOException {
         try (var is = acquireStream()) {
-            return toPatchContext(path, is);
+            return toPatchContext(resource, is);
         }
     }
 
-    abstract @NonNull PatchContext toPatchContext(DatabindPath.@NonNull Data path, @NonNull InputStream inputStream)
+    abstract @NonNull PatchContext toPatchContext(@NonNull ResourceContext resource, @NonNull InputStream inputStream)
         throws IOException;
 
-    static final YangInstanceIdentifier parsePatchTarget(final DatabindPath.@NonNull Data path, final String target) {
+    static final Data parsePatchTarget(final @NonNull ResourceContext resource, final String target) {
         // As per: https://www.rfc-editor.org/rfc/rfc8072#page-18:
         //
         //        "Identifies the target data node for the edit
@@ -54,14 +78,14 @@ public abstract sealed class PatchBody extends AbstractBody permits JsonPatchBod
                 ErrorType.RPC, ErrorTag.MALFORMED_MESSAGE, e);
         }
 
-        final YangInstanceIdentifier result;
+        final Data result;
         try {
-            result = ApiPathNormalizer.normalizeSubResource(path, targetPath).instance();
+            result = resource.resolveRelative(targetPath).path;
         } catch (RestconfDocumentedException e) {
             throw new RestconfDocumentedException("Invalid edit target '" + targetPath + "'",
                 ErrorType.RPC, ErrorTag.MALFORMED_MESSAGE, e);
         }
-        if (result.isEmpty()) {
+        if (result.instance().isEmpty()) {
             throw new RestconfDocumentedException("Target node resource must not be a datastore resource",
                 ErrorType.RPC, ErrorTag.MALFORMED_MESSAGE);
         }
index 5f3642cd67975fd8bf2f2d8670d32fbc21965812..11e9ada615942816ca2c7ba4249fd73dcfbf4d0b 100644 (file)
@@ -43,9 +43,9 @@ public final class XmlPatchBody extends PatchBody {
     }
 
     @Override
-    PatchContext toPatchContext(final DatabindPath.Data path, final InputStream inputStream) throws IOException {
+    PatchContext toPatchContext(final ResourceContext resource, final InputStream inputStream) throws IOException {
         try {
-            return parse(path, UntrustedXML.newDocumentBuilder().parse(inputStream));
+            return parse(resource, UntrustedXML.newDocumentBuilder().parse(inputStream));
         } catch (XMLStreamException | SAXException | URISyntaxException e) {
             LOG.debug("Failed to parse YANG Patch XML", e);
             throw new RestconfDocumentedException("Error parsing YANG Patch XML: " + e.getMessage(), ErrorType.PROTOCOL,
@@ -53,12 +53,11 @@ public final class XmlPatchBody extends PatchBody {
         }
     }
 
-    private static @NonNull PatchContext parse(final DatabindPath.@NonNull Data path, final Document doc)
+    private static @NonNull PatchContext parse(final ResourceContext resource, final Document doc)
             throws XMLStreamException, IOException, SAXException, URISyntaxException {
         final var entities = ImmutableList.<PatchEntity>builder();
         final var patchId = doc.getElementsByTagName("patch-id").item(0).getFirstChild().getNodeValue();
         final var editNodes = doc.getElementsByTagName("edit");
-        final var databind = path.databind();
 
         for (int i = 0; i < editNodes.getLength(); i++) {
             final Element element = (Element) editNodes.item(i);
@@ -70,31 +69,30 @@ public final class XmlPatchBody extends PatchBody {
             final Element firstValueElement = values != null ? values.get(0) : null;
 
             // find complete path to target, it can be also empty (only slash)
-            final var targetII = parsePatchTarget(path, target);
-            // move schema node
-            final var lookup = databind.schemaTree().enterPath(targetII).orElseThrow();
-
-            final var stack = lookup.stack();
-            final var inference = stack.toInference();
+            final var targetData = parsePatchTarget(resource, target);
+            final var inference = targetData.inference();
+            final var stack = inference.toSchemaInferenceStack();
             if (!stack.isEmpty()) {
                 stack.exit();
             }
 
+            final var targetPath = targetData.instance();
+
             if (requiresValue(oper)) {
                 final var resultHolder = new NormalizationResultHolder();
                 final var writer = ImmutableNormalizedNodeStreamWriter.from(resultHolder);
-                final var xmlParser = XmlParserStream.create(writer, databind.xmlCodecs(), inference);
+                final var xmlParser = XmlParserStream.create(writer, resource.path.databind().xmlCodecs(), inference);
                 xmlParser.traverse(new DOMSource(firstValueElement));
 
                 final var result = resultHolder.getResult().data();
                 // for lists allow to manipulate with list items through their parent
-                if (targetII.getLastPathArgument() instanceof NodeIdentifierWithPredicates) {
-                    entities.add(new PatchEntity(editId, oper, targetII.getParent(), result));
+                if (targetPath.getLastPathArgument() instanceof NodeIdentifierWithPredicates) {
+                    entities.add(new PatchEntity(editId, oper, targetPath.getParent(), result));
                 } else {
-                    entities.add(new PatchEntity(editId, oper, targetII, result));
+                    entities.add(new PatchEntity(editId, oper, targetPath, result));
                 }
             } else {
-                entities.add(new PatchEntity(editId, oper, targetII));
+                entities.add(new PatchEntity(editId, oper, targetPath));
             }
         }
 
index 0fcb57c554318527c555bf8de62ba458ed48fcdc..b817354281e4d90638acccb9b1b78100c3e0f07f 100644 (file)
@@ -113,10 +113,9 @@ public final class ApiPathNormalizer implements PointNormalizer {
             namespace, firstStep, it);
     }
 
-    private @NonNull DatabindPath normalizeSteps(final SchemaInferenceStack stack,
-            final @NonNull DataSchemaContext rootNode, final @NonNull List<PathArgument> pathPrefix,
-            final @NonNull QNameModule firstNamespace, final @NonNull Step firstStep,
-            final Iterator<@NonNull Step> it) {
+    @NonNull DatabindPath normalizeSteps(final SchemaInferenceStack stack, final @NonNull DataSchemaContext rootNode,
+            final @NonNull List<PathArgument> pathPrefix, final @NonNull QNameModule firstNamespace,
+            final @NonNull Step firstStep, final Iterator<@NonNull Step> it) {
         var parentNode = rootNode;
         var namespace = firstNamespace;
         var step = firstStep;
@@ -216,31 +215,6 @@ public final class ApiPathNormalizer implements PointNormalizer {
             ErrorType.PROTOCOL, ErrorTag.DATA_MISSING);
     }
 
-    public static @NonNull Data normalizeSubResource(final Data resource, final ApiPath subResource) {
-        // If subResource is empty just return the resource
-        final var steps = subResource.steps();
-        if (steps.isEmpty()) {
-            return requireNonNull(resource);
-        }
-
-        final var normalizer = new ApiPathNormalizer(resource.databind());
-        final var urlPath = resource.instance();
-        if (urlPath.isEmpty()) {
-            // URL indicates the datastore resource, let's just normalize targetPath
-            return normalizer.normalizeDataPath(subResource);
-        }
-
-        // Defer to normalizePath(), faking things a bit. Then check the result.
-        final var it = steps.iterator();
-        final var path = normalizer.normalizeSteps(resource.inference().toSchemaInferenceStack(), resource.schema(),
-            urlPath.getPathArguments(), urlPath.getLastPathArgument().getNodeType().getModule(), it.next(), it);
-        if (path instanceof Data dataPath) {
-            return dataPath;
-        }
-        throw new RestconfDocumentedException("Sub-resource '" + subResource + "' resolves to non-data " + path,
-            ErrorType.PROTOCOL, ErrorTag.DATA_MISSING);
-    }
-
     @Override
     public PathArgument normalizePoint(final ApiPath value) {
         final var path = normalizePath(value);
diff --git a/restconf/restconf-nb/src/main/java/org/opendaylight/restconf/server/spi/DefaultResourceContext.java b/restconf/restconf-nb/src/main/java/org/opendaylight/restconf/server/spi/DefaultResourceContext.java
new file mode 100644 (file)
index 0000000..58f8aa8
--- /dev/null
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2024 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.server.spi;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.opendaylight.restconf.api.ApiPath;
+import org.opendaylight.restconf.common.errors.RestconfDocumentedException;
+import org.opendaylight.restconf.server.api.DatabindPath.Data;
+import org.opendaylight.restconf.server.api.PatchBody.ResourceContext;
+import org.opendaylight.yangtools.yang.common.ErrorTag;
+import org.opendaylight.yangtools.yang.common.ErrorType;
+
+/**
+ * Default implementation of a {@link ResourceContext}.
+ */
+@NonNullByDefault
+public final class DefaultResourceContext extends ResourceContext {
+    public DefaultResourceContext(final Data path) {
+        super(path);
+    }
+
+    @Override
+    protected ResourceContext resolveRelative(final ApiPath apiPath) {
+        // If subResource is empty just return this resource
+        final var steps = apiPath.steps();
+        if (steps.isEmpty()) {
+            return this;
+        }
+
+        final var normalizer = new ApiPathNormalizer(path.databind());
+        final var urlPath = path.instance();
+        if (urlPath.isEmpty()) {
+            // URL indicates the datastore resource, let's just normalize targetPath
+            return new DefaultResourceContext(normalizer.normalizeDataPath(apiPath));
+        }
+
+        // Defer to normalizeSteps(), faking things a bit. Then check the result.
+        final var it = steps.iterator();
+        final var resolved = normalizer.normalizeSteps(path.inference().toSchemaInferenceStack(), path.schema(),
+            urlPath.getPathArguments(), urlPath.getLastPathArgument().getNodeType().getModule(), it.next(), it);
+        if (resolved instanceof Data dataPath) {
+            return new DefaultResourceContext(dataPath);
+        }
+        throw new RestconfDocumentedException("Sub-resource '" + apiPath + "' resolves to non-data " + resolved,
+            ErrorType.PROTOCOL, ErrorTag.DATA_MISSING);
+    }
+}
index e4a64988ff99dd8f26759abbb89b702e38c779c9..96411a8d90acf0c7642c91ff26c6a3da4f27985c 100644 (file)
@@ -36,6 +36,7 @@ import org.opendaylight.restconf.common.patch.PatchContext;
 import org.opendaylight.restconf.nb.rfc8040.AbstractInstanceIdentifierTest;
 import org.opendaylight.restconf.nb.rfc8040.rests.transactions.MdsalRestconfStrategy;
 import org.opendaylight.restconf.server.api.PatchBody;
+import org.opendaylight.restconf.server.spi.DefaultResourceContext;
 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier;
 
 @RunWith(MockitoJUnitRunner.Silent.class)
@@ -99,7 +100,7 @@ abstract class AbstractPatchBodyTest extends AbstractInstanceIdentifierTest {
         final var stratAndPath = strategy.resolveStrategyPath(apiPath);
 
         try (var body = bodyConstructor.apply(stringInputStream(patchBody))) {
-            return body.toPatchContext(stratAndPath.path());
+            return body.toPatchContext(new DefaultResourceContext(stratAndPath.path()));
         }
     }
 }