--- /dev/null
+/*
+ * 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());
+ }
+}
--- /dev/null
+/*
+ * 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);
+ }
+}
--- /dev/null
+/*
+ * 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();
+}
--- /dev/null
+/*
+ * 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);
+ }
+}
--- /dev/null
+/*
+ * 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;
+ }
+}
--- /dev/null
+/*
+ * 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);
+ }
+}
--- /dev/null
+/*
+ * 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);
+}
--- /dev/null
+/*
+ * 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;
+ }
+}
--- /dev/null
+/*
+ * 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);
+ }
+}
--- /dev/null
+/*
+ * 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);
+ }
+}
+++ /dev/null
-/*
- * 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();
- }
-}
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;
/**
* <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;
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
*/
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.
*
*/
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
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);
}
+++ /dev/null
-/*
- * 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;
- }
-}
--- /dev/null
+/*
+ * 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);
+ };
+ }
+
+}
--- /dev/null
+/*
+ * 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;
+ }
+}
--- /dev/null
+/*
+ * 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());
+ }
+}
--- /dev/null
+/*
+ * 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);
+ }
+ }
+}
--- /dev/null
+/*
+ * 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));
+ }
+}
--- /dev/null
+/*
+ * 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());
+ }
+}
--- /dev/null
+/*
+ * 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);
+ };
+ }
+}
--- /dev/null
+/*
+ * 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));
+ }
+}
--- /dev/null
+/*
+ * 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;
+ }
+}
--- /dev/null
+/*
+ * 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;
+ }
+}
--- /dev/null
+/*
+ * 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);
+ }
+ }
+}
--- /dev/null
+/*
+ * 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);
+ }
+}
--- /dev/null
+/*
+ * 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);
+ };
+ }
+}
--- /dev/null
+/*
+ * 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);
+}
--- /dev/null
+/*
+ * 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);
+}
--- /dev/null
+/*
+ * 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);
+ }
+}
--- /dev/null
+/*
+ * 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());
+ }
+}
--- /dev/null
+/*
+ * 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);
+ }
+}
--- /dev/null
+/*
+ * 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);
+ };
+ }
+}
--- /dev/null
+/*
+ * 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);
+ }
+}
--- /dev/null
+/*
+ * 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
+}
--- /dev/null
+/*
+ * 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;
+}
+++ /dev/null
-/*
- * 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;
- }
-}
--- /dev/null
+/*
+ * 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
+}
+++ /dev/null
-/*
- * 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();
- }
-}
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);
}
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());
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;
}
}
// 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;
}
}
* 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();
// 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
+++ /dev/null
-/*
- * 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;
- }
-}
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;
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();
}
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
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
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 {
*/
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;
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;
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;
@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),
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) {
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
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
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));
}
}
*/
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;
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;
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;
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;
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";
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
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()) {
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() {
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;
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;
// 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;
}
}
@Override
- protected MoreObjects.ToStringHelper addToStringAttributes(MoreObjects.ToStringHelper helper) {
+ protected MoreObjects.ToStringHelper addToStringAttributes(final MoreObjects.ToStringHelper helper) {
return helper;
}
};
}
}
- 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));
}
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;
}
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;
<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",