Define a replacement for Restconf{Error,Exception,Future} 63/111363/8
authorRobert Varga <robert.varga@pantheon.tech>
Thu, 11 Apr 2024 23:33:24 +0000 (01:33 +0200)
committerRobert Varga <nite@hq.sk>
Sun, 14 Apr 2024 07:08:48 +0000 (07:08 +0000)
Server-side API is opinionated about how it reports errors. This patch
defines:
- ErrorMessage to hold the RFC6241 definition of what is an error
  message
- ServerError to replace RestconfError
- ServerErrorInfo to hold the error-info element
- ServerErrorPath to hold the error-path element
- ServerException to replace RestconfDocumentedException

ServerException has a more logical API, holding only a single error and
performing explicit mapping when instantiated with just a Throwable
cause. This mode of operation maps:
- IllegalArgumentException to ErrorTag.INVALID_VALUE
- UnsupportedOperationException to ErrorTag.OPERATION_NOT_SUPPORTED

JIRA: NETCONF-1188
Change-Id: I07e7a9a56e4e02c699ead96bed1c115136767c4c
Signed-off-by: Robert Varga <robert.varga@pantheon.tech>
protocol/restconf-api/src/main/java/org/opendaylight/restconf/api/ErrorMessage.java [new file with mode: 0644]
restconf/restconf-common/src/main/java/org/opendaylight/restconf/common/errors/RestconfDocumentedException.java
restconf/restconf-common/src/main/java/org/opendaylight/restconf/common/errors/RestconfError.java
restconf/restconf-nb/src/main/java/org/opendaylight/restconf/server/api/DatabindPath.java
restconf/restconf-nb/src/main/java/org/opendaylight/restconf/server/api/ServerError.java [new file with mode: 0644]
restconf/restconf-nb/src/main/java/org/opendaylight/restconf/server/api/ServerErrorInfo.java [new file with mode: 0644]
restconf/restconf-nb/src/main/java/org/opendaylight/restconf/server/api/ServerErrorPath.java [new file with mode: 0644]
restconf/restconf-nb/src/main/java/org/opendaylight/restconf/server/api/ServerException.java [new file with mode: 0644]
restconf/restconf-nb/src/main/java/org/opendaylight/restconf/server/api/package-info.java
restconf/restconf-nb/src/test/java/org/opendaylight/restconf/server/api/ServerExceptionTest.java [new file with mode: 0644]

diff --git a/protocol/restconf-api/src/main/java/org/opendaylight/restconf/api/ErrorMessage.java b/protocol/restconf-api/src/main/java/org/opendaylight/restconf/api/ErrorMessage.java
new file mode 100644 (file)
index 0000000..19d18ec
--- /dev/null
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2024 PANTHEON.tech, s.r.o. and others.  All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.restconf.api;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.base.MoreObjects;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The contents of a {@code error-message} element as defined in
+ * <a href="https://www.rfc-editor.org/rfc/rfc8040#page-83">RFC8040 errors grouping</a>. This object can optionally
+ * transport the <a href="https://www.w3.org/TR/xml/#sec-lang-tag">Language Identification</a> as conveyed via,
+ * for example, <a href="https://www.rfc-editor.org/rfc/rfc6241#page-17">RFC6241 error-message element</a>.
+ *
+ * @param elementBody the string to be displayed
+ * @param xmlLang optional Language Identification string
+ */
+@NonNullByDefault
+// TODO: consider sharing this class with netconf-api's NetconfDocumentedException
+public record ErrorMessage(String elementBody, @Nullable String xmlLang) {
+    public ErrorMessage {
+        requireNonNull(elementBody);
+    }
+
+    public ErrorMessage(final String elementBody) {
+        this(elementBody, null);
+    }
+
+    @Override
+    public String toString() {
+        return MoreObjects.toStringHelper(this).omitNullValues()
+            .add("elementBody", elementBody)
+            .add("xmlLang", xmlLang)
+            .toString();
+    }
+}
\ No newline at end of file
index 6abb2fd9950e82c90f3cac4635a8c5725968a949..64f1880cac64c22eb64e571774fbc388379bb9a1 100644 (file)
@@ -26,6 +26,7 @@ import org.opendaylight.yangtools.yang.model.api.EffectiveModelContext;
  * @author Devin Avery
  * @author Thomas Pantelis
  */
