/* * 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 static org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.restconf.rev170126.$YangModuleInfoImpl.qnameOf; 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.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.ErrorTags; import org.opendaylight.restconf.common.errors.RestconfDocumentedException; import org.opendaylight.restconf.common.errors.RestconfError; import org.opendaylight.restconf.nb.rfc8040.MediaTypes; import org.opendaylight.restconf.nb.rfc8040.handlers.SchemaContextHandler; import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.restconf.rev170126.errors.Errors; import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.restconf.rev170126.errors.errors.Error; import org.opendaylight.yangtools.yang.common.QName; import org.opendaylight.yangtools.yang.common.XMLNamespace; 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.builder.DataContainerNodeBuilder; 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.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 { 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; // Note: we are using container's QName reference to trim imports private static final SchemaPath ERRORS_GROUPING_PATH = SchemaPath.create(true, Errors.QNAME); private static final QName ERROR_TYPE_QNAME = qnameOf("error-type"); private static final QName ERROR_TAG_QNAME = qnameOf("error-tag"); private static final QName ERROR_APP_TAG_QNAME = qnameOf("error-app-tag"); private static final QName ERROR_MESSAGE_QNAME = qnameOf("error-message"); private static final QName ERROR_INFO_QNAME = qnameOf("error-info"); private static final QName ERROR_PATH_QNAME = qnameOf("error-path"); private static final XMLNamespace IETF_RESTCONF_URI = Errors.QNAME.getModule().getNamespace(); @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 && exception.getErrors().isEmpty()) { // There should be at least one error entry for 4xx errors except 409 according to RFC8040, but we do not // have it. Issue a warning with the call trace so we can fix whoever was the originator. LOG.warn("Input exception has a family of 4xx but does not contain any descriptive errors", exception); } final ContainerNode errorsContainer = buildErrorsContainer(exception); final String serializedResponseBody; final MediaType responseMediaType = transformToResponseMediaType(getSupportedMediaType()); if (MediaTypes.APPLICATION_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 static ContainerNode buildErrorsContainer(final RestconfDocumentedException exception) { return ImmutableContainerNodeBuilder.create() .withNodeIdentifier(NodeIdentifier.create(Errors.QNAME)) .withChild(ImmutableUnkeyedListNodeBuilder.create() .withNodeIdentifier(NodeIdentifier.create(Error.QNAME)) .withValue(exception.getErrors().stream() .map(RestconfDocumentedExceptionMapper::createErrorEntry) .collect(Collectors.toList())) .build()) .build(); } /** * Building of one error entry using provided {@link RestconfError}. * * @param restconfError Error details. * @return Built list entry. */ private static UnkeyedListEntryNode createErrorEntry(final RestconfError restconfError) { // filling in mandatory leafs final DataContainerNodeBuilder entryBuilder = ImmutableUnkeyedListEntryNodeBuilder.create() .withNodeIdentifier(NodeIdentifier.create(Error.QNAME)) .withChild(ImmutableNodes.leafNode(ERROR_TYPE_QNAME, restconfError.getErrorType().elementBody())) .withChild(ImmutableNodes.leafNode(ERROR_TAG_QNAME, restconfError.getErrorTag().elementBody())); // filling in optional fields if (restconfError.getErrorMessage() != null) { entryBuilder.withChild(ImmutableNodes.leafNode(ERROR_MESSAGE_QNAME, restconfError.getErrorMessage())); } if (restconfError.getErrorAppTag() != null) { entryBuilder.withChild(ImmutableNodes.leafNode(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(ERROR_INFO_QNAME, restconfError.getErrorInfo())); } if (restconfError.getErrorPath() != null) { entryBuilder.withChild(ImmutableNodes.leafNode(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); ) { return writeNormalizedNode(errorsContainer, outputStream, new JsonStreamWriterWithDisabledValidation( ERROR_INFO_QNAME, streamStreamWriter, ERRORS_GROUPING_PATH, IETF_RESTCONF_URI, schemaContextHandler)); } 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()) { return writeNormalizedNode(errorsContainer, outputStream, new XmlStreamWriterWithDisabledValidation( ERROR_INFO_QNAME, outputStream, ERRORS_GROUPING_PATH, schemaContextHandler)); } 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 StreamWriterWithDisabledValidation streamWriter) { try (NormalizedNodeWriter nnWriter = NormalizedNodeWriter.forStreamWriter(streamWriter)) { nnWriter.write(errorsContainer); } catch (IOException e) { throw new IllegalStateException("Cannot write error response body", e); } return outputStream.toString(StandardCharsets.UTF_8); } /** * 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 -> ErrorTags.statusOf(restconfError.getErrorTag())) // 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 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.isEmpty()) { // 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 MediaTypes.APPLICATION_YANG_DATA_JSON_TYPE; } else if (isXmlCompatibleMediaType(mediaTypeBase)) { return MediaTypes.APPLICATION_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(MediaTypes.APPLICATION_YANG_DATA_JSON_TYPE) || mediaType.isCompatible(MediaTypes.APPLICATION_YANG_PATCH_JSON_TYPE); } private static boolean isXmlCompatibleMediaType(final MediaType mediaType) { return mediaType.isCompatible(MediaType.APPLICATION_XML_TYPE) || mediaType.isCompatible(MediaTypes.APPLICATION_YANG_DATA_XML_TYPE) || mediaType.isCompatible(MediaTypes.APPLICATION_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; } }