Added ExceptionMapper to RFC-8040 impl 89/83789/2
authorJaroslav Tóth <jtoth@frinx.io>
Thu, 8 Aug 2019 15:01:13 +0000 (17:01 +0200)
committerRobert Varga <nite@hq.sk>
Tue, 20 Aug 2019 13:22:47 +0000 (13:22 +0000)
It is similar to draft-02 implementation but with several diffs
that had to be made because of compliancy against specification,
different revisions of QNames, and need for refactoring:
- Response must have yang-data+json or yang-data+xml types, the
  parsing of request content type and acceptable media types was
  adjusted accordingly.
- Schema context was used as feed to NormalizedNode builders -
  this approach is useless.
- Anonymous classes used for special serializaton with disabled
  validation of error-info leaf were moved to distinct classes
  (they share some lines of code, so the abstract parent class
  is added).
- Added error-path leaf to serialized JSON/XML data (it was in
  todo tag for unknown reasons).
- Next small fixes and refactoring of code by extraction of
  methods.
- Unit test was written from scratch, because of changes done
  to parsing of status code and media type.

Change-Id: I9c17229e8df1b382a37ca588a506128040c6d42f
Signed-off-by: Jaroslav Tóth <jtoth@frinx.io>
Signed-off-by: Robert Varga <robert.varga@pantheon.tech>
(cherry picked from commit 1b6442243f1ad2107c764a7f61d0bca9e21864c1)

restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/RestconfApplication.java
restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/Rfc8040.java
restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/jersey/providers/errors/JsonStreamWriterWithDisabledValidation.java [new file with mode: 0644]
restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/jersey/providers/errors/RestconfDocumentedExceptionMapper.java [new file with mode: 0644]
restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/jersey/providers/errors/StreamWriterWithDisabledValidation.java [new file with mode: 0644]
restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/jersey/providers/errors/XmlStreamWriterWithDisabledValidation.java [new file with mode: 0644]
restconf/restconf-nb-rfc8040/src/test/java/org/opendaylight/restconf/nb/rfc8040/jersey/providers/errors/RestconfDocumentedExceptionMapperTest.java [new file with mode: 0644]

index d5acb749254bbdf1f986d28a6d3826e69505fa6e..36528b8b824889461555b99aa703997ea1ac4d67 100644 (file)
@@ -19,6 +19,7 @@ import org.opendaylight.restconf.nb.rfc8040.jersey.providers.JsonNormalizedNodeB
 import org.opendaylight.restconf.nb.rfc8040.jersey.providers.NormalizedNodeJsonBodyWriter;
 import org.opendaylight.restconf.nb.rfc8040.jersey.providers.NormalizedNodeXmlBodyWriter;
 import org.opendaylight.restconf.nb.rfc8040.jersey.providers.XmlNormalizedNodeBodyReader;
+import org.opendaylight.restconf.nb.rfc8040.jersey.providers.errors.RestconfDocumentedExceptionMapper;
 import org.opendaylight.restconf.nb.rfc8040.jersey.providers.patch.JsonToPatchBodyReader;
 import org.opendaylight.restconf.nb.rfc8040.jersey.providers.patch.PatchJsonBodyWriter;
 import org.opendaylight.restconf.nb.rfc8040.jersey.providers.patch.PatchXmlBodyWriter;
@@ -59,6 +60,7 @@ public class RestconfApplication extends Application {
         singletons.add(new JsonToPatchBodyReader(schemaContextHandler, mountPointServiceHandler));
         singletons.add(new XmlNormalizedNodeBodyReader(schemaContextHandler, mountPointServiceHandler));
         singletons.add(new XmlToPatchBodyReader(schemaContextHandler, mountPointServiceHandler));
+        singletons.add(new RestconfDocumentedExceptionMapper(schemaContextHandler));
         return singletons;
     }
 }
