Turn ApiPath into a record
[netconf.git] / restconf / restconf-nb / src / main / java / org / opendaylight / restconf / server / spi / ApiPathNormalizer.java
index 6bd98ff075cf21c11b0a2c438fa664880069a39b..d2841c43430f770fba5a4ceeb85a3786cdc8b593 100644 (file)
@@ -13,7 +13,6 @@ import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.VerifyException;
 import com.google.common.collect.ImmutableMap;
-import java.text.ParseException;
 import java.util.ArrayList;
 import java.util.List;
 import org.eclipse.jdt.annotation.NonNull;
@@ -23,7 +22,9 @@ import org.opendaylight.restconf.api.ApiPath.ListInstance;
 import org.opendaylight.restconf.common.errors.RestconfDocumentedException;
 import org.opendaylight.restconf.nb.rfc8040.Insert.PointNormalizer;
 import org.opendaylight.restconf.server.api.DatabindContext;
-import org.opendaylight.restconf.server.spi.ApiPathNormalizer.OperationPath.Rpc;
+import org.opendaylight.restconf.server.spi.ApiPathNormalizer.Path.Action;
+import org.opendaylight.restconf.server.spi.ApiPathNormalizer.Path.Data;
+import org.opendaylight.restconf.server.spi.ApiPathNormalizer.Path.Rpc;
 import org.opendaylight.yangtools.yang.common.ErrorTag;
 import org.opendaylight.yangtools.yang.common.ErrorType;
 import org.opendaylight.yangtools.yang.common.QName;
@@ -32,72 +33,103 @@ import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier;
 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeIdentifierWithPredicates;
 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeWithValue;
 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.PathArgument;
-import org.opendaylight.yangtools.yang.data.impl.codec.TypeDefinitionAwareCodec;
 import org.opendaylight.yangtools.yang.data.util.DataSchemaContext;
 import org.opendaylight.yangtools.yang.data.util.DataSchemaContext.PathMixin;
 import org.opendaylight.yangtools.yang.model.api.ActionNodeContainer;
 import org.opendaylight.yangtools.yang.model.api.DataSchemaNode;
-import org.opendaylight.yangtools.yang.model.api.EffectiveModelContext;
+import org.opendaylight.yangtools.yang.model.api.EffectiveStatementInference;
+import org.opendaylight.yangtools.yang.model.api.LeafListSchemaNode;
 import org.opendaylight.yangtools.yang.model.api.ListSchemaNode;
-import org.opendaylight.yangtools.yang.model.api.TypeDefinition;
 import org.opendaylight.yangtools.yang.model.api.TypedDataSchemaNode;
 import org.opendaylight.yangtools.yang.model.api.stmt.ActionEffectiveStatement;
-import org.opendaylight.yangtools.yang.model.api.stmt.IdentityEffectiveStatement;
 import org.opendaylight.yangtools.yang.model.api.stmt.InputEffectiveStatement;
+import org.opendaylight.yangtools.yang.model.api.stmt.OutputEffectiveStatement;
 import org.opendaylight.yangtools.yang.model.api.stmt.RpcEffectiveStatement;
 import org.opendaylight.yangtools.yang.model.api.stmt.SchemaTreeEffectiveStatement;
