Introduce RestconfServer.dataPUT() operations 15/109015/3
authorRobert Varga <robert.varga@pantheon.tech>
Fri, 17 Nov 2023 21:46:07 +0000 (22:46 +0100)
committerRobert Varga <robert.varga@pantheon.tech>
Sat, 18 Nov 2023 08:00:58 +0000 (09:00 +0100)
Migrate PUT operations on /data to MdsalRestconfServer/RestconfImpl.
This temporarily duplicates Insert parsing logic, which will be
eliminated in a follow-up patch.

JIRA: NETCONF-773
Change-Id: I8692fede3238cdcca283dfc7d9bc8e123b18aea7
Signed-off-by: Robert Varga <robert.varga@pantheon.tech>
12 files changed:
restconf/restconf-nb/src/main/java/org/opendaylight/restconf/nb/rfc8040/Insert.java
restconf/restconf-nb/src/main/java/org/opendaylight/restconf/nb/rfc8040/ReceiveEventsParams.java
restconf/restconf-nb/src/main/java/org/opendaylight/restconf/nb/rfc8040/databind/jaxrs/QueryParams.java
restconf/restconf-nb/src/main/java/org/opendaylight/restconf/nb/rfc8040/rests/services/impl/MdsalRestconfServer.java
restconf/restconf-nb/src/main/java/org/opendaylight/restconf/nb/rfc8040/rests/services/impl/RestconfDataServiceImpl.java
restconf/restconf-nb/src/main/java/org/opendaylight/restconf/nb/rfc8040/rests/services/impl/RestconfImpl.java
restconf/restconf-nb/src/main/java/org/opendaylight/restconf/nb/rfc8040/rests/transactions/RestconfStrategy.java
restconf/restconf-nb/src/main/java/org/opendaylight/restconf/nb/rfc8040/streams/SSEStreamService.java
restconf/restconf-nb/src/main/java/org/opendaylight/restconf/server/api/DataPutResult.java [new file with mode: 0644]
restconf/restconf-nb/src/main/java/org/opendaylight/restconf/server/api/RestconfServer.java
restconf/restconf-nb/src/test/java/org/opendaylight/restconf/nb/rfc8040/rests/services/impl/RestconfDataPutTest.java [new file with mode: 0644]
restconf/restconf-nb/src/test/java/org/opendaylight/restconf/nb/rfc8040/rests/services/impl/RestconfDataServiceImplTest.java

index ec513fed0af5b0d83565740d05b4a6e50fc54e85..d611cd9c474495f2d29edd21fd180e1424dafb19 100644 (file)
@@ -8,16 +8,20 @@
 package org.opendaylight.restconf.nb.rfc8040;
 
 import static java.util.Objects.requireNonNull;
+import static org.opendaylight.restconf.nb.rfc8040.ReceiveEventsParams.optionalParam;
 
 import com.google.common.annotations.Beta;
 import com.google.common.base.MoreObjects;
+import java.util.Map;
 import org.eclipse.jdt.annotation.NonNull;
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
 import org.opendaylight.restconf.api.query.InsertParam;
 import org.opendaylight.restconf.api.query.PointParam;
+import org.opendaylight.restconf.nb.rfc8040.utils.parser.YangInstanceIdentifierDeserializer;
 import org.opendaylight.yangtools.concepts.Immutable;
 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.PathArgument;