index 8de2d24da9a5469e3d436129a4916f47539f2646..0d055c43a9453cb3a8ca6b86fa05f71acc1a4021 100644 (file)
@@ -82,11 +82,13 @@ public final class Rfc8040 {
         public static final String ERRORS_CONTAINER_SCHEMA_NODE = "errors";
         public static final String ERROR_LIST_SCHEMA_NODE = "error";
 
+        public static final QName ERRORS_GROUPING_QNAME =
+                QName.create(IETF_RESTCONF_QNAME, ERRORS_GROUPING_SCHEMA_NODE).intern();
         public static final QName ERRORS_CONTAINER_QNAME =
-                QName.create(IETF_RESTCONF_QNAME, ERRORS_CONTAINER_SCHEMA_NODE);
+                QName.create(IETF_RESTCONF_QNAME, ERRORS_CONTAINER_SCHEMA_NODE).intern();
         public static final QName ERROR_LIST_QNAME = QName.create(IETF_RESTCONF_QNAME, ERROR_LIST_SCHEMA_NODE).intern();
         public static final QName ERROR_TYPE_QNAME = QName.create(IETF_RESTCONF_QNAME, "error-type").intern();
-        public static final QName ERROR_TAG_QNAME = QName.create(IETF_RESTCONF_QNAME, "error-tag".intern());
+        public static final QName ERROR_TAG_QNAME = QName.create(IETF_RESTCONF_QNAME, "error-tag").intern();
         public static final QName ERROR_APP_TAG_QNAME = QName.create(IETF_RESTCONF_QNAME, "error-app-tag").intern();
         public static final QName ERROR_MESSAGE_QNAME = QName.create(IETF_RESTCONF_QNAME, "error-message").intern();
         public static final QName ERROR_INFO_QNAME = QName.create(IETF_RESTCONF_QNAME, "error-info").intern();
diff --git a/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/jersey/providers/errors/JsonStreamWriterWithDisabledValidation.java b/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/jersey/providers/errors/JsonStreamWriterWithDisabledValidation.java
new file mode 100644 (file)
index 0000000..4b000dd
--- /dev/null
@@ -0,0 +1,74 @@
+/*
+ * Copyright © 2019 FRINX s.r.o. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.restconf.nb.rfc8040.jersey.providers.errors;
+
+import com.google.gson.stream.JsonWriter;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.net.URI;
+import org.opendaylight.restconf.nb.rfc8040.handlers.SchemaContextHandler;
+import org.opendaylight.yangtools.yang.common.QName;
+import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeIdentifier;
+import org.opendaylight.yangtools.yang.data.api.schema.NormalizedNode;
+import org.opendaylight.yangtools.yang.data.api.schema.stream.NormalizedNodeStreamWriter;
+import org.opendaylight.yangtools.yang.data.codec.gson.JSONCodecFactorySupplier;
+import org.opendaylight.yangtools.yang.data.codec.gson.JSONNormalizedNodeStreamWriter;
+import org.opendaylight.yangtools.yang.data.codec.gson.JsonWriterFactory;
+import org.opendaylight.yangtools.yang.model.api.SchemaPath;
+
+/**
+ * JSON stream-writer with disabled leaf-type validation for specified QName.
+ */
+final class JsonStreamWriterWithDisabledValidation extends StreamWriterWithDisabledValidation {
+
+    private final JsonWriter jsonWriter;
+    private final NormalizedNodeStreamWriter jsonNodeStreamWriter;
+
+    /**
+     * Creation of the custom JSON stream-writer.
+     *
+     * @param excludedQName        QName of the element that is excluded from type-check.
+     * @param outputWriter         Output stream that is used for creation of JSON writers.
+     * @param schemaPath           Schema-path of the {@link NormalizedNode} to be written.
+     * @param initialNs            Initial namespace derived from schema node of the data that are serialized.
+     * @param schemaContextHandler Handler that holds actual schema context.
+     */
+    JsonStreamWriterWithDisabledValidation(final QName excludedQName, final OutputStreamWriter outputWriter,
+            final SchemaPath schemaPath, final URI initialNs, final SchemaContextHandler schemaContextHandler) {
+        super(excludedQName);
+        this.jsonWriter = JsonWriterFactory.createJsonWriter(outputWriter);
+        this.jsonNodeStreamWriter = JSONNormalizedNodeStreamWriter.createExclusiveWriter(
+                JSONCodecFactorySupplier.RFC7951.getShared(schemaContextHandler.get()),
+                schemaPath, initialNs, jsonWriter);
+    }
+
+    @Override
+    protected NormalizedNodeStreamWriter delegate() {
+        return jsonNodeStreamWriter;
+    }
+
+    @Override
+    void startLeafNodeWithDisabledValidation(final NodeIdentifier nodeIdentifier) throws IOException {
+        jsonWriter.name(nodeIdentifier.getNodeType().getLocalName());
+    }
+
+    @Override
+    void scalarValueWithDisabledValidation(final Object value) throws IOException {
+        jsonWriter.value(value.toString());
+    }
+
+    @Override
+    void endNodeWithDisabledValidation() {
+        // nope
+    }
+
+    @Override
+    public void close() throws IOException {
+        jsonWriter.close();
+    }
+}
\ No newline at end of file
diff --git a/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/jersey/providers/errors/RestconfDocumentedExceptionMapper.java b/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/jersey/providers/errors/RestconfDocumentedExceptionMapper.java
new file mode 100644 (file)
index 0000000..edc78bf
--- /dev/null
@@ -0,0 +1,389 @@
+/*
+ * Copyright © 2019 FRINX s.r.o. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.restconf.nb.rfc8040.jersey.providers.errors;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.annotations.VisibleForTesting;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.io.UnsupportedEncodingException;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.Response.Status;
+import javax.ws.rs.ext.ExceptionMapper;
+import javax.ws.rs.ext.Provider;
+import org.opendaylight.restconf.common.errors.RestconfDocumentedException;
+import org.opendaylight.restconf.common.errors.RestconfError;
+import org.opendaylight.restconf.nb.rfc8040.Rfc8040.MediaTypes;
+import org.opendaylight.restconf.nb.rfc8040.Rfc8040.RestconfModule;
+import org.opendaylight.restconf.nb.rfc8040.handlers.SchemaContextHandler;
+import org.opendaylight.restconf.nb.rfc8040.utils.RestconfConstants;
+import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier;
+import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeIdentifier;
+import org.opendaylight.yangtools.yang.data.api.schema.ContainerNode;
+import org.opendaylight.yangtools.yang.data.api.schema.NormalizedNode;
+import org.opendaylight.yangtools.yang.data.api.schema.UnkeyedListEntryNode;
+import org.opendaylight.yangtools.yang.data.api.schema.stream.NormalizedNodeWriter;
+import org.opendaylight.yangtools.yang.data.impl.schema.ImmutableNodes;
+import org.opendaylight.yangtools.yang.data.impl.schema.builder.api.DataContainerNodeBuilder;
+import org.opendaylight.yangtools.yang.data.impl.schema.builder.impl.ImmutableContainerNodeBuilder;
+import org.opendaylight.yangtools.yang.data.impl.schema.builder.impl.ImmutableUnkeyedListEntryNodeBuilder;
+import org.opendaylight.yangtools.yang.data.impl.schema.builder.impl.ImmutableUnkeyedListNodeBuilder;
+import org.opendaylight.yangtools.yang.model.api.SchemaPath;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ *  Mapper that is responsible for transformation of thrown {@link RestconfDocumentedException} to errors structure
+ *  that is modelled by RESTCONF module (see section 8 of RFC-8040).
+ */
+@Provider
+public final class RestconfDocumentedExceptionMapper implements ExceptionMapper<RestconfDocumentedException> {
+    @VisibleForTesting
+    static final MediaType YANG_DATA_JSON_TYPE = MediaType.valueOf(MediaTypes.DATA + RestconfConstants.JSON);
+    @VisibleForTesting
+    static final MediaType YANG_DATA_XML_TYPE = MediaType.valueOf(MediaTypes.DATA + RestconfConstants.XML);
+    @VisibleForTesting
+    static final MediaType YANG_PATCH_JSON_TYPE = MediaType.valueOf(MediaTypes.PATCH + RestconfConstants.JSON);
+    @VisibleForTesting
+    static final MediaType YANG_PATCH_XML_TYPE = MediaType.valueOf(MediaTypes.PATCH + RestconfConstants.XML);
+
+    private static final Logger LOG = LoggerFactory.getLogger(RestconfDocumentedExceptionMapper.class);
+    private static final MediaType DEFAULT_MEDIA_TYPE = MediaType.APPLICATION_JSON_TYPE;
+    private static final Status DEFAULT_STATUS_CODE = Status.INTERNAL_SERVER_ERROR;
+    private static final SchemaPath ERRORS_GROUPING_PATH = SchemaPath.create(true,
+            RestconfModule.ERRORS_GROUPING_QNAME);
+
+    @Context
+    private HttpHeaders headers;
+    private final SchemaContextHandler schemaContextHandler;
+
+    /**
+     * Initialization of the exception mapper.
+     *
+     * @param schemaContextHandler Handler that provides actual schema context.
+     */
+    public RestconfDocumentedExceptionMapper(final SchemaContextHandler schemaContextHandler) {
+        this.schemaContextHandler = schemaContextHandler;
+    }
+
+    @Override
+    @SuppressFBWarnings(value = "SLF4J_MANUALLY_PROVIDED_MESSAGE", justification = "In the debug messages "
+            + "we don't to have full stack trace - getMessage(..) method provides finer output.")
+    public Response toResponse(final RestconfDocumentedException exception) {
+        LOG.debug("Starting to map received exception to error response: {}", exception.getMessage());
+        final Status responseStatus = getResponseStatusCode(exception);
+        if (responseStatus != Response.Status.FORBIDDEN
+                && responseStatus.getFamily() == Response.Status.Family.CLIENT_ERROR) {
+            // there should be at least one error entry for 4xx errors except 409 according to the RFC 8040
+            // - creation of WARN log that something went wrong way on the server side
+            LOG.warn("Input exception has a family of 4xx but doesn't contain any descriptive errors: {}",
+                    exception.getMessage());
+        }
+
+        final ContainerNode errorsContainer = buildErrorsContainer(exception);
+        final String serializedResponseBody;
+        final MediaType responseMediaType = transformToResponseMediaType(getSupportedMediaType());
+        if (YANG_DATA_JSON_TYPE.equals(responseMediaType)) {
+            serializedResponseBody = serializeErrorsContainerToJson(errorsContainer);
+        } else {
+            serializedResponseBody = serializeErrorsContainerToXml(errorsContainer);
+        }
+
+        final Response preparedResponse = Response.status(responseStatus)
+                .type(responseMediaType)
+                .entity(serializedResponseBody)
+                .build();
+        LOG.debug("Exception {} has been successfully mapped to response: {}",
+                exception.getMessage(), preparedResponse);
+        return preparedResponse;
+    }
+
+    /**
+     * Filling up of the errors container with data from input {@link RestconfDocumentedException}.
+     *
+     * @param exception Thrown exception.
+     * @return Built errors container.
+     */
+    private ContainerNode buildErrorsContainer(final RestconfDocumentedException exception) {
+        final List<UnkeyedListEntryNode> errorEntries = exception.getErrors().stream()
+                .map(this::createErrorEntry)
+                .collect(Collectors.toList());
+        return ImmutableContainerNodeBuilder.create()
+                .withNodeIdentifier(YangInstanceIdentifier.NodeIdentifier.create(
+                        RestconfModule.ERRORS_CONTAINER_QNAME))
+                .withChild(ImmutableUnkeyedListNodeBuilder.create()
+                        .withNodeIdentifier(YangInstanceIdentifier.NodeIdentifier.create(
+                                RestconfModule.ERROR_LIST_QNAME))
+                        .withValue(errorEntries)
+                        .build())
+                .build();
+    }
+
+    /**
+     * Building of one error entry using provided {@link RestconfError}.
+     *
+     * @param restconfError Error details.
+     * @return Built list entry.
+     */
+    private UnkeyedListEntryNode createErrorEntry(final RestconfError restconfError) {
+        // filling in mandatory leafs
+        final DataContainerNodeBuilder<NodeIdentifier, UnkeyedListEntryNode> entryBuilder
+                = ImmutableUnkeyedListEntryNodeBuilder.create()
+                .withNodeIdentifier(NodeIdentifier.create(RestconfModule.ERROR_LIST_QNAME))
+                .withChild(ImmutableNodes.leafNode(RestconfModule.ERROR_TYPE_QNAME,
+                        restconfError.getErrorType().getErrorTypeTag()))
+                .withChild(ImmutableNodes.leafNode(RestconfModule.ERROR_TAG_QNAME,
+                        restconfError.getErrorTag().getTagValue()));
+
+        // filling in optional fields
+        if (restconfError.getErrorMessage() != null) {
+            entryBuilder.withChild(ImmutableNodes.leafNode(
+                    RestconfModule.ERROR_MESSAGE_QNAME, restconfError.getErrorMessage()));
+        }
+        if (restconfError.getErrorAppTag() != null) {
+            entryBuilder.withChild(ImmutableNodes.leafNode(
+                    RestconfModule.ERROR_APP_TAG_QNAME, restconfError.getErrorAppTag()));
+        }
+        if (restconfError.getErrorInfo() != null) {
+            // Oddly, error-info is defined as an empty container in the restconf yang. Apparently the
+            // intention is for implementors to define their own data content so we'll just treat it as a leaf
+            // with string data.
+            entryBuilder.withChild(ImmutableNodes.leafNode(
+                    RestconfModule.ERROR_INFO_QNAME, restconfError.getErrorInfo()));
+        }
+
+        if (restconfError.getErrorPath() != null) {
+            entryBuilder.withChild(ImmutableNodes.leafNode(
+                    RestconfModule.ERROR_PATH_QNAME, restconfError.getErrorPath()));
+        }
+        return entryBuilder.build();
+    }
+
+    /**
+     * Serialization of the errors container into JSON representation.
+     *
+     * @param errorsContainer To be serialized errors container.
+     * @return JSON representation of the errors container.
+     */
+    private String serializeErrorsContainerToJson(final ContainerNode errorsContainer) {
+        try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+             OutputStreamWriter streamStreamWriter = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8);
+             StreamWriterWithDisabledValidation jsonStreamWriter = new JsonStreamWriterWithDisabledValidation(
+                     RestconfModule.ERROR_INFO_QNAME, streamStreamWriter, ERRORS_GROUPING_PATH,
+                     RestconfModule.URI_MODULE, schemaContextHandler);
+             NormalizedNodeWriter nnWriter = NormalizedNodeWriter.forStreamWriter(jsonStreamWriter)
+        ) {
+            return writeNormalizedNode(errorsContainer, outputStream, nnWriter);
+        } catch (IOException e) {
+            throw new IllegalStateException("Cannot close some of the output JSON writers", e);
+        }
+    }
+
+    /**
+     * Serialization of the errors container into XML representation.
+     *
+     * @param errorsContainer To be serialized errors container.
+     * @return XML representation of the errors container.
+     */
+    private String serializeErrorsContainerToXml(final ContainerNode errorsContainer) {
+        try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+             StreamWriterWithDisabledValidation streamWriter = new XmlStreamWriterWithDisabledValidation(
+                     RestconfModule.ERROR_INFO_QNAME, outputStream, ERRORS_GROUPING_PATH, schemaContextHandler);
+             NormalizedNodeWriter nnWriter = NormalizedNodeWriter.forStreamWriter(streamWriter)
+        ) {
+            return writeNormalizedNode(errorsContainer, outputStream, nnWriter);
+        } catch (IOException e) {
+            throw new IllegalStateException("Cannot close some of the output XML writers", e);
+        }
+    }
+
+    private static String writeNormalizedNode(final NormalizedNode<?, ?> errorsContainer,
+            final ByteArrayOutputStream outputStream, final NormalizedNodeWriter nnWriter) {
+        try {
+            nnWriter.write(errorsContainer);
+            nnWriter.flush();
+        } catch (IOException e) {
+            throw new IllegalStateException("Cannot write error response body", e);
+        }
+        try {
+            return outputStream.toString(StandardCharsets.UTF_8.name());
+        } catch (UnsupportedEncodingException e) {
+            throw new IllegalStateException("Output stream cannot be converted to string representation", e);
+        }
+    }
+
+    /**
+     * Deriving of the status code from the thrown exception. At the first step, status code is tried to be read using
+     * {@link RestconfDocumentedException#getStatus()}. If it is {@code null}, status code will be derived from status
+     * codes appended to error entries (the first that will be found). If there are not any error entries,
+     * {@link RestconfDocumentedExceptionMapper#DEFAULT_STATUS_CODE} will be used.
+     *
+     * @param exception Thrown exception.
+     * @return Derived status code.
+     */
+    private static Status getResponseStatusCode(final RestconfDocumentedException exception) {
+        final Status status = exception.getStatus();
+        if (status != null) {
+            // status code that is specified directly as field in exception has the precedence over error entries
+            return status;
+        }
+
+        final List<RestconfError> errors = exception.getErrors();
+        if (errors.isEmpty()) {
+            // if the module, that thrown exception, doesn't specify status code, it is treated as internal
+            // server error
+            return DEFAULT_STATUS_CODE;
+        }
+
+        final Set<Integer> allStatusCodesOfErrorEntries = errors.stream()
+                .map(restconfError -> restconfError.getErrorTag().getStatusCode())
+                // we would like to preserve iteration order in collected entries - hence usage of LinkedHashSet
+                .collect(Collectors.toCollection(LinkedHashSet::new));
+        // choosing of the first status code from appended errors, if there are different status codes in error
+        // entries, we should create WARN message
+        if (allStatusCodesOfErrorEntries.size() > 1) {
+            LOG.warn("An unexpected error occurred during translation of exception {} to response: "
+                    + "Different status codes have been found in appended error entries: {}. The first error "
+                    + "entry status code is chosen for response.", exception, allStatusCodesOfErrorEntries);
+        }
+        return Status.fromStatusCode(allStatusCodesOfErrorEntries.iterator().next());
+    }
+
+    /**
+     * Selection of media type that will be used for creation suffix of 'application/yang-data'. Selection criteria
+     * is described in RFC 8040, section 7.1. At the first step, accepted media-type is analyzed and only supported
+     * media-types are filtered out. If both XML and JSON media-types are accepted, JSON is selected as a default one
+     * used in RESTCONF. If accepted-media type is not specified, the media-type used in request is chosen only if it
+     * is supported one. If it is not supported or it is not specified, again, the default one (JSON) is selected.
+     *
+     * @return Media type.
+     */
+    private MediaType getSupportedMediaType() {
+        final Set<MediaType> acceptableAndSupportedMediaTypes = headers.getAcceptableMediaTypes().stream()
+                .filter(RestconfDocumentedExceptionMapper::isCompatibleMediaType)
+                .collect(Collectors.toSet());
+        if (acceptableAndSupportedMediaTypes.size() == 0) {
+            // check content type of the request
+            final MediaType requestMediaType = headers.getMediaType();
+            return requestMediaType == null ? DEFAULT_MEDIA_TYPE
+                    : chooseMediaType(Collections.singletonList(requestMediaType)).orElseGet(() -> {
+                        LOG.warn("Request doesn't specify accepted media-types and the media-type '{}' used by "
+                                + "request is not supported - using of default '{}' media-type.",
+                                requestMediaType, DEFAULT_MEDIA_TYPE);
+                        return DEFAULT_MEDIA_TYPE;
+                    });
+        }
+
+        // at first step, fully specified types without any wildcards are considered (for example, application/json)
+        final List<MediaType> fullySpecifiedMediaTypes = acceptableAndSupportedMediaTypes.stream()
+                .filter(mediaType -> !mediaType.isWildcardType() && !mediaType.isWildcardSubtype())
+                .collect(Collectors.toList());
+        if (!fullySpecifiedMediaTypes.isEmpty()) {
+            return chooseAndCheckMediaType(fullySpecifiedMediaTypes);
+        }
+
+        // at the second step, only types with specified subtype are considered (for example, */json)
+        final List<MediaType> mediaTypesWithSpecifiedSubtypes = acceptableAndSupportedMediaTypes.stream()
+                .filter(mediaType -> !mediaType.isWildcardSubtype())
+                .collect(Collectors.toList());
+        if (!mediaTypesWithSpecifiedSubtypes.isEmpty()) {
+            return chooseAndCheckMediaType(mediaTypesWithSpecifiedSubtypes);
+        }
+
+        // at the third step, only types with specified parent are considered (for example, application/*)
+        final List<MediaType> mediaTypesWithSpecifiedParent = acceptableAndSupportedMediaTypes.stream()
+                .filter(mediaType -> !mediaType.isWildcardType())
+                .collect(Collectors.toList());
+        if (!mediaTypesWithSpecifiedParent.isEmpty()) {
+            return chooseAndCheckMediaType(mediaTypesWithSpecifiedParent);
+        }
+
+        // it must be fully-wildcard-ed type - */*
+        return DEFAULT_MEDIA_TYPE;
+    }
+
+    private static MediaType chooseAndCheckMediaType(final List<MediaType> options) {
+        final Optional<MediaType> mediaTypeOpt = chooseMediaType(options);
+        checkState(mediaTypeOpt.isPresent());
+        return mediaTypeOpt.get();
+    }
+
+    /**
+     * This method is responsible for choosing of he media type from multiple options. At the first step,
+     * JSON-compatible types are considered, then, if there are not any JSON types, XML types are considered. The first
+     * compatible media-type is chosen.
+     *
+     * @param options Supported media types.
+     * @return Selected one media type or {@link Optional#empty()} if none of the provided options are compatible with
+     *     RESTCONF.
+     */
+    private static Optional<MediaType> chooseMediaType(final List<MediaType> options) {
+        return options.stream()
+                .filter(RestconfDocumentedExceptionMapper::isJsonCompatibleMediaType)
+                .findFirst()
+                .map(Optional::of)
+                .orElse(options.stream()
+                        .filter(RestconfDocumentedExceptionMapper::isXmlCompatibleMediaType)
+                        .findFirst());
+    }
+
+    /**
+     * Mapping of JSON-compatible type to {@link RestconfDocumentedExceptionMapper#YANG_DATA_JSON_TYPE}
+     * or XML-compatible type to {@link RestconfDocumentedExceptionMapper#YANG_DATA_XML_TYPE}.
+     *
+     * @param mediaTypeBase Base media type from which the response media-type is built.
+     * @return Derived media type.
+     */
+    private static MediaType transformToResponseMediaType(final MediaType mediaTypeBase) {
+        if (isJsonCompatibleMediaType(mediaTypeBase)) {
+            return YANG_DATA_JSON_TYPE;
+        } else if (isXmlCompatibleMediaType(mediaTypeBase)) {
+            return YANG_DATA_XML_TYPE;
+        } else {
+            throw new IllegalStateException(String.format("Unexpected input media-type %s "
+                    + "- it should be JSON/XML compatible type.", mediaTypeBase));
+        }
+    }
+
+    private static boolean isCompatibleMediaType(final MediaType mediaType) {
+        return isJsonCompatibleMediaType(mediaType) || isXmlCompatibleMediaType(mediaType);
+    }
+
+    private static boolean isJsonCompatibleMediaType(final MediaType mediaType) {
+        return mediaType.isCompatible(MediaType.APPLICATION_JSON_TYPE)
+                || mediaType.isCompatible(YANG_DATA_JSON_TYPE) || mediaType.isCompatible(YANG_PATCH_JSON_TYPE);
+    }
+
+    private static boolean isXmlCompatibleMediaType(final MediaType mediaType) {
+        return mediaType.isCompatible(MediaType.APPLICATION_XML_TYPE)
+                || mediaType.isCompatible(YANG_DATA_XML_TYPE) || mediaType.isCompatible(YANG_PATCH_XML_TYPE);
+    }
+
+    /**
+     * Used just for testing purposes - simulation of HTTP headers with different accepted types and content type.
+     *
+     * @param httpHeaders Mocked HTTP headers.
+     */
+    @VisibleForTesting
+    void setHttpHeaders(final HttpHeaders httpHeaders) {
+        this.headers = httpHeaders;
+    }
+}
\ No newline at end of file
diff --git a/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/jersey/providers/errors/StreamWriterWithDisabledValidation.java b/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/jersey/providers/errors/StreamWriterWithDisabledValidation.java
new file mode 100644 (file)
index 0000000..c5c6568
--- /dev/null
@@ -0,0 +1,87 @@
+/*
+ * Copyright © 2019 FRINX s.r.o. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.restconf.nb.rfc8040.jersey.providers.errors;
+
+import java.io.IOException;
+import org.opendaylight.yangtools.yang.common.QName;
+import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeIdentifier;
+import org.opendaylight.yangtools.yang.data.api.schema.NormalizedNode;
+import org.opendaylight.yangtools.yang.data.api.schema.stream.ForwardingNormalizedNodeStreamWriter;
+
+/**
+ * Created delegating writer to special-case error-info as error-info is defined as an empty container in the restconf
+ * yang schema but we create a leaf node so we can output it. The delegate stream writer validates the node type against
+ * the schema and thus will expect a LeafSchemaNode but the schema has a ContainerSchemaNode so, to avoid an error,
+ * we override the leafNode behavior for error-info.
+ */
+abstract class StreamWriterWithDisabledValidation extends ForwardingNormalizedNodeStreamWriter {
+
+    private final QName excludedQName;
+    private boolean inOurLeaf;
+
+    /**
+     * Creation of the {@link NormalizedNode} stream-writer with {@link QName} that is excluded from type-check.
+     *
+     * @param excludedQName QName of the element that is excluded from type-check.
+     */
+    StreamWriterWithDisabledValidation(final QName excludedQName) {
+        this.excludedQName = excludedQName;
+    }
+
+    @Override
+    public void startLeafNode(final NodeIdentifier name) throws IOException {
+        if (name.getNodeType().equals(excludedQName)) {
+            inOurLeaf = true;
+            startLeafNodeWithDisabledValidation(name);
+        } else {
+            super.startLeafNode(name);
+        }
+    }
+
+    /**
+     * Writing of the excluded leaf to the output stream.
+     *
+     * @param nodeIdentifier Node identifier of the leaf to be written to output stream.
+     * @throws IOException Writing of the leaf to output stream failed.
+     */
+    abstract void startLeafNodeWithDisabledValidation(NodeIdentifier nodeIdentifier) throws IOException;
+
+    @Override
+    public void scalarValue(final Object value) throws IOException {
+        if (inOurLeaf) {
+            scalarValueWithDisabledValidation(value);
+        } else {
+            super.scalarValue(value);
+        }
+    }
+
+    /**
+     * Writing of the value of the excluded leaf to the output stream.
+     *
+     * @param value Value of the excluded leaf.
+     * @throws IOException Writing of the leaf value to the output stream failed.
+     */
+    abstract void scalarValueWithDisabledValidation(Object value) throws IOException;
+
+    @Override
+    public void endNode() throws IOException {
+        if (inOurLeaf) {
+            inOurLeaf = false;
+            endNodeWithDisabledValidation();
+        } else {
+            super.endNode();
+        }
+    }
+
+    /**
+     * Writing of the end element with disabled validation.
+     *
+     * @throws IOException Writing of the end element to the output stream failed.
+     */
+    abstract void endNodeWithDisabledValidation() throws IOException;
+}
\ No newline at end of file
diff --git a/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/jersey/providers/errors/XmlStreamWriterWithDisabledValidation.java b/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/jersey/providers/errors/XmlStreamWriterWithDisabledValidation.java
new file mode 100644 (file)
index 0000000..d1c8917
--- /dev/null
@@ -0,0 +1,104 @@
+/*
+ * Copyright © 2019 FRINX s.r.o. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.restconf.nb.rfc8040.jersey.providers.errors;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import javax.xml.XMLConstants;
+import javax.xml.stream.FactoryConfigurationError;
+import javax.xml.stream.XMLOutputFactory;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamWriter;
+import org.opendaylight.restconf.nb.rfc8040.handlers.SchemaContextHandler;
+import org.opendaylight.yangtools.yang.common.QName;
+import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeIdentifier;
+import org.opendaylight.yangtools.yang.data.api.schema.NormalizedNode;
+import org.opendaylight.yangtools.yang.data.api.schema.stream.NormalizedNodeStreamWriter;
+import org.opendaylight.yangtools.yang.data.codec.xml.XMLStreamNormalizedNodeStreamWriter;
+import org.opendaylight.yangtools.yang.model.api.SchemaPath;
+
+/**
+ * XML stream-writer with disabled leaf-type validation for specified QName.
+ */
+final class XmlStreamWriterWithDisabledValidation extends StreamWriterWithDisabledValidation {
+
+    private static final XMLOutputFactory XML_FACTORY;
+
+    static {
+        XML_FACTORY = XMLOutputFactory.newFactory();
+        XML_FACTORY.setProperty(XMLOutputFactory.IS_REPAIRING_NAMESPACES, true);
+    }
+
+    private final XMLStreamWriter xmlWriter;
+    private final NormalizedNodeStreamWriter xmlNodeStreamWriter;
+
+    /**
+     * Creation of the custom XML stream-writer.
+     *
+     * @param excludedQName        QName of the element that is excluded from type-check.
+     * @param outputStream         Output stream that is used for creation of JSON writers.
+     * @param schemaPath           Schema-path of the {@link NormalizedNode} to be written.
+     * @param schemaContextHandler Handler that holds actual schema context.
+     */
+    XmlStreamWriterWithDisabledValidation(final QName excludedQName, final OutputStream outputStream,
+            final SchemaPath schemaPath, final SchemaContextHandler schemaContextHandler) {
+        super(excludedQName);
+        try {
+            this.xmlWriter = XML_FACTORY.createXMLStreamWriter(outputStream, StandardCharsets.UTF_8.name());
+        } catch (final XMLStreamException | FactoryConfigurationError e) {
+            throw new IllegalStateException("Cannot create XML writer", e);
+        }
+        this.xmlNodeStreamWriter = XMLStreamNormalizedNodeStreamWriter.create(xmlWriter,
+                schemaContextHandler.get(), schemaPath);
+    }
+
+    @Override
+    protected NormalizedNodeStreamWriter delegate() {
+        return xmlNodeStreamWriter;
+    }
+
+    @Override
+    void startLeafNodeWithDisabledValidation(final NodeIdentifier nodeIdentifier) throws IOException {
+        final String namespace = nodeIdentifier.getNodeType().getNamespace().toString();
+        try {
+            xmlWriter.writeStartElement(XMLConstants.DEFAULT_NS_PREFIX,
+                    nodeIdentifier.getNodeType().getLocalName(), namespace);
+        } catch (XMLStreamException e) {
+            throw new IOException("Error writing leaf node", e);
+        }
+    }
+
+    @Override
+    void scalarValueWithDisabledValidation(final Object value) throws IOException {
+        try {
+            xmlWriter.writeCharacters(value.toString());
+        } catch (XMLStreamException e) {
+            throw new IOException("Error writing value", e);
+        }
+    }
+
+    @Override
+    void endNodeWithDisabledValidation() throws IOException {
+        try {
+            xmlWriter.writeEndElement();
+        } catch (XMLStreamException e) {
+            throw new IOException("Error writing end-node", e);
+        }
+    }
+
+    @Override
+    public void close() throws IOException {
+        xmlNodeStreamWriter.close();
+        try {
+            xmlWriter.close();
+        } catch (XMLStreamException e) {
+            throw new IOException(e);
+        }
+    }
+}
\ No newline at end of file
diff --git a/restconf/restconf-nb-rfc8040/src/test/java/org/opendaylight/restconf/nb/rfc8040/jersey/providers/errors/RestconfDocumentedExceptionMapperTest.java b/restconf/restconf-nb-rfc8040/src/test/java/org/opendaylight/restconf/nb/rfc8040/jersey/providers/errors/RestconfDocumentedExceptionMapperTest.java
new file mode 100644 (file)
index 0000000..dd4ab69
--- /dev/null
@@ -0,0 +1,318 @@
+/*
+ * Copyright © 2019 FRINX s.r.o. All rights reserved.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v1.0 which accompanies this distribution,
+ * and is available at http://www.eclipse.org/legal/epl-v10.html
+ */
+package org.opendaylight.restconf.nb.rfc8040.jersey.providers.errors;
+
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+
+import com.google.common.collect.Lists;
+import java.net.URI;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.Response.Status;
+import org.json.JSONObject;
+import org.json.XML;
+import org.junit.Assert;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+import org.opendaylight.restconf.common.errors.RestconfDocumentedException;
+import org.opendaylight.restconf.common.errors.RestconfError;
+import org.opendaylight.restconf.common.errors.RestconfError.ErrorTag;
+import org.opendaylight.restconf.common.errors.RestconfError.ErrorType;
+import org.opendaylight.restconf.nb.rfc8040.handlers.SchemaContextHandler;
+import org.opendaylight.yangtools.yang.common.QName;
+import org.opendaylight.yangtools.yang.common.QNameModule;
+import org.opendaylight.yangtools.yang.common.Revision;
+import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier;
+import org.opendaylight.yangtools.yang.model.api.SchemaContext;
+import org.opendaylight.yangtools.yang.test.util.YangParserTestUtils;
+import org.skyscreamer.jsonassert.JSONAssert;
+
+@RunWith(Parameterized.class)
+public class RestconfDocumentedExceptionMapperTest {
+
+    private static final String EMPTY_XML = "<errors xmlns=\"urn:ietf:params:xml:ns:yang:ietf-restconf\"></errors>";
+    private static final String EMPTY_JSON = "{}";
+    private static QNameModule MONITORING_MODULE_INFO = QNameModule.create(
+            URI.create("instance:identifier:patch:module"), Revision.of("2015-11-21"));
+
+    private static RestconfDocumentedExceptionMapper exceptionMapper;
+
+    @BeforeClass
+    public static void setupExceptionMapper() {
+        final SchemaContext schemaContext = YangParserTestUtils.parseYangResources(
+                RestconfDocumentedExceptionMapperTest.class, "/restconf/impl/ietf-restconf@2017-01-26.yang",
+                "/instanceidentifier/yang/instance-identifier-patch-module.yang");
+        final SchemaContextHandler schemaContextHandler = mock(SchemaContextHandler.class);
+        doReturn(schemaContext).when(schemaContextHandler).get();
+
+        exceptionMapper = new RestconfDocumentedExceptionMapper(schemaContextHandler);
+    }
+
+    /**
+     * Testing entries 0 - 6: testing of media types and empty responses.
+     * Testing entries 7 - 8: testing of deriving of status codes from error entries.
+     * Testing entries 9 - 10: testing of serialization of different optional fields of error entries (JSON/XML).
+     *
+     * @return Testing data for parametrized test.
+     */
+    @Parameters(name = "{index}: {0}: {1}")
+    public static Iterable<Object[]> data() {
+        final RestconfDocumentedException sampleComplexError
+                = new RestconfDocumentedException("general message", new IllegalStateException("cause"),
+                Lists.newArrayList(
+                        new RestconfError(ErrorType.APPLICATION, ErrorTag.BAD_ATTRIBUTE,
+                                "message 1", "app tag #1"),
+                        new RestconfError(ErrorType.APPLICATION, ErrorTag.OPERATION_FAILED,
+                                "message 2", "app tag #2", "my info"),
+                        new RestconfError(ErrorType.RPC, ErrorTag.DATA_MISSING,
+                                "message 3", " app tag #3", "my error info", YangInstanceIdentifier.builder()
+                                .node(QName.create(MONITORING_MODULE_INFO, "patch-cont"))
+                                .node(QName.create(MONITORING_MODULE_INFO, "my-list1"))
+                                .nodeWithKey(QName.create(MONITORING_MODULE_INFO, "my-list1"),
+                                        QName.create(MONITORING_MODULE_INFO, "name"), "sample")
+                                .node(QName.create(MONITORING_MODULE_INFO, "my-leaf12"))
+                                .build())));
+
+        return Arrays.asList(new Object[][]{
+            {
+                "Mapping of the exception without any errors and XML output derived from content type",
+                new RestconfDocumentedException(Status.BAD_REQUEST),
+                mockHttpHeaders(MediaType.APPLICATION_XML_TYPE, Collections.emptyList()),
+                Response.status(Status.BAD_REQUEST)
+                        .type(RestconfDocumentedExceptionMapper.YANG_DATA_XML_TYPE)
+                        .entity(EMPTY_XML)
+                        .build()
+            },
+            {
+                "Mapping of the exception without any errors and JSON output derived from unsupported content type",
+                new RestconfDocumentedException(Status.INTERNAL_SERVER_ERROR),
+                mockHttpHeaders(MediaType.APPLICATION_FORM_URLENCODED_TYPE, Collections.emptyList()),
+                Response.status(Status.INTERNAL_SERVER_ERROR)
+                        .type(RestconfDocumentedExceptionMapper.YANG_DATA_JSON_TYPE)
+                        .entity(EMPTY_JSON)
+                        .build()
+            },
+            {
+                "Mapping of the exception without any errors and JSON output derived from missing content type "
+                        + "and accepted media types",
+                new RestconfDocumentedException(Status.NOT_IMPLEMENTED),
+                mockHttpHeaders(null, Collections.emptyList()),
+                Response.status(Status.NOT_IMPLEMENTED)
+                        .type(RestconfDocumentedExceptionMapper.YANG_DATA_JSON_TYPE)
+                        .entity(EMPTY_JSON)
+                        .build()
+            },
+            {
+                "Mapping of the exception without any errors and JSON output derived from expected types - both JSON"
+                        + "and XML types are accepted, but server should prefer JSON format",
+                new RestconfDocumentedException(Status.INTERNAL_SERVER_ERROR),
+                mockHttpHeaders(MediaType.APPLICATION_JSON_TYPE, Lists.newArrayList(
+                        MediaType.APPLICATION_FORM_URLENCODED_TYPE, MediaType.APPLICATION_XML_TYPE,
+                        MediaType.APPLICATION_JSON_TYPE, MediaType.APPLICATION_OCTET_STREAM_TYPE)),
+                Response.status(Status.INTERNAL_SERVER_ERROR)
+                        .type(RestconfDocumentedExceptionMapper.YANG_DATA_JSON_TYPE)
+                        .entity(EMPTY_JSON)
+                        .build()
+            },
+            {
+                "Mapping of the exception without any errors and JSON output derived from expected types - there"
+                        + "is only a wildcard type that should be mapped to default type",
+                new RestconfDocumentedException(Status.NOT_FOUND),
+                mockHttpHeaders(null, Lists.newArrayList(MediaType.WILDCARD_TYPE)),
+                Response.status(Status.NOT_FOUND)
+                        .type(RestconfDocumentedExceptionMapper.YANG_DATA_JSON_TYPE)
+                        .entity(EMPTY_JSON)
+                        .build()
+            },
+            {
+                "Mapping of the exception without any errors and XML output derived from expected types - "
+                        + "we should choose the most specific and supported type",
+                new RestconfDocumentedException(Status.NOT_FOUND),
+                mockHttpHeaders(null, Lists.newArrayList(MediaType.valueOf("*/yang-data+json"),
+                        MediaType.valueOf("application/yang-data+xml"), MediaType.WILDCARD_TYPE)),
+                Response.status(Status.NOT_FOUND)
+                        .type(RestconfDocumentedExceptionMapper.YANG_DATA_XML_TYPE)
+                        .entity(EMPTY_XML)
+                        .build()
+            },
+            {
+                "Mapping of the exception without any errors and XML output derived from expected types - "
+                        + "we should choose the most specific and supported type",
+                new RestconfDocumentedException(Status.NOT_FOUND),
+                mockHttpHeaders(null, Lists.newArrayList(MediaType.valueOf("*/unsupported"),
+                        MediaType.valueOf("application/*"), MediaType.WILDCARD_TYPE)),
+                Response.status(Status.NOT_FOUND)
+                        .type(RestconfDocumentedExceptionMapper.YANG_DATA_JSON_TYPE)
+                        .entity(EMPTY_JSON)
+                        .build()
+            },
+            {
+                "Mapping of the exception with one error entry but null status code. This status code should"
+                        + "be derived from single error entry; JSON output",
+                new RestconfDocumentedException("Sample error message"),
+                mockHttpHeaders(MediaType.APPLICATION_JSON_TYPE, Collections.singletonList(
+                        RestconfDocumentedExceptionMapper.YANG_PATCH_JSON_TYPE)),
+                Response.status(Status.INTERNAL_SERVER_ERROR)
+                        .type(RestconfDocumentedExceptionMapper.YANG_DATA_JSON_TYPE)
+                        .entity("{\n"
+                                + "  \"errors\": {\n"
+                                + "    \"error\": [\n"
+                                + "      {\n"
+                                + "        \"error-message\": \"Sample error message\",\n"
+                                + "        \"error-tag\": \"operation-failed\",\n"
+                                + "        \"error-type\": \"application\"\n"
+                                + "      }\n"
+                                + "    ]\n"
+                                + "  }\n"
+                                + "}\n")
+                        .build()
+            },
+            {
+                "Mapping of the exception with two error entries but null status code. This status code should"
+                        + "be derived from the first error entry that is specified; XML output",
+                new RestconfDocumentedException("general message", new IllegalStateException("cause"),
+                        Lists.newArrayList(
+                                new RestconfError(ErrorType.APPLICATION, ErrorTag.BAD_ATTRIBUTE, "message 1"),
+                                new RestconfError(ErrorType.APPLICATION, ErrorTag.OPERATION_FAILED, "message 2"))),
+                mockHttpHeaders(MediaType.APPLICATION_JSON_TYPE, Collections.singletonList(
+                        RestconfDocumentedExceptionMapper.YANG_PATCH_XML_TYPE)),
+                Response.status(Status.BAD_REQUEST)
+                        .type(RestconfDocumentedExceptionMapper.YANG_DATA_XML_TYPE)
+                        .entity("<errors xmlns=\"urn:ietf:params:xml:ns:yang:ietf-restconf\">\n"
+                                + "<error>\n"
+                                + "<error-message>message 1</error-message>\n"
+                                + "<error-tag>bad-attribute</error-tag>\n"
+                                + "<error-type>application</error-type>\n"
+                                + "</error>\n"
+                                + "<error>\n"
+                                + "<error-message>message 2</error-message>\n"
+                                + "<error-tag>operation-failed</error-tag>\n"
+                                + "<error-type>application</error-type>\n"
+                                + "</error>\n"
+                                + "</errors>")
+                        .build()
+            },
+            {
+                "Mapping of the exception with three entries and optional entries set: error app tag (the first error),"
+                        + " error info (the second error), and error path (the last error); JSON output",
+                sampleComplexError, mockHttpHeaders(MediaType.APPLICATION_JSON_TYPE, Collections.singletonList(
+                        MediaType.APPLICATION_JSON_TYPE)),
+                Response.status(Status.BAD_REQUEST)
+                        .type(RestconfDocumentedExceptionMapper.YANG_DATA_JSON_TYPE)
+                        .entity("{\n"
+                                + "  \"errors\": {\n"
+                                + "    \"error\": [\n"
+                                + "      {\n"
+                                + "        \"error-type\": \"application\",\n"
+                                + "        \"error-message\": \"message 1\",\n"
+                                + "        \"error-tag\": \"bad-attribute\",\n"
+                                + "        \"error-app-tag\": \"app tag #1\"\n"
+                                + "      },\n"
+                                + "      {\n"
+                                + "        \"error-type\": \"application\",\n"
+                                + "        \"error-message\": \"message 2\",\n"
+                                + "        \"error-tag\": \"operation-failed\",\n"
+                                + "        \"error-app-tag\": \"app tag #2\",\n"
+                                + "        \"error-info\": \"my info\"\n"
+                                + "      },\n"
+                                + "      {\n"
+                                + "        \"error-type\": \"rpc\",\n"
+                                + "        \"error-path\": \"/instance-identifier-patch-module:patch-cont/"
+                                + "my-list1[name='sample']/my-leaf12\",\n"
+                                + "        \"error-message\": \"message 3\",\n"
+                                + "        \"error-tag\": \"data-missing\",\n"
+                                + "        \"error-app-tag\": \" app tag #3\",\n"
+                                + "        \"error-info\": \"my error info\"\n"
+                                + "      }\n"
+                                + "    ]\n"
+                                + "  }\n"
+                                + "}\n")
+                        .build()
+            },
+            {
+                "Mapping of the exception with three entries and optional entries set: error app tag (the first error),"
+                        + " error info (the second error), and error path (the last error); XML output",
+                sampleComplexError, mockHttpHeaders(RestconfDocumentedExceptionMapper.YANG_PATCH_JSON_TYPE,
+                        Collections.singletonList(RestconfDocumentedExceptionMapper.YANG_DATA_XML_TYPE)),
+                Response.status(Status.BAD_REQUEST)
+                        .type(RestconfDocumentedExceptionMapper.YANG_DATA_XML_TYPE)
+                        .entity("<errors xmlns=\"urn:ietf:params:xml:ns:yang:ietf-restconf\">\n"
+                                + "<error>\n"
+                                + "<error-type>application</error-type>\n"
+                                + "<error-message>message 1</error-message>\n"
+                                + "<error-tag>bad-attribute</error-tag>\n"
+                                + "<error-app-tag>app tag #1</error-app-tag>\n"
+                                + "</error>\n"
+                                + "<error>\n"
+                                + "<error-type>application</error-type>\n"
+                                + "<error-message>message 2</error-message>\n"
+                                + "<error-tag>operation-failed</error-tag>\n"
+                                + "<error-app-tag>app tag #2</error-app-tag>\n"
+                                + "<error-info>my info</error-info></error>\n"
+                                + "<error>\n"
+                                + "<error-type>rpc</error-type>\n"
+                                + "<error-path xmlns:a=\"instance:identifier:patch:module\">/a:patch-cont/"
+                                + "a:my-list1[a:name='sample']/a:my-leaf12</error-path>\n"
+                                + "<error-message>message 3</error-message>\n"
+                                + "<error-tag>data-missing</error-tag>\n"
+                                + "<error-app-tag> app tag #3</error-app-tag>\n"
+                                + "<error-info>my error info</error-info>\n"
+                                + "</error>\n"
+                                + "</errors>")
+                        .build()
+            }
+        });
+    }
+
+    @Parameter
+    public String testDescription;
+    @Parameter(1)
+    public RestconfDocumentedException thrownException;
+    @Parameter(2)
+    public HttpHeaders httpHeaders;
+    @Parameter(3)
+    public Response expectedResponse;
+
+    @Test
+    public void testMappingOfExceptionToResponse() {
+        exceptionMapper.setHttpHeaders(httpHeaders);
+        final Response response = exceptionMapper.toResponse(thrownException);
+        compareResponseWithExpectation(expectedResponse, response);
+    }
+
+    private static HttpHeaders mockHttpHeaders(final MediaType contentType, final List<MediaType> acceptedTypes) {
+        final HttpHeaders httpHeaders = mock(HttpHeaders.class);
+        doReturn(contentType).when(httpHeaders).getMediaType();
+        doReturn(acceptedTypes).when(httpHeaders).getAcceptableMediaTypes();
+        return httpHeaders;
+    }
+
+    private static void compareResponseWithExpectation(final Response expectedResponse, final Response actualResponse) {
+        final String errorMessage = String.format("Actual response %s doesn't equal to expected response %s",
+                actualResponse, expectedResponse);
+        Assert.assertEquals(errorMessage, expectedResponse.getStatus(), actualResponse.getStatus());
+        Assert.assertEquals(errorMessage, expectedResponse.getMediaType(), actualResponse.getMediaType());
+        if (RestconfDocumentedExceptionMapper.YANG_DATA_JSON_TYPE.equals(expectedResponse.getMediaType())) {
+            JSONAssert.assertEquals(expectedResponse.getEntity().toString(),
+                    actualResponse.getEntity().toString(), true);
+        } else {
+            final JSONObject expectedResponseInJson = XML.toJSONObject(expectedResponse.getEntity().toString());
+            final JSONObject actualResponseInJson = XML.toJSONObject(actualResponse.getEntity().toString());
+            JSONAssert.assertEquals(expectedResponseInJson, actualResponseInJson, true);
+        }
+    }
+}
\ No newline at end of file