-import org.opendaylight.yangtools.yang.model.api.type.IdentityrefTypeDefinition;
-import org.opendaylight.yangtools.yang.model.api.type.InstanceIdentifierTypeDefinition;
-import org.opendaylight.yangtools.yang.model.api.type.LeafrefTypeDefinition;
-import org.opendaylight.yangtools.yang.model.api.type.UnionTypeDefinition;
 import org.opendaylight.yangtools.yang.model.util.SchemaInferenceStack;
 import org.opendaylight.yangtools.yang.model.util.SchemaInferenceStack.Inference;
 
 /**
- * Deserializer for {@link String} to {@link YangInstanceIdentifier} for restconf.
+ * Utility for normalizing {@link ApiPath}s. An {@link ApiPath} can represent a number of different constructs, as
+ * denoted to in the {@link Path} interface hierarchy.
+ *
+ * <p>
+ * This process is governed by
+ * <a href="https://www.rfc-editor.org/rfc/rfc8040#section-3.5.3">RFC8040, section 3.5.3</a>. The URI provides the
+ * equivalent of NETCONF XML filter encoding, with data values being escaped RFC7891 strings.
  */
 public final class ApiPathNormalizer implements PointNormalizer {
+    /**
+     * A normalized {@link ApiPath}. This can be either
+     * <ul>
+     *   <li>a {@link Data} pointing to a datastore resource, or</li>
+     *   <li>an {@link Rpc} pointing to a YANG {@code rpc} statement, or</li>
+     *   <li>an {@link Action} pointing to an instantiation of a YANG {@code action} statement</li>
+     * </ul>
+     */
     @NonNullByDefault
     public sealed interface Path {
-
+        /**
+         * Returns the {@link EffectiveStatementInference} made by this path.
+         *
+         * @return the {@link EffectiveStatementInference} made by this path
+         */
         Inference inference();
-    }
-
-    @NonNullByDefault
-    public sealed interface InstanceReference extends Path {
-
-        YangInstanceIdentifier instance();
-    }
-
-    @NonNullByDefault
-    public record DataPath(Inference inference, YangInstanceIdentifier instance, DataSchemaContext schema)
-            implements InstanceReference {
-        public DataPath {
-            requireNonNull(inference);
-            requireNonNull(instance);
-            requireNonNull(schema);
-        }
-    }
-
-    @NonNullByDefault
-    public sealed interface OperationPath extends Path {
-
-        InputEffectiveStatement inputStatement();
 
+        /**
+         * A {@link Path} denoting an invocation of a YANG {@code action}.
+         *
+         * @param inference the {@link EffectiveStatementInference} made by this path
+         * @param instance the {@link YangInstanceIdentifier} of the instance being referenced, guaranteed to be
+         *        non-empty
+         * @param action the {@code action}
+         */
         record Action(Inference inference, YangInstanceIdentifier instance, ActionEffectiveStatement action)
                 implements OperationPath, InstanceReference {
             public Action {
                 requireNonNull(inference);
                 requireNonNull(action);
-                requireNonNull(instance);
+                if (instance.isEmpty()) {
+                    throw new IllegalArgumentException("action must be instantiated on a data resource");
+                }
             }
 
             @Override
             public InputEffectiveStatement inputStatement() {
                 return action.input();
             }
+
+            @Override
+            public OutputEffectiveStatement outputStatement() {
+                return action.output();
+            }
+        }
+
+        /**
+         * A {@link Path} denoting a datastore instance.
+         *
+         * @param inference the {@link EffectiveStatementInference} made by this path
+         * @param instance the {@link YangInstanceIdentifier} of the instance being referenced,
+         *                 {@link YangInstanceIdentifier#empty()} denotes the datastore
+         * @param schema the {@link DataSchemaContext} of the datastore instance
+         */
+        // FIXME: split into 'Datastore' and 'Data' with non-empty instance, so we can bind to correct
+        //        instance-identifier semantics, which does not allow YangInstanceIdentifier.empty()
+        record Data(Inference inference, YangInstanceIdentifier instance, DataSchemaContext schema)
+                implements InstanceReference {
+            public Data {
+                requireNonNull(inference);
+                requireNonNull(instance);
+                requireNonNull(schema);
+            }
         }
 
+        /**
+         * A {@link Path} denoting an invocation of a YANG {@code rpc}.
+         *
+         * @param inference the {@link EffectiveStatementInference} made by this path
+         * @param rpc the {@code rpc}
+         */
         record Rpc(Inference inference, RpcEffectiveStatement rpc) implements OperationPath {
             public Rpc {
                 requireNonNull(inference);
@@ -108,21 +140,60 @@ public final class ApiPathNormalizer implements PointNormalizer {
             public InputEffectiveStatement inputStatement() {
                 return rpc.input();
             }
+
+            @Override
+            public OutputEffectiveStatement outputStatement() {
+                return rpc.output();
+            }
         }
     }
 
-    private final @NonNull EffectiveModelContext modelContext;
+    /**
+     * An intermediate trait of {@link Path}s which are referencing a YANG data resource. This can be either
+     * a {@link Data}, or an {@link Action}}.
+     */
+    @NonNullByDefault
+    public sealed interface InstanceReference extends Path {
+        /**
+         * Returns the {@link YangInstanceIdentifier} of the instance being referenced.
+         *
+         * @return the {@link YangInstanceIdentifier} of the instance being referenced,
+         *         {@link YangInstanceIdentifier#empty()} denotes the datastora
+         */
+        YangInstanceIdentifier instance();
+    }
+
+    /**
+     * An intermediate trait of {@link Path}s which are referencing a YANG operation. This can be either
+     * an {@link Action} on an {@link Rpc}.
+     */
+    @NonNullByDefault
+    public sealed interface OperationPath extends Path {
+        /**
+         * Returns the {@code input} statement of this operation.
+         *
+         * @return the {@code input} statement of this operation
+         */
+        InputEffectiveStatement inputStatement();
+
+        /**
+         * Returns the {@code output} statement of this operation.
+         *
+         * @return the {@code output} statement of this operation
+         */
+        OutputEffectiveStatement outputStatement();
+    }
+
     private final @NonNull DatabindContext databind;
 
     public ApiPathNormalizer(final DatabindContext databind) {
         this.databind = requireNonNull(databind);
-        modelContext = databind.modelContext();
     }
 
     public @NonNull Path normalizePath(final ApiPath apiPath) {
         final var it = apiPath.steps().iterator();
         if (!it.hasNext()) {
-            return new DataPath(Inference.ofDataTreePath(modelContext), YangInstanceIdentifier.of(),
+            return new Data(Inference.ofDataTreePath(databind.modelContext()), YangInstanceIdentifier.of(),
                 databind.schemaTree().getRoot());
         }
 
@@ -143,6 +214,7 @@ public final class ApiPathNormalizer implements PointNormalizer {
         var qname = step.identifier().bindTo(namespace);
 
         // We go through more modern APIs here to get this special out of the way quickly
+        final var modelContext = databind.modelContext();
         final var optRpc = modelContext.findModuleStatement(namespace).orElseThrow()
             .findSchemaTreeNode(RpcEffectiveStatement.class, qname);
         if (optRpc.isPresent()) {
@@ -161,7 +233,7 @@ public final class ApiPathNormalizer implements PointNormalizer {
             final var stack = SchemaInferenceStack.of(modelContext);
             final var stmt = stack.enterSchemaTree(rpc.argument());
             verify(rpc.equals(stmt), "Expecting %s, inferred %s", rpc, stmt);
-            return new OperationPath.Rpc(stack.toInference(), rpc);
+            return new Rpc(stack.toInference(), rpc);
         }
 
         final var stack = SchemaInferenceStack.of(modelContext);
@@ -188,7 +260,7 @@ public final class ApiPathNormalizer implements PointNormalizer {
                     final var actionStmt = action.asEffectiveStatement();
                     verify(actionStmt.equals(stmt), "Expecting %s, inferred %s", actionStmt, stmt);
 
-                    return new OperationPath.Action(stack.toInference(), YangInstanceIdentifier.of(path), actionStmt);
+                    return new Action(stack.toInference(), YangInstanceIdentifier.of(path), actionStmt);
                 }
             }
 
@@ -212,9 +284,19 @@ public final class ApiPathNormalizer implements PointNormalizer {
             if (step instanceof ListInstance listStep) {
                 final var values = listStep.keyValues();
                 final var schema = childNode.dataSchemaNode();
-                pathArg = schema instanceof ListSchemaNode listSchema
-                    ? prepareNodeWithPredicates(stack, qname, listSchema, values)
-                        : prepareNodeWithValue(stack, qname, schema, values);
+                if (schema instanceof ListSchemaNode listSchema) {
+                    pathArg = prepareNodeWithPredicates(stack, qname, listSchema, values);
+                } else if (schema instanceof LeafListSchemaNode leafListSchema) {
+                    if (values.size() != 1) {
+                        throw new RestconfDocumentedException("Entry '" + qname + "' requires one value predicate.",
+                            ErrorType.PROTOCOL, ErrorTag.BAD_ATTRIBUTE);
+                    }
+                    pathArg = new NodeWithValue<>(qname, parserJsonValue(stack, leafListSchema, values.get(0)));
+                } else {
+                    throw new RestconfDocumentedException(
+                        "Entry '" + qname + "' does not take a key or value predicate.",
+                        ErrorType.PROTOCOL, ErrorTag.MISSING_ATTRIBUTE);
+                }
             } else {
                 if (childNode.dataSchemaNode() instanceof ListSchemaNode list && !list.getKeyDefinition().isEmpty()) {
                     throw new RestconfDocumentedException(
@@ -227,7 +309,7 @@ public final class ApiPathNormalizer implements PointNormalizer {
             path.add(pathArg);
 
             if (!it.hasNext()) {
-                return new DataPath(stack.toInference(), YangInstanceIdentifier.of(path), childNode);
+                return new Data(stack.toInference(), YangInstanceIdentifier.of(path), childNode);
             }
 
             parentNode = childNode;
@@ -241,9 +323,9 @@ public final class ApiPathNormalizer implements PointNormalizer {
         }
     }
 
-    public @NonNull DataPath normalizeDataPath(final ApiPath apiPath) {
+    public @NonNull Data normalizeDataPath(final ApiPath apiPath) {
         final var path = normalizePath(apiPath);
-        if (path instanceof DataPath dataPath) {
+        if (path instanceof Data dataPath) {
             return dataPath;
         }
         throw new RestconfDocumentedException("Point '" + apiPath + "' resolves to non-data " + path,
@@ -253,7 +335,7 @@ public final class ApiPathNormalizer implements PointNormalizer {
     @Override
     public PathArgument normalizePoint(final ApiPath value) {
         final var path = normalizePath(value);
-        if (path instanceof DataPath dataPath) {
+        if (path instanceof Data dataPath) {
             final var lastArg = dataPath.instance().getLastPathArgument();
             if (lastArg != null) {
                 return lastArg;
@@ -263,7 +345,7 @@ public final class ApiPathNormalizer implements PointNormalizer {
         throw new IllegalArgumentException("Point '" + value + "' resolves to non-data " + path);
     }
 
-    public @NonNull Rpc normalizeRpcPath(final ApiPath apiPath) {
+    public Path.@NonNull Rpc normalizeRpcPath(final ApiPath apiPath) {
         final var steps = apiPath.steps();
         return switch (steps.size()) {
             case 0 -> throw new RestconfDocumentedException("RPC name must be present", ErrorType.PROTOCOL,
@@ -274,7 +356,7 @@ public final class ApiPathNormalizer implements PointNormalizer {
         };
     }
 
-    public @NonNull Rpc normalizeRpcPath(final ApiPath.Step step) {
+    public Path.@NonNull Rpc normalizeRpcPath(final ApiPath.Step step) {
         final var firstModule = step.module();
         if (firstModule == null) {
             throw new RestconfDocumentedException(
@@ -284,7 +366,7 @@ public final class ApiPathNormalizer implements PointNormalizer {
 
         final var namespace = resolveNamespace(firstModule);
         final var qname = step.identifier().bindTo(namespace);
-        final var stack = SchemaInferenceStack.of(modelContext);
+        final var stack = SchemaInferenceStack.of(databind.modelContext());
         final SchemaTreeEffectiveStatement<?> stmt;
         try {
             stmt = stack.enterSchemaTree(qname);
@@ -300,14 +382,12 @@ public final class ApiPathNormalizer implements PointNormalizer {
     }
 
     public @NonNull InstanceReference normalizeDataOrActionPath(final ApiPath apiPath) {
-
-
         // FIXME: optimize this
         final var path = normalizePath(apiPath);
-        if (path instanceof DataPath dataPath) {
+        if (path instanceof Data dataPath) {
             return dataPath;
         }
-        if (path instanceof OperationPath.Action actionPath) {
+        if (path instanceof Action actionPath) {
             return actionPath;
         }
         throw new RestconfDocumentedException("Unexpected path " + path, ErrorType.PROTOCOL, ErrorTag.DATA_MISSING);
@@ -340,110 +420,30 @@ public final class ApiPathNormalizer implements PointNormalizer {
     private Object prepareValueByType(final SchemaInferenceStack stack, final DataSchemaNode schemaNode,
             final @NonNull String value) {
         if (schemaNode instanceof TypedDataSchemaNode typedSchema) {
-            return prepareValueByType(stack, typedSchema, typedSchema.getType(), value);
+            return parserJsonValue(stack, typedSchema, value);
         }
         throw new VerifyException("Unhandled schema " + schemaNode + " decoding '" + value + "'");
     }
 
-    private Object prepareValueByType(final SchemaInferenceStack stack, final TypedDataSchemaNode schemaNode,
-            final TypeDefinition<?> unresolved, final @NonNull String value) {
-        // Resolve 'type leafref' before dispatching on type
-        final TypeDefinition<?> typedef;
-        if (unresolved instanceof LeafrefTypeDefinition leafref) {
-            typedef = stack.resolveLeafref(leafref);
-        } else {
-            typedef = unresolved;
-        }
-
-        // Complex types
-        if (typedef instanceof IdentityrefTypeDefinition) {
-            return toIdentityrefQName(value, schemaNode);
-        }
-        if (typedef instanceof InstanceIdentifierTypeDefinition) {
-            return toInstanceIdentifier(value, schemaNode);
-        }
-        if (typedef instanceof UnionTypeDefinition union) {
-            return toUnion(stack, schemaNode, union, value);
-        }
-
-        // Simple types
-        final var codec = verifyNotNull(TypeDefinitionAwareCodec.from(typedef), "Unhandled type %s decoding %s",
-            typedef, value);
+    private Object parserJsonValue(final SchemaInferenceStack stack, final TypedDataSchemaNode schemaNode,
+            final String value) {
+        // As per https://www.rfc-editor.org/rfc/rfc8040#page-29:
+        //            The syntax for
+        //            "api-identifier" and "key-value" MUST conform to the JSON identifier
+        //            encoding rules in Section 4 of [RFC7951]: The RESTCONF root resource
+        //            path is required.  Additional sub-resource identifiers are optional.
+        //            The characters in a key value string are constrained, and some
+        //            characters need to be percent-encoded, as described in Section 3.5.3.
         try {
-            return codec.deserialize(value);
+            return databind.jsonCodecs().codecFor(schemaNode, stack).parseValue(null, value);
         } catch (IllegalArgumentException e) {
             throw new RestconfDocumentedException("Invalid value '" + value + "' for " + schemaNode.getQName(),
                 ErrorType.PROTOCOL, ErrorTag.INVALID_VALUE, e);
         }
     }
 
-    private NodeWithValue<?> prepareNodeWithValue(final SchemaInferenceStack stack, final QName qname,
-            final DataSchemaNode schema, final List<String> keyValues) {
-        // TODO: qname should be always equal to schema.getQName(), right?
-        return new NodeWithValue<>(qname, prepareValueByType(stack, schema,
-            // FIXME: ahem: we probably want to do something differently here
-            keyValues.get(0)));
-    }
-
-    private Object toUnion(final SchemaInferenceStack stack, final TypedDataSchemaNode schemaNode,
-            final UnionTypeDefinition union, final @NonNull String value) {
-        // As per https://www.rfc-editor.org/rfc/rfc7950#section-9.12:
-        //   'type union' must have at least one 'type'
-        // hence this variable will always end up being non-null before being used
-        RestconfDocumentedException cause = null;
-        for (var type : union.getTypes()) {
-            try {
-                return prepareValueByType(stack, schemaNode, type, value);
-            } catch (RestconfDocumentedException e) {
-                if (cause == null) {
-                    cause = e;
-                } else {
-                    cause.addSuppressed(e);
-                }
-            }
-        }
-        throw new RestconfDocumentedException("Invalid value '" + value + "' for " + schemaNode.getQName(),
-            ErrorType.PROTOCOL, ErrorTag.INVALID_VALUE, cause);
-    }
-
-    private YangInstanceIdentifier toInstanceIdentifier(final String value, final TypedDataSchemaNode schemaNode) {
-        if (value.isEmpty() || !value.startsWith("/")) {
-            throw new RestconfDocumentedException("Invalid value '" + value + "' for " + schemaNode.getQName(),
-                ErrorType.PROTOCOL, ErrorTag.INVALID_VALUE);
-        }
-
-        try {
-            return normalizeDataPath(ApiPath.parse(value.substring(1))).instance();
-        } catch (ParseException | RestconfDocumentedException e) {
-            throw new RestconfDocumentedException("Invalid value '" + value + "' for " + schemaNode.getQName(),
-                ErrorType.PROTOCOL, ErrorTag.INVALID_VALUE, e);
-        }
-    }
-
-    private QName toIdentityrefQName(final String value, final TypedDataSchemaNode schemaNode) {
-        final QNameModule namespace;
-        final String localName;
-        final int firstColon = value.indexOf(':');
-        if (firstColon != -1) {
-            namespace = resolveNamespace(value.substring(0, firstColon));
-            localName = value.substring(firstColon + 1);
-        } else {
-            namespace = schemaNode.getQName().getModule();
-            localName = value;
-        }
-
-        return modelContext.getModuleStatement(namespace)
-            .streamEffectiveSubstatements(IdentityEffectiveStatement.class)
-            .map(IdentityEffectiveStatement::argument)
-            .filter(qname -> localName.equals(qname.getLocalName()))
-            .findFirst()
-            .orElseThrow(() -> new RestconfDocumentedException(
-                "No identity found for '" + localName + "' in namespace " + namespace,
-                ErrorType.PROTOCOL, ErrorTag.INVALID_VALUE));
-    }
-
     private @NonNull QNameModule resolveNamespace(final String moduleName) {
-        final var it = modelContext.findModuleStatements(moduleName).iterator();
+        final var it = databind.modelContext().findModuleStatements(moduleName).iterator();
         if (it.hasNext()) {
             return it.next().localQNameModule();
         }