2 * Copyright © 2019 FRINX s.r.o. All rights reserved.
4 * This program and the accompanying materials are made available under the
5 * terms of the Eclipse Public License v1.0 which accompanies this distribution,
6 * and is available at http://www.eclipse.org/legal/epl-v10.html
8 package org.opendaylight.restconf.nb.rfc8040.jersey.providers.errors;
10 import static java.util.Objects.requireNonNull;
11 import static org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.restconf.rev170126.$YangModuleInfoImpl.qnameOf;
13 import com.google.common.annotations.VisibleForTesting;
14 import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
15 import java.io.ByteArrayOutputStream;
16 import java.io.IOException;
17 import java.io.OutputStreamWriter;
18 import java.nio.charset.StandardCharsets;
19 import java.util.Collections;
20 import java.util.LinkedHashSet;
21 import java.util.List;
22 import java.util.Optional;
24 import java.util.stream.Collectors;
25 import javax.ws.rs.core.Context;
26 import javax.ws.rs.core.HttpHeaders;
27 import javax.ws.rs.core.MediaType;
28 import javax.ws.rs.core.Response;
29 import javax.ws.rs.core.Response.Status;
30 import javax.ws.rs.ext.ExceptionMapper;
31 import javax.ws.rs.ext.Provider;
32 import org.opendaylight.restconf.common.errors.RestconfDocumentedException;
33 import org.opendaylight.restconf.common.errors.RestconfError;
34 import org.opendaylight.restconf.nb.jaxrs.JaxRsMediaTypes;
35 import org.opendaylight.restconf.nb.rfc8040.legacy.ErrorTags;
36 import org.opendaylight.restconf.server.spi.DatabindProvider;
37 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.restconf.rev170126.errors.Errors;
38 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.restconf.rev170126.errors.errors.Error;
39 import org.opendaylight.yangtools.yang.common.QName;
40 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeIdentifier;
41 import org.opendaylight.yangtools.yang.data.api.schema.ContainerNode;
42 import org.opendaylight.yangtools.yang.data.api.schema.NormalizedNode;
43 import org.opendaylight.yangtools.yang.data.api.schema.UnkeyedListEntryNode;
44 import org.opendaylight.yangtools.yang.data.api.schema.stream.NormalizedNodeWriter;
45 import org.opendaylight.yangtools.yang.data.impl.schema.Builders;
46 import org.opendaylight.yangtools.yang.data.impl.schema.ImmutableNodes;
47 import org.slf4j.Logger;
48 import org.slf4j.LoggerFactory;
51 * An {@link ExceptionMapper} that is responsible for transformation of thrown {@link RestconfDocumentedException} to
52 * {@code errors} structure that is modelled by RESTCONF module (see section 8 of RFC-8040).
56 // FIXME: NETCONF-1188: eliminate the need for this class by having a separate exception which a has a HTTP status and
57 // optionally holds an ErrorsBody -- i.e. the equivalent of Errors, perhaps as NormalizedNode,
58 // with sufficient context to send it to JSON or XML -- very similar to a NormalizedNodePayload
61 public final class RestconfDocumentedExceptionMapper implements ExceptionMapper<RestconfDocumentedException> {
62 private static final Logger LOG = LoggerFactory.getLogger(RestconfDocumentedExceptionMapper.class);
63 private static final MediaType DEFAULT_MEDIA_TYPE = MediaType.APPLICATION_JSON_TYPE;
64 private static final Status DEFAULT_STATUS_CODE = Status.INTERNAL_SERVER_ERROR;
65 private static final QName ERROR_TYPE_QNAME = qnameOf("error-type");
66 private static final QName ERROR_TAG_QNAME = qnameOf("error-tag");
67 private static final QName ERROR_APP_TAG_QNAME = qnameOf("error-app-tag");
68 private static final QName ERROR_MESSAGE_QNAME = qnameOf("error-message");
69 private static final QName ERROR_PATH_QNAME = qnameOf("error-path");
70 static final QName ERROR_INFO_QNAME = qnameOf("error-info");
72 private final DatabindProvider databindProvider;
75 private HttpHeaders headers;
78 * Initialization of the exception mapper.
80 * @param databindProvider A {@link DatabindProvider}
82 public RestconfDocumentedExceptionMapper(final DatabindProvider databindProvider) {
83 this.databindProvider = requireNonNull(databindProvider);
87 @SuppressFBWarnings(value = "SLF4J_MANUALLY_PROVIDED_MESSAGE", justification = "In the debug messages "
88 + "we don't to have full stack trace - getMessage(..) method provides finer output.")
89 public Response toResponse(final RestconfDocumentedException exception) {
90 LOG.debug("Starting to map received exception to error response: {}", exception.getMessage());
91 final Status responseStatus = getResponseStatusCode(exception);
92 if (responseStatus != Response.Status.FORBIDDEN
93 && responseStatus.getFamily() == Response.Status.Family.CLIENT_ERROR
94 && exception.getErrors().isEmpty()) {
95 // There should be at least one error entry for 4xx errors except 409 according to RFC8040, but we do not
96 // have it. Issue a warning with the call trace so we can fix whoever was the originator.
97 LOG.warn("Input exception has a family of 4xx but does not contain any descriptive errors", exception);
100 final ContainerNode errorsContainer = buildErrorsContainer(exception);
101 final String serializedResponseBody;
102 final MediaType responseMediaType = transformToResponseMediaType(getSupportedMediaType());
103 if (JaxRsMediaTypes.APPLICATION_YANG_DATA_JSON.equals(responseMediaType)) {
104 serializedResponseBody = serializeErrorsContainerToJson(errorsContainer);
106 serializedResponseBody = serializeErrorsContainerToXml(errorsContainer);
109 final Response preparedResponse = Response.status(responseStatus)
110 .type(responseMediaType)
111 .entity(serializedResponseBody)
113 LOG.debug("Exception {} has been successfully mapped to response: {}",
114 exception.getMessage(), preparedResponse);
115 return preparedResponse;
119 * Filling up of the errors container with data from input {@link RestconfDocumentedException}.
121 * @param exception Thrown exception.
122 * @return Built errors container.
124 private static ContainerNode buildErrorsContainer(final RestconfDocumentedException exception) {
125 return Builders.containerBuilder()
126 .withNodeIdentifier(NodeIdentifier.create(Errors.QNAME))
127 .withChild(Builders.unkeyedListBuilder()
128 .withNodeIdentifier(NodeIdentifier.create(Error.QNAME))
129 .withValue(exception.getErrors().stream()
130 .map(RestconfDocumentedExceptionMapper::createErrorEntry)
131 .collect(Collectors.toList()))
137 * Building of one error entry using provided {@link RestconfError}.
139 * @param restconfError Error details.
140 * @return Built list entry.
142 private static UnkeyedListEntryNode createErrorEntry(final RestconfError restconfError) {
143 // filling in mandatory leafs
144 final var entryBuilder = Builders.unkeyedListEntryBuilder()
145 .withNodeIdentifier(NodeIdentifier.create(Error.QNAME))
146 .withChild(ImmutableNodes.leafNode(ERROR_TYPE_QNAME, restconfError.getErrorType().elementBody()))
147 .withChild(ImmutableNodes.leafNode(ERROR_TAG_QNAME, restconfError.getErrorTag().elementBody()));
149 // filling in optional fields
150 if (restconfError.getErrorMessage() != null) {
151 entryBuilder.withChild(ImmutableNodes.leafNode(ERROR_MESSAGE_QNAME, restconfError.getErrorMessage()));
153 if (restconfError.getErrorAppTag() != null) {
154 entryBuilder.withChild(ImmutableNodes.leafNode(ERROR_APP_TAG_QNAME, restconfError.getErrorAppTag()));
156 if (restconfError.getErrorInfo() != null) {
157 // Oddly, error-info is defined as an empty container in the restconf yang. Apparently the
158 // intention is for implementors to define their own data content so we'll just treat it as a leaf
160 entryBuilder.withChild(ImmutableNodes.leafNode(ERROR_INFO_QNAME, restconfError.getErrorInfo()));
163 if (restconfError.getErrorPath() != null) {
164 entryBuilder.withChild(ImmutableNodes.leafNode(ERROR_PATH_QNAME, restconfError.getErrorPath()));
166 return entryBuilder.build();
170 * Serialization of the errors container into JSON representation.
172 * @param errorsContainer To be serialized errors container.
173 * @return JSON representation of the errors container.
175 private String serializeErrorsContainerToJson(final ContainerNode errorsContainer) {
176 try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
177 OutputStreamWriter streamStreamWriter = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8);
179 return writeNormalizedNode(errorsContainer, outputStream,
180 new JsonStreamWriterWithDisabledValidation(databindProvider.currentDatabind(), streamStreamWriter));
181 } catch (IOException e) {
182 throw new IllegalStateException("Cannot close some of the output JSON writers", e);
187 * Serialization of the errors container into XML representation.
189 * @param errorsContainer To be serialized errors container.
190 * @return XML representation of the errors container.
192 private String serializeErrorsContainerToXml(final ContainerNode errorsContainer) {
193 try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
194 return writeNormalizedNode(errorsContainer, outputStream,
195 new XmlStreamWriterWithDisabledValidation(databindProvider.currentDatabind(), outputStream));
196 } catch (IOException e) {
197 throw new IllegalStateException("Cannot close some of the output XML writers", e);
201 private static String writeNormalizedNode(final NormalizedNode errorsContainer,
202 final ByteArrayOutputStream outputStream, final StreamWriterWithDisabledValidation streamWriter) {
203 try (NormalizedNodeWriter nnWriter = NormalizedNodeWriter.forStreamWriter(streamWriter)) {
204 nnWriter.write(errorsContainer);
205 } catch (IOException e) {
206 throw new IllegalStateException("Cannot write error response body", e);
208 return outputStream.toString(StandardCharsets.UTF_8);
212 * Deriving of the status code from the thrown exception. At the first step, status code is tried to be read using
213 * {@link RestconfDocumentedException#getStatus()}. If it is {@code null}, status code will be derived from status
214 * codes appended to error entries (the first that will be found). If there are not any error entries,
215 * {@link RestconfDocumentedExceptionMapper#DEFAULT_STATUS_CODE} will be used.
217 * @param exception Thrown exception.
218 * @return Derived status code.
220 private static Status getResponseStatusCode(final RestconfDocumentedException exception) {
221 final var errors = exception.getErrors();
222 if (errors.isEmpty()) {
223 // if the module, that thrown exception, doesn't specify status code, it is treated as internal
225 return DEFAULT_STATUS_CODE;
228 final var allStatusCodesOfErrorEntries = errors.stream()
229 .map(restconfError -> ErrorTags.statusOf(restconfError.getErrorTag()))
230 // we would like to preserve iteration order in collected entries - hence usage of LinkedHashSet
231 .collect(Collectors.toCollection(LinkedHashSet::new));
232 // choosing of the first status code from appended errors, if there are different status codes in error
233 // entries, we should create WARN message
234 if (allStatusCodesOfErrorEntries.size() > 1) {
236 An unexpected error occurred during translation of exception {} to response: Different status codes
237 have been found in appended error entries: {}. The first error entry status code is chosen for
238 response.""", exception, allStatusCodesOfErrorEntries);
240 return allStatusCodesOfErrorEntries.iterator().next();
244 * Selection of media type that will be used for creation suffix of 'application/yang-data'. Selection criteria
245 * is described in RFC 8040, section 7.1. At the first step, accepted media-type is analyzed and only supported
246 * media-types are filtered out. If both XML and JSON media-types are accepted, JSON is selected as a default one
247 * used in RESTCONF. If accepted-media type is not specified, the media-type used in request is chosen only if it
248 * is supported one. If it is not supported or it is not specified, again, the default one (JSON) is selected.
250 * @return Media type.
252 private MediaType getSupportedMediaType() {
253 final Set<MediaType> acceptableAndSupportedMediaTypes = headers.getAcceptableMediaTypes().stream()
254 .filter(RestconfDocumentedExceptionMapper::isCompatibleMediaType)
255 .collect(Collectors.toSet());
256 if (acceptableAndSupportedMediaTypes.isEmpty()) {
257 // check content type of the request
258 final MediaType requestMediaType = headers.getMediaType();
259 return requestMediaType == null ? DEFAULT_MEDIA_TYPE
260 : chooseMediaType(Collections.singletonList(requestMediaType)).orElseGet(() -> {
261 LOG.warn("Request doesn't specify accepted media-types and the media-type '{}' used by "
262 + "request is not supported - using of default '{}' media-type.",
263 requestMediaType, DEFAULT_MEDIA_TYPE);
264 return DEFAULT_MEDIA_TYPE;
268 // at first step, fully specified types without any wildcards are considered (for example, application/json)
269 final List<MediaType> fullySpecifiedMediaTypes = acceptableAndSupportedMediaTypes.stream()
270 .filter(mediaType -> !mediaType.isWildcardType() && !mediaType.isWildcardSubtype())
271 .collect(Collectors.toList());
272 if (!fullySpecifiedMediaTypes.isEmpty()) {
273 return chooseAndCheckMediaType(fullySpecifiedMediaTypes);
276 // at the second step, only types with specified subtype are considered (for example, */json)
277 final List<MediaType> mediaTypesWithSpecifiedSubtypes = acceptableAndSupportedMediaTypes.stream()
278 .filter(mediaType -> !mediaType.isWildcardSubtype())
279 .collect(Collectors.toList());
280 if (!mediaTypesWithSpecifiedSubtypes.isEmpty()) {
281 return chooseAndCheckMediaType(mediaTypesWithSpecifiedSubtypes);
284 // at the third step, only types with specified parent are considered (for example, application/*)
285 final List<MediaType> mediaTypesWithSpecifiedParent = acceptableAndSupportedMediaTypes.stream()
286 .filter(mediaType -> !mediaType.isWildcardType())
287 .collect(Collectors.toList());
288 if (!mediaTypesWithSpecifiedParent.isEmpty()) {
289 return chooseAndCheckMediaType(mediaTypesWithSpecifiedParent);
292 // it must be fully-wildcard-ed type - */*
293 return DEFAULT_MEDIA_TYPE;
296 private static MediaType chooseAndCheckMediaType(final List<MediaType> options) {
297 return chooseMediaType(options).orElseThrow(IllegalStateException::new);
301 * This method is responsible for choosing of he media type from multiple options. At the first step,
302 * JSON-compatible types are considered, then, if there are not any JSON types, XML types are considered. The first
303 * compatible media-type is chosen.
305 * @param options Supported media types.
306 * @return Selected one media type or {@link Optional#empty()} if none of the provided options are compatible with
309 private static Optional<MediaType> chooseMediaType(final List<MediaType> options) {
310 return options.stream()
311 .filter(RestconfDocumentedExceptionMapper::isJsonCompatibleMediaType)
314 .orElse(options.stream()
315 .filter(RestconfDocumentedExceptionMapper::isXmlCompatibleMediaType)
320 * Mapping of JSON-compatible type to {@link RestconfDocumentedExceptionMapper#YANG_DATA_JSON_TYPE}
321 * or XML-compatible type to {@link RestconfDocumentedExceptionMapper#YANG_DATA_XML_TYPE}.
323 * @param mediaTypeBase Base media type from which the response media-type is built.
324 * @return Derived media type.
326 private static MediaType transformToResponseMediaType(final MediaType mediaTypeBase) {
327 if (isJsonCompatibleMediaType(mediaTypeBase)) {
328 return JaxRsMediaTypes.APPLICATION_YANG_DATA_JSON;
329 } else if (isXmlCompatibleMediaType(mediaTypeBase)) {
330 return JaxRsMediaTypes.APPLICATION_YANG_DATA_XML;
332 throw new IllegalStateException(String.format("Unexpected input media-type %s "
333 + "- it should be JSON/XML compatible type.", mediaTypeBase));
337 private static boolean isCompatibleMediaType(final MediaType mediaType) {
338 return isJsonCompatibleMediaType(mediaType) || isXmlCompatibleMediaType(mediaType);
341 private static boolean isJsonCompatibleMediaType(final MediaType mediaType) {
342 return mediaType.isCompatible(MediaType.APPLICATION_JSON_TYPE)
343 || mediaType.isCompatible(JaxRsMediaTypes.APPLICATION_YANG_DATA_JSON)
344 || mediaType.isCompatible(JaxRsMediaTypes.APPLICATION_YANG_PATCH_JSON);
347 private static boolean isXmlCompatibleMediaType(final MediaType mediaType) {
348 return mediaType.isCompatible(MediaType.APPLICATION_XML_TYPE)
349 || mediaType.isCompatible(JaxRsMediaTypes.APPLICATION_YANG_DATA_XML)
350 || mediaType.isCompatible(JaxRsMediaTypes.APPLICATION_YANG_PATCH_XML);
354 * Used just for testing purposes - simulation of HTTP headers with different accepted types and content type.
356 * @param httpHeaders Mocked HTTP headers.
359 void setHttpHeaders(final HttpHeaders httpHeaders) {
360 headers = httpHeaders;