+import org.opendaylight.yangtools.yang.model.api.EffectiveModelContext;
 
 /**
  * Parser and holder of query parameters from uriInfo for data and datastore modification operations.
@@ -44,6 +48,43 @@ public final class Insert implements Immutable {
         this.pointArg = pointArg;
     }
 
+    /**
+     * Return an {@link Insert} parameter for specified query parameters.
+     *
+     * @param queryParameters Parameters and their values
+     * @return An {@link Insert}, or {@code null} if no insert information is present
+     * @throws NullPointerException if any argument is {@code null}
+     * @throws IllegalArgumentException if the parameters are invalid
+     */
+    public static @Nullable Insert ofQueryParameters(final EffectiveModelContext modelContext,
+            final Map<String, String> queryParameters) {
+        InsertParam insert = null;
+        PointParam point = null;
+
+        for (var entry : queryParameters.entrySet()) {
+            final var paramName = entry.getKey();
+            final var paramValue = entry.getValue();
+
+            switch (paramName) {
+                case InsertParam.uriName:
+                    insert = optionalParam(InsertParam::forUriValue, paramName, paramValue);
+                    break;
+                case PointParam.uriName:
+                    point = optionalParam(PointParam::forUriValue, paramName, paramValue);
+                    break;
+                default:
+                    throw new IllegalArgumentException("Invalid parameter: " + paramName);
+            }
+        }
+
+        return Insert.forParams(insert, point,
+            // TODO: instead of a EffectiveModelContext, we should have received
+            //       YangInstanceIdentifierDeserializer.Result, from which we can use to seed the parser. This
+            //       call-site should not support 'yang-ext:mount' and should just reuse DataSchemaContextTree,
+            //       saving a lookup
+            value -> YangInstanceIdentifierDeserializer.create(modelContext, value).path.getLastPathArgument());
+    }
+
     public static @Nullable Insert forParams(final @Nullable InsertParam insert, final @Nullable PointParam point,
             final PointParser pointParser) {
         if (insert == null) {
index bde0ba97ef73bed789943e8c5e00f78d31dec7ab..fca7f705c06ff073e4e5cb121cbbe50477feb566 100644 (file)
@@ -54,8 +54,11 @@ public record ReceiveEventsParams(
 
     /**
      * Return {@link ReceiveEventsParams} for specified query parameters.
+     *
      * @param queryParameters Parameters and their values
      * @return A {@link ReceiveEventsParams}
+     * @throws NullPointerException if {@code queryParameters} is {@code null}
+     * @throws IllegalArgumentException if the parameters are invalid
      */
     public static @NonNull ReceiveEventsParams ofQueryParameters(final Map<String, String> queryParameters) {
         StartTimeParam startTime = null;
@@ -130,7 +133,7 @@ public record ReceiveEventsParams(
         return helper.toString();
     }
 
-    private static <T> @Nullable T optionalParam(final Function<String, @NonNull T> factory, final String name,
+    static <T> @Nullable T optionalParam(final Function<String, @NonNull T> factory, final String name,
             final String value) {
         try {
             return factory.apply(requireNonNull(value));
index 152c537a1051fd43c997c8e90663b26a7629b8fa..9196840e2d837b797f70db7a71d523bf4a123a7f 100644 (file)
@@ -11,6 +11,7 @@ import static java.util.Objects.requireNonNull;
 
 import com.google.common.annotations.Beta;
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableMap;
 import java.util.List;
 import java.util.Set;
 import java.util.function.Function;
@@ -62,6 +63,33 @@ public final class QueryParams {
         // Utility class
     }
 
+    /**
+     * Normalize query parameters from an {@link UriInfo}.
+     *
+     * @param uriInfo An {@link UriInfo}
+     * @return Normalized query parameters
+     * @throws NullPointerException if {@code uriInfo} is {@code null}
+     * @throws IllegalArgumentException if there are multiple values for a parameter
+     */
+    public static @NonNull ImmutableMap<String, String> normalize(final UriInfo uriInfo) {
+        final var builder = ImmutableMap.<String, String>builder();
+        for (var entry : uriInfo.getQueryParameters().entrySet()) {
+            final var values = entry.getValue();
+            switch (values.size()) {
+                case 0:
+                    // No-op
+                    break;
+                case 1:
+                    builder.put(entry.getKey(), values.get(0));
+                    break;
+                default:
+                    throw new IllegalArgumentException(
+                        "Parameter " + entry.getKey() + " can appear at most once in request URI");
+            }
+        }
+        return builder.build();
+    }
+
     public static QueryParameters newQueryParameters(final ReadDataParams params,
             final InstanceIdentifierContext identifier) {
         final var fields = params.fields();
index f97ee6312cf55ae44aecf39665e47bbf93637750..960901b3d5769201bea57e2d526373f46a08b6aa 100644 (file)
@@ -25,6 +25,7 @@ import java.lang.invoke.VarHandle;
 import java.net.URI;
 import java.util.Comparator;
 import java.util.List;
+import java.util.Map;
 import java.util.Map.Entry;
 import java.util.concurrent.CancellationException;
 import javax.inject.Inject;
@@ -46,6 +47,7 @@ import org.opendaylight.restconf.common.errors.RestconfFuture;
 import org.opendaylight.restconf.common.errors.SettableRestconfFuture;
 import org.opendaylight.restconf.common.patch.PatchContext;
 import org.opendaylight.restconf.common.patch.PatchStatusContext;
+import org.opendaylight.restconf.nb.rfc8040.Insert;
 import org.opendaylight.restconf.nb.rfc8040.ReadDataParams;
 import org.opendaylight.restconf.nb.rfc8040.databind.DatabindContext;
 import org.opendaylight.restconf.nb.rfc8040.databind.DatabindProvider;
@@ -58,6 +60,7 @@ import org.opendaylight.restconf.nb.rfc8040.legacy.NormalizedNodePayload;
 import org.opendaylight.restconf.nb.rfc8040.rests.transactions.MdsalRestconfStrategy;
 import org.opendaylight.restconf.nb.rfc8040.rests.transactions.RestconfStrategy;
 import org.opendaylight.restconf.nb.rfc8040.utils.parser.ParserIdentifier;
+import org.opendaylight.restconf.server.api.DataPutResult;
 import org.opendaylight.restconf.server.api.OperationsContent;
 import org.opendaylight.restconf.server.api.RestconfServer;
 import org.opendaylight.restconf.server.spi.OperationInput;
@@ -292,13 +295,37 @@ public final class MdsalRestconfServer implements RestconfServer {
      * @param schemaPath schema path of data
      * @return {@link DOMActionResult}
      */
-    private static RestconfFuture<DOMActionResult> dataInvokePOST(final ContainerNode data,
-            final Absolute schemaPath, final YangInstanceIdentifier yangIId, final DOMMountPoint mountPoint) {
+    private static RestconfFuture<DOMActionResult> dataInvokePOST(final ContainerNode data, final Absolute schemaPath,
+            final YangInstanceIdentifier yangIId, final DOMMountPoint mountPoint) {
         final var actionService = mountPoint.getService(DOMActionService.class);
         return actionService.isPresent() ? dataInvokePOST(data, schemaPath, yangIId, actionService.orElseThrow())
             : RestconfFuture.failed(new RestconfDocumentedException("DOMActionService is missing."));
     }
 
+    @Override
+    public RestconfFuture<DataPutResult> dataPUT(final ResourceBody body, final Map<String, String> query) {
+        return dataPUT(bindRequestRoot(), body, query);
+    }
+
+    @Override
+    public RestconfFuture<DataPutResult> dataPUT(final String identifier, final ResourceBody body,
+             final Map<String, String> queryParameters) {
+        return dataPUT(bindRequestPath(identifier), body, queryParameters);
+    }
+
+    private @NonNull RestconfFuture<DataPutResult> dataPUT(final InstanceIdentifierContext reqPath,
+            final ResourceBody body, final Map<String, String> queryParameters) {
+        final Insert insert;
+        try {
+            insert = Insert.ofQueryParameters(reqPath.getSchemaContext(), queryParameters);
+        } catch (IllegalArgumentException e) {
+            return RestconfFuture.failed(new RestconfDocumentedException(e.getMessage(),
+                ErrorType.PROTOCOL, ErrorTag.INVALID_VALUE, e));
+        }
+        final var req = bindResourceRequest(reqPath, body);
+        return req.strategy().putData(req.path(), req.data(), insert);
+    }
+
     @Override
     public OperationsContent operationsGET() {
         return operationsGET(databindProvider.currentContext().modelContext());
index 413b28d62d833c6fdd7e9bbba76eef81c59ed4fc..03d9ca7283fa7e21323c9cfb37b958f33c7f1c0c 100644 (file)
@@ -15,7 +15,6 @@ import java.util.List;
 import javax.ws.rs.Consumes;
 import javax.ws.rs.Encoded;
 import javax.ws.rs.POST;
-import javax.ws.rs.PUT;
 import javax.ws.rs.Path;
 import javax.ws.rs.PathParam;
 import javax.ws.rs.container.AsyncResponse;
@@ -33,16 +32,12 @@ import org.opendaylight.restconf.nb.rfc8040.databind.ChildBody;
 import org.opendaylight.restconf.nb.rfc8040.databind.DatabindProvider;
 import org.opendaylight.restconf.nb.rfc8040.databind.JsonChildBody;
 import org.opendaylight.restconf.nb.rfc8040.databind.JsonOperationInputBody;
-import org.opendaylight.restconf.nb.rfc8040.databind.JsonResourceBody;
 import org.opendaylight.restconf.nb.rfc8040.databind.OperationInputBody;
-import org.opendaylight.restconf.nb.rfc8040.databind.ResourceBody;
 import org.opendaylight.restconf.nb.rfc8040.databind.XmlChildBody;
 import org.opendaylight.restconf.nb.rfc8040.databind.XmlOperationInputBody;
-import org.opendaylight.restconf.nb.rfc8040.databind.XmlResourceBody;
 import org.opendaylight.restconf.nb.rfc8040.databind.jaxrs.QueryParams;
 import org.opendaylight.restconf.nb.rfc8040.legacy.InstanceIdentifierContext;
 import org.opendaylight.restconf.nb.rfc8040.legacy.NormalizedNodePayload;
-import org.opendaylight.restconf.nb.rfc8040.rests.transactions.RestconfStrategy.CreateOrReplaceResult;
 import org.opendaylight.restconf.nb.rfc8040.utils.parser.IdentifierCodec;
 import org.opendaylight.yangtools.yang.common.Empty;
 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier;
@@ -68,106 +63,6 @@ public final class RestconfDataServiceImpl {
         this.server = requireNonNull(server);
     }
 
-    /**
-     * Replace the data store.
-     *
-     * @param uriInfo request URI information
-     * @param body data node for put to config DS
-     * @param ar {@link AsyncResponse} which needs to be completed
-     */
-    @PUT
-    @Path("/data")
-    @Consumes({
-        MediaTypes.APPLICATION_YANG_DATA_JSON,
-        MediaType.APPLICATION_JSON,
-    })
-    public void putDataJSON(@Context final UriInfo uriInfo, final InputStream body, @Suspended final AsyncResponse ar) {
-        try (var jsonBody = new JsonResourceBody(body)) {
-            putData(null, uriInfo, jsonBody, ar);
-        }
-    }
-
-    /**
-     * Create or replace the target data resource.
-     *
-     * @param identifier path to target
-     * @param uriInfo request URI information
-     * @param body data node for put to config DS
-     * @param ar {@link AsyncResponse} which needs to be completed
-     */
-    @PUT
-    @Path("/data/{identifier:.+}")
-    @Consumes({
-        MediaTypes.APPLICATION_YANG_DATA_JSON,
-        MediaType.APPLICATION_JSON,
-    })
-    public void putDataJSON(@Encoded @PathParam("identifier") final String identifier,
-            @Context final UriInfo uriInfo, final InputStream body, @Suspended final AsyncResponse ar) {
-        try (var jsonBody = new JsonResourceBody(body)) {
-            putData(identifier, uriInfo, jsonBody, ar);
-        }
-    }
-
-    /**
-     * Replace the data store.
-     *
-     * @param uriInfo request URI information
-     * @param body data node for put to config DS
-     * @param ar {@link AsyncResponse} which needs to be completed
-     */
-    @PUT
-    @Path("/data")
-    @Consumes({
-        MediaTypes.APPLICATION_YANG_DATA_XML,
-        MediaType.APPLICATION_XML,
-        MediaType.TEXT_XML
-    })
-    public void putDataXML(@Context final UriInfo uriInfo, final InputStream body, @Suspended final AsyncResponse ar) {
-        try (var xmlBody = new XmlResourceBody(body)) {
-            putData(null, uriInfo, xmlBody, ar);
-        }
-    }
-
-    /**
-     * Create or replace the target data resource.
-     *
-     * @param identifier path to target
-     * @param uriInfo request URI information
-     * @param body data node for put to config DS
-     * @param ar {@link AsyncResponse} which needs to be completed
-     */
-    @PUT
-    @Path("/data/{identifier:.+}")
-    @Consumes({
-        MediaTypes.APPLICATION_YANG_DATA_XML,
-        MediaType.APPLICATION_XML,
-        MediaType.TEXT_XML
-    })
-    public void putDataXML(@Encoded @PathParam("identifier") final String identifier,
-            @Context final UriInfo uriInfo, final InputStream body, @Suspended final AsyncResponse ar) {
-        try (var xmlBody = new XmlResourceBody(body)) {
-            putData(identifier, uriInfo, xmlBody, ar);
-        }
-    }
-
-    private void putData(final @Nullable String identifier, final UriInfo uriInfo, final ResourceBody body,
-            final AsyncResponse ar) {
-        final var reqPath = server.bindRequestPath(identifier);
-        final var insert = QueryParams.parseInsert(reqPath.getSchemaContext(), uriInfo);
-        final var req = server.bindResourceRequest(reqPath, body);
-
-        req.strategy().putData(req.path(), req.data(), insert).addCallback(new JaxRsRestconfCallback<>(ar) {
-            @Override
-            Response transform(final CreateOrReplaceResult result) {
-                return switch (result) {
-                    // Note: no Location header, as it matches the request path
-                    case CREATED -> Response.status(Status.CREATED).build();
-                    case REPLACED -> Response.noContent().build();
-                };
-            }
-        });
-    }
-
     /**
      * Create a top-level data resource.
      *
index c75152a14ee3b24b52b1b0275720dafe8fe83fc7..709b66d0115c0b6b92db481fcd33da8dd5078fe5 100644 (file)
@@ -21,6 +21,7 @@ import javax.ws.rs.GET;
 import javax.ws.rs.NotFoundException;
 import javax.ws.rs.PATCH;
 import javax.ws.rs.POST;
+import javax.ws.rs.PUT;
 import javax.ws.rs.Path;
 import javax.ws.rs.PathParam;
 import javax.ws.rs.Produces;
@@ -47,6 +48,7 @@ import org.opendaylight.restconf.nb.rfc8040.databind.XmlResourceBody;
 import org.opendaylight.restconf.nb.rfc8040.databind.jaxrs.QueryParams;
 import org.opendaylight.restconf.nb.rfc8040.legacy.ErrorTags;
 import org.opendaylight.restconf.nb.rfc8040.legacy.NormalizedNodePayload;
+import org.opendaylight.restconf.server.api.DataPutResult;
 import org.opendaylight.restconf.server.api.OperationsContent;
 import org.opendaylight.restconf.server.api.RestconfServer;
 import org.opendaylight.restconf.server.spi.OperationOutput;
@@ -356,6 +358,101 @@ public final class RestconfImpl {
         });
     }
 
+    /**
+     * Replace the data store.
+     *
+     * @param uriInfo request URI information
+     * @param body data node for put to config DS
+     * @param ar {@link AsyncResponse} which needs to be completed
+     */
+    @PUT
+    @Path("/data")
+    @Consumes({
+        MediaTypes.APPLICATION_YANG_DATA_JSON,
+        MediaType.APPLICATION_JSON,
+    })
+    public void dataJsonPUT(@Context final UriInfo uriInfo, final InputStream body, @Suspended final AsyncResponse ar) {
+        try (var jsonBody = new JsonResourceBody(body)) {
+            completeDataPUT(server.dataPUT(jsonBody, QueryParams.normalize(uriInfo)), ar);
+        }
+    }
+
+    /**
+     * Create or replace the target data resource.
+     *
+     * @param identifier path to target
+     * @param uriInfo request URI information
+     * @param body data node for put to config DS
+     * @param ar {@link AsyncResponse} which needs to be completed
+     */
+    @PUT
+    @Path("/data/{identifier:.+}")
+    @Consumes({
+        MediaTypes.APPLICATION_YANG_DATA_JSON,
+        MediaType.APPLICATION_JSON,
+    })
+    public void dataJsonPUT(@Encoded @PathParam("identifier") final String identifier,
+            @Context final UriInfo uriInfo, final InputStream body, @Suspended final AsyncResponse ar) {
+        try (var jsonBody = new JsonResourceBody(body)) {
+            completeDataPUT(server.dataPUT(identifier, jsonBody, QueryParams.normalize(uriInfo)), ar);
+        }
+    }
+
+    /**
+     * Replace the data store.
+     *
+     * @param uriInfo request URI information
+     * @param body data node for put to config DS
+     * @param ar {@link AsyncResponse} which needs to be completed
+     */
+    @PUT
+    @Path("/data")
+    @Consumes({
+        MediaTypes.APPLICATION_YANG_DATA_XML,
+        MediaType.APPLICATION_XML,
+        MediaType.TEXT_XML
+    })
+    public void dataXmlPUT(@Context final UriInfo uriInfo, final InputStream body, @Suspended final AsyncResponse ar) {
+        try (var xmlBody = new XmlResourceBody(body)) {
+            completeDataPUT(server.dataPUT(xmlBody, QueryParams.normalize(uriInfo)), ar);
+        }
+    }
+
+    /**
+     * Create or replace the target data resource.
+     *
+     * @param identifier path to target
+     * @param uriInfo request URI information
+     * @param body data node for put to config DS
+     * @param ar {@link AsyncResponse} which needs to be completed
+     */
+    @PUT
+    @Path("/data/{identifier:.+}")
+    @Consumes({
+        MediaTypes.APPLICATION_YANG_DATA_XML,
+        MediaType.APPLICATION_XML,
+        MediaType.TEXT_XML
+    })
+    public void dataXmlPUT(@Encoded @PathParam("identifier") final String identifier,
+            @Context final UriInfo uriInfo, final InputStream body, @Suspended final AsyncResponse ar) {
+        try (var xmlBody = new XmlResourceBody(body)) {
+            completeDataPUT(server.dataPUT(identifier, xmlBody, QueryParams.normalize(uriInfo)), ar);
+        }
+    }
+
+    private static void completeDataPUT(final RestconfFuture<DataPutResult> future, final AsyncResponse ar) {
+        future.addCallback(new JaxRsRestconfCallback<>(ar) {
+            @Override
+            Response transform(final DataPutResult result) {
+                return switch (result) {
+                    // Note: no Location header, as it matches the request path
+                    case CREATED -> Response.status(Status.CREATED).build();
+                    case REPLACED -> Response.noContent().build();
+                };
+            }
+        });
+    }
+
     /**
      * List RPC and action operations in RFC7951 format.
      *
index f7dcd7bc26e1d59aa1f67c92cdab5cc147f2cbbd..c491b4d5879161976d6cdbd57b2d5ef07d5e5abb 100644 (file)
@@ -44,6 +44,7 @@ import org.opendaylight.restconf.common.patch.PatchContext;
 import org.opendaylight.restconf.common.patch.PatchStatusContext;
 import org.opendaylight.restconf.common.patch.PatchStatusEntity;
 import org.opendaylight.restconf.nb.rfc8040.Insert;
+import org.opendaylight.restconf.server.api.DataPutResult;
 import org.opendaylight.restconf.server.spi.OperationInput;
 import org.opendaylight.restconf.server.spi.OperationOutput;
 import org.opendaylight.restconf.server.spi.RpcImplementation;
@@ -97,22 +98,6 @@ import org.slf4j.LoggerFactory;
 // FIXME: it seems the first three operations deal with lifecycle of a transaction, while others invoke various
 //        operations. This should be handled through proper allocation indirection.
 public abstract class RestconfStrategy {
-    /**
-     * Result of a {@code PUT} request as defined in
-     * <a href="https://www.rfc-editor.org/rfc/rfc8040#section-4.5">RFC8040 section 4.5</a>. The definition makes it
-     * clear that the logical operation is {@code create-or-replace}.
-     */
-    public enum CreateOrReplaceResult {
-        /**
-         * A new resource has been created.
-         */
-        CREATED,
-        /*
-         * An existing resources has been replaced.
-         */
-        REPLACED;
-    }
-
     private static final Logger LOG = LoggerFactory.getLogger(RestconfStrategy.class);
 
     private final @NonNull EffectiveModelContext modelContext;
@@ -247,9 +232,9 @@ public abstract class RestconfStrategy {
      * @param path    path of data
      * @param data    data
      * @param insert  {@link Insert}
-     * @return A {@link CreateOrReplaceResult}
+     * @return A {@link DataPutResult}
      */
-    public final RestconfFuture<CreateOrReplaceResult> putData(final YangInstanceIdentifier path,
+    public final RestconfFuture<DataPutResult> putData(final YangInstanceIdentifier path,
             final NormalizedNode data, final @Nullable Insert insert) {
         final var exists = TransactionUtil.syncAccess(exists(path), path);
 
@@ -262,12 +247,12 @@ public abstract class RestconfStrategy {
             commitFuture = replaceAndCommit(prepareWriteExecution(), path, data);
         }
 
-        final var ret = new SettableRestconfFuture<CreateOrReplaceResult>();
+        final var ret = new SettableRestconfFuture<DataPutResult>();
 
         Futures.addCallback(commitFuture, new FutureCallback<CommitInfo>() {
             @Override
             public void onSuccess(final CommitInfo result) {
-                ret.set(exists ? CreateOrReplaceResult.REPLACED : CreateOrReplaceResult.CREATED);
+                ret.set(exists ? DataPutResult.REPLACED : DataPutResult.CREATED);
             }
 
             @Override
index 59f52a586bdfe185e61d88a798438a3454dcb83e..839491a3d01abd1857fba9388d6970c7b4943d75 100644 (file)
@@ -24,6 +24,7 @@ import javax.ws.rs.sse.Sse;
 import javax.ws.rs.sse.SseEventSink;
 import javax.xml.xpath.XPathExpressionException;
 import org.opendaylight.restconf.nb.rfc8040.ReceiveEventsParams;
+import org.opendaylight.restconf.nb.rfc8040.databind.jaxrs.QueryParams;
 import org.opendaylight.restconf.server.spi.RestconfStream;
 import org.opendaylight.restconf.server.spi.RestconfStream.EncodingName;
 import org.slf4j.Logger;
@@ -66,25 +67,16 @@ final class SSEStreamService {
             throw new NotFoundException("No such stream: " + streamName);
         }
 
-        final var queryParameters = ImmutableMap.<String, String>builder();
-        for (var entry : uriInfo.getQueryParameters().entrySet()) {
-            final var values = entry.getValue();
-            switch (values.size()) {
-                case 0:
-                    // No-op
-                    break;
-                case 1:
-                    queryParameters.put(entry.getKey(), values.get(0));
-                    break;
-                default:
-                    throw new BadRequestException(
-                        "Parameter " + entry.getKey() + " can appear at most once in request URI");
-            }
+        final ImmutableMap<String, String> queryParameters;
+        try {
+            queryParameters = QueryParams.normalize(uriInfo);
+        } catch (IllegalArgumentException e) {
+            throw new BadRequestException(e.getMessage(), e);
         }
 
         final ReceiveEventsParams params;
         try {
-            params = ReceiveEventsParams.ofQueryParameters(queryParameters.build());
+            params = ReceiveEventsParams.ofQueryParameters(queryParameters);
         } catch (IllegalArgumentException e) {
             throw new BadRequestException(e.getMessage(), e);
         }
diff --git a/restconf/restconf-nb/src/main/java/org/opendaylight/restconf/server/api/DataPutResult.java b/restconf/restconf-nb/src/main/java/org/opendaylight/restconf/server/api/DataPutResult.java
new file mode 100644 (file)
index 0000000..019b047
--- /dev/null
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2023 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.api;
+
+/**
+ * Result of a {@code PUT} request as defined in
+ * <a href="https://www.rfc-editor.org/rfc/rfc8040#section-4.5">RFC8040 section 4.5</a>. The definition makes it
+ * clear that the logical operation is {@code create-or-replace}.
+ */
+public enum DataPutResult {
+    /**
+     * A new resource has been created.
+     */
+    CREATED,
+    /*
+     * An existing resources has been replaced.
+     */
+    REPLACED;
+}
\ No newline at end of file
index 09b66bf2c82e3c108b9bf6ae9e720b74c9e9882c..400044fc68bf8847a87c1be7741152a7c35532d0 100644 (file)
@@ -8,6 +8,7 @@
 package org.opendaylight.restconf.server.api;
 
 import java.net.URI;
+import java.util.Map;
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
 import org.opendaylight.restconf.api.ApiPath;
@@ -91,6 +92,25 @@ public interface RestconfServer {
      */
     RestconfFuture<PatchStatusContext> dataPATCH(String identifier, PatchBody body);
 
+    /**
+     * Replace the data store.
+     *
+     * @param body data node for put to config DS
+     * @param queryParameters Query parameters
+     * @return A {@link RestconfFuture} completing with {@link DataPutResult}
+     */
+    RestconfFuture<DataPutResult> dataPUT(ResourceBody body, Map<String, String> queryParameters);
+
+    /**
+     * Create or replace a data store resource.
+     *
+     * @param identifier resource identifier
+     * @param body data node for put to config DS
+     * @param queryParameters Query parameters
+     * @return A {@link RestconfFuture} completing with {@link DataPutResult}
+     */
+    RestconfFuture<DataPutResult> dataPUT(String identifier, ResourceBody body, Map<String, String> queryParameters);
+
     /**
      * Return the set of supported RPCs supported by {@link #operationsPOST(URI, String, OperationInputBody)}.
      *
diff --git a/restconf/restconf-nb/src/test/java/org/opendaylight/restconf/nb/rfc8040/rests/services/impl/RestconfDataPutTest.java b/restconf/restconf-nb/src/test/java/org/opendaylight/restconf/nb/rfc8040/rests/services/impl/RestconfDataPutTest.java
new file mode 100644 (file)
index 0000000..591d51f
--- /dev/null
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2023 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.rests.services.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doReturn;
+import static org.opendaylight.yangtools.util.concurrent.FluentFutures.immediateTrueFluentFuture;
+
+import java.util.Optional;
+import javax.ws.rs.core.MultivaluedHashMap;
+import javax.ws.rs.core.MultivaluedMap;
+import javax.ws.rs.core.Response;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.opendaylight.mdsal.common.api.CommitInfo;
+import org.opendaylight.mdsal.common.api.LogicalDatastoreType;
+import org.opendaylight.mdsal.dom.api.DOMDataBroker;
+import org.opendaylight.mdsal.dom.api.DOMDataTreeReadTransaction;
+import org.opendaylight.mdsal.dom.api.DOMDataTreeReadWriteTransaction;
+import org.opendaylight.mdsal.dom.api.DOMRpcService;
+import org.opendaylight.mdsal.dom.api.DOMSchemaService;
+import org.opendaylight.mdsal.dom.spi.FixedDOMSchemaService;
+import org.opendaylight.netconf.dom.api.NetconfDataTreeService;
+
+@ExtendWith(MockitoExtension.class)
+class RestconfDataPutTest extends AbstractRestconfTest {
+    private final MultivaluedMap<String, String> queryParamenters = new MultivaluedHashMap<>();
+
+    @Mock
+    private DOMDataTreeReadTransaction readTx;
+    @Mock
+    private DOMDataTreeReadWriteTransaction rwTx;
+
+    @BeforeEach
+    void beforeEach() {
+        doReturn(queryParamenters).when(uriInfo).getQueryParameters();
+        doReturn(readTx).when(dataBroker).newReadOnlyTransaction();
+        doReturn(rwTx).when(dataBroker).newReadWriteTransaction();
+        doReturn(CommitInfo.emptyFluentFuture()).when(rwTx).commit();
+    }
+
+    @Test
+    void testPutData() {
+        doReturn(immediateTrueFluentFuture()).when(readTx).exists(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID);
+        doNothing().when(rwTx).put(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID, EMPTY_JUKEBOX);
+
+        doReturn(true).when(asyncResponse).resume(responseCaptor.capture());
+        restconf.dataJsonPUT("example-jukebox:jukebox", uriInfo, stringInputStream("""
+            {
+              "example-jukebox:jukebox" : {
+                 "player": {
+                   "gap": "0.2"
+                 }
+              }
+            }"""), asyncResponse);
+        final var response = responseCaptor.getValue();
+        assertEquals(Response.Status.NO_CONTENT.getStatusCode(), response.getStatus());
+    }
+
+    @Test
+    void testPutDataWithMountPoint() {
+        doReturn(immediateTrueFluentFuture()).when(readTx).exists(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID);
+        doNothing().when(rwTx).put(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID, EMPTY_JUKEBOX);
+        doReturn(Optional.of(mountPoint)).when(mountPointService).getMountPoint(any());
+        doReturn(Optional.of(FixedDOMSchemaService.of(JUKEBOX_SCHEMA))).when(mountPoint)
+        .getService(DOMSchemaService.class);
+        doReturn(Optional.of(dataBroker)).when(mountPoint).getService(DOMDataBroker.class);
+        doReturn(Optional.of(rpcService)).when(mountPoint).getService(DOMRpcService.class);
+        doReturn(Optional.empty()).when(mountPoint).getService(NetconfDataTreeService.class);
+
+        doReturn(true).when(asyncResponse).resume(responseCaptor.capture());
+        restconf.dataXmlPUT("example-jukebox:jukebox/yang-ext:mount/example-jukebox:jukebox",
+            uriInfo, stringInputStream("""
+                <jukebox xmlns="http://example.com/ns/example-jukebox">
+                  <player>
+                    <gap>0.2</gap>
+                  </player>
+                </jukebox>"""), asyncResponse);
+        final var response = responseCaptor.getValue();
+        assertEquals(Response.Status.NO_CONTENT.getStatusCode(), response.getStatus());
+    }
+}
index 34d458c4ee958e77b6f27a4dd12f0bc19cadc061..7f08a96c450b94d0b85a40303ff47fb46d7436cc 100644 (file)
@@ -8,19 +8,14 @@
 package org.opendaylight.restconf.nb.rfc8040.rests.services.impl;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.mock;
 import static org.opendaylight.yangtools.util.concurrent.FluentFutures.immediateFalseFluentFuture;
-import static org.opendaylight.yangtools.util.concurrent.FluentFutures.immediateTrueFluentFuture;
 
 import java.net.URI;
-import java.util.Optional;
-import java.util.Set;
 import javax.ws.rs.container.AsyncResponse;
 import javax.ws.rs.core.MultivaluedHashMap;
-import javax.ws.rs.core.MultivaluedMap;
 import javax.ws.rs.core.Response;
 import javax.ws.rs.core.UriBuilder;
 import javax.ws.rs.core.UriInfo;
@@ -35,18 +30,12 @@ import org.opendaylight.mdsal.common.api.CommitInfo;
 import org.opendaylight.mdsal.common.api.LogicalDatastoreType;
 import org.opendaylight.mdsal.dom.api.DOMActionService;
 import org.opendaylight.mdsal.dom.api.DOMDataBroker;
-import org.opendaylight.mdsal.dom.api.DOMDataTreeReadTransaction;
 import org.opendaylight.mdsal.dom.api.DOMDataTreeReadWriteTransaction;
-import org.opendaylight.mdsal.dom.api.DOMMountPoint;
 import org.opendaylight.mdsal.dom.api.DOMMountPointService;
 import org.opendaylight.mdsal.dom.api.DOMRpcService;
-import org.opendaylight.mdsal.dom.api.DOMSchemaService;
-import org.opendaylight.mdsal.dom.spi.FixedDOMSchemaService;
-import org.opendaylight.netconf.dom.api.NetconfDataTreeService;
 import org.opendaylight.restconf.nb.rfc8040.AbstractJukeboxTest;
 import org.opendaylight.restconf.nb.rfc8040.databind.DatabindContext;
 import org.opendaylight.restconf.nb.rfc8040.databind.DatabindProvider;
-import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier;
 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeIdentifier;
 import org.opendaylight.yangtools.yang.data.impl.schema.Builders;
 
@@ -57,22 +46,14 @@ public class RestconfDataServiceImplTest extends AbstractJukeboxTest {
     @Mock
     private DOMDataTreeReadWriteTransaction readWrite;
     @Mock
-    private DOMDataTreeReadTransaction read;
-    @Mock
     private DOMMountPointService mountPointService;
     @Mock
-    private DOMMountPoint mountPoint;
-    @Mock
     private DOMDataBroker mountDataBroker;
     @Mock
-    private NetconfDataTreeService netconfService;
-    @Mock
     private DOMActionService actionService;
     @Mock
     private DOMRpcService rpcService;
     @Mock
-    private MultivaluedMap<String, String> queryParamenters;
-    @Mock
     private AsyncResponse asyncResponse;
     @Captor
     private ArgumentCaptor<Response> responseCaptor;
@@ -81,64 +62,14 @@ public class RestconfDataServiceImplTest extends AbstractJukeboxTest {
 
     @Before
     public void setUp() throws Exception {
-        doReturn(Set.of()).when(queryParamenters).entrySet();
-        doReturn(queryParamenters).when(uriInfo).getQueryParameters();
-
         doReturn(CommitInfo.emptyFluentFuture()).when(readWrite).commit();
 
         final var dataBroker = mock(DOMDataBroker.class);
-        doReturn(read).when(dataBroker).newReadOnlyTransaction();
         doReturn(readWrite).when(dataBroker).newReadWriteTransaction();
 
         final DatabindProvider databindProvider = () -> DatabindContext.ofModel(JUKEBOX_SCHEMA);
         dataService = new RestconfDataServiceImpl(databindProvider,
             new MdsalRestconfServer(databindProvider, dataBroker, rpcService, actionService, mountPointService));
-        doReturn(Optional.of(mountPoint)).when(mountPointService)
-                .getMountPoint(any(YangInstanceIdentifier.class));
-        doReturn(Optional.of(FixedDOMSchemaService.of(JUKEBOX_SCHEMA))).when(mountPoint)
-                .getService(DOMSchemaService.class);
-        doReturn(Optional.of(mountDataBroker)).when(mountPoint).getService(DOMDataBroker.class);
-        doReturn(Optional.of(rpcService)).when(mountPoint).getService(DOMRpcService.class);
-        doReturn(Optional.empty()).when(mountPoint).getService(NetconfDataTreeService.class);
-        doReturn(read).when(mountDataBroker).newReadOnlyTransaction();
-        doReturn(readWrite).when(mountDataBroker).newReadWriteTransaction();
-    }
-
-    @Test
-    public void testPutData() {
-        doReturn(immediateTrueFluentFuture()).when(read)
-                .exists(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID);
-        doNothing().when(readWrite).put(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID, EMPTY_JUKEBOX);
-
-        doReturn(true).when(asyncResponse).resume(responseCaptor.capture());
-        dataService.putDataJSON("example-jukebox:jukebox", uriInfo, stringInputStream("""
-            {
-              "example-jukebox:jukebox" : {
-                 "player": {
-                   "gap": "0.2"
-                 }
-              }
-            }"""), asyncResponse);
-        final var response = responseCaptor.getValue();
-        assertEquals(Response.Status.NO_CONTENT.getStatusCode(), response.getStatus());
-    }
-
-    @Test
-    public void testPutDataWithMountPoint() {
-        doReturn(immediateTrueFluentFuture()).when(read)
-                .exists(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID);
-        doNothing().when(readWrite).put(LogicalDatastoreType.CONFIGURATION, JUKEBOX_IID, EMPTY_JUKEBOX);
-
-        doReturn(true).when(asyncResponse).resume(responseCaptor.capture());
-        dataService.putDataXML("example-jukebox:jukebox/yang-ext:mount/example-jukebox:jukebox",
-            uriInfo, stringInputStream("""
-                <jukebox xmlns="http://example.com/ns/example-jukebox">
-                  <player>
-                    <gap>0.2</gap>
-                  </player>
-                </jukebox>"""), asyncResponse);
-        final var response = responseCaptor.getValue();
-        assertEquals(Response.Status.NO_CONTENT.getStatusCode(), response.getStatus());
     }
 
     @Test