Refactor RESTCONF request dispatch 93/113793/26
authorRobert Varga <robert.varga@pantheon.tech>
Tue, 1 Oct 2024 17:05:57 +0000 (19:05 +0200)
committerRobert Varga <robert.varga@pantheon.tech>
Mon, 7 Oct 2024 12:40:12 +0000 (14:40 +0200)
The act of servicing an HTTP request has four distinct phases:
- request validation and binding, synchronous
- request execution, asynchronous
- result interpretation, synchronous
- result streaming, asynchronous

We have first two cobbled together in RestconfRequestDispatcher, while
the last two are handled synchronously in ResponseUtils and the various
RestconfRequest subclasses.

This patch tackles the problem in RestconfRequestDispatcher, separating
the two phases.

We introduce a prepare() method, whose sole raison d'etre is to
synchronously process the contents of HttpRequest, i.e. method, URI and
headers, so as to arrive at a specific request (and its parameters) that
we want to be executing.

The result is communicated via PreparedRequest, which is a sealed
interface with two specializations:

1. CompletedRequest, holding an immediate result -- typically an error
   response, or a response that is statically known (such as the answer
   to 'OPTIONS /restconf/operations HTTP/1.1')
2. PendingRequest, represents has a number of specializations for the
   various requests we can execute asynchronously

The former is handled by immediate conversion to a FullHttpResponse,
which is then sent to the client. The latter is then fed any request
body, if present and executed, with the results processed in the usual
way.

We also eliminate two implementation hotspots:

1. RequestParameters, which has comindled the various request-related
   concerns in a structure-like blob. We now track the various
   parameters in the various AbstractPendingRequest subclasses, keeping
   them colocated with their users.

2. ResponseUtils along with the message builder -- which are now
   replaces through the Response interface, which offers decoupling of
   response shapes from how they map to HTTP constructs -- which is
   really a pipeline-side concern.

The result is that we have separated out the concerns, so that each
processing stage has a natural view of what it needs to handle --
resulting in much more modular codebase, where individual concerns can
be further evolved without touching others.

Most significantly, the process of turning RestconfServer responses
into ByteBufs is driven completely from PendingRequestListener -- i.e.
fully at the discretion of the Netty pipeline side of the house.

JIRA: NETCONF-1379
Change-Id: I58b89a389faac4a305881f425b5ec6cfac0d77f4
Signed-off-by: Robert Varga <robert.varga@pantheon.tech>
50 files changed:
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/AbstractDataPendingGet.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/AbstractPendingGet.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/AbstractPendingModulesGet.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/AbstractPendingOptions.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/AbstractPendingRequest.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/CharSourceResponse.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/CompletedRequest.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/DefaultCompletedRequest.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/EndpointInvariants.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/FormattableDataResponse.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/FormattableServerRequest.java [deleted file]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/MessageEncoding.java
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/NettyMediaTypes.java
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/NettyServerRequest.java
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/OptionsServerRequest.java [deleted file]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingDataCreate.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingDataDelete.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingDataGet.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingDataOptions.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingDataPatchPlain.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingDataPatchYang.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingDataPost.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingDataPut.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingModulesGetYang.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingModulesGetYin.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingOperationsGet.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingOperationsOptions.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingOperationsPost.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingRequest.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingRequestListener.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingRequestWithApiPath.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingRequestWithBody.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingRequestWithOutput.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingRequestWithResource.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingYangLibraryVersionGet.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PreparedRequest.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/RequestBodyHandling.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/RequestParameters.java [deleted file]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/Response.java [new file with mode: 0644]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/ResponseUtils.java [deleted file]
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/RestconfRequest.java
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/RestconfRequestDispatcher.java
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/RestconfSession.java
protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/ServerErrorException.java [deleted file]
protocol/restconf-server/src/test/java/org/opendaylight/restconf/server/AbstractRequestProcessorTest.java
protocol/restconf-server/src/test/java/org/opendaylight/restconf/server/DataRequestProcessorTest.java
protocol/restconf-server/src/test/java/org/opendaylight/restconf/server/ErrorHandlerTest.java
protocol/restconf-server/src/test/java/org/opendaylight/restconf/server/ModulesRequestProcessorTest.java
protocol/restconf-server/src/test/java/org/opendaylight/restconf/server/TestUtils.java
protocol/restconf-server/src/test/java/org/opendaylight/restconf/server/WellKnownResourcesTest.java

diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/AbstractDataPendingGet.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/AbstractDataPendingGet.java
new file mode 100644 (file)
index 0000000..d1b923b
--- /dev/null
@@ -0,0 +1,42 @@
+/*
+ * 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;
+
+import io.netty.handler.codec.http.HttpHeaderNames;
+import io.netty.handler.codec.http.HttpHeaders;
+import java.net.URI;
+import java.security.Principal;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.api.FormattableBody;
+
+/**
+ * An {@link AbstractPendingGet} subclass for YANG Data results in {@link FormattableBody} format and nothing else. The
+ * only header it produces is {@code Content-Type} based on selected {@link MessageEncoding}.
+ */
+// FIXME: This name is confusing, can we come up with something better?
+//        While we are pondering that possibility, this remains sealed with explicitly named subclasses, so as to
+//        prevent accidents.
+@NonNullByDefault
+abstract sealed class AbstractDataPendingGet extends AbstractPendingGet<FormattableBody>
+        permits PendingOperationsGet, PendingYangLibraryVersionGet {
+    AbstractDataPendingGet(final EndpointInvariants invariants, final URI targetUri,
+            final @Nullable Principal principal, final MessageEncoding encoding, final boolean withContent) {
+        super(invariants, targetUri, principal, encoding, withContent);
+    }
+
+    @Override
+    final Response transformResultImpl(final NettyServerRequest<?> request, final FormattableBody result) {
+        return new FormattableDataResponse(result, encoding, request.prettyPrint());
+    }
+
+    @Override
+    final void fillHeaders(final FormattableBody result, final HttpHeaders headers) {
+        headers.set(HttpHeaderNames.CONTENT_TYPE, encoding.dataMediaType());
+    }
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/AbstractPendingGet.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/AbstractPendingGet.java
new file mode 100644 (file)
index 0000000..46512ec
--- /dev/null
@@ -0,0 +1,65 @@
+/*
+ * 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;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.base.MoreObjects.ToStringHelper;
+import io.netty.handler.codec.http.HttpHeaders;
+import io.netty.handler.codec.http.HttpResponseStatus;
+import java.net.URI;
+import java.security.Principal;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.api.query.PrettyPrintParam;
+
+/**
+ * An abstract base class for {@link PendingRequest}s servicing both GET and HEAD requests. It handles result
+ * transformation so that of HEAD requests we only respond with appropriate headers.
+ *
+ * <p>
+ * We deliberately do not expose {@link PrettyPrintParam} to {@link #fillHeaders(Object, HttpHeaders)}, so that
+ * subclasses are not tempted to attempt to attain the {@code Content-Type} header. While this may seem to be
+ * a violation of {@code HEAD} method mechanics, it is in fact taking full advantage of efficiencies outline in second
+ * paragraph of <a href="https://www.rfc-editor.org/rfc/rfc9110#name-head">RFC9110, section 9.3.2</a>:
+ *
+ * @param <T> server response type
+ */
+@NonNullByDefault
+abstract class AbstractPendingGet<T> extends AbstractPendingRequest<T> {
+    private final boolean withContent;
+    final MessageEncoding encoding;
+
+    AbstractPendingGet(final EndpointInvariants invariants, final URI targetUri, final @Nullable Principal principal,
+            final MessageEncoding encoding, final boolean withContent) {
+        super(invariants, targetUri, principal);
+        this.encoding = requireNonNull(encoding);
+        this.withContent = withContent;
+    }
+
+    @Override
+    final Response transformResult(final NettyServerRequest<?> request, final T result) {
+        if (withContent) {
+            return transformResultImpl(request, result);
+        }
+
+        final var headers = HEADERS_FACTORY.newEmptyHeaders();
+        fillHeaders(result, headers);
+        return new DefaultCompletedRequest(HttpResponseStatus.OK, headers);
+    }
+
+    abstract Response transformResultImpl(NettyServerRequest<?> request, T result);
+
+    abstract void fillHeaders(T result, HttpHeaders headers);
+
+    @Override
+    protected ToStringHelper addToStringAttributes(final ToStringHelper helper) {
+        return super.addToStringAttributes(helper)
+            .add("method", withContent ? ImplementedMethod.GET : ImplementedMethod.HEAD);
+    }
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/AbstractPendingModulesGet.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/AbstractPendingModulesGet.java
new file mode 100644 (file)
index 0000000..5459966
--- /dev/null
@@ -0,0 +1,58 @@
+/*
+ * 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;
+
+import static java.util.Objects.requireNonNull;
+
+import io.netty.util.AsciiString;
+import java.io.InputStream;
+import java.net.URI;
+import java.security.Principal;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.api.ApiPath;
+import org.opendaylight.restconf.server.api.ModulesGetResult;
+
+/**
+ * An abstract class for implementations of a GET or HEAD request to the /modules resource.
+ */
+@NonNullByDefault
+abstract sealed class AbstractPendingModulesGet extends AbstractPendingRequest<ModulesGetResult>
+        permits PendingModulesGetYang, PendingModulesGetYin {
+    private final ApiPath mountPath;
+    private final String fileName;
+
+    AbstractPendingModulesGet(final EndpointInvariants invariants, final URI targetUri,
+            final @Nullable Principal principal, final ApiPath mountPath, final String fileName) {
+        super(invariants, targetUri, principal);
+        this.mountPath = requireNonNull(mountPath);
+        this.fileName = requireNonNull(fileName);
+    }
+
+    @Override
+    final void execute(final NettyServerRequest<ModulesGetResult> request, final InputStream body) {
+        final var revision = request.queryParameters().lookup("revision");
+        if (mountPath.isEmpty()) {
+            execute(request, fileName, revision);
+        } else {
+            execute(request, mountPath, fileName, revision);
+        }
+    }
+
+    abstract void execute(NettyServerRequest<ModulesGetResult> request, String fileName, @Nullable String revision);
+
+    abstract void execute(NettyServerRequest<ModulesGetResult> request, ApiPath mountPath, String fileName,
+        @Nullable String revision);
+
+    @Override
+    final CharSourceResponse transformResult(final NettyServerRequest<?> request, final ModulesGetResult result) {
+        return new CharSourceResponse(result.source(), mediaType());
+    }
+
+    abstract AsciiString mediaType();
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/AbstractPendingOptions.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/AbstractPendingOptions.java
new file mode 100644 (file)
index 0000000..d1bc205
--- /dev/null
@@ -0,0 +1,81 @@
+/*
+ * 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;
+
+import io.netty.handler.codec.http.HttpHeaderNames;
+import io.netty.handler.codec.http.HttpHeaderValues;
+import io.netty.handler.codec.http.HttpHeaders;
+import io.netty.handler.codec.http.HttpResponseStatus;
+import java.net.URI;
+import java.security.Principal;
+import java.util.List;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.api.ApiPath;
+import org.opendaylight.restconf.api.MediaTypes;
+import org.opendaylight.restconf.server.api.OptionsResult;
+
+/**
+ * Abstract base class for {@link PendingRequest}s which result in an {@link OptionsResult}. These are mapped to a
+ * response which contains a {@code Allow} header and perhaps an {@code Accept-Patch} header.
+ */
+@NonNullByDefault
+abstract class AbstractPendingOptions extends PendingRequestWithApiPath<OptionsResult> {
+    /**
+     * The set of media types we accept for the PATCH method, formatted as a comma-separated single string.
+     */
+    static final String ACCEPTED_PATCH_MEDIA_TYPES = String.join(", ", List.of(
+        // Plain patch
+        MediaTypes.APPLICATION_YANG_DATA_JSON,
+        MediaTypes.APPLICATION_YANG_DATA_XML,
+        // YANG patch
+        MediaTypes.APPLICATION_YANG_PATCH_JSON,
+        MediaTypes.APPLICATION_YANG_PATCH_XML,
+        // Legacy plain patch data
+        // FIXME: do not advertize these types, because https://www.rfc-editor.org/errata/eid3169 specifically calls
+        //        this out as NOT being the right thing
+        HttpHeaderValues.APPLICATION_JSON.toString(),
+        HttpHeaderValues.APPLICATION_XML.toString(),
+        NettyMediaTypes.TEXT_XML.toString()));
+
+    static final HttpHeaders HEADERS_ACTION = headers("OPTIONS, POST");
+    static final HttpHeaders HEADERS_DATASTORE = patchHeaders("GET, HEAD, OPTIONS, PATCH, POST, PUT");
+    static final HttpHeaders HEADERS_READ_ONLY = headers("GET, HEAD, OPTIONS");
+    static final HttpHeaders HEADERS_RESOURCE = patchHeaders("DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT");
+    static final HttpHeaders HEADERS_RPC = headers("GET, HEAD, OPTIONS, POST");
+
+    static final CompletedRequest ACTION = new DefaultCompletedRequest(HttpResponseStatus.OK, HEADERS_ACTION);
+    static final CompletedRequest DATASTORE = new DefaultCompletedRequest(HttpResponseStatus.OK, HEADERS_DATASTORE);
+    static final CompletedRequest READ_ONLY = new DefaultCompletedRequest(HttpResponseStatus.OK, HEADERS_READ_ONLY);
+    static final CompletedRequest RESOURCE = new DefaultCompletedRequest(HttpResponseStatus.OK, HEADERS_RESOURCE);
+    static final CompletedRequest RPC = new DefaultCompletedRequest(HttpResponseStatus.OK, HEADERS_RPC);
+
+    AbstractPendingOptions(final EndpointInvariants invariants, final URI targetUri,
+            final @Nullable Principal principal, final ApiPath apiPath) {
+        super(invariants, targetUri, principal, apiPath);
+    }
+
+    @Override
+    final CompletedRequest transformResult(final NettyServerRequest<?> request, final OptionsResult result) {
+        return switch (result) {
+            case ACTION -> ACTION;
+            case DATASTORE -> DATASTORE;
+            case READ_ONLY -> READ_ONLY;
+            case RESOURCE -> RESOURCE;
+            case RPC -> RPC;
+        };
+    }
+
+    private static HttpHeaders headers(final String allowValue) {
+        return HEADERS_FACTORY.newEmptyHeaders().set(HttpHeaderNames.ALLOW, allowValue);
+    }
+
+    private static HttpHeaders patchHeaders(final String allowValue) {
+        return headers(allowValue).set(HttpHeaderNames.ACCEPT_PATCH, ACCEPTED_PATCH_MEDIA_TYPES);
+    }
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/AbstractPendingRequest.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/AbstractPendingRequest.java
new file mode 100644 (file)
index 0000000..f966d82
--- /dev/null
@@ -0,0 +1,139 @@
+/*
+ * 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;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.base.MoreObjects.ToStringHelper;
+import io.netty.handler.codec.DateFormatter;
+import io.netty.handler.codec.http.DefaultHttpHeadersFactory;
+import io.netty.handler.codec.http.HttpHeaderNames;
+import io.netty.handler.codec.http.HttpHeaders;
+import io.netty.handler.codec.http.HttpResponseStatus;
+import java.io.InputStream;
+import java.net.URI;
+import java.security.Principal;
+import java.util.Date;
+import org.eclipse.jdt.annotation.NonNull;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.api.FormattableBody;
+import org.opendaylight.restconf.api.HttpStatusCode;
+import org.opendaylight.restconf.server.api.ConfigurationMetadata;
+import org.opendaylight.restconf.server.api.RestconfServer;
+
+/**
+ * An abstract implementation of {@link PendingRequest} contract for RESTCONF endpoint. This class and its subclasses
+ * act as an intermediary between the Netty pipeline and backend {@link RestconfServer}:
+ * <ul>
+ *   <li>when instructed to start execution, this class allocates the {@link NettyServerRequest} and passes it to the
+ *       concrete subclass, which is then responsible for passing the request, and whatever additional arguments, to the
+ *       correct {@link RestconfServer} method</li>
+ *   <li>upon {@link NettyServerRequest} success, this class calls down to the concrete subclass to translate the server
+ *       response to the appropriate HTTP {@link Response} and then notifies the {@link PendingRequestListener}</li>
+ *   <li>when {@link NettyServerRequest} fails, this class will wrap the failure in a {@link FormattableDataResponse},
+ *       and passes it to {@link PendingRequestListener}</li>
+ * </ul>
+ *
+ * @param <T> server response type
+ */
+// Note: not @NonNullByDefault because SpotBugs throws a tantrum on @Nullable field
+abstract class AbstractPendingRequest<T> extends PendingRequest<T> {
+    static final @NonNull CompletedRequest NO_CONTENT = new DefaultCompletedRequest(HttpResponseStatus.NO_CONTENT);
+    static final @NonNull DefaultHttpHeadersFactory HEADERS_FACTORY = DefaultHttpHeadersFactory.headersFactory();
+
+    final @NonNull EndpointInvariants invariants;
+    final @NonNull URI targetUri;
+    final @Nullable Principal principal;
+
+    @NonNullByDefault
+    AbstractPendingRequest(final EndpointInvariants invariants, final URI targetUri,
+            final @Nullable Principal principal) {
+        this.invariants = requireNonNull(invariants);
+        this.targetUri = requireNonNull(targetUri);
+        this.principal = principal;
+    }
+
+    final @NonNull RestconfServer server() {
+        return invariants.server();
+    }
+
+    /**
+     * Return the absolute URI pointing at the root API resource, as seen from the perspective of specified request.
+     *
+     * @return An absolute URI
+     */
+    final @NonNull URI restconfURI() {
+        return targetUri.resolve(invariants.restconfPath());
+    }
+
+    @Override
+    @SuppressWarnings("checkstyle:illegalCatch")
+    final void execute(final PendingRequestListener listener, final InputStream body) {
+        try {
+            execute(new NettyServerRequest<>(this, listener), body);
+        } catch (RuntimeException e) {
+            listener.requestFailed(this, e);
+        }
+    }
+
+    /**
+     * Execute this request on the backend {@link RestconfServer}.
+     *
+     * @param request the {@link NettyServerRequest} to pass to the server method
+     * @param body request body
+     */
+    @NonNullByDefault
+    abstract void execute(NettyServerRequest<T> request, InputStream body);
+
+    @NonNullByDefault
+    final void onFailure(final PendingRequestListener listener, final NettyServerRequest<T> request,
+            final HttpStatusCode status, final FormattableBody body) {
+        listener.requestComplete(this, new FormattableDataResponse(HttpResponseStatus.valueOf(status.code()), null,
+            // FIXME: need to pick encoding
+            body, null, request.prettyPrint()));
+    }
+
+    @NonNullByDefault
+    final void onSuccess(final PendingRequestListener listener, final NettyServerRequest<T> request, final T result) {
+        listener.requestComplete(this, transformResult(request, result));
+    }
+
+    /**
+     * Transform a RestconfServer result to a {@link Response}.
+     *
+     * @param request {@link NettyServerRequest} handle
+     * @param result the result
+     * @return A {@link Response}
+     */
+    @NonNullByDefault
+    abstract Response transformResult(NettyServerRequest<?> request, T result);
+
+    @Override
+    protected ToStringHelper addToStringAttributes(final ToStringHelper helper) {
+        return helper.add("target", targetUri);
+    }
+
+    @NonNullByDefault
+    static final HttpHeaders metadataHeaders(final ConfigurationMetadata metadata) {
+        return setMetadataHeaders(HEADERS_FACTORY.newEmptyHeaders(), metadata);
+    }
+
+    static final HttpHeaders setMetadataHeaders(final HttpHeaders headers, final ConfigurationMetadata metadata) {
+        final var etag = metadata.entityTag();
+        if (etag != null) {
+            headers.set(HttpHeaderNames.ETAG, etag.value());
+        }
+        final var lastModified = metadata.lastModified();
+        if (lastModified != null) {
+            // FIXME: uses a thread local: we should be able to do better!
+            headers.set(HttpHeaderNames.LAST_MODIFIED, DateFormatter.format(Date.from(lastModified)));
+        }
+        return headers;
+    }
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/CharSourceResponse.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/CharSourceResponse.java
new file mode 100644 (file)
index 0000000..8f9a09a
--- /dev/null
@@ -0,0 +1,25 @@
+/*
+ * 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;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.io.CharSource;
+import io.netty.util.AsciiString;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * A {@link Response} containing a {@link CharSource} with some accompanying headers.
+ */
+@NonNullByDefault
+record CharSourceResponse(CharSource source, AsciiString mediaType) implements Response {
+    CharSourceResponse {
+        requireNonNull(source);
+        requireNonNull(mediaType);
+    }
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/CompletedRequest.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/CompletedRequest.java
new file mode 100644 (file)
index 0000000..dc05388
--- /dev/null
@@ -0,0 +1,26 @@
+/*
+ * 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;
+
+import io.netty.handler.codec.http.FullHttpResponse;
+import io.netty.handler.codec.http.HttpVersion;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * A {@link PreparedRequest} which is already complete, this qualifying to be a {@link Response}.
+ */
+@NonNullByDefault
+non-sealed interface CompletedRequest extends PreparedRequest, Response {
+    /**
+     * Return a {@link FullHttpResponse} representation of this object.
+     *
+     * @param version HTTP version to use
+     * @return a {@link FullHttpResponse}
+     */
+    FullHttpResponse toHttpResponse(HttpVersion version);
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/DefaultCompletedRequest.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/DefaultCompletedRequest.java
new file mode 100644 (file)
index 0000000..6ff28f4
--- /dev/null
@@ -0,0 +1,65 @@
+/*
+ * 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;
+
+import static java.util.Objects.requireNonNull;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
+import io.netty.handler.codec.http.DefaultFullHttpResponse;
+import io.netty.handler.codec.http.FullHttpResponse;
+import io.netty.handler.codec.http.HttpHeaderNames;
+import io.netty.handler.codec.http.HttpHeaders;
+import io.netty.handler.codec.http.HttpResponseStatus;
+import io.netty.handler.codec.http.HttpVersion;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * A {@link PreparedRequest} which is already complete.
+ *
+ * @param status response status
+ * @param headers additional response headers
+ * @param content response body content
+ */
+// TODO: body + no content-type should be disallowed: reconsider the design of this class
+@NonNullByDefault
+record DefaultCompletedRequest(HttpResponseStatus status, @Nullable HttpHeaders headers, @Nullable ByteBuf content)
+        implements CompletedRequest {
+    DefaultCompletedRequest {
+        requireNonNull(status);
+    }
+
+    DefaultCompletedRequest(final HttpResponseStatus status) {
+        this(status, null, null);
+    }
+
+    DefaultCompletedRequest(final HttpResponseStatus status, final HttpHeaders headers) {
+        this(status, requireNonNull(headers), null);
+    }
+
+    @Override
+    public FullHttpResponse toHttpResponse(final HttpVersion version) {
+        return toHttpResponse(version, status, headers, content);
+    }
+
+    // split out to make make null checks work against local variables
+    private static FullHttpResponse toHttpResponse(final HttpVersion version, final HttpResponseStatus status,
+            final @Nullable HttpHeaders headers, final @Nullable ByteBuf content) {
+        final var response = new DefaultFullHttpResponse(version, status,
+            content != null ? content : Unpooled.EMPTY_BUFFER);
+        final var responseHeaders = response.headers();
+        if (headers != null) {
+            responseHeaders.set(headers);
+        }
+        if (content != null) {
+            responseHeaders.setInt(HttpHeaderNames.CONTENT_LENGTH, content.readableBytes());
+        }
+        return response;
+    }
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/EndpointInvariants.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/EndpointInvariants.java
new file mode 100644 (file)
index 0000000..a03fdc5
--- /dev/null
@@ -0,0 +1,38 @@
+/*
+ * 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;
+
+import static java.util.Objects.requireNonNull;
+
+import java.net.URI;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.opendaylight.restconf.api.query.PrettyPrintParam;
+import org.opendaylight.restconf.server.api.RestconfServer;
+import org.opendaylight.restconf.server.spi.ErrorTagMapping;
+
+/**
+ * Invariant setup of a particular endpoint. This DTO exists only to make method signatures less verbose.
+ *
+ * @param restconfPath {@code /{+restconf}/}, i.e. an absolute path conforming to {@link RestconfServer}'s
+ *                     {@code restconfURI} contract
+ */
+@NonNullByDefault
+record EndpointInvariants(
+        RestconfServer server,
+        PrettyPrintParam defaultPrettyPrint,
+        ErrorTagMapping errorTagMapping,
+        MessageEncoding defaultEncoding,
+        URI restconfPath) {
+    EndpointInvariants {
+        requireNonNull(server);
+        requireNonNull(defaultPrettyPrint);
+        requireNonNull(errorTagMapping);
+        requireNonNull(defaultEncoding);
+        requireNonNull(restconfPath);
+    }
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/FormattableDataResponse.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/FormattableDataResponse.java
new file mode 100644 (file)
index 0000000..5a61b09
--- /dev/null
@@ -0,0 +1,51 @@
+/*
+ * 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;
+
+import static java.util.Objects.requireNonNull;
+
+import io.netty.handler.codec.http.HttpHeaders;
+import io.netty.handler.codec.http.HttpResponseStatus;
+import java.io.IOException;
+import java.io.OutputStream;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.api.FormattableBody;
+import org.opendaylight.restconf.api.query.PrettyPrintParam;
+
+/**
+ * A {@link Response} containing a YANG Data as its content.
+ */
+@NonNullByDefault
+record FormattableDataResponse(
+        HttpResponseStatus status,
+        @Nullable HttpHeaders headers,
+        FormattableBody body,
+        MessageEncoding encoding,
+        PrettyPrintParam prettyPrint) implements Response {
+    FormattableDataResponse {
+        requireNonNull(status);
+        requireNonNull(body);
+        requireNonNull(encoding);
+        requireNonNull(prettyPrint);
+    }
+
+    FormattableDataResponse(final HttpHeaders headers, final FormattableBody body, final MessageEncoding encoding,
+            final PrettyPrintParam prettyPrint) {
+        this(HttpResponseStatus.OK, requireNonNull(headers), body, encoding, prettyPrint);
+    }
+
+    FormattableDataResponse(final FormattableBody body, final MessageEncoding encoding,
+            final PrettyPrintParam prettyPrint) {
+        this(HttpResponseStatus.OK, null, body, encoding, prettyPrint);
+    }
+
+    void writeTo(final OutputStream out) throws IOException {
+        encoding.formatBody(body, prettyPrint, out);
+    }
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/FormattableServerRequest.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/FormattableServerRequest.java
deleted file mode 100644 (file)
index e254685..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
- * 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;
-
-import static org.opendaylight.restconf.server.ResponseUtils.responseBuilder;
-
-import io.netty.handler.codec.http.FullHttpResponse;
-import io.netty.handler.codec.http.HttpResponseStatus;
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.opendaylight.restconf.api.FormattableBody;
-
-/**
- * A {@link NettyServerRequest} resulting in a {@link FormattableBody}.
- */
-@NonNullByDefault
-final class FormattableServerRequest extends NettyServerRequest<FormattableBody> {
-    FormattableServerRequest(final RequestParameters requestParameters, final RestconfRequest callback) {
-        super(requestParameters, callback);
-    }
-
-    @Override
-    FullHttpResponse transform(final FormattableBody result) {
-        return responseBuilder(requestParams, HttpResponseStatus.OK).setBody(result).build();
-    }
-}
index 1bbd68ffea46875c9565ea1f266c15069d712188..03d21c559863707a9259afacd9eb48baba9e48e1 100644 (file)
@@ -9,9 +9,14 @@ package org.opendaylight.restconf.server;
 
 import static java.util.Objects.requireNonNull;
 
+import io.netty.handler.codec.http.HttpHeaderValues;
 import io.netty.util.AsciiString;
+import java.io.IOException;
+import java.io.OutputStream;
 import java.util.Set;
 import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.opendaylight.restconf.api.FormattableBody;
+import org.opendaylight.restconf.api.query.PrettyPrintParam;
 import org.opendaylight.restconf.server.spi.RestconfStream.EncodingName;
 
 /**
@@ -25,13 +30,25 @@ public enum MessageEncoding {
      * <a href="https://www.rfc-editor.org/rfc/rfc7952#section-5.2">RFC7952, section 5.2</a>.
      */
     JSON(NettyMediaTypes.APPLICATION_YANG_DATA_JSON, NettyMediaTypes.APPLICATION_YANG_PATCH_JSON,
-        EncodingName.RFC8040_JSON, NettyMediaTypes.APPLICATION_JSON),
+            EncodingName.RFC8040_JSON, HttpHeaderValues.APPLICATION_JSON) {
+        @Override
+        void formatBody(final FormattableBody body, final PrettyPrintParam prettyPrint, final OutputStream out)
+                throws IOException {
+            body.formatToJSON(prettyPrint, out);
+        }
+    },
     /**
      * JSON encoding, as specified in <a href="https://www.rfc-editor.org/rfc/rfc7950">RFC7950</a> and extended in
      * <a href="https://www.rfc-editor.org/rfc/rfc7952#section-5.1">RFC7952, section 5.1</a>.
      */
     XML(NettyMediaTypes.APPLICATION_YANG_DATA_XML, NettyMediaTypes.APPLICATION_YANG_PATCH_XML,
-        EncodingName.RFC8040_XML, NettyMediaTypes.APPLICATION_XML , NettyMediaTypes.TEXT_XML);
+            EncodingName.RFC8040_XML, HttpHeaderValues.APPLICATION_XML, NettyMediaTypes.TEXT_XML) {
+        @Override
+        void formatBody(final FormattableBody body, final PrettyPrintParam prettyPrint, final OutputStream out)
+                throws IOException {
+            body.formatToXML(prettyPrint, out);
+        }
+    };
 
     private final AsciiString dataMediaType;
     private final AsciiString patchMediaType;
@@ -86,4 +103,14 @@ public enum MessageEncoding {
     boolean producesDataCompatibleWith(final AsciiString mediaType) {
         return dataMediaType.equals(mediaType) || compatibleDataMediaTypes.contains(mediaType);
     }
+
+    /**
+     * Process a {@link FormattableBody}, invoking its formatting method appropriate for this encoding.
+     *
+     * @param body body to format
+     * @param prettyPrint pretty-print parameter
+     * @param out output stream
+     * @throws IOException when an I/O error occurs
+     */
+    abstract void formatBody(FormattableBody body, PrettyPrintParam prettyPrint, OutputStream out) throws IOException;
 }
\ No newline at end of file
index 350c64b1b64058f8ae21cf7bd2fd85133d35aba7..3ae447cd1fe3a93db3b2683629ff554c059d8947 100644 (file)
@@ -7,18 +7,11 @@
  */
 package org.opendaylight.restconf.server;
 
-import io.netty.handler.codec.http.HttpHeaderValues;
 import io.netty.util.AsciiString;
-import java.util.Set;
-import java.util.stream.Stream;
 import org.opendaylight.restconf.api.MediaTypes;
 import org.opendaylight.yangtools.yang.common.YangConstants;
 
-final class NettyMediaTypes {
-    public static final AsciiString TEXT_XML = AsciiString.cached("text/xml");
-    public static final AsciiString APPLICATION_XML = HttpHeaderValues.APPLICATION_XML;
-    public static final AsciiString APPLICATION_JSON = HttpHeaderValues.APPLICATION_JSON;
-
+public final class NettyMediaTypes {
     /**
      * A {@link AsciiString} constant representing {@value MediaTypes#APPLICATION_XRD_XML} media type.
      *
@@ -66,17 +59,7 @@ final class NettyMediaTypes {
      */
     public static final AsciiString APPLICATION_YIN_XML = AsciiString.cached(YangConstants.RFC6020_YIN_MEDIA_TYPE);
 
-
-    static final Set<AsciiString> RESTCONF_TYPES = Set.of(TEXT_XML, APPLICATION_XML, APPLICATION_JSON,
-        APPLICATION_YANG_DATA_XML, APPLICATION_YANG_DATA_JSON);
-    static final Set<AsciiString> JSON_TYPES = Set.of(APPLICATION_JSON, APPLICATION_YANG_DATA_JSON,
-        APPLICATION_YANG_PATCH_JSON);
-    static final Set<AsciiString> XML_TYPES = Set.of(TEXT_XML, APPLICATION_XML, APPLICATION_YANG_DATA_XML,
-        APPLICATION_YANG_PATCH_XML, APPLICATION_XRD_XML, APPLICATION_YIN_XML);
-    static final Set<AsciiString> YANG_PATCH_TYPES = Set.of(APPLICATION_YANG_PATCH_XML, APPLICATION_YANG_PATCH_JSON);
-
-    static final String ACCEPT_PATCH_HEADER_VALUE = String.join(", ",
-        Stream.concat(RESTCONF_TYPES.stream(), YANG_PATCH_TYPES.stream()).map(AsciiString::toString).sorted().toList());
+    public static final AsciiString TEXT_XML = AsciiString.cached("text/xml");
 
     private NettyMediaTypes() {
         // hidden on purpose
index e8927cbb5d164fd59b0ace60cb0cbed28f32cbb5..584f568acc3ac63489b1f2288fcd3e6602f7e245 100644 (file)
@@ -8,52 +8,56 @@
 package org.opendaylight.restconf.server;
 
 import static java.util.Objects.requireNonNull;
-import static org.opendaylight.restconf.server.ResponseUtils.responseBuilder;
 
-import io.netty.handler.codec.http.FullHttpResponse;
-import io.netty.handler.codec.http.HttpResponseStatus;
+import io.netty.handler.codec.http.QueryStringDecoder;
 import java.security.Principal;
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jdt.annotation.NonNull;
 import org.opendaylight.restconf.api.FormattableBody;
 import org.opendaylight.restconf.api.HttpStatusCode;
+import org.opendaylight.restconf.api.QueryParameters;
+import org.opendaylight.restconf.server.api.RestconfServer;
+import org.opendaylight.restconf.server.api.ServerRequest;
 import org.opendaylight.restconf.server.api.TransportSession;
 import org.opendaylight.restconf.server.spi.MappingServerRequest;
 
-@NonNullByDefault
-abstract class NettyServerRequest<T> extends MappingServerRequest<T> {
-    final RequestParameters requestParams;
-
-    private final RestconfRequest callback;
+/**
+ * The {@link ServerRequest}s implementation we are passing to {@link RestconfServer}. Completion callbacks are routed
+ * through the supplied {@link AbstractPendingRequest} towards the supplied {@link PendingRequestListener}.
+ */
+final class NettyServerRequest<T> extends MappingServerRequest<T> {
+    private final @NonNull AbstractPendingRequest<T> request;
+    private final @NonNull PendingRequestListener listener;
+
+    private NettyServerRequest(final EndpointInvariants invariants, final AbstractPendingRequest<T> request,
+            final PendingRequestListener listener) {
+        super(QueryParameters.ofMultiValue(new QueryStringDecoder(request.targetUri).parameters()),
+            invariants.defaultPrettyPrint(), invariants.errorTagMapping());
+        this.request = requireNonNull(request);
+        this.listener = requireNonNull(listener);
+    }
 
-    NettyServerRequest(final RequestParameters requestParams, final RestconfRequest callback) {
-        super(requestParams.queryParameters(), requestParams.defaultPrettyPrint(), requestParams.errorTagMapping());
-        this.requestParams = requireNonNull(requestParams);
-        this.callback = requireNonNull(callback);
+    NettyServerRequest(final AbstractPendingRequest<@NonNull T> request, final PendingRequestListener listener) {
+        this(request.invariants, request, listener);
     }
 
     @Override
-    public final @Nullable Principal principal() {
-        return requestParams.principal();
+    public Principal principal() {
+        return request.principal;
     }
 
     @Override
-    protected final void onSuccess(final T result) {
-        callback.onSuccess(transform(result));
+    public TransportSession session() {
+        // FIXME: NETCONF-714: return the correct NettyTransportSession, RestconfSession in our case
+        return null;
     }
 
     @Override
-    protected final void onFailure(final HttpStatusCode status, final FormattableBody body) {
-        callback.onSuccess(responseBuilder(requestParams, HttpResponseStatus.valueOf(status.code()))
-            .setBody(body)
-            .build());
+    protected void onFailure(final HttpStatusCode status, final FormattableBody body) {
+        request.onFailure(listener, this, status, body);
     }
 
     @Override
-    public final @Nullable TransportSession session() {
-        // FIXME: NETCONF-714: return the correct NettyTransportSession, RestconfSession in our case
-        return null;
+    protected void onSuccess(final T result) {
+        request.onSuccess(listener, this, result);
     }
-
-    abstract FullHttpResponse transform(T result);
 }
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/OptionsServerRequest.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/OptionsServerRequest.java
deleted file mode 100644 (file)
index aac4114..0000000
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * 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;
-
-import io.netty.handler.codec.http.DefaultFullHttpResponse;
-import io.netty.handler.codec.http.FullHttpResponse;
-import io.netty.handler.codec.http.HttpHeaderNames;
-import io.netty.handler.codec.http.HttpResponseStatus;
-import io.netty.handler.codec.http.HttpVersion;
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.opendaylight.restconf.server.api.OptionsResult;
-
-@NonNullByDefault
-final class OptionsServerRequest extends NettyServerRequest<OptionsResult> {
-    OptionsServerRequest(final RequestParameters requestParameters, final RestconfRequest callback) {
-        super(requestParameters, callback);
-    }
-
-    @Override
-    FullHttpResponse transform(final OptionsResult result) {
-        return switch (result) {
-            case ACTION -> withoutPatch("OPTIONS, POST");
-            case DATASTORE -> withPatch("GET, HEAD, OPTIONS, PATCH, POST, PUT");
-            case RESOURCE -> withPatch("DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT");
-            case READ_ONLY -> withoutPatch("GET, HEAD, OPTIONS");
-            case RPC -> withoutPatch("GET, HEAD, OPTIONS, POST");
-        };
-    }
-
-    private FullHttpResponse withPatch(final String allow) {
-        final var response = withoutPatch(allow);
-        response.headers().add(HttpHeaderNames.ACCEPT_PATCH, NettyMediaTypes.ACCEPT_PATCH_HEADER_VALUE);
-        return response;
-    }
-
-    private FullHttpResponse withoutPatch(final String allow) {
-        return withoutPatch(requestParams.protocolVersion(), allow);
-    }
-
-    static FullHttpResponse withoutPatch(final HttpVersion version, final String allow) {
-        final var response = new DefaultFullHttpResponse(version, HttpResponseStatus.OK);
-        response.headers().add(HttpHeaderNames.ALLOW, allow);
-        return response;
-    }
-}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingDataCreate.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingDataCreate.java
new file mode 100644 (file)
index 0000000..6c2a398
--- /dev/null
@@ -0,0 +1,48 @@
+/*
+ * 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;
+
+import java.io.InputStream;
+import java.net.URI;
+import java.security.Principal;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.server.api.ChildBody;
+import org.opendaylight.restconf.server.api.CreateResourceResult;
+import org.opendaylight.restconf.server.api.JsonChildBody;
+import org.opendaylight.restconf.server.api.XmlChildBody;
+
+/**
+ * A POST request to the /data resource.
+ */
+@NonNullByDefault
+final class PendingDataCreate extends PendingRequestWithBody<CreateResourceResult, ChildBody> {
+    PendingDataCreate(final EndpointInvariants invariants, final URI targetUri, final @Nullable Principal principal,
+            final MessageEncoding contentEncoding) {
+        super(invariants, targetUri, principal, contentEncoding);
+    }
+
+    @Override
+    void execute(final NettyServerRequest<CreateResourceResult> request, final ChildBody body) {
+        server().dataPOST(request, body);
+    }
+
+    @Override
+    Response transformResult(final NettyServerRequest<?> request, final CreateResourceResult result) {
+        return transformCreateResource(result);
+    }
+
+    @Override
+    ChildBody wrapBody(final InputStream body) {
+        return switch (contentEncoding) {
+            case JSON -> new JsonChildBody(body);
+            case XML -> new XmlChildBody(body);
+        };
+    }
+
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingDataDelete.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingDataDelete.java
new file mode 100644 (file)
index 0000000..863e670
--- /dev/null
@@ -0,0 +1,37 @@
+/*
+ * 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;
+
+import java.io.InputStream;
+import java.net.URI;
+import java.security.Principal;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.api.ApiPath;
+import org.opendaylight.yangtools.yang.common.Empty;
+
+/**
+ * A DELETE request to the /data resource.
+ */
+@NonNullByDefault
+final class PendingDataDelete extends PendingRequestWithApiPath<Empty> {
+    PendingDataDelete(final EndpointInvariants invariants, final URI targetUri, final @Nullable Principal principal,
+            final ApiPath apiPath) {
+        super(invariants, targetUri, principal, apiPath);
+    }
+
+    @Override
+    void execute(final NettyServerRequest<Empty> request, final InputStream body) {
+        server().dataDELETE(request, apiPath);
+    }
+
+    @Override
+    CompletedRequest transformResult(final NettyServerRequest<?> request, final Empty result) {
+        return NO_CONTENT;
+    }
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingDataGet.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingDataGet.java
new file mode 100644 (file)
index 0000000..e4a80cc
--- /dev/null
@@ -0,0 +1,58 @@
+/*
+ * 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;
+
+import static java.util.Objects.requireNonNull;
+
+import io.netty.handler.codec.http.HttpHeaderNames;
+import io.netty.handler.codec.http.HttpHeaderValues;
+import io.netty.handler.codec.http.HttpHeaders;
+import java.io.InputStream;
+import java.net.URI;
+import java.security.Principal;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.api.ApiPath;
+import org.opendaylight.restconf.server.api.DataGetResult;
+
+/**
+ * A GET or HEAD request to the /data resource.
+ */
+@NonNullByDefault
+final class PendingDataGet extends AbstractPendingGet<DataGetResult> {
+    private final ApiPath apiPath;
+
+    PendingDataGet(final EndpointInvariants invariants, final URI targetUri, final @Nullable Principal principal,
+            final MessageEncoding encoding, final ApiPath apiPath, final boolean withContent) {
+        super(invariants, targetUri, principal, encoding, withContent);
+        this.apiPath = requireNonNull(apiPath);
+    }
+
+    @Override
+    void execute(final NettyServerRequest<DataGetResult> request, final InputStream body) {
+        if (apiPath.isEmpty()) {
+            server().dataGET(request);
+        } else {
+            server().dataGET(request, apiPath);
+        }
+    }
+
+    @Override
+    Response transformResultImpl(final NettyServerRequest<?> request, final DataGetResult result) {
+        return new FormattableDataResponse(
+            metadataHeaders(result).set(HttpHeaderNames.CACHE_CONTROL, HttpHeaderValues.NO_CACHE), result.body(),
+            encoding, request.prettyPrint());
+    }
+
+    @Override
+    void fillHeaders(final DataGetResult result, final HttpHeaders headers) {
+        setMetadataHeaders(headers, result)
+            .set(HttpHeaderNames.CACHE_CONTROL, HttpHeaderValues.NO_CACHE)
+            .set(HttpHeaderNames.CONTENT_TYPE, encoding.dataMediaType());
+    }
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingDataOptions.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingDataOptions.java
new file mode 100644 (file)
index 0000000..1f00e91
--- /dev/null
@@ -0,0 +1,36 @@
+/*
+ * 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;
+
+import java.io.InputStream;
+import java.net.URI;
+import java.security.Principal;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.api.ApiPath;
+import org.opendaylight.restconf.server.api.OptionsResult;
+
+/**
+ * An OPTIONS request to the /data resource.
+ */
+@NonNullByDefault
+final class PendingDataOptions extends AbstractPendingOptions {
+    PendingDataOptions(final EndpointInvariants invariants, final URI targetUri, final @Nullable Principal principal,
+            final ApiPath apiPath) {
+        super(invariants, targetUri, principal, apiPath);
+    }
+
+    @Override
+    void execute(final NettyServerRequest<OptionsResult> request, final InputStream body) {
+        if (apiPath.isEmpty()) {
+            server().dataOPTIONS(request);
+        } else {
+            server().dataOPTIONS(request, apiPath);
+        }
+    }
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingDataPatchPlain.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingDataPatchPlain.java
new file mode 100644 (file)
index 0000000..08a99ca
--- /dev/null
@@ -0,0 +1,42 @@
+/*
+ * 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;
+
+import io.netty.handler.codec.http.HttpResponseStatus;
+import java.net.URI;
+import java.security.Principal;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.api.ApiPath;
+import org.opendaylight.restconf.server.api.DataPatchResult;
+import org.opendaylight.restconf.server.api.ResourceBody;
+
+/**
+ * A PATCH request to the /data resource with YANG Data payload.
+ */
+@NonNullByDefault
+final class PendingDataPatchPlain extends PendingRequestWithResource<DataPatchResult> {
+    PendingDataPatchPlain(final EndpointInvariants invariants, final URI targetUri, final @Nullable Principal principal,
+            final MessageEncoding contentEncoding, final ApiPath apiPath) {
+        super(invariants, targetUri, principal, contentEncoding, apiPath);
+    }
+
+    @Override
+    void execute(final NettyServerRequest<DataPatchResult> request, final ResourceBody body) {
+        if (apiPath.isEmpty()) {
+            server().dataPATCH(request, body);
+        } else {
+            server().dataPATCH(request, apiPath, body);
+        }
+    }
+
+    @Override
+    Response transformResult(final NettyServerRequest<?> request, final DataPatchResult result) {
+        return new DefaultCompletedRequest(HttpResponseStatus.OK, metadataHeaders(result));
+    }
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingDataPatchYang.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingDataPatchYang.java
new file mode 100644 (file)
index 0000000..aed0339
--- /dev/null
@@ -0,0 +1,81 @@
+/*
+ * 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;
+
+import io.netty.handler.codec.http.HttpResponseStatus;
+import java.io.InputStream;
+import java.net.URI;
+import java.security.Principal;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.api.ApiPath;
+import org.opendaylight.restconf.server.api.DataYangPatchResult;
+import org.opendaylight.restconf.server.api.JsonPatchBody;
+import org.opendaylight.restconf.server.api.PatchBody;
+import org.opendaylight.restconf.server.api.PatchStatusContext;
+import org.opendaylight.restconf.server.api.XmlPatchBody;
+import org.opendaylight.restconf.server.spi.YangPatchStatusBody;
+import org.opendaylight.yangtools.yang.common.ErrorTag;
+
+/**
+ * A PATCH request to the /data resource with YANG Data payload.
+ */
+@NonNullByDefault
+final class PendingDataPatchYang extends PendingRequestWithOutput<DataYangPatchResult, PatchBody> {
+    PendingDataPatchYang(final EndpointInvariants invariants, final URI targetUri, final @Nullable Principal principal,
+            final MessageEncoding contentEncoding, final MessageEncoding acceptEncoding, final ApiPath apiPath) {
+        super(invariants, targetUri, principal, contentEncoding, acceptEncoding, apiPath);
+    }
+
+    @Override
+    void execute(final NettyServerRequest<DataYangPatchResult> request, final PatchBody body) {
+        if (apiPath.isEmpty()) {
+            server().dataPATCH(request, body);
+        } else {
+            server().dataPATCH(request, apiPath, body);
+        }
+    }
+
+    @Override
+    Response transformResult(final NettyServerRequest<?> request, final DataYangPatchResult result) {
+        final var patchStatus = result.status();
+        return new FormattableDataResponse(patchResponseStatus(patchStatus), metadataHeaders(result),
+            new YangPatchStatusBody(patchStatus), acceptEncoding, request.prettyPrint());
+    }
+
+    @Override
+    PatchBody wrapBody(final InputStream body) {
+        return switch (contentEncoding) {
+            case JSON -> new JsonPatchBody(body);
+            case XML -> new XmlPatchBody(body);
+        };
+    }
+
+    private HttpResponseStatus patchResponseStatus(final PatchStatusContext statusContext) {
+        if (statusContext.ok()) {
+            return HttpResponseStatus.OK;
+        }
+        final var globalErrors = statusContext.globalErrors();
+        if (globalErrors != null && !globalErrors.isEmpty()) {
+            return responseStatus(globalErrors.getFirst().tag());
+        }
+        for (var edit : statusContext.editCollection()) {
+            if (!edit.isOk()) {
+                final var editErrors = edit.getEditErrors();
+                if (editErrors != null && !editErrors.isEmpty()) {
+                    return responseStatus(editErrors.getFirst().tag());
+                }
+            }
+        }
+        return HttpResponseStatus.INTERNAL_SERVER_ERROR;
+    }
+
+    private HttpResponseStatus responseStatus(final ErrorTag errorTag) {
+        return HttpResponseStatus.valueOf(invariants.errorTagMapping().statusOf(errorTag).code());
+    }
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingDataPost.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingDataPost.java
new file mode 100644 (file)
index 0000000..1faef6c
--- /dev/null
@@ -0,0 +1,53 @@
+/*
+ * 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;
+
+import java.io.InputStream;
+import java.net.URI;
+import java.security.Principal;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.api.ApiPath;
+import org.opendaylight.restconf.server.api.CreateResourceResult;
+import org.opendaylight.restconf.server.api.DataPostBody;
+import org.opendaylight.restconf.server.api.DataPostResult;
+import org.opendaylight.restconf.server.api.InvokeResult;
+import org.opendaylight.restconf.server.api.JsonDataPostBody;
+import org.opendaylight.restconf.server.api.XmlDataPostBody;
+
+/**
+ * A POST request to a child of /data resource.
+ */
+@NonNullByDefault
+final class PendingDataPost extends PendingRequestWithOutput<DataPostResult, DataPostBody> {
+    PendingDataPost(final EndpointInvariants invariants, final URI targetUri, final @Nullable Principal principal,
+            final MessageEncoding contentEncoding, final MessageEncoding acceptEncoding, final ApiPath apiPath) {
+        super(invariants, targetUri, principal, contentEncoding, acceptEncoding, apiPath);
+    }
+
+    @Override
+    void execute(final NettyServerRequest<DataPostResult> request, final DataPostBody body) {
+        server().dataPOST(request, apiPath, body);
+    }
+
+    @Override
+    Response transformResult(final NettyServerRequest<?> request, final DataPostResult result) {
+        return switch (result) {
+            case CreateResourceResult create -> transformCreateResource(create);
+            case InvokeResult invoke -> transformInvoke(request, invoke, acceptEncoding);
+        };
+    }
+
+    @Override
+    DataPostBody wrapBody(final InputStream body) {
+        return switch (contentEncoding) {
+            case JSON -> new JsonDataPostBody(body);
+            case XML -> new XmlDataPostBody(body);
+        };
+    }
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingDataPut.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingDataPut.java
new file mode 100644 (file)
index 0000000..a7c42e1
--- /dev/null
@@ -0,0 +1,43 @@
+/*
+ * 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;
+
+import io.netty.handler.codec.http.HttpResponseStatus;
+import java.net.URI;
+import java.security.Principal;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.api.ApiPath;
+import org.opendaylight.restconf.server.api.DataPutResult;
+import org.opendaylight.restconf.server.api.ResourceBody;
+
+/**
+ * A PUT request to the /data resource.
+ */
+@NonNullByDefault
+final class PendingDataPut extends PendingRequestWithResource<DataPutResult> {
+    PendingDataPut(final EndpointInvariants invariants, final URI targetUri, final @Nullable Principal principal,
+            final MessageEncoding contentEncoding, final ApiPath apiPath) {
+        super(invariants, targetUri, principal, contentEncoding, apiPath);
+    }
+
+    @Override
+    void execute(final NettyServerRequest<DataPutResult> request, final ResourceBody body) {
+        if (apiPath.isEmpty()) {
+            server().dataPUT(request, body);
+        } else {
+            server().dataPUT(request, apiPath, body);
+        }
+    }
+
+    @Override
+    CompletedRequest transformResult(final NettyServerRequest<?> request, final DataPutResult result) {
+        return new DefaultCompletedRequest(
+            result.created() ? HttpResponseStatus.CREATED : HttpResponseStatus.NO_CONTENT, metadataHeaders(result));
+    }
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingModulesGetYang.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingModulesGetYang.java
new file mode 100644 (file)
index 0000000..9aaaa12
--- /dev/null
@@ -0,0 +1,44 @@
+/*
+ * 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;
+
+import io.netty.util.AsciiString;
+import java.net.URI;
+import java.security.Principal;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.api.ApiPath;
+import org.opendaylight.restconf.server.api.ModulesGetResult;
+
+/**
+ * A GET or HEAD request to the /modules resource in YANG form.
+ */
+@NonNullByDefault
+final class PendingModulesGetYang extends AbstractPendingModulesGet {
+    PendingModulesGetYang(final EndpointInvariants invariants, final URI targetUri, final @Nullable Principal principal,
+            final ApiPath mountPath, final String fileName) {
+        super(invariants, targetUri, principal, mountPath, fileName);
+    }
+
+    @Override
+    void execute(final NettyServerRequest<ModulesGetResult> request, final String fileName,
+            final @Nullable String revision) {
+        server().modulesYangGET(request, fileName, revision);
+    }
+
+    @Override
+    void execute(final NettyServerRequest<ModulesGetResult> request, final ApiPath mountPath, final String fileName,
+            final @Nullable String revision) {
+        server().modulesYangGET(request, mountPath, fileName, revision);
+    }
+
+    @Override
+    AsciiString mediaType() {
+        return NettyMediaTypes.APPLICATION_YANG;
+    }
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingModulesGetYin.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingModulesGetYin.java
new file mode 100644 (file)
index 0000000..fc2feb1
--- /dev/null
@@ -0,0 +1,44 @@
+/*
+ * 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;
+
+import io.netty.util.AsciiString;
+import java.net.URI;
+import java.security.Principal;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.api.ApiPath;
+import org.opendaylight.restconf.server.api.ModulesGetResult;
+
+/**
+ * A GET or HEAD request to the /modules resource.
+ */
+@NonNullByDefault
+final class PendingModulesGetYin extends AbstractPendingModulesGet {
+    PendingModulesGetYin(final EndpointInvariants invariants, final URI targetUri, final @Nullable Principal principal,
+            final ApiPath mountPath, final String fileName) {
+        super(invariants, targetUri, principal, mountPath, fileName);
+    }
+
+    @Override
+    void execute(final NettyServerRequest<ModulesGetResult> request, final String fileName,
+            final @Nullable String revision) {
+        server().modulesYinGET(request, fileName, revision);
+    }
+
+    @Override
+    void execute(final NettyServerRequest<ModulesGetResult> request, final ApiPath mountPath, final String fileName,
+            final @Nullable String revision) {
+        server().modulesYinGET(request, mountPath, fileName, revision);
+    }
+
+    @Override
+    AsciiString mediaType() {
+        return NettyMediaTypes.APPLICATION_YIN_XML;
+    }
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingOperationsGet.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingOperationsGet.java
new file mode 100644 (file)
index 0000000..62f863c
--- /dev/null
@@ -0,0 +1,41 @@
+/*
+ * 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;
+
+import static java.util.Objects.requireNonNull;
+
+import java.io.InputStream;
+import java.net.URI;
+import java.security.Principal;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.api.ApiPath;
+import org.opendaylight.restconf.api.FormattableBody;
+
+/**
+ * A GET or HEAD request to the /operations resource.
+ */
+@NonNullByDefault
+final class PendingOperationsGet extends AbstractDataPendingGet {
+    private final ApiPath apiPath;
+
+    PendingOperationsGet(final EndpointInvariants invariants, final URI targetUri, final @Nullable Principal principal,
+            final MessageEncoding encoding, final ApiPath apiPath, final boolean withContent) {
+        super(invariants, targetUri, principal, encoding, withContent);
+        this.apiPath = requireNonNull(apiPath);
+    }
+
+    @Override
+    void execute(final NettyServerRequest<FormattableBody> request, final InputStream body) {
+        if (apiPath.isEmpty()) {
+            server().operationsGET(request);
+        } else {
+            server().operationsGET(request, apiPath);
+        }
+    }
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingOperationsOptions.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingOperationsOptions.java
new file mode 100644 (file)
index 0000000..ebc43d4
--- /dev/null
@@ -0,0 +1,32 @@
+/*
+ * 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;
+
+import java.io.InputStream;
+import java.net.URI;
+import java.security.Principal;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.api.ApiPath;
+import org.opendaylight.restconf.server.api.OptionsResult;
+
+/**
+ * An OPTIONS request to the /operations resource.
+ */
+@NonNullByDefault
+final class PendingOperationsOptions extends AbstractPendingOptions {
+    PendingOperationsOptions(final EndpointInvariants invariants, final URI targetUri,
+            final @Nullable Principal principal, final ApiPath apiPath) {
+        super(invariants, targetUri, principal, apiPath);
+    }
+
+    @Override
+    void execute(final NettyServerRequest<OptionsResult> request, final InputStream body) {
+        server().operationsOPTIONS(request, apiPath);
+    }
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingOperationsPost.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingOperationsPost.java
new file mode 100644 (file)
index 0000000..977fe55
--- /dev/null
@@ -0,0 +1,48 @@
+/*
+ * 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;
+
+import java.io.InputStream;
+import java.net.URI;
+import java.security.Principal;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.api.ApiPath;
+import org.opendaylight.restconf.server.api.InvokeResult;
+import org.opendaylight.restconf.server.api.JsonOperationInputBody;
+import org.opendaylight.restconf.server.api.OperationInputBody;
+import org.opendaylight.restconf.server.api.XmlOperationInputBody;
+
+/**
+ * A POST request to the /operations resource.
+ */
+@NonNullByDefault
+final class PendingOperationsPost extends PendingRequestWithOutput<InvokeResult, OperationInputBody> {
+    PendingOperationsPost(final EndpointInvariants invariants, final URI targetUri, final @Nullable Principal principal,
+            final MessageEncoding contentEncoding, final MessageEncoding acceptEncoding, final ApiPath apiPath) {
+        super(invariants, targetUri, principal, contentEncoding, acceptEncoding, apiPath);
+    }
+
+    @Override
+    void execute(final NettyServerRequest<InvokeResult> request, final OperationInputBody body) {
+        server().operationsPOST(request, restconfURI(), apiPath, body);
+    }
+
+    @Override
+    Response transformResult(final NettyServerRequest<?> request, final InvokeResult result) {
+        return transformInvoke(request, result, acceptEncoding);
+    }
+
+    @Override
+    OperationInputBody wrapBody(final InputStream body) {
+        return switch (contentEncoding) {
+            case JSON -> new JsonOperationInputBody(body);
+            case XML -> new XmlOperationInputBody(body);
+        };
+    }
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingRequest.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingRequest.java
new file mode 100644 (file)
index 0000000..9cf6c5c
--- /dev/null
@@ -0,0 +1,55 @@
+/*
+ * 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;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.MoreObjects.ToStringHelper;
+import java.io.InputStream;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * A {@link PreparedRequest} which is pending execution. This object knows what needs to be executed, where an now based
+ * on having seen the contents of the HTTP request line and HTTP headers.
+ *
+ * <p>
+ * The pipeline-side of wiring is using these objects to represent and track asynchronous execution. Once the pipeline
+ * has set up everything it needs, it will invoke {@link #execute(PendingRequestListener, InputStream)} to kick off this
+ * request. Subclasses are required to notify of request completion by invoking the appropriate method on the supplied
+ * {@link PendingRequestListener}.
+ *
+ * @param <T> server response type
+ */
+@NonNullByDefault
+abstract non-sealed class PendingRequest<T> implements PreparedRequest {
+    /**
+     * Execute this request. Implementations are required to (eventually) signal completion via supplied
+     * {@link PendingRequestListener}.
+     *
+     * @param listener the {@link PendingRequestListener} to notify on completion
+     * @param body the HTTP request body
+     */
+    abstract void execute(PendingRequestListener listener, InputStream body);
+
+    @Override
+    public final int hashCode() {
+        return super.hashCode();
+    }
+
+    @Override
+    public final boolean equals(final @Nullable Object obj) {
+        return super.equals(obj);
+    }
+
+    @Override
+    public final String toString() {
+        return addToStringAttributes(MoreObjects.toStringHelper(this)).toString();
+    }
+
+    protected abstract ToStringHelper addToStringAttributes(ToStringHelper helper);
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingRequestListener.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingRequestListener.java
new file mode 100644 (file)
index 0000000..6ee4f65
--- /dev/null
@@ -0,0 +1,21 @@
+/*
+ * 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;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * An entity listening to {@link PendingRequest}s completions.
+ */
+@NonNullByDefault
+interface PendingRequestListener {
+
+    void requestComplete(PendingRequest<?> request, Response reply);
+
+    void requestFailed(PendingRequest<?> request, Exception cause);
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingRequestWithApiPath.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingRequestWithApiPath.java
new file mode 100644 (file)
index 0000000..621d221
--- /dev/null
@@ -0,0 +1,30 @@
+/*
+ * 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;
+
+import static java.util.Objects.requireNonNull;
+
+import java.net.URI;
+import java.security.Principal;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.api.ApiPath;
+
+/**
+ * An {@link AbstractPendingRequest} with a corresponding (potentially empty) {@link ApiPath}.
+ */
+@NonNullByDefault
+abstract class PendingRequestWithApiPath<T> extends AbstractPendingRequest<T> {
+    final ApiPath apiPath;
+
+    PendingRequestWithApiPath(final EndpointInvariants invariants, final URI targetUri,
+            final @Nullable Principal principal, final ApiPath apiPath) {
+        super(invariants, targetUri, principal);
+        this.apiPath = requireNonNull(apiPath);
+    }
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingRequestWithBody.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingRequestWithBody.java
new file mode 100644 (file)
index 0000000..3e3e670
--- /dev/null
@@ -0,0 +1,69 @@
+/*
+ * 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;
+
+import static java.util.Objects.requireNonNull;
+
+import io.netty.handler.codec.http.HttpHeaderNames;
+import io.netty.handler.codec.http.HttpResponseStatus;
+import java.io.InputStream;
+import java.net.URI;
+import java.security.Principal;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.api.ConsumableBody;
+import org.opendaylight.restconf.server.api.CreateResourceResult;
+import org.opendaylight.restconf.server.api.InvokeResult;
+
+/**
+ * A {@link PendingRequestWithEncoding} with a significant {@link ConsumableBody}. This class communicates takes care
+ * of wrapping the incoming {@link InputStream} body with the corresponding {@link ConsumableBody} and ensures it gets
+ * deallocated when no longer needed.
+ *
+ * @param <T> server response type
+ * @param <B> request message body type
+ */
+@NonNullByDefault
+abstract class PendingRequestWithBody<T, B extends ConsumableBody> extends AbstractPendingRequest<T> {
+    // Note naming: derived from 'Content-Type'
+    final MessageEncoding contentEncoding;
+
+    PendingRequestWithBody(final EndpointInvariants invariants, final URI targetUri,
+            final @Nullable Principal principal, final MessageEncoding contentEncoding) {
+        super(invariants, targetUri, principal);
+        this.contentEncoding = requireNonNull(contentEncoding);
+    }
+
+    @Override
+    final void execute(final NettyServerRequest<T> request, final InputStream body) {
+        try (var wrapped = wrapBody(body)) {
+            execute(request, wrapped);
+        }
+    }
+
+    abstract void execute(NettyServerRequest<T> request, B body);
+
+    /**
+     * Returns the provided {@link InputStream} body wrapped with request-specific {@link ConsumableBody}.
+     *
+     * @param body body as an {@link InputStream}
+     * @return body as a {@link ConsumableBody}
+     */
+    abstract B wrapBody(InputStream body);
+
+    final Response transformCreateResource(final CreateResourceResult result) {
+        return new DefaultCompletedRequest(HttpResponseStatus.CREATED,
+            metadataHeaders(result).set(HttpHeaderNames.LOCATION, restconfURI() + "data/" + result.createdPath()));
+    }
+
+    static final Response transformInvoke(final NettyServerRequest<?> request, final InvokeResult result,
+            final MessageEncoding acceptEncoding) {
+        final var output = result.output();
+        return output == null ? NO_CONTENT : new FormattableDataResponse(output, acceptEncoding, request.prettyPrint());
+    }
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingRequestWithOutput.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingRequestWithOutput.java
new file mode 100644 (file)
index 0000000..30b65a0
--- /dev/null
@@ -0,0 +1,38 @@
+/*
+ * 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;
+
+import static java.util.Objects.requireNonNull;
+
+import java.net.URI;
+import java.security.Principal;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.api.ApiPath;
+import org.opendaylight.restconf.api.ConsumableBody;
+
+/**
+ * Abstract base class for {@link PendingRequestWithBody}s which also produce an output.
+ *
+ * @param <T> server response type
+ * @param <B> request message body type
+ */
+@NonNullByDefault
+abstract class PendingRequestWithOutput<T, B extends ConsumableBody> extends PendingRequestWithBody<T, B> {
+    // Note naming: derived from 'Accept'
+    final MessageEncoding acceptEncoding;
+    final ApiPath apiPath;
+
+    PendingRequestWithOutput(final EndpointInvariants invariants, final URI targetUri,
+            final @Nullable Principal principal, final MessageEncoding contentEncoding,
+            final MessageEncoding acceptEncoding, final ApiPath apiPath) {
+        super(invariants, targetUri, principal, contentEncoding);
+        this.acceptEncoding = requireNonNull(acceptEncoding);
+        this.apiPath = requireNonNull(apiPath);
+    }
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingRequestWithResource.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingRequestWithResource.java
new file mode 100644 (file)
index 0000000..5aa538a
--- /dev/null
@@ -0,0 +1,48 @@
+/*
+ * 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;
+
+import static java.util.Objects.requireNonNull;
+
+import java.io.InputStream;
+import java.net.URI;
+import java.security.Principal;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.api.ApiPath;
+import org.opendaylight.restconf.server.api.JsonResourceBody;
+import org.opendaylight.restconf.server.api.ResourceBody;
+import org.opendaylight.restconf.server.api.XmlResourceBody;
+
+/**
+ * A {@link PendingRequestWithBody} with a {@link ResourceBody}.
+ *
+ * @param <T> server response type
+ */
+// TODO: From the semantic perspective we could use a better name, as we have exactly two subclasses:
+//       - PatchPlain, i.e. 'merge into target resource'
+//       - Put, i.e. 'create or replace target resource'
+@NonNullByDefault
+abstract sealed class PendingRequestWithResource<T> extends PendingRequestWithBody<T, ResourceBody>
+        permits PendingDataPatchPlain, PendingDataPut {
+    final ApiPath apiPath;
+
+    PendingRequestWithResource(final EndpointInvariants invariants, final URI targetUri,
+            final @Nullable Principal principal, final MessageEncoding contentEncoding, final ApiPath apiPath) {
+        super(invariants, targetUri, principal, contentEncoding);
+        this.apiPath = requireNonNull(apiPath);
+    }
+
+    @Override
+    final ResourceBody wrapBody(final InputStream body) {
+        return switch (contentEncoding) {
+            case JSON -> new JsonResourceBody(body);
+            case XML -> new XmlResourceBody(body);
+        };
+    }
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingYangLibraryVersionGet.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PendingYangLibraryVersionGet.java
new file mode 100644 (file)
index 0000000..0679b61
--- /dev/null
@@ -0,0 +1,31 @@
+/*
+ * 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;
+
+import java.io.InputStream;
+import java.net.URI;
+import java.security.Principal;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.api.FormattableBody;
+
+/**
+ * A GET or HEAD request to the /yang-library-version resource.
+ */
+@NonNullByDefault
+final class PendingYangLibraryVersionGet extends AbstractDataPendingGet {
+    PendingYangLibraryVersionGet(final EndpointInvariants invariants, final URI targetUri,
+            final @Nullable Principal principal, final MessageEncoding encoding, final boolean withContent) {
+        super(invariants, targetUri, principal, encoding, withContent);
+    }
+
+    @Override
+    void execute(final NettyServerRequest<FormattableBody> request, final InputStream body) {
+        server().yangLibraryVersionGET(request);
+    }
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PreparedRequest.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/PreparedRequest.java
new file mode 100644 (file)
index 0000000..d241130
--- /dev/null
@@ -0,0 +1,17 @@
+/*
+ * 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;
+
+/**
+ * The result of
+ * {@link RestconfRequestDispatcher#prepare(java.net.URI, SegmentPeeler, io.netty.handler.codec.http.HttpRequest)}. This
+ * can either be a {@link CompletedRequest} or a {@link PendingRequest}.
+ */
+sealed interface PreparedRequest permits CompletedRequest, PendingRequest {
+    // Nothing else
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/RequestBodyHandling.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/RequestBodyHandling.java
new file mode 100644 (file)
index 0000000..0c7985a
--- /dev/null
@@ -0,0 +1,34 @@
+/*
+ * 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;
+
+/**
+ * An enumeration of encoding kinds we support for request bodies.
+ */
+enum RequestBodyHandling {
+    /**
+     * The request does not indicate presence of a body.
+     */
+    NOT_PRESENT,
+    /**
+     * The body is has a XML-type encoding.
+     */
+    JSON,
+    /**
+     * The body is has a XML-type encoding.
+     */
+    XML,
+    /**
+     * The body does not specify a media type.
+     */
+    UNSPECIFIED,
+    /**
+     * The body specifies a media type which is not recognized.
+     */
+    UNRECOGNIZED;
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/RequestParameters.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/RequestParameters.java
deleted file mode 100644 (file)
index 199b6f8..0000000
+++ /dev/null
@@ -1,194 +0,0 @@
-/*
- * 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;
-
-import static java.util.Objects.requireNonNull;
-
-import io.netty.buffer.ByteBufInputStream;
-import io.netty.handler.codec.http.FullHttpRequest;
-import io.netty.handler.codec.http.HttpHeaderNames;
-import io.netty.handler.codec.http.HttpHeaders;
-import io.netty.handler.codec.http.HttpVersion;
-import io.netty.handler.codec.http.QueryStringDecoder;
-import io.netty.util.AsciiString;
-import java.io.InputStream;
-import java.net.URI;
-import java.security.Principal;
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.opendaylight.restconf.api.QueryParameters;
-import org.opendaylight.restconf.api.query.PrettyPrintParam;
-import org.opendaylight.restconf.server.api.RestconfServer;
-import org.opendaylight.restconf.server.spi.ErrorTagMapping;
-
-/**
- * Request parameters. Provides parameters parsed from request object.
- */
-@NonNullByDefault
-final class RequestParameters {
-    private final ImplementedMethod method;
-    private final URI restconfURI;
-    private final String remainingRawPath;
-    private final AsciiString contentType;
-    private final MessageEncoding defaultEncoding;
-    private final QueryParameters queryParameters;
-    private final FullHttpRequest request;
-    private final ErrorTagMapping errorTagMapping;
-    private final @Nullable Principal principal;
-    private final PrettyPrintParam defaultPrettyPrint;
-
-    RequestParameters(final ImplementedMethod method, final URI restconfURI, final QueryStringDecoder decoder,
-            final FullHttpRequest request, final @Nullable Principal principal, final ErrorTagMapping errorTagMapping,
-            final MessageEncoding defaultEncoding, final PrettyPrintParam defaultPrettyPrint) {
-        this.method = requireNonNull(method);
-        this.restconfURI = requireNonNull(restconfURI);
-        this.request = requireNonNull(request);
-        this.principal = principal;
-        this.errorTagMapping = requireNonNull(errorTagMapping);
-        this.defaultEncoding = requireNonNull(defaultEncoding);
-        this.defaultPrettyPrint = requireNonNull(defaultPrettyPrint);
-
-        contentType = extractContentType(request, defaultEncoding.dataMediaType());
-        remainingRawPath = decoder.rawPath();
-        queryParameters = QueryParameters.ofMultiValue(decoder.parameters());
-    }
-
-    /**
-     * Returns HTTP request method.
-     *
-     * @return request method
-     */
-    public ImplementedMethod method() {
-        return method;
-    }
-
-    /**
-     * Returns content-type header value if defined. Otherwise
-     * <li>either default Accept type if request has zero length content (to avoid Unsupported media type error when
-     * avoidable)</li>
-     * <li>or empty string if request contains content to decode</li>
-     *
-     * @return content-type value as {@link AsciiString} if defined.
-     */
-    public AsciiString contentType() {
-        return contentType;
-    }
-
-    /**
-     * Returns the absolute URI of {@code {+restconf}/} of this request. This format matches the specification of the
-     * second argument to {@link RestconfServer#operationsPOST(org.opendaylight.restconf.server.api.ServerRequest, URI,
-     * org.opendaylight.restconf.api.ApiPath, org.opendaylight.restconf.server.api.OperationInputBody)}.
-     *
-     * @return absolute URI of {@code {+restconf}/}
-     */
-    public URI restconfURI() {
-        return restconfURI;
-    }
-
-    /**
-     * The remaining absolute raw path or empty string if nothing remains.
-     *
-     * @return remaining absolute raw path or empty string if nothing remains
-     */
-    public String remainingRawPath() {
-        return remainingRawPath;
-    }
-
-    /**
-     * Returns query parameters.
-     *
-     * @return query parameters
-     */
-    public QueryParameters queryParameters() {
-        return queryParameters;
-    }
-
-    /**
-     * Returns request headers.
-     *
-     * @return request headers
-     */
-    public HttpHeaders requestHeaders() {
-        return request.headers();
-    }
-
-    /**
-     * Returns HTTP protocol version.
-     *
-     * @return HTTP protocol version
-     */
-    public HttpVersion protocolVersion() {
-        return request.protocolVersion();
-    }
-
-    /**
-     * Returns request body (content).
-     *
-     * @return request body as {@link InputStream}
-     */
-    public InputStream requestBody() {
-        return new ByteBufInputStream(request.content());
-    }
-
-    /**
-     * Returns configured default content-type value for response encoding.
-     * To be used if 'accept' header is absent.
-     *
-     * @return default value configured
-     */
-    public AsciiString defaultAcceptType() {
-        return defaultEncoding.dataMediaType();
-    }
-
-    /**
-     * Returns configured default value for pretty print parameter.
-     *
-     * @return default value configured
-     */
-    public PrettyPrintParam defaultPrettyPrint() {
-        return defaultPrettyPrint;
-    }
-
-    /**
-     * Returns pretty print parameter value to be used for current request .
-     *
-     * @return parameter value
-     */
-    public PrettyPrintParam prettyPrint() {
-        final var requestParam = queryParameters.lookup(PrettyPrintParam.uriName, PrettyPrintParam::forUriValue);
-        return requestParam != null ? requestParam : defaultPrettyPrint;
-    }
-
-    /**
-     * Returns error tag mapping configured.
-     *
-     * @return error tag mapping instance
-     */
-    public ErrorTagMapping errorTagMapping() {
-        return errorTagMapping;
-    }
-
-    /**
-     * Returns user principal associated with current request.
-     *
-     * @return principal object if defined, null otherwise
-     */
-    public @Nullable Principal principal() {
-        return principal;
-    }
-
-    private static AsciiString extractContentType(final FullHttpRequest request, final AsciiString defaultType) {
-        final var contentType = request.headers().get(HttpHeaderNames.CONTENT_TYPE);
-        if (contentType != null) {
-            return AsciiString.of(contentType);
-        }
-        // when request body is empty content-type value plays no role, and eligible to be absent,
-        // in this case apply default type to prevent unsupported media type error when checked subsequently
-        return request.content().readableBytes() == 0 ? defaultType : AsciiString.EMPTY_STRING;
-    }
-}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/Response.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/Response.java
new file mode 100644 (file)
index 0000000..6f85005
--- /dev/null
@@ -0,0 +1,18 @@
+/*
+ * 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;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * A response to request.
+ */
+@NonNullByDefault
+sealed interface Response permits CharSourceResponse, CompletedRequest, FormattableDataResponse {
+    // Nothing else here
+}
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/ResponseUtils.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/ResponseUtils.java
deleted file mode 100644 (file)
index ce0bb6b..0000000
+++ /dev/null
@@ -1,193 +0,0 @@
-/*
- * 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;
-
-import static java.util.Objects.requireNonNull;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Splitter;
-import com.google.common.io.CountingOutputStream;
-import io.netty.buffer.ByteBufOutputStream;
-import io.netty.handler.codec.DateFormatter;
-import io.netty.handler.codec.http.DefaultFullHttpResponse;
-import io.netty.handler.codec.http.FullHttpResponse;
-import io.netty.handler.codec.http.HttpHeaderNames;
-import io.netty.handler.codec.http.HttpResponseStatus;
-import io.netty.util.AsciiString;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.Locale;
-import org.opendaylight.restconf.api.FormattableBody;
-import org.opendaylight.restconf.server.api.ConfigurationMetadata;
-import org.opendaylight.restconf.server.api.ServerError;
-import org.opendaylight.restconf.server.api.YangErrorsBody;
-import org.opendaylight.restconf.server.spi.ErrorTagMapping;
-import org.opendaylight.yangtools.yang.common.ErrorTag;
-import org.opendaylight.yangtools.yang.common.ErrorType;
-
-final class ResponseUtils {
-    @VisibleForTesting
-    static final String UNMAPPED_REQUEST_ERROR = "Requested resource was not found.";
-    @VisibleForTesting
-    static final String UNSUPPORTED_MEDIA_TYPE_ERROR = "Request media type is not supported.";
-    @VisibleForTesting
-    static final String ENCODING_RESPONSE_ERROR = "Exception encoding response content. ";
-
-    private static final ServerError UNMAPPED_REQUEST_SERVER_ERROR =
-        new ServerError(ErrorType.PROTOCOL, ErrorTag.DATA_MISSING, UNMAPPED_REQUEST_ERROR);
-    private static final ServerError UNSUPPORTED_ENCODING_SERVER_ERROR =
-        new ServerError(ErrorType.PROTOCOL, ErrorTag.INVALID_VALUE, UNSUPPORTED_MEDIA_TYPE_ERROR);
-    private static final Splitter COMMA_SPLITTER = Splitter.on(',');
-
-    private ResponseUtils() {
-        // hidden on purpose
-    }
-
-    static FullHttpResponse unmappedRequestErrorResponse(final RequestParameters params) {
-        return responseBuilder(params, responseStatus(ErrorTag.DATA_MISSING, params.errorTagMapping()))
-            .setBody(new YangErrorsBody(UNMAPPED_REQUEST_SERVER_ERROR)).build();
-    }
-
-    static FullHttpResponse unsupportedMediaTypeErrorResponse(final RequestParameters params) {
-        return responseBuilder(params, HttpResponseStatus.UNSUPPORTED_MEDIA_TYPE)
-            .setBody(new YangErrorsBody(UNSUPPORTED_ENCODING_SERVER_ERROR)).build();
-    }
-
-    static FullHttpResponse simpleErrorResponse(final RequestParameters params, final ErrorTag errorTag,
-            final String errorMessage) {
-        // any error response should be returned with body
-        // https://datatracker.ietf.org/doc/html/rfc8040#section-7.1
-        return responseBuilder(params, responseStatus(errorTag, params.errorTagMapping()))
-            .setBody(new YangErrorsBody(new ServerError(ErrorType.PROTOCOL, errorTag, errorMessage)))
-            .build();
-    }
-
-    static HttpResponseStatus responseStatus(final ErrorTag errorTag, final ErrorTagMapping errorTagMapping) {
-        final var statusCode = errorTagMapping.statusOf(errorTag).code();
-        return HttpResponseStatus.valueOf(statusCode);
-    }
-
-    static FullHttpResponse simpleResponse(final RequestParameters params, final HttpResponseStatus responseStatus) {
-        return new DefaultFullHttpResponse(params.protocolVersion(), responseStatus);
-    }
-
-    static FullHttpResponse simpleResponse(final RequestParameters params, final HttpResponseStatus responseStatus,
-            final AsciiString contentType, final byte[] content) {
-        return responseBuilder(params, responseStatus)
-            .setBody(content).setHeader(HttpHeaderNames.CONTENT_TYPE, contentType).build();
-    }
-
-    static ResponseBuilder responseBuilder(final RequestParameters params, final HttpResponseStatus status) {
-        return new ResponseBuilder(params, status);
-    }
-
-    public static final class ResponseBuilder {
-        private final RequestParameters params;
-        private final FullHttpResponse response;
-
-        private ResponseBuilder(final RequestParameters params, final HttpResponseStatus status) {
-            this.params = requireNonNull(params);
-            response = new DefaultFullHttpResponse(params.protocolVersion(), status);
-        }
-
-        ResponseBuilder setHeader(final CharSequence name, final CharSequence value) {
-            response.headers().set(name, value);
-            return this;
-        }
-
-        ResponseBuilder setMetadataHeaders(final ConfigurationMetadata metadata) {
-            final var etag = metadata.entityTag();
-            if (etag != null) {
-                response.headers().set(HttpHeaderNames.ETAG, etag.value());
-            }
-            final var lastModified = metadata.lastModified();
-            if (lastModified != null) {
-                response.headers().set(HttpHeaderNames.LAST_MODIFIED, DateFormatter.format(Date.from(lastModified)));
-            }
-            return this;
-        }
-
-        ResponseBuilder setBody(final byte[] bytes) {
-            if (params.method() != ImplementedMethod.HEAD) {
-                // don't write content if head only requested
-                response.content().writeBytes(bytes);
-            }
-            response.headers().set(HttpHeaderNames.CONTENT_LENGTH, bytes.length);
-            return this;
-        }
-
-        ResponseBuilder setBody(final FormattableBody body) {
-            setContent(params, response, body);
-            return this;
-        }
-
-        FullHttpResponse build() {
-            return response;
-        }
-    }
-
-    private static void setContent(final RequestParameters params, final FullHttpResponse response,
-            final FormattableBody body) {
-        final var contentType = responseTypeFromAccept(params);
-        try (var out = new CountingOutputStream(
-            params.method() == ImplementedMethod.HEAD
-                // don't write content if head only requested
-                ? OutputStream.nullOutputStream()
-                : new ByteBufOutputStream(response.content()))) {
-            if (NettyMediaTypes.APPLICATION_YANG_DATA_JSON.equals(contentType)) {
-                body.formatToJSON(params.prettyPrint(), out);
-            } else {
-                body.formatToXML(params.prettyPrint(), out);
-            }
-            response.headers()
-                .set(HttpHeaderNames.CONTENT_TYPE, contentType)
-                .setInt(HttpHeaderNames.CONTENT_LENGTH, (int) out.getCount());
-        } catch (IOException e) {
-            throw new ServerErrorException(ErrorTag.OPERATION_FAILED,
-                ENCODING_RESPONSE_ERROR + e.getMessage(), e);
-        }
-    }
-
-    private static AsciiString responseTypeFromAccept(final RequestParameters params) {
-        final var acceptValues = params.requestHeaders().getAll(HttpHeaderNames.ACCEPT);
-        if (acceptValues.isEmpty()) {
-            return params.defaultAcceptType();
-        }
-
-        // FIXME: this algorithm is quite naive and ignores https://www.rfc-editor.org/rfc/rfc9110#name-accept, i.e.
-        //        it does not handle wildcards at all.
-        //        furthermore it completely ignores https://www.rfc-editor.org/rfc/rfc9110#name-quality-values, i.e.
-        //        it does not consider client-supplied weights during media type selection AND it treats q=0 as an
-        //        inclusion of a media type rather than its exclusion
-        final var acceptTypes = new ArrayList<AsciiString>();
-        for (var accept : acceptValues) {
-            // use english locale lowercase to ignore possible case variants
-            for (var type : COMMA_SPLITTER.split(accept.toLowerCase(Locale.ENGLISH))) {
-                acceptTypes.add(AsciiString.of(type.trim()));
-            }
-        }
-
-        // if accept type is not defined or client accepts both xml and json types
-        // the server configured default will be used
-        boolean isJson = false;
-        boolean isXml = false;
-        for (var acceptType : acceptTypes) {
-            isJson |= NettyMediaTypes.JSON_TYPES.contains(acceptType);
-            isXml |= NettyMediaTypes.XML_TYPES.contains(acceptType);
-        }
-        if (isJson && !isXml) {
-            return NettyMediaTypes.APPLICATION_YANG_DATA_JSON;
-        }
-        if (isXml && !isJson) {
-            return NettyMediaTypes.APPLICATION_YANG_DATA_XML;
-        }
-        return params.defaultAcceptType();
-    }
-}
index a929ee764e7f171ad19097066cb4863342a2044e..a55eb94ce33101c02c5e4c0c0fe53fdabeab8e0a 100644 (file)
@@ -10,20 +10,9 @@ package org.opendaylight.restconf.server;
 import io.netty.handler.codec.http.FullHttpResponse;
 import org.eclipse.jdt.annotation.NonNullByDefault;
 
+// FIXME: NETCONF-1379: eliminate this class
 @NonNullByDefault
 abstract class RestconfRequest {
-    // FIXME: expand the semantics of this class:
-    //        - it is instantiated from the EventLoop of a particular RestconfSession
-    //        - it may represent an HTTP/1.1 (pipelined) or HTTP/2 (concurrent) request
-    //        - it may be completed synchronously during dispatch (i.e. due to validation)
-    //        - it may be tied to a ServerRequest, which usually completes asynchronously
-    //          -- this transition has to be explicit, as RestconfSession needs to be able to perform some bookkeeping
-    //             w.r.t. how subsequent HttpRequests are handled
-    //          -- ServerRequests typically finish with a FormattableBody, which can contain a huge entity, which we
-    //             do *not* want to completely buffer to a FullHttpResponse
-    //          -- that means each asynchronously-completed request needs to result in a virtual thread which translates
-    //             the result into either a FullHttpResponse (if under 256KiB) or a HttpResponse followed by a number
-    //             of HttpContents and finished by a LastHttpContent
 
     abstract void onSuccess(FullHttpResponse response);
 }
index 92ed2e2019626a21bccea22e85dfcac319e0d5a1..aba8bd4571301f4cba13fc29e5c8acadcba8ae4f 100644 (file)
@@ -8,97 +8,89 @@
 package org.opendaylight.restconf.server;
 
 import static java.util.Objects.requireNonNull;
-import static org.opendaylight.restconf.server.NettyMediaTypes.RESTCONF_TYPES;
-import static org.opendaylight.restconf.server.NettyMediaTypes.YANG_PATCH_TYPES;
-import static org.opendaylight.restconf.server.ResponseUtils.responseBuilder;
-import static org.opendaylight.restconf.server.ResponseUtils.responseStatus;
-import static org.opendaylight.restconf.server.ResponseUtils.simpleErrorResponse;
-import static org.opendaylight.restconf.server.ResponseUtils.simpleResponse;
-import static org.opendaylight.restconf.server.ResponseUtils.unmappedRequestErrorResponse;
-import static org.opendaylight.restconf.server.ResponseUtils.unsupportedMediaTypeErrorResponse;
-
-import com.google.common.annotations.VisibleForTesting;
-import io.netty.buffer.Unpooled;
+
+import io.netty.buffer.ByteBufInputStream;
+import io.netty.buffer.ByteBufOutputStream;
+import io.netty.buffer.ByteBufUtil;
+import io.netty.buffer.UnpooledByteBufAllocator;
 import io.netty.handler.codec.http.DefaultFullHttpResponse;
+import io.netty.handler.codec.http.DefaultHttpHeadersFactory;
 import io.netty.handler.codec.http.FullHttpRequest;
 import io.netty.handler.codec.http.FullHttpResponse;
 import io.netty.handler.codec.http.HttpHeaderNames;
 import io.netty.handler.codec.http.HttpHeaderValues;
+import io.netty.handler.codec.http.HttpHeaders;
 import io.netty.handler.codec.http.HttpResponseStatus;
+import io.netty.handler.codec.http.HttpUtil;
 import io.netty.handler.codec.http.QueryStringDecoder;
 import io.netty.util.AsciiString;
 import java.io.IOException;
 import java.io.InputStream;
 import java.net.URI;
 import java.nio.charset.StandardCharsets;
+import java.security.Principal;
+import java.sql.PreparedStatement;
 import java.text.ParseException;
 import java.util.List;
 import java.util.function.Function;
 import java.util.stream.Collectors;
 import org.eclipse.jdt.annotation.NonNull;
 import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
 import org.opendaylight.restconf.api.ApiPath;
-import org.opendaylight.restconf.api.ConsumableBody;
+import org.opendaylight.restconf.api.MediaTypes;
 import org.opendaylight.restconf.api.query.PrettyPrintParam;
-import org.opendaylight.restconf.server.api.CreateResourceResult;
-import org.opendaylight.restconf.server.api.DataGetResult;
-import org.opendaylight.restconf.server.api.DataPatchResult;
-import org.opendaylight.restconf.server.api.DataPostResult;
-import org.opendaylight.restconf.server.api.DataPutResult;
-import org.opendaylight.restconf.server.api.DataYangPatchResult;
-import org.opendaylight.restconf.server.api.InvokeResult;
-import org.opendaylight.restconf.server.api.JsonChildBody;
-import org.opendaylight.restconf.server.api.JsonDataPostBody;
-import org.opendaylight.restconf.server.api.JsonOperationInputBody;
-import org.opendaylight.restconf.server.api.JsonPatchBody;
-import org.opendaylight.restconf.server.api.JsonResourceBody;
-import org.opendaylight.restconf.server.api.ModulesGetResult;
-import org.opendaylight.restconf.server.api.PatchStatusContext;
 import org.opendaylight.restconf.server.api.RestconfServer;
-import org.opendaylight.restconf.server.api.ServerRequest;
-import org.opendaylight.restconf.server.api.XmlChildBody;
-import org.opendaylight.restconf.server.api.XmlDataPostBody;
-import org.opendaylight.restconf.server.api.XmlOperationInputBody;
-import org.opendaylight.restconf.server.api.XmlPatchBody;
-import org.opendaylight.restconf.server.api.XmlResourceBody;
 import org.opendaylight.restconf.server.spi.ErrorTagMapping;
-import org.opendaylight.restconf.server.spi.YangPatchStatusBody;
-import org.opendaylight.yangtools.yang.common.Empty;
-import org.opendaylight.yangtools.yang.common.ErrorTag;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 final class RestconfRequestDispatcher {
     private static final Logger LOG = LoggerFactory.getLogger(RestconfRequestDispatcher.class);
 
-    @VisibleForTesting
-    static final String REVISION = "revision";
-    @VisibleForTesting
-    static final String MISSING_FILENAME_ERROR = "Module name is missing";
-    @VisibleForTesting
-    static final String SOURCE_READ_FAILURE_ERROR = "Failure reading module source: ";
+    private static final @NonNull CompletedRequest METHOD_NOT_ALLOWED_DATASTORE =
+        new DefaultCompletedRequest(HttpResponseStatus.METHOD_NOT_ALLOWED, AbstractPendingOptions.HEADERS_DATASTORE);
+    private static final @NonNull CompletedRequest METHOD_NOT_ALLOWED_READ_ONLY =
+        new DefaultCompletedRequest(HttpResponseStatus.METHOD_NOT_ALLOWED, AbstractPendingOptions.HEADERS_READ_ONLY);
+    private static final @NonNull CompletedRequest METHOD_NOT_ALLOWED_RPC =
+        new DefaultCompletedRequest(HttpResponseStatus.METHOD_NOT_ALLOWED, AbstractPendingOptions.HEADERS_RPC);
+
+    private static final @NonNull CompletedRequest NOT_FOUND =
+        new DefaultCompletedRequest(HttpResponseStatus.NOT_FOUND);
+
+    private static final @NonNull CompletedRequest NOT_ACCEPTABLE_DATA;
+    private static final @NonNull CompletedRequest UNSUPPORTED_MEDIA_TYPE_DATA;
+    private static final @NonNull CompletedRequest UNSUPPORTED_MEDIA_TYPE_PATCH;
+
+    static {
+        final var factory = DefaultHttpHeadersFactory.headersFactory();
+
+        final var headers = factory.newEmptyHeaders().set(HttpHeaderNames.ACCEPT, String.join(", ", List.of(
+            MediaTypes.APPLICATION_YANG_DATA_JSON,
+            MediaTypes.APPLICATION_YANG_DATA_XML,
+            // FIXME: do not advertize these types
+            HttpHeaderValues.APPLICATION_JSON.toString(),
+            HttpHeaderValues.APPLICATION_XML.toString(),
+            NettyMediaTypes.TEXT_XML.toString())));
+
+        NOT_ACCEPTABLE_DATA = new DefaultCompletedRequest(HttpResponseStatus.NOT_ACCEPTABLE, headers);
+        UNSUPPORTED_MEDIA_TYPE_DATA = new DefaultCompletedRequest(HttpResponseStatus.UNSUPPORTED_MEDIA_TYPE, headers);
+        UNSUPPORTED_MEDIA_TYPE_PATCH = new DefaultCompletedRequest(HttpResponseStatus.UNSUPPORTED_MEDIA_TYPE,
+            factory.newEmptyHeaders().set(HttpHeaderNames.ACCEPT, AbstractPendingOptions.ACCEPTED_PATCH_MEDIA_TYPES));
+    }
 
-    private final RestconfServer server;
-    private final PrincipalService principalService;
-    private final ErrorTagMapping errorTagMapping;
-    private final MessageEncoding defaultEncoding;
-    private final PrettyPrintParam defaultPrettyPrint;
+    private final @NonNull EndpointInvariants invariants;
+    private final @NonNull PrincipalService principalService;
 
     private final String firstSegment;
     private final List<String> otherSegments;
 
-    // '/{+restconf}/', i.e. an absolute path conforming to RestconfServer's 'restconfURI'
-    private final URI restconfPath;
-
     RestconfRequestDispatcher(final RestconfServer server, final PrincipalService principalService,
             final List<String> segments, final String restconfPath, final ErrorTagMapping errorTagMapping,
             final MessageEncoding defaultEncoding, final PrettyPrintParam defaultPrettyPrint) {
-        this.server = requireNonNull(server);
+        invariants = new EndpointInvariants(server, defaultPrettyPrint, errorTagMapping, defaultEncoding,
+            URI.create(requireNonNull(restconfPath)));
         this.principalService = requireNonNull(principalService);
-        this.restconfPath = URI.create(requireNonNull(restconfPath));
-        this.errorTagMapping = requireNonNull(errorTagMapping);
-        this.defaultEncoding = requireNonNull(defaultEncoding);
-        this.defaultPrettyPrint = requireNonNull(defaultPrettyPrint);
 
         firstSegment = segments.getFirst();
         otherSegments = segments.stream().skip(1).collect(Collectors.toUnmodifiableList());
@@ -112,19 +104,123 @@ final class RestconfRequestDispatcher {
         return firstSegment;
     }
 
-    @SuppressWarnings("IllegalCatch")
-    void dispatch(final @NonNull ImplementedMethod method, final URI targetUri, final SegmentPeeler peeler,
+    @NonNullByDefault
+    void dispatch(final SegmentPeeler peeler, final ImplementedMethod method, final URI targetUri,
             final FullHttpRequest request, final RestconfRequest callback) {
-        LOG.debug("Dispatching {} {}", method, targetUri);
+        final var version = request.protocolVersion();
+
+        switch (prepare(peeler, method, targetUri, request.headers(), principalService.acquirePrincipal(request))) {
+            case CompletedRequest completed -> callback.onSuccess(completed.toHttpResponse(version));
+            case PendingRequest<?> pending -> {
+                LOG.debug("Dispatching {} {}", request.method(), targetUri);
+
+                final var content = request.content();
+                pending.execute(new PendingRequestListener() {
+                    @Override
+                    public void requestFailed(final PendingRequest<?> request, final Exception cause) {
+                        LOG.warn("Internal error while processing {}", request, cause);
+                        final var response = new DefaultFullHttpResponse(version,
+                            HttpResponseStatus.INTERNAL_SERVER_ERROR);
+                        final var content = response.content();
+                        // Note: we are tempted to do a cause.toString() here, but we are dealing with unhandled badness
+                        //       here, so we do not want to be too revealing -- hence a message is all the user gets.
+                        ByteBufUtil.writeUtf8(content, cause.getMessage());
+                        HttpUtil.setContentLength(response, content.readableBytes());
+                        callback.onSuccess(response);
+                    }
 
-        // FIXME: this is here just because of test structure
-        final var principal = principalService.acquirePrincipal(request);
+                    @Override
+                    public void requestComplete(final PendingRequest<?> request, final Response reply) {
+                        // FIXME: ServerRequests typically finish with a FormattableBody, which can contain a huge
+                        //        entity, which we do *not* want to completely buffer to a FullHttpResponse.
+                        final FullHttpResponse response;
+                        switch (reply) {
+                            case CompletedRequest completed -> {
+                                response = completed.toHttpResponse(version);
+                            }
+
+                            // FIXME: these payloads use a synchronous dump of data into the socket. We cannot safely
+                            //        do that on the event loop, because a slow client would end up throttling our IO
+                            //        threads simply because of TCP window and similar queuing/backpressure things.
+                            //
+                            //        we really want to kick off a virtual thread to take care of that, i.e. doing its
+                            //        own synchronous write thing, talking to a short queue (SPSC?) of HttpObjects.
+                            //
+                            //        the event loop of each channel would be the consumer of that queue, picking them
+                            //        off as quickly as possible, but execting backpressure if the amount of pending
+                            //        stuff goes up.
+                            //
+                            //        as for the HttpObjects: this effectively means that the OutputStreams used in the
+                            //        below code should be replaced with entities which perform chunking:
+                            //        - buffer initial stuff, so that we produce a FullHttpResponse if the payload is
+                            //          below 256KiB (or so), i.e. producing Content-Length header and dumping the thing
+                            //          in one go
+                            //        - otherwise emit just HttpResponse with Transfer-Enconding: chunked and continue
+                            //          sending out chunks (of reasonable size).
+                            //        - finish up with a LastHttpContent
+
+                            case CharSourceResponse charSource -> {
+                                response = new DefaultFullHttpResponse(version, HttpResponseStatus.OK);
+                                final var content = response.content();
+                                try (var os = new ByteBufOutputStream(content)) {
+                                    charSource.source().asByteSource(StandardCharsets.UTF_8).copyTo(os);
+                                } catch (IOException e) {
+                                    requestFailed(request, e);
+                                    return;
+                                }
+
+                                response.headers()
+                                    .set(HttpHeaderNames.CONTENT_TYPE, charSource.mediaType())
+                                    .set(HttpHeaderNames.CONTENT_LENGTH, content.readableBytes());
+                            }
+                            case FormattableDataResponse formattable -> {
+                                response = new DefaultFullHttpResponse(version, formattable.status());
+                                final var content = response.content();
+
+                                try (var os = new ByteBufOutputStream(content)) {
+                                    formattable.writeTo(os);
+                                } catch (IOException e) {
+                                    requestFailed(request, e);
+                                    return;
+                                }
+
+                                final var headers = response.headers();
+                                final var extra = formattable.headers();
+                                if (extra != null) {
+                                    headers.set(extra);
+                                }
+                                headers
+                                    .set(HttpHeaderNames.CONTENT_TYPE, formattable.encoding().dataMediaType())
+                                    .set(HttpHeaderNames.CONTENT_LENGTH, content.readableBytes());
+                            }
+                        }
+                        callback.onSuccess(response);
+                    }
+                }, content.readableBytes() == 0 ? InputStream.nullInputStream() : new ByteBufInputStream(content));
+            }
+        }
+    }
+
+    /**
+     * Prepare to service a request, by binding the request HTTP method and the request path to a resource and
+     * validating request headers in that context. This method is required to not block.
+     *
+     * @param peeler the {@link SegmentPeeler} holding the unprocessed part of the request path
+     * @param method the method being invoked
+     * @param targetUri the URI of the target resource
+     * @param headers request headers
+     * @param principal the {@link Principal} making this request, {@code null} if not known
+     * @return A {@link PreparedStatement}
+     */
+    @NonNullByDefault
+    private PreparedRequest prepare(final SegmentPeeler peeler, final ImplementedMethod method, final URI targetUri,
+            final HttpHeaders headers, final @Nullable Principal principal) {
+        LOG.debug("Preparing {} {}", method, targetUri);
 
         // peel all other segments out
         for (var segment : otherSegments) {
             if (!peeler.hasNext() || !segment.equals(peeler.next())) {
-                callback.onSuccess(notFound(request));
-                return;
+                return NOT_FOUND;
             }
         }
 
@@ -132,390 +228,361 @@ final class RestconfRequestDispatcher {
             // FIXME: we are rejecting requests to '{+restconf}', which matches JAX-RS server behaviour, but is not
             //        correct: we should be reporting the entire API Resource, as described in
             //        https://www.rfc-editor.org/rfc/rfc8040#section-3.3
-            callback.onSuccess(notFound(request));
-            return;
+            return NOT_FOUND;
         }
 
         final var segment = peeler.next();
-        final var rawPath = peeler.remaining();
-        final var rawQuery = targetUri.getRawQuery();
-        final var decoder = new QueryStringDecoder(rawQuery != null ? rawPath + "?" + rawQuery : rawPath);
-        final var params = new RequestParameters(method, targetUri.resolve(restconfPath), decoder, request, principal,
-            errorTagMapping, defaultEncoding, defaultPrettyPrint);
-
-        try {
-            switch (segment) {
-                case "data" -> processDataRequest(params, callback);
-                case "operations" -> processOperationsRequest(params, callback);
-                case "yang-library-version" -> processYangLibraryVersion(params, callback);
-                case "modules" -> processModules(params, callback);
-                default -> callback.onSuccess(method == ImplementedMethod.OPTIONS
-                    ? optionsResponse(params, ImplementedMethod.OPTIONS.toString()) : notFound(request));
-            }
-        } catch (RuntimeException e) {
-            LOG.error("Error processing request {} {}", method, request.uri(), e);
-            final var errorTag = e instanceof ServerErrorException see ? see.errorTag() : ErrorTag.OPERATION_FAILED;
-            callback.onSuccess(simpleErrorResponse(params, errorTag, e.getMessage()));
-        }
-    }
-
-    @NonNullByDefault
-    private static FullHttpResponse notFound(final FullHttpRequest request) {
-        return new DefaultFullHttpResponse(request.protocolVersion(), HttpResponseStatus.NOT_FOUND);
+        final var path = peeler.remaining();
+        return switch (segment) {
+            case "data" -> prepareData(method, targetUri, headers, principal, path);
+            case "operations" -> prepareOperations(method, targetUri, headers, principal, path);
+            case "yang-library-version" -> prepareYangLibraryVersion(method, targetUri, headers, principal, path);
+            case "modules" -> prepareModules(method, targetUri, headers, principal, path);
+            default -> NOT_FOUND;
+        };
     }
 
     /**
      * Process a request to <a href="https://www.rfc-editor.org/rfc/rfc8040#section-3.3.1">RFC 8040 {+restconf}/data</a>
      * resource.
      */
-    private void processDataRequest(final RequestParameters params, final RestconfRequest callback) {
-        final var contentType = params.contentType();
-        final var apiPath = extractApiPath(params);
-        switch (params.method()) {
-            // resource options -> https://www.rfc-editor.org/rfc/rfc8040#section-4.1
-            case OPTIONS -> {
-                final var request = new OptionsServerRequest(params, callback);
-                if (apiPath.isEmpty()) {
-                    server.dataOPTIONS(request);
-                } else {
-                    server.dataOPTIONS(request, apiPath);
-                }
-            }
-            // retrieve data and metadata for a resource -> https://www.rfc-editor.org/rfc/rfc8040#section-4.3
-            // HEAD is same as GET but without content -> https://www.rfc-editor.org/rfc/rfc8040#section-4.2
-            case HEAD, GET -> getData(params, callback, apiPath);
-            case POST -> {
-                if (RESTCONF_TYPES.contains(contentType)) {
-                    // create resource -> https://www.rfc-editor.org/rfc/rfc8040#section-4.4.1
-                    // or invoke an action -> https://www.rfc-editor.org/rfc/rfc8040#section-3.6
-                    postData(params, callback, apiPath);
-                } else {
-                    callback.onSuccess(unsupportedMediaTypeErrorResponse(params));
-                }
-            }
-            case PUT -> {
-                if (RESTCONF_TYPES.contains(contentType)) {
-                    // create or replace target resource -> https://www.rfc-editor.org/rfc/rfc8040#section-4.5
-                    putData(params, callback, apiPath);
-                } else {
-                    callback.onSuccess(unsupportedMediaTypeErrorResponse(params));
-                }
-            }
-            case PATCH -> {
-                if (RESTCONF_TYPES.contains(contentType)) {
-                    // Plain RESTCONF patch = merge target resource content ->
-                    // https://www.rfc-editor.org/rfc/rfc8040#section-4.6.1
-                    patchData(params, callback, apiPath);
-                } else if (YANG_PATCH_TYPES.contains(contentType)) {
-                    // YANG Patch = ordered list of edits that are applied to the target datastore ->
-                    // https://www.rfc-editor.org/rfc/rfc8072#section-2
-                    yangPatchData(params, callback, apiPath);
-                } else {
-                    callback.onSuccess(unsupportedMediaTypeErrorResponse(params));
-                }
-            }
-            // delete target resource -> https://www.rfc-editor.org/rfc/rfc8040#section-4.7
-            case DELETE -> deleteData(params, callback, apiPath);
-            default -> callback.onSuccess(unmappedRequestErrorResponse(params));
-        }
-    }
-
-    private void getData(final RequestParameters params, final RestconfRequest callback, final ApiPath apiPath) {
-        final var request = new NettyServerRequest<DataGetResult>(params, callback) {
-            @Override
-            FullHttpResponse transform(final DataGetResult result) {
-                return responseBuilder(requestParams, HttpResponseStatus.OK)
-                    .setHeader(HttpHeaderNames.CACHE_CONTROL, HttpHeaderValues.NO_CACHE)
-                    .setMetadataHeaders(result)
-                    .setBody(result.body())
-                    .build();
-            }
+    @NonNullByDefault
+    private PreparedRequest prepareData(final ImplementedMethod method, final URI targetUri, final HttpHeaders headers,
+            final @Nullable Principal principal, final String path) {
+        return switch (method) {
+            case DELETE -> prepareDataDelete(targetUri, headers, principal, path);
+            case GET -> prepareDataGet(targetUri, headers, principal, path, true);
+            case HEAD -> prepareDataGet(targetUri, headers, principal, path, false);
+            case OPTIONS -> prepareDataOptions(targetUri, principal, path);
+            case PATCH -> prepareDataPatch(targetUri, headers, principal, path);
+            case POST -> prepareDataPost(targetUri, headers, principal, path);
+            case PUT -> prepareDataPut(targetUri, headers, principal, path);
         };
+    }
 
-        if (apiPath.isEmpty()) {
-            server.dataGET(request);
-        } else {
-            server.dataGET(request, apiPath);
-        }
+    // delete target resource -> https://www.rfc-editor.org/rfc/rfc8040#section-4.7
+    @NonNullByDefault
+    private PreparedRequest prepareDataDelete(final URI targetUri, final HttpHeaders headers,
+            final @Nullable Principal principal, final String path) {
+        return path.isEmpty() ? METHOD_NOT_ALLOWED_DATASTORE
+            : requiredApiPath(path, apiPath -> new PendingDataDelete(invariants, targetUri, principal, apiPath));
     }
 
-    private void postData(final RequestParameters params, final RestconfRequest callback, final ApiPath apiPath) {
-        if (apiPath.isEmpty()) {
-            server.dataPOST(postRequest(params, callback),
-                requestBody(params, JsonChildBody::new, XmlChildBody::new));
-        } else {
-            server.dataPOST(postRequest(params, callback), apiPath,
-                requestBody(params, JsonDataPostBody::new, XmlDataPostBody::new));
-        }
+    // retrieve data and metadata for a resource -> https://www.rfc-editor.org/rfc/rfc8040#section-4.3
+    // HEAD is same as GET but without content -> https://www.rfc-editor.org/rfc/rfc8040#section-4.2
+    @NonNullByDefault
+    private PreparedRequest prepareDataGet(final URI targetUri, final HttpHeaders headers,
+            final @Nullable Principal principal, final String path, final boolean withContent) {
+        // Attempt to choose an encoding based on user's preference. If we cannot pick one, responding with a 406 status
+        // and list the encodings we support
+        final var encoding = chooseOutputEncoding(headers);
+        return encoding == null ? NOT_ACCEPTABLE_DATA : optionalApiPath(path,
+            apiPath -> new PendingDataGet(invariants, targetUri, principal, encoding, apiPath, withContent));
     }
 
-    private static <T extends DataPostResult> ServerRequest<T> postRequest(final RequestParameters params,
-            final RestconfRequest callback) {
-        return new NettyServerRequest<>(params, callback) {
-            @Override
-            FullHttpResponse transform(final DataPostResult result) {
-                return switch (result) {
-                    case CreateResourceResult createResult -> {
-                        yield responseBuilder(requestParams, HttpResponseStatus.CREATED)
-                            .setHeader(HttpHeaderNames.LOCATION,
-                                requestParams.restconfURI() + "data/" + createResult.createdPath())
-                            .setMetadataHeaders(createResult)
-                            .build();
-                    }
-                    case InvokeResult invokeResult -> {
-                        final var output = invokeResult.output();
-                        yield output == null ? simpleResponse(requestParams, HttpResponseStatus.NO_CONTENT)
-                            : responseBuilder(requestParams, HttpResponseStatus.OK).setBody(output).build();
-                    }
-                };
-            }
-        };
+    // resource options -> https://www.rfc-editor.org/rfc/rfc8040#section-4.1
+    @NonNullByDefault
+    private PreparedRequest prepareDataOptions(final URI targetUri, final @Nullable Principal principal,
+            final String path) {
+        return optionalApiPath(path, apiPath -> new PendingDataOptions(invariants, targetUri, principal, apiPath));
     }
 
-    private void putData(final RequestParameters params, final RestconfRequest callback, final ApiPath apiPath) {
-        final var request = new NettyServerRequest<DataPutResult>(params, callback) {
-            @Override
-            FullHttpResponse transform(final DataPutResult result) {
-                final var status = result.created() ? HttpResponseStatus.CREATED : HttpResponseStatus.NO_CONTENT;
-                return responseBuilder(requestParams, status).setMetadataHeaders(result).build();
+    // PATCH -> https://www.rfc-editor.org/rfc/rfc8040#section-4.6
+    @NonNullByDefault
+    private PreparedRequest prepareDataPatch(final URI targetUri, final HttpHeaders headers,
+            final @Nullable Principal principal, final String path) {
+        final var contentType = headers.get(HttpHeaderNames.CONTENT_TYPE);
+        if (contentType == null) {
+            return UNSUPPORTED_MEDIA_TYPE_PATCH;
+        }
+        final var mimeType = HttpUtil.getMimeType(contentType);
+        if (mimeType == null) {
+            return UNSUPPORTED_MEDIA_TYPE_PATCH;
+        }
+        final var mediaType = AsciiString.of(mimeType);
+
+        for (var encoding : MessageEncoding.values()) {
+            // FIXME: tighten this check to just dataMediaType
+            if (encoding.producesDataCompatibleWith(mediaType)) {
+                // Plain RESTCONF patch = merge target resource content ->
+                // https://www.rfc-editor.org/rfc/rfc8040#section-4.6.1
+                return optionalApiPath(path,
+                    apiPath -> new PendingDataPatchPlain(invariants, targetUri, principal, encoding, apiPath));
+            }
+            if (encoding.patchMediaType().equals(mediaType)) {
+                // YANG Patch = ordered list of edits that are applied to the target datastore ->
+                // https://www.rfc-editor.org/rfc/rfc8072#section-2
+                final var accept = chooseOutputEncoding(headers);
+                return accept == null ? NOT_ACCEPTABLE_DATA : optionalApiPath(path,
+                    apiPath -> new PendingDataPatchYang(invariants, targetUri, principal, encoding, accept, apiPath));
             }
-        };
-        final var dataResourceBody = requestBody(params, JsonResourceBody::new, XmlResourceBody::new);
-        if (apiPath.isEmpty()) {
-            server.dataPUT(request, dataResourceBody);
-        } else {
-            server.dataPUT(request, apiPath, dataResourceBody);
         }
+
+        return UNSUPPORTED_MEDIA_TYPE_PATCH;
     }
 
-    private void patchData(final RequestParameters params, final RestconfRequest callback, final ApiPath apiPath) {
-        final var request = new NettyServerRequest<DataPatchResult>(params, callback) {
-            @Override
-            FullHttpResponse transform(final DataPatchResult result) {
-                return responseBuilder(requestParams, HttpResponseStatus.OK).setMetadataHeaders(result).build();
-            }
+    // create resource -> https://www.rfc-editor.org/rfc/rfc8040#section-4.4.1
+    // or invoke an action -> https://www.rfc-editor.org/rfc/rfc8040#section-3.6
+    @NonNullByDefault
+    private PreparedRequest prepareDataPost(final URI targetUri, final HttpHeaders headers,
+            final @Nullable Principal principal, final String path) {
+        return switch (chooseInputEncoding(headers)) {
+            case UNRECOGNIZED, UNSPECIFIED -> UNSUPPORTED_MEDIA_TYPE_DATA;
+            case NOT_PRESENT -> prepareDataPost(targetUri, headers, principal, path, invariants.defaultEncoding());
+            case JSON -> prepareDataPost(targetUri, headers, principal, path, MessageEncoding.JSON);
+            case XML -> prepareDataPost(targetUri, headers, principal, path, MessageEncoding.XML);
         };
-        final var dataResourceBody = requestBody(params, JsonResourceBody::new, XmlResourceBody::new);
-        if (apiPath.isEmpty()) {
-            server.dataPATCH(request, dataResourceBody);
-        } else {
-            server.dataPATCH(request, apiPath, dataResourceBody);
-        }
     }
 
-    private void yangPatchData(final RequestParameters params, final RestconfRequest callback, final ApiPath apiPath) {
-        final var request = new NettyServerRequest<DataYangPatchResult>(params, callback) {
-            @Override
-            FullHttpResponse transform(final DataYangPatchResult result) {
-                final var patchStatus = result.status();
-                return responseBuilder(requestParams, patchResponseStatus(patchStatus, requestParams.errorTagMapping()))
-                    .setBody(new YangPatchStatusBody(patchStatus))
-                    .setMetadataHeaders(result)
-                    .build();
-            }
-        };
-        final var yangPatchBody = requestBody(params, JsonPatchBody::new, XmlPatchBody::new);
-        if (apiPath.isEmpty()) {
-            server.dataPATCH(request, yangPatchBody);
-        } else {
-            server.dataPATCH(request, apiPath, yangPatchBody);
+    @NonNullByDefault
+    private PreparedRequest prepareDataPost(final URI targetUri, final HttpHeaders headers,
+            final @Nullable Principal principal, final String path, final MessageEncoding content) {
+        if (path.isEmpty()) {
+            return new PendingDataCreate(invariants, targetUri, principal, content);
         }
+
+        final var accept = chooseOutputEncoding(headers);
+        return accept == null ? NOT_ACCEPTABLE_DATA
+            : requiredApiPath(path,
+                apiPath -> new PendingDataPost(invariants, targetUri, principal, content, accept, apiPath));
     }
 
-    private static HttpResponseStatus patchResponseStatus(final PatchStatusContext statusContext,
-        final ErrorTagMapping errorTagMapping) {
-        if (statusContext.ok()) {
-            return HttpResponseStatus.OK;
-        }
-        final var globalErrors = statusContext.globalErrors();
-        if (globalErrors != null && !globalErrors.isEmpty()) {
-            return responseStatus(globalErrors.getFirst().tag(), errorTagMapping);
-        }
-        for (var edit : statusContext.editCollection()) {
-            if (!edit.isOk()) {
-                final var editErrors = edit.getEditErrors();
-                if (editErrors != null && !editErrors.isEmpty()) {
-                    return responseStatus(editErrors.getFirst().tag(), errorTagMapping);
-                }
-            }
-        }
-        return HttpResponseStatus.INTERNAL_SERVER_ERROR;
+    // create or replace target resource -> https://www.rfc-editor.org/rfc/rfc8040#section-4.5
+    @NonNullByDefault
+    private PreparedRequest prepareDataPut(final URI targetUri, final HttpHeaders headers,
+            final @Nullable Principal principal, final String path) {
+        return switch (chooseInputEncoding(headers)) {
+            case UNRECOGNIZED, UNSPECIFIED -> UNSUPPORTED_MEDIA_TYPE_DATA;
+            case NOT_PRESENT -> prepareDataPut(targetUri, principal, path, invariants.defaultEncoding());
+            case JSON -> prepareDataPut(targetUri, principal, path, MessageEncoding.JSON);
+            case XML -> prepareDataPut(targetUri, principal, path, MessageEncoding.XML);
+        };
     }
 
-    private void deleteData(final RequestParameters params, final RestconfRequest callback, final ApiPath apiPath) {
-        server.dataDELETE(new NettyServerRequest<>(params, callback) {
-            @Override
-            FullHttpResponse transform(final Empty result) {
-                return simpleResponse(requestParams, HttpResponseStatus.NO_CONTENT);
-            }
-        }, apiPath);
+    @NonNullByDefault
+    private PreparedRequest prepareDataPut(final URI targetUri, final @Nullable Principal principal, final String path,
+            final MessageEncoding encoding) {
+        return optionalApiPath(path,
+            apiPath -> new PendingDataPut(invariants, targetUri, principal, encoding, apiPath));
     }
 
     /**
-     * Process a request to
+     * Prepare a request to
      * <a href="https://www.rfc-editor.org/rfc/rfc8040#section-3.3.2">RFC 8040 {+restconf}/operations</a> resource.
      */
-    private void processOperationsRequest(final RequestParameters params, final RestconfRequest callback) {
-        final var apiPath = extractApiPath(params);
-        switch (params.method()) {
-            case OPTIONS -> {
-                if (apiPath.isEmpty()) {
-                    callback.onSuccess(OptionsServerRequest.withoutPatch(params.protocolVersion(),
-                        "GET, HEAD, OPTIONS"));
-                } else {
-                    server.operationsOPTIONS(new OptionsServerRequest(params, callback), apiPath);
-                }
-            }
-            case HEAD, GET -> getOperations(params, callback, apiPath);
-            case POST -> {
-                if (NettyMediaTypes.RESTCONF_TYPES.contains(params.contentType())) {
-                    // invoke rpc -> https://www.rfc-editor.org/rfc/rfc8040#section-4.4.2
-                    postOperations(params, callback, apiPath);
-                } else {
-                    callback.onSuccess(unsupportedMediaTypeErrorResponse(params));
-                }
-            }
-            default -> callback.onSuccess(unmappedRequestErrorResponse(params));
-        }
+    @NonNullByDefault
+    private PreparedRequest prepareOperations(final ImplementedMethod method, final URI targetUri,
+            final HttpHeaders headers, final @Nullable Principal principal, final String path) {
+        return switch (method) {
+            case GET -> prepareOperationsGet(targetUri, headers, principal, path, true);
+            case HEAD -> prepareOperationsGet(targetUri, headers, principal, path, false);
+            case OPTIONS -> prepareOperationsOptions(targetUri, principal, path);
+            case POST -> prepareOperationsPost(targetUri, headers, principal, path);
+            default -> prepareOperationsDefault(targetUri, path);
+        };
     }
 
-    private void getOperations(final RequestParameters params, final RestconfRequest callback, final ApiPath apiPath) {
-        final var request = new FormattableServerRequest(params, callback);
-        if (apiPath.isEmpty()) {
-            server.operationsGET(request);
-        } else {
-            server.operationsGET(request, apiPath);
-        }
+    @NonNullByDefault
+    private PreparedRequest prepareOperationsGet(final URI targetUri, final HttpHeaders headers,
+            final @Nullable Principal principal, final String path, final boolean withContent) {
+        final var encoding = chooseOutputEncoding(headers);
+        return encoding == null ? NOT_ACCEPTABLE_DATA : optionalApiPath(path,
+            apiPath -> new PendingOperationsGet(invariants, targetUri, principal, encoding, apiPath, withContent));
     }
 
-    private void postOperations(final RequestParameters params, final RestconfRequest callback,
-            final ApiPath apiPath) {
-        server.operationsPOST(new NettyServerRequest<>(params, callback) {
-            @Override
-            FullHttpResponse transform(final InvokeResult result) {
-                final var output = result.output();
-                return output == null ? simpleResponse(requestParams, HttpResponseStatus.NO_CONTENT)
-                    : responseBuilder(requestParams, HttpResponseStatus.OK).setBody(output).build();
-            }
-        }, params.restconfURI(), apiPath,
-            requestBody(params, JsonOperationInputBody::new, XmlOperationInputBody::new));
+    @NonNullByDefault
+    private PreparedRequest prepareOperationsOptions(final URI targetUri, final @Nullable Principal principal,
+            final String path) {
+        return path.isEmpty() ? AbstractPendingOptions.READ_ONLY
+            : requiredApiPath(path, apiPath -> new PendingOperationsOptions(invariants, targetUri, principal, apiPath));
+    }
+
+    // invoke rpc -> https://www.rfc-editor.org/rfc/rfc8040#section-4.4.2
+    @NonNullByDefault
+    private PreparedRequest prepareOperationsPost(final URI targetUri, final HttpHeaders headers,
+            final @Nullable Principal principal, final String path) {
+        final var accept = chooseOutputEncoding(headers);
+        return accept == null ? NOT_ACCEPTABLE_DATA : switch (chooseInputEncoding(headers)) {
+            case NOT_PRESENT ->
+                prepareOperationsPost(targetUri, principal, path, invariants.defaultEncoding(), accept);
+            case JSON -> prepareOperationsPost(targetUri, principal, path, MessageEncoding.JSON, accept);
+            case XML -> prepareOperationsPost(targetUri, principal, path, MessageEncoding.XML, accept);
+            case UNRECOGNIZED, UNSPECIFIED -> UNSUPPORTED_MEDIA_TYPE_DATA;
+        };
+    }
+
+    @NonNullByDefault
+    private PreparedRequest prepareOperationsPost(final URI targetUri, final @Nullable Principal principal,
+            final String path, final MessageEncoding content, final MessageEncoding accept) {
+        return optionalApiPath(path,
+            apiPath -> new PendingOperationsPost(invariants, targetUri, principal, content, accept, apiPath));
+    }
+
+    @NonNullByDefault
+    private static PreparedRequest prepareOperationsDefault(final URI targetUri, final String path) {
+        return path.isEmpty() ? METHOD_NOT_ALLOWED_READ_ONLY
+            // TODO: This is incomplete. We are always reporting 405 Method Not Allowed, but we can do better.
+            //       We should fire off an OPTIONS request for the apiPath and see if it exists: if it does not,
+            //       we should report a 404 Not Found instead.
+            : requiredApiPath(path, apiPath -> METHOD_NOT_ALLOWED_RPC);
     }
 
     /**
-     * Process a request to
+     * Prepare a request to
      * <a href="https://www.rfc-editor.org/rfc/rfc8040#section-3.3.3">{+restconf}/yang-library-version</a> resource.
      */
-    private void processYangLibraryVersion(final RequestParameters params, final RestconfRequest callback) {
-        switch (params.method()) {
-            case OPTIONS -> callback.onSuccess(optionsResponse(params, "GET, HEAD, OPTIONS"));
-            case HEAD, GET -> server.yangLibraryVersionGET(new FormattableServerRequest(params, callback));
-            default -> callback.onSuccess(unmappedRequestErrorResponse(params));
-        }
+    @NonNullByDefault
+    private PreparedRequest prepareYangLibraryVersion(final ImplementedMethod method, final URI targetUri,
+            final HttpHeaders headers, final @Nullable Principal principal, final String path) {
+        return !path.isEmpty() ? NOT_FOUND : switch (method) {
+            case GET -> prepareYangLibraryVersionGet(targetUri, headers, principal, true);
+            case HEAD -> prepareYangLibraryVersionGet(targetUri, headers, principal, false);
+            case OPTIONS -> AbstractPendingOptions.READ_ONLY;
+            default -> METHOD_NOT_ALLOWED_READ_ONLY;
+        };
+    }
+
+    @NonNullByDefault
+    private PreparedRequest prepareYangLibraryVersionGet(final URI targetUri, final HttpHeaders headers,
+            final @Nullable Principal principal, final boolean withContent) {
+        final var encoding = chooseOutputEncoding(headers);
+        return encoding == null ? UNSUPPORTED_MEDIA_TYPE_DATA
+            : new PendingYangLibraryVersionGet(invariants, targetUri, principal, encoding, withContent);
     }
 
     /**
      * Access to YANG modules.
      */
-    private void processModules(final RequestParameters params, final RestconfRequest callback) {
-        switch (params.method()) {
-            case OPTIONS -> callback.onSuccess(optionsResponse(params, "GET, HEAD, OPTIONS"));
-            case HEAD, GET -> getModule(params, callback);
-            default -> callback.onSuccess(unmappedRequestErrorResponse(params));
-        }
+    @NonNullByDefault
+    private PreparedRequest prepareModules(final ImplementedMethod method, final URI targetUri,
+            final HttpHeaders headers, final @Nullable Principal principal, final String path) {
+        return switch (method) {
+            case GET -> prepareModulesGet(targetUri, headers, principal, path, true);
+            case HEAD -> prepareModulesGet(targetUri, headers, principal, path, false);
+            case OPTIONS -> AbstractPendingOptions.READ_ONLY;
+            default -> METHOD_NOT_ALLOWED_READ_ONLY;
+        };
     }
 
-    private void getModule(final RequestParameters params, final RestconfRequest callback) {
-        final var rawPath = params.remainingRawPath();
-        if (rawPath.isEmpty()) {
-            callback.onSuccess(simpleErrorResponse(params, ErrorTag.MISSING_ELEMENT, MISSING_FILENAME_ERROR));
-            return;
+    @NonNullByDefault
+    private PreparedRequest prepareModulesGet(final URI targetUri, final HttpHeaders headers,
+            final @Nullable Principal principal, final String path, final boolean withContent) {
+        if (path.isEmpty()) {
+            return NOT_FOUND;
         }
 
-        final var file = extractModuleFile(rawPath.substring(1));
-        final var revision = params.queryParameters().lookup(REVISION);
-        if (file.name().isEmpty()) {
-            callback.onSuccess(simpleErrorResponse(params, ErrorTag.MISSING_ELEMENT, MISSING_FILENAME_ERROR));
-            return;
-        }
-        final var acceptYang = params.requestHeaders()
-            .contains(HttpHeaderNames.ACCEPT, NettyMediaTypes.APPLICATION_YANG, true);
-        final var acceptYin = params.requestHeaders()
-            .contains(HttpHeaderNames.ACCEPT, NettyMediaTypes.APPLICATION_YIN_XML, true);
-        if (acceptYin && !acceptYang) {
-            // YIN if explicitly requested
-            final var request = getModuleRequest(params, callback, NettyMediaTypes.APPLICATION_YIN_XML);
-            if (file.mountPath.isEmpty()) {
-                server.modulesYinGET(request, file.name(), revision);
-            } else {
-                server.modulesYinGET(request, file.mountPath(), file.name(), revision);
+        // optional mountPath followed by file name separated by slash
+        final var str = path.substring(1);
+        final var lastSlash = str.lastIndexOf('/');
+        final ApiPath mountPath;
+        final String fileName;
+        if (lastSlash != -1) {
+            final var mountString = str.substring(0, lastSlash);
+            try {
+                mountPath = ApiPath.parse(mountString);
+            } catch (ParseException e) {
+                return badApiPath(mountString, e);
             }
+            fileName = str.substring(lastSlash + 1);
         } else {
-            // YANG by default, incl accept any
-            final var request = getModuleRequest(params, callback, NettyMediaTypes.APPLICATION_YANG);
-            if (file.mountPath.isEmpty()) {
-                server.modulesYangGET(request, file.name(), revision);
-            } else {
-                server.modulesYangGET(request, file.mountPath(), file.name(), revision);
-            }
+            mountPath = ApiPath.empty();
+            fileName = str;
         }
-    }
 
-    private static ServerRequest<ModulesGetResult> getModuleRequest(final RequestParameters params,
-            final RestconfRequest callback, final AsciiString mediaType) {
-        return new NettyServerRequest<>(params, callback) {
-            @Override
-            FullHttpResponse transform(final ModulesGetResult result) {
-                final byte[] bytes;
-                try {
-                    bytes = result.source().asByteSource(StandardCharsets.UTF_8).read();
-                } catch (IOException e) {
-                    throw new ServerErrorException(ErrorTag.OPERATION_FAILED,
-                        SOURCE_READ_FAILURE_ERROR + e.getMessage(), e);
-                }
-                return simpleResponse(requestParams, HttpResponseStatus.OK, mediaType, bytes);
-            }
-        };
-    }
-
-    private static ModuleFile extractModuleFile(final String path) {
-        // optional mountPath followed by file name separated by slash
-        final var lastIndex = path.length() - 1;
-        final var splitIndex = path.lastIndexOf('/');
-        if (splitIndex < 0) {
-            return new ModuleFile(ApiPath.empty(), QueryStringDecoder.decodeComponent(path));
+        if (fileName.isEmpty()) {
+            return NOT_FOUND;
         }
-        final var apiPath = extractApiPath(path.substring(0, splitIndex));
-        final var name = splitIndex == lastIndex ? "" : path.substring(splitIndex + 1);
-        return new ModuleFile(apiPath, QueryStringDecoder.decodeComponent(name));
+
+        // YIN if explicitly requested
+        // YANG by default, incl accept any
+        // FIXME: we should use client's preferences
+        final var doYin = headers.contains(HttpHeaderNames.ACCEPT, NettyMediaTypes.APPLICATION_YIN_XML, true)
+            && !headers.contains(HttpHeaderNames.ACCEPT, NettyMediaTypes.APPLICATION_YANG, true);
+        final var decoded = QueryStringDecoder.decodeComponent(fileName);
+
+        return doYin ? new PendingModulesGetYin(invariants, targetUri, principal, mountPath, decoded)
+            : new PendingModulesGetYang(invariants, targetUri, principal, mountPath, decoded);
     }
 
-    private static ApiPath extractApiPath(final RequestParameters params) {
-        final var str = params.remainingRawPath();
-        return str.isEmpty() ? ApiPath.empty() : extractApiPath(str.substring(1));
+    @NonNullByDefault
+    private static PreparedRequest optionalApiPath(final String path, final Function<ApiPath, PreparedRequest> func) {
+        return path.isEmpty() ? func.apply(ApiPath.empty()) : requiredApiPath(path, func);
     }
 
-    private static ApiPath extractApiPath(final String path) {
+    @NonNullByDefault
+    private static PreparedRequest requiredApiPath(final String path, final Function<ApiPath, PreparedRequest> func) {
+        final ApiPath apiPath;
+        final var str = path.substring(1);
         try {
-            return ApiPath.parse(path);
+            apiPath = ApiPath.parse(str);
         } catch (ParseException e) {
-            throw new ServerErrorException(ErrorTag.BAD_ELEMENT,
-                "API Path value '%s' is invalid. %s".formatted(path, e.getMessage()), e);
+            return badApiPath(str, e);
         }
+        return func.apply(apiPath);
+    }
+
+    private static @NonNull CompletedRequest badApiPath(final String path, final ParseException cause) {
+        LOG.debug("Failed to parse API path", cause);
+        return new DefaultCompletedRequest(HttpResponseStatus.BAD_REQUEST, null,
+            ByteBufUtil.writeUtf8(UnpooledByteBufAllocator.DEFAULT,
+                "Bad request path '%s': '%s'".formatted(path, cause.getMessage())));
     }
 
-    private static <T extends ConsumableBody> T requestBody(final RequestParameters params,
-            final Function<InputStream, T> jsonBodyBuilder, final Function<InputStream, T> xmlBodyBuilder) {
-        return NettyMediaTypes.JSON_TYPES.contains(params.contentType())
-            ? jsonBodyBuilder.apply(params.requestBody()) : xmlBodyBuilder.apply(params.requestBody());
+    @NonNullByDefault
+    private static RequestBodyHandling chooseInputEncoding(final HttpHeaders headers) {
+        if (!headers.contains(HttpHeaderNames.CONTENT_LENGTH) && !headers.contains(HttpHeaderNames.TRANSFER_ENCODING)) {
+            return RequestBodyHandling.NOT_PRESENT;
+        }
+        final var contentType = headers.get(HttpHeaderNames.CONTENT_TYPE);
+        if (contentType == null) {
+            // No Content-Type
+            return RequestBodyHandling.UNSPECIFIED;
+        }
+        final var mimeType = HttpUtil.getMimeType(contentType);
+        if (mimeType == null) {
+            // Content-Type without a proper media type
+            return RequestBodyHandling.UNSPECIFIED;
+        }
+        final var mediaType = AsciiString.of(mimeType);
+        if (MessageEncoding.JSON.producesDataCompatibleWith(mediaType)) {
+            return RequestBodyHandling.JSON;
+        }
+        if (MessageEncoding.XML.producesDataCompatibleWith(mediaType)) {
+            return RequestBodyHandling.XML;
+        }
+        return RequestBodyHandling.UNRECOGNIZED;
     }
 
-    private static FullHttpResponse optionsResponse(final RequestParameters params, final String allowHeaderValue) {
-        final var response = new DefaultFullHttpResponse(params.protocolVersion(), HttpResponseStatus.OK,
-            Unpooled.EMPTY_BUFFER);
-        response.headers().set(HttpHeaderNames.ALLOW, allowHeaderValue);
-        return response;
+    private @Nullable MessageEncoding chooseOutputEncoding(final HttpHeaders headers) {
+        final var acceptValues = headers.getAll(HttpHeaderNames.ACCEPT);
+        if (acceptValues.isEmpty()) {
+            return invariants.defaultEncoding();
+        }
+
+        for (var acceptValue : acceptValues) {
+            final var encoding = matchEncoding(acceptValue);
+            if (encoding != null) {
+                return encoding;
+            }
+        }
+        return null;
     }
 
-    private record ModuleFile(ApiPath mountPath, String name) {
+    // FIXME: this algorithm is quite naive and ignores https://www.rfc-editor.org/rfc/rfc9110#name-accept, i.e.
+    //        it does not handle wildcards at all.
+    //        furthermore it completely ignores https://www.rfc-editor.org/rfc/rfc9110#name-quality-values, i.e.
+    //        it does not consider client-supplied weights during media type selection AND it treats q=0 as an
+    //        inclusion of a media type rather than its exclusion
+    private static @Nullable MessageEncoding matchEncoding(final String acceptValue) {
+        final var mimeType = HttpUtil.getMimeType(acceptValue);
+        if (mimeType != null) {
+            final var mediaType = AsciiString.of(mimeType);
+            for (var encoding : MessageEncoding.values()) {
+                if (encoding.producesDataCompatibleWith(mediaType)) {
+                    return encoding;
+                }
+            }
+        }
+        return null;
     }
 }
index 4f2fffbed194a573750cc837a4ae468d1520c26b..0e80e2f06cd88d07de5983213371fea3de2fdc55 100644 (file)
@@ -39,6 +39,20 @@ import org.slf4j.LoggerFactory;
  * as glue between a Netty channel and a RESTCONF server and may be servicing one (HTTP/1.1) or more (HTTP/2) logical
  * connections.
  */
+// FIXME: HTTP/1.1 and HTTP/2 behave differently w.r.t. incoming requests and their servicing:
+//        1. HTTP/1.1 uses pipelining, therefore:
+//           - we cannot halt incoming pipeline
+//           - we may run request prepare() while a request is executing in the background, but
+//           - we MUST send responses out in the same order we have received them
+//           - SSE GET turns the session into a sender, i.e. no new requests can be processed
+//        2. HTTP/2 uses concurrent execution, therefore
+//           - we need to track which streams are alive and support terminating pruning requests when client resets
+//             a stream
+//           - SSE is nothing special
+//           - we have Http2Settings, which has framesize -- which we should use when streaming responses
+//        We support HTTP/1.1 -> HTTP/2 upgrade for the first request only -- hence we know before processing the first
+//        result which mode of operation is effective. We probably need to have two subclasses of this thing, with
+//        HTTP/1.1 and HTTP/2 specializations.
 final class RestconfSession extends SimpleChannelInboundHandler<FullHttpRequest> implements TransportSession {
     private static final Logger LOG = LoggerFactory.getLogger(RestconfSession.class);
     private static final AsciiString STREAM_ID = ExtensionHeaderNames.STREAM_ID.text();
@@ -161,19 +175,37 @@ final class RestconfSession extends SimpleChannelInboundHandler<FullHttpRequest>
             // Well-known resources are immediately available and are trivial to service
             msg.release();
             respond(ctx, streamId, wellKnown.request(version, method, peeler));
-        } else if (segment.equals(dispatcher.firstSegment())) {
-            dispatcher.dispatch(method, targetUri, peeler, msg, new RestconfRequest() {
-                @Override
-                public void onSuccess(final FullHttpResponse response) {
-                    msg.release();
-                    respond(ctx, streamId, response);
-                }
-            });
-        } else {
+            return;
+        }
+        if (!segment.equals(dispatcher.firstSegment())) {
+            // Does not match the dispatcher -- we are done now
             LOG.debug("No resource for {}", requestUri);
             msg.release();
             respond(ctx, streamId, new DefaultFullHttpResponse(version, HttpResponseStatus.NOT_FOUND));
+            return;
         }
+
+        // FIXME: NETCONF-1379: first part of integration here:
+        //        - invoke dispatcher.prepare() from here first
+        //        - handle CompletedRequest to synchronous dispatch just like the above two cases, as it is that simple
+
+        dispatcher.dispatch(peeler, method, targetUri, msg, new RestconfRequest() {
+            @Override
+            public void onSuccess(final FullHttpResponse response) {
+                msg.release();
+                respond(ctx, streamId, response);
+            }
+        });
+
+        // FIXME: NETCONF-1379: second part of integration here:
+        //        - we will have PendingRequest<?>, which is the asynchronous invocation
+        //        - add a new field to track them:
+        //          ConcurrentMap<PendingRequest<?>, RequestContext> executingRequests;
+        //        - RequestContext is a DTO that holds streamId, ctx, msg (maybe) and perhaps some more state as needed
+        //        - this class implements PendingRequestListener:
+        //          - when request{Completed,Failed} is invoked, perform executingRequests.remove(req) to get
+        //            the corresponding RequestContext
+        //          - use that to call respond() with a formatted response (for now)
     }
 
     @VisibleForTesting
diff --git a/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/ServerErrorException.java b/protocol/restconf-server/src/main/java/org/opendaylight/restconf/server/ServerErrorException.java
deleted file mode 100644 (file)
index 01377c3..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * 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;
-
-import org.opendaylight.yangtools.yang.common.ErrorTag;
-
-final class ServerErrorException extends RuntimeException {
-    @java.io.Serial
-    private static final long serialVersionUID = 1L;
-
-    private final ErrorTag errorTag;
-
-    ServerErrorException(final ErrorTag errorTag, final String message) {
-        super(message);
-        this.errorTag = errorTag;
-    }
-
-    ServerErrorException(final ErrorTag errorTag, final String message, final Throwable cause) {
-        super(message, cause);
-        this.errorTag = errorTag;
-    }
-
-    ErrorTag errorTag() {
-        return errorTag;
-    }
-}
index c3cfd2a983c21cfa5530bbb595a8872d22d6c140..c5a66d0dddb0af523ff800e8306c0e761a9be8d2 100644 (file)
@@ -82,7 +82,7 @@ public class AbstractRequestProcessorTest {
         final var peeler = new SegmentPeeler(targetUri);
         assertEquals("rests", peeler.next());
         final var nettyMethod = request.method();
-        dispatcher.dispatch(switch (nettyMethod.name()) {
+        dispatcher.dispatch(peeler, switch (nettyMethod.name()) {
             case "DELETE" -> ImplementedMethod.DELETE;
             case "GET" -> ImplementedMethod.GET;
             case "OPTIONS" -> ImplementedMethod.OPTIONS;
@@ -90,7 +90,7 @@ public class AbstractRequestProcessorTest {
             case "POST" -> ImplementedMethod.POST;
             case "PUT" -> ImplementedMethod.PUT;
             default -> throw new AssertionError("Unhandled method " + nettyMethod);
-        }, targetUri, peeler, request, callback);
+        }, targetUri, request, callback);
         verify(callback).onSuccess(responseCaptor.capture());
         return responseCaptor.getValue();
     }
index e9b3d6926b1702c105c370e2273563cbd1b46102..8b38b3ea8b27236af7e83d63860fcef63adfcd39 100644 (file)
@@ -104,8 +104,13 @@ class DataRequestProcessorTest extends AbstractRequestProcessorTest {
         assertResponseHeaders(response, Map.of(
             HttpHeaderNames.ALLOW, "GET, HEAD, OPTIONS, PATCH, POST, PUT",
             HttpHeaderNames.ACCEPT_PATCH, """
-                application/json, application/xml, application/yang-data+json, application/yang-data+xml, \
-                application/yang-patch+json, application/yang-patch+xml, text/xml"""));
+                application/yang-data+json, \
+                application/yang-data+xml, \
+                application/yang-patch+json, \
+                application/yang-patch+xml, \
+                application/json, \
+                application/xml, \
+                text/xml"""));
     }
 
     @Test
@@ -138,8 +143,13 @@ class DataRequestProcessorTest extends AbstractRequestProcessorTest {
         assertResponseHeaders(response, Map.of(
             HttpHeaderNames.ALLOW, "DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT",
             HttpHeaderNames.ACCEPT_PATCH, """
-                application/json, application/xml, application/yang-data+json, application/yang-data+xml, \
-                application/yang-patch+json, application/yang-patch+xml, text/xml"""));
+                application/yang-data+json, \
+                application/yang-data+xml, \
+                application/yang-patch+json, \
+                application/yang-patch+xml, \
+                application/json, \
+                application/xml, \
+                text/xml"""));
     }
 
     @Test
@@ -218,21 +228,6 @@ class DataRequestProcessorTest extends AbstractRequestProcessorTest {
         assertResponseHeaders(response, Map.of(HttpHeaderNames.LOCATION, PATH_CREATED));
     }
 
-    @ParameterizedTest
-    @MethodSource("encodings")
-    void postDataRootRpc(final TestEncoding encoding, final String content) throws Exception {
-        final var invokeResult = new InvokeResult(formattableBody(encoding, content));
-        final var answer = new FuglyRestconfServerAnswer(
-            encoding.isJson() ? JsonChildBody.class : XmlChildBody.class, 1, invokeResult);
-        doAnswer(answer).when(server).dataPOST(any(), any(ChildBody.class));
-
-        final var request = buildRequest(HttpMethod.POST, DATA_PATH, encoding, content);
-        final var response = dispatch(request);
-        answer.assertContent(content);
-
-        assertResponse(response, HttpResponseStatus.OK);
-    }
-
     @ParameterizedTest
     @MethodSource("encodings")
     void postDataWithIdRpc(final TestEncoding encoding, final String content) throws Exception {
index ff163ad040ce5498a9cefc4beebfe073247fd05b..ad1b097474e5c1d03060f688eb8a230e57542d75 100644 (file)
@@ -7,18 +7,14 @@
  */
 package org.opendaylight.restconf.server;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doThrow;
 import static org.opendaylight.restconf.server.PathParameters.MODULES;
 import static org.opendaylight.restconf.server.PathParameters.OPERATIONS;
 import static org.opendaylight.restconf.server.PathParameters.YANG_LIBRARY_VERSION;
-import static org.opendaylight.restconf.server.ResponseUtils.ENCODING_RESPONSE_ERROR;
-import static org.opendaylight.restconf.server.ResponseUtils.UNMAPPED_REQUEST_ERROR;
-import static org.opendaylight.restconf.server.ResponseUtils.UNSUPPORTED_MEDIA_TYPE_ERROR;
 import static org.opendaylight.restconf.server.TestUtils.answerCompleteWith;
-import static org.opendaylight.restconf.server.TestUtils.assertErrorContent;
-import static org.opendaylight.restconf.server.TestUtils.assertErrorResponse;
 import static org.opendaylight.restconf.server.TestUtils.assertResponse;
 import static org.opendaylight.restconf.server.TestUtils.assertResponseHeaders;
 import static org.opendaylight.restconf.server.TestUtils.buildRequest;
@@ -30,6 +26,7 @@ import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.util.Map;
 import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
@@ -37,7 +34,6 @@ import org.mockito.Mock;
 import org.opendaylight.restconf.api.FormattableBody;
 import org.opendaylight.restconf.server.TestUtils.TestEncoding;
 import org.opendaylight.restconf.server.api.DataGetResult;
-import org.opendaylight.yangtools.yang.common.ErrorTag;
 
 class ErrorHandlerTest extends AbstractRequestProcessorTest {
     private static final String OPERATIONS_PATH = BASE_PATH + OPERATIONS;
@@ -64,14 +60,14 @@ class ErrorHandlerTest extends AbstractRequestProcessorTest {
 
     @ParameterizedTest
     @MethodSource
-    void unmappedRequest(final TestEncoding encoding, final HttpMethod method, final String uri) {
+    void methodNotAllowed(final TestEncoding encoding, final HttpMethod method, final String uri) {
         final var response = dispatch(buildRequest(method, uri, encoding, CONTENT));
-        assertErrorResponse(response, encoding, ErrorTag.DATA_MISSING, UNMAPPED_REQUEST_ERROR);
+        assertEquals(HttpResponseStatus.METHOD_NOT_ALLOWED, response.status());
+        assertResponseHeaders(response, Map.of(HttpHeaderNames.ALLOW, "GET, HEAD, OPTIONS"));
     }
 
-    private static Stream<Arguments> unmappedRequest() {
+    private static Stream<Arguments> methodNotAllowed() {
         return Stream.of(
-            // no processor matching api resource
             // valid URI, unsupported HTTP method (1 per URI used)
             Arguments.of(TestEncoding.XML, HttpMethod.PUT, OPERATIONS_PATH),
             Arguments.of(TestEncoding.XML, HttpMethod.POST, BASE_PATH + YANG_LIBRARY_VERSION),
@@ -85,21 +81,39 @@ class ErrorHandlerTest extends AbstractRequestProcessorTest {
         final var request = buildRequest(method, uri, encoding, CONTENT);
         request.headers().remove(HttpHeaderNames.CONTENT_TYPE);
         final var response = dispatch(request);
-        final var content = response.content().toString(StandardCharsets.UTF_8);
         assertResponse(response, HttpResponseStatus.UNSUPPORTED_MEDIA_TYPE);
-        assertResponseHeaders(response, Map.of(HttpHeaderNames.CONTENT_TYPE, encoding.responseType));
-        assertErrorContent(content, encoding, ErrorTag.INVALID_VALUE, UNSUPPORTED_MEDIA_TYPE_ERROR);
+        assertResponseHeaders(response, Map.of(HttpHeaderNames.ACCEPT, """
+            application/yang-data+json, \
+            application/yang-data+xml, \
+            application/json, \
+            application/xml, \
+            text/xml"""));
     }
 
     private static Stream<Arguments> unsupportedMediaType() {
         return Stream.of(
             Arguments.of(TestEncoding.XML, HttpMethod.POST, DATA_PATH),
             Arguments.of(TestEncoding.JSON, HttpMethod.PUT, DATA_PATH),
-            Arguments.of(TestEncoding.XML, HttpMethod.PATCH, DATA_PATH),
             Arguments.of(TestEncoding.JSON, HttpMethod.POST, OPERATIONS_WITH_ID_PATH)
         );
     }
 
+    @Test
+    void unsupportedMediaTypePatch() {
+        final var request = buildRequest(HttpMethod.PATCH, DATA_PATH, TestEncoding.XML, CONTENT);
+        request.headers().remove(HttpHeaderNames.CONTENT_TYPE);
+        final var response = dispatch(request);
+        assertResponse(response, HttpResponseStatus.UNSUPPORTED_MEDIA_TYPE);
+        assertResponseHeaders(response, Map.of(HttpHeaderNames.ACCEPT, """
+            application/yang-data+json, \
+            application/yang-data+xml, \
+            application/yang-patch+json, \
+            application/yang-patch+xml, \
+            application/json, \
+            application/xml, \
+            text/xml"""));
+    }
+
     @ParameterizedTest
     @MethodSource("encodings")
     void runtimeException(final TestEncoding encoding) {
@@ -107,7 +121,8 @@ class ErrorHandlerTest extends AbstractRequestProcessorTest {
         doThrow(new IllegalStateException(errorMessage)).when(server).dataGET(any());
         final var request = buildRequest(HttpMethod.GET, DATA_PATH, encoding, null);
         final var response = dispatch(request);
-        assertErrorResponse(response, encoding, ErrorTag.OPERATION_FAILED, errorMessage);
+        assertEquals(HttpResponseStatus.INTERNAL_SERVER_ERROR, response.status());
+        assertEquals("runtime-error", response.content().toString(StandardCharsets.UTF_8));
     }
 
     @ParameterizedTest
@@ -115,7 +130,9 @@ class ErrorHandlerTest extends AbstractRequestProcessorTest {
     void apiPathParseFailure(final TestEncoding encoding) {
         final var request = buildRequest(HttpMethod.GET, DATA_PATH + "/-invalid", encoding, null);
         final var response = dispatch(request);
-        assertErrorResponse(response, encoding, ErrorTag.BAD_ELEMENT, "API Path");
+        assertEquals(HttpResponseStatus.BAD_REQUEST, response.status());
+        assertEquals("Bad request path '-invalid': 'Expecting [a-zA-Z_], not '-''",
+            response.content().toString(StandardCharsets.UTF_8));
     }
 
     @ParameterizedTest
@@ -134,6 +151,7 @@ class ErrorHandlerTest extends AbstractRequestProcessorTest {
 
         final var request = buildRequest(HttpMethod.GET, DATA_PATH, encoding, null);
         final var response = dispatch(request);
-        assertErrorResponse(response, encoding, ErrorTag.OPERATION_FAILED, ENCODING_RESPONSE_ERROR + errorMessage);
+        assertEquals(HttpResponseStatus.INTERNAL_SERVER_ERROR, response.status());
+        assertEquals("encoding-error", response.content().toString(StandardCharsets.UTF_8));
     }
 }
index b69d3d4aa87f0f1cfa6faa0d8002d1b0690fc03d..28221780353e05dc129a509ba4fee33e0addd468 100644 (file)
@@ -7,6 +7,7 @@
  */
 package org.opendaylight.restconf.server;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.isNull;
@@ -15,11 +16,7 @@ import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.doThrow;
 import static org.opendaylight.restconf.server.PathParameters.MODULES;
 import static org.opendaylight.restconf.server.PathParameters.YANG_LIBRARY_VERSION;
-import static org.opendaylight.restconf.server.RestconfRequestDispatcher.MISSING_FILENAME_ERROR;
-import static org.opendaylight.restconf.server.RestconfRequestDispatcher.REVISION;
-import static org.opendaylight.restconf.server.RestconfRequestDispatcher.SOURCE_READ_FAILURE_ERROR;
 import static org.opendaylight.restconf.server.TestUtils.answerCompleteWith;
-import static org.opendaylight.restconf.server.TestUtils.assertErrorResponse;
 import static org.opendaylight.restconf.server.TestUtils.assertOptionsResponse;
 import static org.opendaylight.restconf.server.TestUtils.assertResponse;
 import static org.opendaylight.restconf.server.TestUtils.buildRequest;
@@ -33,6 +30,8 @@ import io.netty.handler.codec.http.HttpMethod;
 import io.netty.handler.codec.http.HttpResponseStatus;
 import io.netty.handler.codec.http.HttpVersion;
 import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
 import java.util.stream.Stream;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
@@ -41,7 +40,6 @@ import org.junit.jupiter.params.provider.ValueSource;
 import org.mockito.Mock;
 import org.opendaylight.restconf.server.TestUtils.TestEncoding;
 import org.opendaylight.restconf.server.api.ModulesGetResult;
-import org.opendaylight.yangtools.yang.common.ErrorTag;
 
 class ModulesRequestProcessorTest extends AbstractRequestProcessorTest {
     private static final String YANG_LIBRARY_VERSION_URI = BASE_PATH + YANG_LIBRARY_VERSION;
@@ -50,7 +48,7 @@ class ModulesRequestProcessorTest extends AbstractRequestProcessorTest {
     private static final String MODULE_URI = MODULES_PATH + MODULE_FILENAME;
     private static final String MODULE_URI_WITH_MOUNT = MODULES_PATH + MOUNT_PATH + "/" + MODULE_FILENAME;
     private static final String REVISION_VALUE = "revision-value";
-    private static final String REVISION_PARAM = "?" + REVISION + "=" + REVISION_VALUE;
+    private static final String REVISION_PARAM = "?revision=" + REVISION_VALUE;
     private static final String YANG_CONTENT = "yang-content";
     private static final String YIN_CONTENT = "yin-content";
 
@@ -127,7 +125,7 @@ class ModulesRequestProcessorTest extends AbstractRequestProcessorTest {
     void noFilenameError(final TestEncoding encoding, final TestEncoding errorEncoding) {
         final var request = buildRequest(HttpMethod.GET, MODULES_PATH, encoding, null);
         final var response = dispatch(request);
-        assertErrorResponse(response, errorEncoding, ErrorTag.MISSING_ELEMENT, MISSING_FILENAME_ERROR);
+        assertEquals(HttpResponseStatus.NOT_FOUND, response.status());
     }
 
     @ParameterizedTest
@@ -135,7 +133,7 @@ class ModulesRequestProcessorTest extends AbstractRequestProcessorTest {
     void sourceReadFailure(final TestEncoding encoding, final TestEncoding errorEncoding) throws IOException {
         final var errorMessage = "source-read-failure";
         doReturn(byteSource).when(source).asByteSource(any());
-        doThrow(new IOException(errorMessage)).when(byteSource).read();
+        doThrow(new IOException(errorMessage)).when(byteSource).copyTo(any(OutputStream.class));
 
         final var result = new ModulesGetResult(source);
         if (encoding.isYin()) {
@@ -146,8 +144,8 @@ class ModulesRequestProcessorTest extends AbstractRequestProcessorTest {
 
         final var request = buildRequest(HttpMethod.GET, MODULE_URI, encoding, null);
         final var response = dispatch(request);
-        assertErrorResponse(response, errorEncoding, ErrorTag.OPERATION_FAILED,
-            SOURCE_READ_FAILURE_ERROR + errorMessage);
+        assertEquals(HttpResponseStatus.INTERNAL_SERVER_ERROR, response.status());
+        assertEquals("source-read-failure", response.content().toString(StandardCharsets.UTF_8));
     }
 
     private static Stream<Arguments> moduleErrorEncodings() {
index 2b2caed0576d62004faabad2033306eb8e3b4c9f..61051de2aeba47eddf3cddc2c93808f1c520542b 100644 (file)
@@ -10,23 +10,20 @@ package org.opendaylight.restconf.server;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.opendaylight.restconf.server.NettyMediaTypes.APPLICATION_JSON;
-import static org.opendaylight.restconf.server.NettyMediaTypes.APPLICATION_XML;
 import static org.opendaylight.restconf.server.NettyMediaTypes.APPLICATION_YANG;
 import static org.opendaylight.restconf.server.NettyMediaTypes.APPLICATION_YANG_DATA_JSON;
 import static org.opendaylight.restconf.server.NettyMediaTypes.APPLICATION_YANG_DATA_XML;
 import static org.opendaylight.restconf.server.NettyMediaTypes.APPLICATION_YANG_PATCH_JSON;
 import static org.opendaylight.restconf.server.NettyMediaTypes.APPLICATION_YANG_PATCH_XML;
 import static org.opendaylight.restconf.server.NettyMediaTypes.APPLICATION_YIN_XML;
-import static org.opendaylight.restconf.server.NettyMediaTypes.JSON_TYPES;
 
 import com.google.common.base.MoreObjects;
 import com.google.common.io.CharSource;
-import io.netty.buffer.Unpooled;
 import io.netty.handler.codec.http.DefaultFullHttpRequest;
 import io.netty.handler.codec.http.FullHttpRequest;
 import io.netty.handler.codec.http.FullHttpResponse;
 import io.netty.handler.codec.http.HttpHeaderNames;
+import io.netty.handler.codec.http.HttpHeaderValues;
 import io.netty.handler.codec.http.HttpMethod;
 import io.netty.handler.codec.http.HttpResponseStatus;
 import io.netty.handler.codec.http.HttpVersion;
@@ -38,6 +35,7 @@ import java.io.StringReader;
 import java.nio.charset.StandardCharsets;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import org.mockito.stubbing.Answer;
 import org.opendaylight.restconf.api.FormattableBody;
 import org.opendaylight.restconf.api.query.PrettyPrintParam;
@@ -55,25 +53,26 @@ final class TestUtils {
         // hidden on purpose
     }
 
-    @SuppressWarnings("unchecked")
-    static <T> Answer<Void> answerCompleteWith(T result) {
+    static <T> Answer<Void> answerCompleteWith(final T result) {
         return invocation -> {
             // server request is always first arg in RestconfServer
-            final var serverRequest =  (ServerRequest<T>) invocation.getArgument(0);
-            serverRequest.completeWith(result);
+            invocation.<ServerRequest<T>>getArgument(0).completeWith(result);
             return null;
         };
     }
 
     static FullHttpRequest buildRequest(final HttpMethod method, final String uri, final TestEncoding encoding,
             final String content) {
-        final var contentBuf = content == null ? Unpooled.EMPTY_BUFFER
-            : Unpooled.wrappedBuffer(content.getBytes(StandardCharsets.UTF_8));
-        final var request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, method, uri, contentBuf);
-        if (method != HttpMethod.GET) {
-            request.headers().set(HttpHeaderNames.CONTENT_TYPE, encoding.requestType);
+        final var request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, method, uri);
+        final var headers = request.headers();
+        if (!HttpMethod.GET.equals(method)) {
+            final var buf = request.content();
+            buf.writeBytes(content.getBytes(StandardCharsets.UTF_8));
+            headers
+                .set(HttpHeaderNames.CONTENT_TYPE, encoding.requestType)
+                .setInt(HttpHeaderNames.CONTENT_LENGTH, buf.readableBytes());
         }
-        request.headers().set(HttpHeaderNames.ACCEPT, encoding.responseType);
+        headers.set(HttpHeaderNames.ACCEPT, encoding.responseType);
         return request;
     }
 
@@ -91,7 +90,7 @@ final class TestUtils {
             }
 
             @Override
-            protected MoreObjects.ToStringHelper addToStringAttributes(MoreObjects.ToStringHelper helper) {
+            protected MoreObjects.ToStringHelper addToStringAttributes(final MoreObjects.ToStringHelper helper) {
                 return helper;
             }
         };
@@ -129,7 +128,7 @@ final class TestUtils {
         }
     }
 
-    static void assertErrorResponse(final FullHttpResponse response, TestEncoding encoding,
+    static void assertErrorResponse(final FullHttpResponse response, final TestEncoding encoding,
             final ErrorTag expectedErrorTag, final String expectedMessage) {
         assertEquals(ERROR_TAG_MAPPING.statusOf(expectedErrorTag).code(), response.status().code());
         assertResponseHeaders(response, Map.of(HttpHeaderNames.CONTENT_TYPE, encoding.responseType));
@@ -163,17 +162,21 @@ final class TestUtils {
     }
 
     enum TestEncoding {
-        XML(APPLICATION_XML, APPLICATION_YANG_DATA_XML),
-        JSON(APPLICATION_JSON, APPLICATION_YANG_DATA_JSON),
+        XML(HttpHeaderValues.APPLICATION_XML, APPLICATION_YANG_DATA_XML),
+        JSON(HttpHeaderValues.APPLICATION_JSON, APPLICATION_YANG_DATA_JSON),
         YANG_PATCH_XML(APPLICATION_YANG_PATCH_XML, APPLICATION_YANG_DATA_XML),
         YANG_PATCH_JSON(APPLICATION_YANG_PATCH_JSON, APPLICATION_YANG_DATA_JSON),
         YANG(null, APPLICATION_YANG),
         YIN(null, APPLICATION_YIN_XML);
 
+        private static final Set<AsciiString> JSON_TYPES = Set.of(
+            NettyMediaTypes.APPLICATION_YANG_DATA_JSON, NettyMediaTypes.APPLICATION_YANG_PATCH_JSON,
+            HttpHeaderValues.APPLICATION_JSON);
+
         AsciiString requestType;
         AsciiString responseType;
 
-        TestEncoding(AsciiString requestType, AsciiString responseType) {
+        TestEncoding(final AsciiString requestType, final AsciiString responseType) {
             this.requestType = requestType;
             this.responseType = responseType;
         }
index 7773accc316e831d2cc1fcd38230dd048c8d8bb1..f6fdb72d5f9af088c65d6965296491bb6e08cfa4 100644 (file)
@@ -12,6 +12,7 @@ import static org.opendaylight.restconf.server.TestUtils.assertResponse;
 import static org.opendaylight.restconf.server.TestUtils.assertResponseHeaders;
 
 import io.netty.handler.codec.http.HttpHeaderNames;
+import io.netty.handler.codec.http.HttpHeaderValues;
 import io.netty.handler.codec.http.HttpResponseStatus;
 import io.netty.handler.codec.http.HttpVersion;
 import io.netty.util.AsciiString;
@@ -50,7 +51,7 @@ class WellKnownResourcesTest {
                 <XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
                     <Link rel="restconf" href="testRestconf"/>
                 </XRD>"""),
-            Arguments.of(JRD_SUFFIX, NettyMediaTypes.APPLICATION_JSON, """
+            Arguments.of(JRD_SUFFIX, HttpHeaderValues.APPLICATION_JSON, """
                 {
                     "links" : {
                         "rel" : "restconf",