From 1b6442243f1ad2107c764a7f61d0bca9e21864c1 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Jaroslav=20T=C3=B3th?= Date: Thu, 8 Aug 2019 17:01:13 +0200 Subject: [PATCH] Added ExceptionMapper to RFC-8040 impl MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Robert Varga --- .../nb/rfc8040/RestconfApplication.java | 2 + .../restconf/nb/rfc8040/Rfc8040.java | 6 +- ...sonStreamWriterWithDisabledValidation.java | 74 ++++ .../RestconfDocumentedExceptionMapper.java | 389 ++++++++++++++++++ .../StreamWriterWithDisabledValidation.java | 87 ++++ ...XmlStreamWriterWithDisabledValidation.java | 104 +++++ ...RestconfDocumentedExceptionMapperTest.java | 318 ++++++++++++++ 7 files changed, 978 insertions(+), 2 deletions(-) create mode 100644 restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/jersey/providers/errors/JsonStreamWriterWithDisabledValidation.java create mode 100644 restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/jersey/providers/errors/RestconfDocumentedExceptionMapper.java create mode 100644 restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/jersey/providers/errors/StreamWriterWithDisabledValidation.java create mode 100644 restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/jersey/providers/errors/XmlStreamWriterWithDisabledValidation.java create mode 100644 restconf/restconf-nb-rfc8040/src/test/java/org/opendaylight/restconf/nb/rfc8040/jersey/providers/errors/RestconfDocumentedExceptionMapperTest.java diff --git a/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/RestconfApplication.java b/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/RestconfApplication.java index d5acb74925..36528b8b82 100644 --- a/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/RestconfApplication.java +++ b/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/RestconfApplication.java @@ -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; } } diff --git a/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/Rfc8040.java b/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/Rfc8040.java index 8de2d24da9..0d055c43a9 100644 --- a/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/Rfc8040.java +++ b/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/Rfc8040.java @@ -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 index 0000000000..4b000ddeb4 --- /dev/null +++ b/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/jersey/providers/errors/JsonStreamWriterWithDisabledValidation.java @@ -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 index 0000000000..edc78bf73c --- /dev/null +++ b/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/jersey/providers/errors/RestconfDocumentedExceptionMapper.java @@ -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 { + @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 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 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 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 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 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 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 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 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 options) { + final Optional 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 chooseMediaType(final List 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 index 0000000000..c5c6568890 --- /dev/null +++ b/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/jersey/providers/errors/StreamWriterWithDisabledValidation.java @@ -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 index 0000000000..d1c8917abb --- /dev/null +++ b/restconf/restconf-nb-rfc8040/src/main/java/org/opendaylight/restconf/nb/rfc8040/jersey/providers/errors/XmlStreamWriterWithDisabledValidation.java @@ -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 index 0000000000..dd4ab693db --- /dev/null +++ b/restconf/restconf-nb-rfc8040/src/test/java/org/opendaylight/restconf/nb/rfc8040/jersey/providers/errors/RestconfDocumentedExceptionMapperTest.java @@ -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 = ""; + 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 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("\n" + + "\n" + + "message 1\n" + + "bad-attribute\n" + + "application\n" + + "\n" + + "\n" + + "message 2\n" + + "operation-failed\n" + + "application\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); 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("\n" + + "\n" + + "application\n" + + "message 1\n" + + "bad-attribute\n" + + "app tag #1\n" + + "\n" + + "\n" + + "application\n" + + "message 2\n" + + "operation-failed\n" + + "app tag #2\n" + + "my info\n" + + "\n" + + "rpc\n" + + "/a:patch-cont/" + + "a:my-list1[a:name='sample']/a:my-leaf12\n" + + "message 3\n" + + "data-missing\n" + + " app tag #3\n" + + "my error info\n" + + "\n" + + "") + .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 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 -- 2.36.6