+@Deprecated
 public class RestconfDocumentedException extends RuntimeException {
     @java.io.Serial
     private static final long serialVersionUID = 3L;
index 36949fd5c94bae2915574bffb9c3da395ccbc134..61c0944c4e803671836ba720a2ef326143d6c4cb 100644 (file)
@@ -22,6 +22,7 @@ import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier;
  *
  * @author Devin Avery
  */
+@Deprecated
 public class RestconfError implements Serializable {
     @java.io.Serial
     private static final long serialVersionUID = 1L;
index 72e1fe45601289c2d322e96e35b20d0a96f27d77..481514cd125ecab8013c37d73470d325ae200a8f 100644 (file)
@@ -132,9 +132,18 @@ public sealed interface DatabindPath extends DatabindAware {
          * Returns the {@link YangInstanceIdentifier} of the instance being referenced.
          *
          * @return the {@link YangInstanceIdentifier} of the instance being referenced,
-         *         {@link YangInstanceIdentifier#empty()} denotes the datastora
+         *         {@link YangInstanceIdentifier#empty()} denotes the data root
          */
         YangInstanceIdentifier instance();
+
+        /**
+         * Returns this reference as a {@link ServerErrorPath}.
+         *
+         * @return this reference as a {@link ServerErrorPath}
+         */
+        default ServerErrorPath toErrorPath() {
+            return new ServerErrorPath(databind(), instance());
+        }
     }
 
     /**
diff --git a/restconf/restconf-nb/src/main/java/org/opendaylight/restconf/server/api/ServerError.java b/restconf/restconf-nb/src/main/java/org/opendaylight/restconf/server/api/ServerError.java
new file mode 100644 (file)
index 0000000..8f3ede0
--- /dev/null
@@ -0,0 +1,59 @@
+/*
+ * 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.api;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.base.MoreObjects;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.api.ErrorMessage;
+import org.opendaylight.yangtools.yang.common.ErrorTag;
+import org.opendaylight.yangtools.yang.common.ErrorType;
+
+/**
+ * Encapsulates a single {@code error} within the
+ * <a href="https://www.rfc-editor.org/rfc/rfc8040#section-3.9">"errors" YANG Data Template</a> as bound to a particular
+ * {@link DatabindContext}.
+ *
+ * @param type value of {@code error-type} leaf
+ * @param tag value of {@code error-tag} leaf
+ * @param message value of {@code error-message} leaf, potentially with metadata
+ * @param appTag value of {@code error-api-tag} leaf
+ * @param path optional {@code error-path} leaf
+ * @param info optional content of {@code error-info} anydata
+ */
+@NonNullByDefault
+public record ServerError(
+        ErrorType type,
+        ErrorTag tag,
+        @Nullable ErrorMessage message,
+        @Nullable String appTag,
+        @Nullable ServerErrorPath path,
+        @Nullable ServerErrorInfo info) {
+    public ServerError {
+        requireNonNull(type);
+        requireNonNull(tag);
+    }
+
+    public ServerError(final ErrorType type, final ErrorTag tag, final String message) {
+        this(type, tag, new ErrorMessage(message), null, null, null);
+    }
+
+    @Override
+    public String toString() {
+        return MoreObjects.toStringHelper(this).omitNullValues()
+            .add("type", type)
+            .add("tag", tag)
+            .add("appTag", appTag)
+            .add("message", message)
+            .add("path", path)
+            .add("info", info)
+            .toString();
+    }
+}
diff --git a/restconf/restconf-nb/src/main/java/org/opendaylight/restconf/server/api/ServerErrorInfo.java b/restconf/restconf-nb/src/main/java/org/opendaylight/restconf/server/api/ServerErrorInfo.java
new file mode 100644 (file)
index 0000000..fed4481
--- /dev/null
@@ -0,0 +1,27 @@
+/*
+ * 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.api;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.annotations.Beta;
+
+/**
+ * The content of {@code error-info} {@code anydata}.
+ */
+// FIXME: String here is legacy coming from RestconfError. This really should be a FormattableBody or similar, i.e.
+//        structured content which itself is formattable -- unlike FormattableBody, though, it needs to be defined as
+//        being formatted to a output. This format should include writing.
+// FIXME: given that the normalized-node-based FormattableBody lives in server.spi, this should probably be an interface
+//        implemented in at server.spi level.
+@Beta
+public record ServerErrorInfo(String value) {
+    public ServerErrorInfo {
+        requireNonNull(value);
+    }
+}
diff --git a/restconf/restconf-nb/src/main/java/org/opendaylight/restconf/server/api/ServerErrorPath.java b/restconf/restconf-nb/src/main/java/org/opendaylight/restconf/server/api/ServerErrorPath.java
new file mode 100644 (file)
index 0000000..b1be1e5
--- /dev/null
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2024 PANTHEON.tech, s.r.o. and others.  All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.restconf.server.api;
+
+import static java.util.Objects.requireNonNull;
+
+import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier;
+
+/**
+ * An {@code error-path} element in a {@link ServerError}.
+ *
+ * @param databind the {@link DatabindContext} to which this path is bound
+ * @param path the {@link YangInstanceIdentifier}, {@link YangInstanceIdentifier#empty()} denotes the data root
+ */
+public record ServerErrorPath(DatabindContext databind, YangInstanceIdentifier path) {
+    public ServerErrorPath {
+        requireNonNull(databind);
+        requireNonNull(path);
+    }
+}
\ No newline at end of file
diff --git a/restconf/restconf-nb/src/main/java/org/opendaylight/restconf/server/api/ServerException.java b/restconf/restconf-nb/src/main/java/org/opendaylight/restconf/server/api/ServerException.java
new file mode 100644 (file)
index 0000000..7e43412
--- /dev/null
@@ -0,0 +1,107 @@
+/*
+ * 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.api;
+
+import static java.util.Objects.requireNonNull;
+
+import java.io.IOException;
+import java.io.NotSerializableException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.ObjectStreamException;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.opendaylight.restconf.api.ErrorMessage;
+import org.opendaylight.yangtools.yang.common.ErrorTag;
+import org.opendaylight.yangtools.yang.common.ErrorType;
+
+/**
+ * A server-side processing exception, reporting a single {@link ServerError}. This exception is not serializable on
+ * purpose.
+ */
+@NonNullByDefault
+public final class ServerException extends Exception {
+    @java.io.Serial
+    private static final long serialVersionUID = 0L;
+
+    private final ServerError error;
+
+    private ServerException(final String message, final ServerError error, final @Nullable Throwable cause) {
+        super(message, cause);
+        this.error = requireNonNull(error);
+    }
+
+    public ServerException(final String message) {
+        this(ErrorType.APPLICATION, ErrorTag.OPERATION_FAILED, requireNonNull(message));
+    }
+
+    public ServerException(final String format, final Object @Nullable ... args) {
+        this(ErrorType.APPLICATION, ErrorTag.OPERATION_FAILED, format, args);
+    }
+
+    public ServerException(final Throwable cause) {
+        this(ErrorType.APPLICATION, errorTagOf(cause), cause);
+    }
+
+    public ServerException(final String message, final @Nullable Throwable cause) {
+        this(ErrorType.APPLICATION, errorTagOf(cause), requireNonNull(message), cause);
+    }
+
+    public ServerException(final ErrorType type, final ErrorTag tag, final String message) {
+        this(type, tag, message, (Throwable) null);
+    }
+
+    public ServerException(final ErrorType type, final ErrorTag tag, final Throwable cause) {
+        this(cause.toString(), new ServerError(type, tag, new ErrorMessage(cause.getMessage()), null, null, null),
+            cause);
+    }
+
+    public ServerException(final ErrorType type, final ErrorTag tag, final String message,
+            final @Nullable Throwable cause) {
+        this(requireNonNull(message), new ServerError(type, tag, message), cause);
+    }
+
+    public ServerException(final ErrorType type, final ErrorTag tag, final String format,
+            final Object @Nullable ... args) {
+        this(type, tag, format.formatted(args));
+    }
+
+    /**
+     * Return the reported {@link ServerError}.
+     *
+     * @return the reported {@link ServerError}
+     */
+    public ServerError error() {
+        return error;
+    }
+
+    @java.io.Serial
+    private void readObjectNoData() throws ObjectStreamException {
+        throw new NotSerializableException();
+    }
+
+    @java.io.Serial
+    private void readObject(final ObjectInputStream stream) throws IOException, ClassNotFoundException {
+        throw new NotSerializableException();
+    }
+
+    @java.io.Serial
+    private void writeObject(final ObjectOutputStream stream) throws IOException {
+        throw new NotSerializableException();
+    }
+
+    private static ErrorTag errorTagOf(final @Nullable Throwable cause) {
+        if (cause instanceof UnsupportedOperationException) {
+            return ErrorTag.OPERATION_NOT_SUPPORTED;
+        } else if (cause instanceof IllegalArgumentException) {
+            return ErrorTag.INVALID_VALUE;
+        } else {
+            return ErrorTag.OPERATION_FAILED;
+        }
+    }
+}
index d31d477278b30e73f07f8d79bfa2c030a30c8a9d..655d0add8d81cde73b1027a1875e044f71f5b411 100644 (file)
@@ -6,6 +6,8 @@
  * and is available at http://www.eclipse.org/legal/epl-v10.html
  */
 /**
- * Interface to a RESTCONF server instance. The primary entry point is {@link RestconfServer}.
+ * Interface to a RESTCONF server instance. The primary entry point is {@link RestconfServer}, which is typically given
+ * a {@link ServerRequest} coupled with an {@link org.opendaylight.restconf.api.ApiPath} and perhaps a
+ * {@link RequestBody}.
  */
 package org.opendaylight.restconf.server.api;
\ No newline at end of file
diff --git a/restconf/restconf-nb/src/test/java/org/opendaylight/restconf/server/api/ServerExceptionTest.java b/restconf/restconf-nb/src/test/java/org/opendaylight/restconf/server/api/ServerExceptionTest.java
new file mode 100644 (file)
index 0000000..7229afa
--- /dev/null
@@ -0,0 +1,90 @@
+/*
+ * 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.api;
+
+import static org.junit.Assert.assertSame;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import org.junit.jupiter.api.Test;
+import org.opendaylight.yangtools.yang.common.ErrorTag;
+import org.opendaylight.yangtools.yang.common.ErrorType;
+
+class ServerExceptionTest {
+    @Test
+    void stringConstructor() {
+        final var ex = new ServerException("some message");
+        assertEquals("some message", ex.getMessage());
+        assertNull(ex.getCause());
+        assertEquals(new ServerError(ErrorType.APPLICATION, ErrorTag.OPERATION_FAILED, "some message"), ex.error());
+    }
+
+    @Test
+    void causeConstructor() {
+        final var cause = new Throwable("cause message");
+        final var ex = new ServerException(cause);
+        assertEquals("java.lang.Throwable: cause message", ex.getMessage());
+        assertSame(cause, ex.getCause());
+        assertEquals(new ServerError(ErrorType.APPLICATION, ErrorTag.OPERATION_FAILED, "cause message"), ex.error());
+    }
+
+    @Test
+    void causeConstructorIAE() {
+        final var cause = new IllegalArgumentException("cause message");
+        final var ex = new ServerException(cause);
+        assertEquals("java.lang.IllegalArgumentException: cause message", ex.getMessage());
+        assertSame(cause, ex.getCause());
+        assertEquals(new ServerError(ErrorType.APPLICATION, ErrorTag.INVALID_VALUE, "cause message"), ex.error());
+    }
+
+    @Test
+    void causeConstructorUOE() {
+        final var cause = new UnsupportedOperationException("cause message");
+        final var ex = new ServerException(cause);
+        assertEquals("java.lang.UnsupportedOperationException: cause message", ex.getMessage());
+        assertSame(cause, ex.getCause());
+        assertEquals(new ServerError(ErrorType.APPLICATION, ErrorTag.OPERATION_NOT_SUPPORTED, "cause message"),
+            ex.error());
+    }
+
+    @Test
+    void messageCauseConstructor() {
+        final var cause = new Throwable("cause message");
+        final var ex = new ServerException("some message", cause);
+        assertEquals("some message", ex.getMessage());
+        assertSame(cause, ex.getCause());
+        assertEquals(new ServerError(ErrorType.APPLICATION, ErrorTag.OPERATION_FAILED, "some message"), ex.error());
+    }
+
+    @Test
+    void messageCauseConstructorIAE() {
+        final var cause = new IllegalArgumentException("cause message");
+        final var ex = new ServerException("some message", cause);
+        assertEquals("some message", ex.getMessage());
+        assertSame(cause, ex.getCause());
+        assertEquals(new ServerError(ErrorType.APPLICATION, ErrorTag.INVALID_VALUE, "some message"), ex.error());
+    }
+
+    @Test
+    void messageCauseConstructorUOE() {
+        final var cause = new UnsupportedOperationException("cause message");
+        final var ex = new ServerException("some message", cause);
+        assertEquals("some message", ex.getMessage());
+        assertSame(cause, ex.getCause());
+        assertEquals(new ServerError(ErrorType.APPLICATION, ErrorTag.OPERATION_NOT_SUPPORTED, "some message"),
+            ex.error());
+    }
+
+    @Test
+    void formatConstructor() {
+        final var ex = new ServerException("huh %s: %s", 1, "hah");
+        assertEquals("huh 1: hah", ex.getMessage());
+        assertNull(ex.getCause());
+        assertEquals(new ServerError(ErrorType.APPLICATION, ErrorTag.OPERATION_FAILED, "huh 1: hah"), ex.error());
+    }
+}