Log full exception when we are missing errors
[netconf.git] / restconf / restconf-nb-rfc8040 / src / main / java / org / opendaylight / restconf / nb / rfc8040 / jersey / providers / errors / RestconfDocumentedExceptionMapper.java
1 /*
2  * Copyright © 2019 FRINX s.r.o. All rights reserved.
3  *
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
7  */
8 package org.opendaylight.restconf.nb.rfc8040.jersey.providers.errors;
9
10 import static com.google.common.base.Preconditions.checkState;
11
12 import com.google.common.annotations.VisibleForTesting;
13 import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
14 import java.io.ByteArrayOutputStream;
15 import java.io.IOException;
16 import java.io.OutputStreamWriter;
17 import java.nio.charset.StandardCharsets;
18 import java.util.Collections;
19 import java.util.LinkedHashSet;
20 import java.util.List;
21 import java.util.Optional;
22 import java.util.Set;
23 import java.util.stream.Collectors;
24 import javax.ws.rs.core.Context;
25 import javax.ws.rs.core.HttpHeaders;
26 import javax.ws.rs.core.MediaType;
27 import javax.ws.rs.core.Response;
28 import javax.ws.rs.core.Response.Status;
29 import javax.ws.rs.ext.ExceptionMapper;
30 import javax.ws.rs.ext.Provider;
31 import org.opendaylight.restconf.common.errors.RestconfDocumentedException;
32 import org.opendaylight.restconf.common.errors.RestconfError;
33 import org.opendaylight.restconf.nb.rfc8040.Rfc8040.MediaTypes;
34 import org.opendaylight.restconf.nb.rfc8040.Rfc8040.RestconfModule;
35 import org.opendaylight.restconf.nb.rfc8040.handlers.SchemaContextHandler;
36 import org.opendaylight.restconf.nb.rfc8040.utils.RestconfConstants;
37 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier;
38 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeIdentifier;
39 import org.opendaylight.yangtools.yang.data.api.schema.ContainerNode;
40 import org.opendaylight.yangtools.yang.data.api.schema.NormalizedNode;
41 import org.opendaylight.yangtools.yang.data.api.schema.UnkeyedListEntryNode;
42 import org.opendaylight.yangtools.yang.data.api.schema.stream.NormalizedNodeWriter;
43 import org.opendaylight.yangtools.yang.data.impl.schema.ImmutableNodes;
44 import org.opendaylight.yangtools.yang.data.impl.schema.builder.api.DataContainerNodeBuilder;
45 import org.opendaylight.yangtools.yang.data.impl.schema.builder.impl.ImmutableContainerNodeBuilder;
46 import org.opendaylight.yangtools.yang.data.impl.schema.builder.impl.ImmutableUnkeyedListEntryNodeBuilder;
47 import org.opendaylight.yangtools.yang.data.impl.schema.builder.impl.ImmutableUnkeyedListNodeBuilder;
48 import org.opendaylight.yangtools.yang.model.api.SchemaPath;
49 import org.slf4j.Logger;
50 import org.slf4j.LoggerFactory;
51
52 /**
53  *  Mapper that is responsible for transformation of thrown {@link RestconfDocumentedException} to errors structure
54  *  that is modelled by RESTCONF module (see section 8 of RFC-8040).
55  */
56 @Provider
57 public final class RestconfDocumentedExceptionMapper implements ExceptionMapper<RestconfDocumentedException> {
58     @VisibleForTesting
59     static final MediaType YANG_DATA_JSON_TYPE = MediaType.valueOf(MediaTypes.DATA + RestconfConstants.JSON);
60     @VisibleForTesting
61     static final MediaType YANG_DATA_XML_TYPE = MediaType.valueOf(MediaTypes.DATA + RestconfConstants.XML);
62     @VisibleForTesting
63     static final MediaType YANG_PATCH_JSON_TYPE = MediaType.valueOf(MediaTypes.YANG_PATCH + RestconfConstants.JSON);
64     @VisibleForTesting
65     static final MediaType YANG_PATCH_XML_TYPE = MediaType.valueOf(MediaTypes.YANG_PATCH + RestconfConstants.XML);
66     private static final MediaType YANG_PATCH_RFC8072_JSON_TYPE =
67         MediaType.valueOf(MediaTypes.YANG_PATCH_RFC8072 + RestconfConstants.JSON);
68     private static final MediaType YANG_PATCH_RFC8072_XML_TYPE =
69         MediaType.valueOf(MediaTypes.YANG_PATCH_RFC8072 + RestconfConstants.XML);
70
71     private static final Logger LOG = LoggerFactory.getLogger(RestconfDocumentedExceptionMapper.class);
72     private static final MediaType DEFAULT_MEDIA_TYPE = MediaType.APPLICATION_JSON_TYPE;
73     private static final Status DEFAULT_STATUS_CODE = Status.INTERNAL_SERVER_ERROR;
74     private static final SchemaPath ERRORS_GROUPING_PATH = SchemaPath.create(true,
75             RestconfModule.ERRORS_GROUPING_QNAME);
76
77     @Context
78     private HttpHeaders headers;
79     private final SchemaContextHandler schemaContextHandler;
80
81     /**
82      * Initialization of the exception mapper.
83      *
84      * @param schemaContextHandler Handler that provides actual schema context.
85      */
86     public RestconfDocumentedExceptionMapper(final SchemaContextHandler schemaContextHandler) {
87         this.schemaContextHandler = schemaContextHandler;
88     }
89
90     @Override
91     @SuppressFBWarnings(value = "SLF4J_MANUALLY_PROVIDED_MESSAGE", justification = "In the debug messages "
92             + "we don't to have full stack trace - getMessage(..) method provides finer output.")
93     public Response toResponse(final RestconfDocumentedException exception) {
94         LOG.debug("Starting to map received exception to error response: {}", exception.getMessage());
95         final Status responseStatus = getResponseStatusCode(exception);
96         if (responseStatus != Response.Status.FORBIDDEN
97                 && responseStatus.getFamily() == Response.Status.Family.CLIENT_ERROR
98                 && exception.getErrors().isEmpty()) {
99             // There should be at least one error entry for 4xx errors except 409 according to RFC8040, but we do not
100             // have it. Issue a warning with the call trace so we can fix whoever was the originator.
101             LOG.warn("Input exception has a family of 4xx but does not contain any descriptive errors", exception);
102         }
103
104         final ContainerNode errorsContainer = buildErrorsContainer(exception);
105         final String serializedResponseBody;
106         final MediaType responseMediaType = transformToResponseMediaType(getSupportedMediaType());
107         if (YANG_DATA_JSON_TYPE.equals(responseMediaType)) {
108             serializedResponseBody = serializeErrorsContainerToJson(errorsContainer);
109         } else {
110             serializedResponseBody = serializeErrorsContainerToXml(errorsContainer);
111         }
112
113         final Response preparedResponse = Response.status(responseStatus)
114                 .type(responseMediaType)
115                 .entity(serializedResponseBody)
116                 .build();
117         LOG.debug("Exception {} has been successfully mapped to response: {}",
118                 exception.getMessage(), preparedResponse);
119         return preparedResponse;
120     }
121
122     /**
123      * Filling up of the errors container with data from input {@link RestconfDocumentedException}.
124      *
125      * @param exception Thrown exception.
126      * @return Built errors container.
127      */
128     private ContainerNode buildErrorsContainer(final RestconfDocumentedException exception) {
129         final List<UnkeyedListEntryNode> errorEntries = exception.getErrors().stream()
130                 .map(this::createErrorEntry)
131                 .collect(Collectors.toList());
132         return ImmutableContainerNodeBuilder.create()
133                 .withNodeIdentifier(YangInstanceIdentifier.NodeIdentifier.create(
134                         RestconfModule.ERRORS_CONTAINER_QNAME))
135                 .withChild(ImmutableUnkeyedListNodeBuilder.create()
136                         .withNodeIdentifier(YangInstanceIdentifier.NodeIdentifier.create(
137                                 RestconfModule.ERROR_LIST_QNAME))
138                         .withValue(errorEntries)
139                         .build())
140                 .build();
141     }
142
143     /**
144      * Building of one error entry using provided {@link RestconfError}.
145      *
146      * @param restconfError Error details.
147      * @return Built list entry.
148      */
149     private UnkeyedListEntryNode createErrorEntry(final RestconfError restconfError) {
150         // filling in mandatory leafs
151         final DataContainerNodeBuilder<NodeIdentifier, UnkeyedListEntryNode> entryBuilder
152                 = ImmutableUnkeyedListEntryNodeBuilder.create()
153                 .withNodeIdentifier(NodeIdentifier.create(RestconfModule.ERROR_LIST_QNAME))
154                 .withChild(ImmutableNodes.leafNode(RestconfModule.ERROR_TYPE_QNAME,
155                         restconfError.getErrorType().getErrorTypeTag()))
156                 .withChild(ImmutableNodes.leafNode(RestconfModule.ERROR_TAG_QNAME,
157                         restconfError.getErrorTag().getTagValue()));
158
159         // filling in optional fields
160         if (restconfError.getErrorMessage() != null) {
161             entryBuilder.withChild(ImmutableNodes.leafNode(
162                     RestconfModule.ERROR_MESSAGE_QNAME, restconfError.getErrorMessage()));
163         }
164         if (restconfError.getErrorAppTag() != null) {
165             entryBuilder.withChild(ImmutableNodes.leafNode(
166                     RestconfModule.ERROR_APP_TAG_QNAME, restconfError.getErrorAppTag()));
167         }
168         if (restconfError.getErrorInfo() != null) {
169             // Oddly, error-info is defined as an empty container in the restconf yang. Apparently the
170             // intention is for implementors to define their own data content so we'll just treat it as a leaf
171             // with string data.
172             entryBuilder.withChild(ImmutableNodes.leafNode(
173                     RestconfModule.ERROR_INFO_QNAME, restconfError.getErrorInfo()));
174         }
175
176         if (restconfError.getErrorPath() != null) {
177             entryBuilder.withChild(ImmutableNodes.leafNode(
178                     RestconfModule.ERROR_PATH_QNAME, restconfError.getErrorPath()));
179         }
180         return entryBuilder.build();
181     }
182
183     /**
184      * Serialization of the errors container into JSON representation.
185      *
186      * @param errorsContainer To be serialized errors container.
187      * @return JSON representation of the errors container.
188      */
189     private String serializeErrorsContainerToJson(final ContainerNode errorsContainer) {
190         try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
191              OutputStreamWriter streamStreamWriter = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8);
192         ) {
193             return writeNormalizedNode(errorsContainer, outputStream, new JsonStreamWriterWithDisabledValidation(
194                 RestconfModule.ERROR_INFO_QNAME, streamStreamWriter, ERRORS_GROUPING_PATH,
195                 RestconfModule.URI_MODULE, schemaContextHandler));
196         } catch (IOException e) {
197             throw new IllegalStateException("Cannot close some of the output JSON writers", e);
198         }
199     }
200
201     /**
202      * Serialization of the errors container into XML representation.
203      *
204      * @param errorsContainer To be serialized errors container.
205      * @return XML representation of the errors container.
206      */
207     private String serializeErrorsContainerToXml(final ContainerNode errorsContainer) {
208         try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
209             return writeNormalizedNode(errorsContainer, outputStream, new XmlStreamWriterWithDisabledValidation(
210                 RestconfModule.ERROR_INFO_QNAME, outputStream, ERRORS_GROUPING_PATH, schemaContextHandler));
211         } catch (IOException e) {
212             throw new IllegalStateException("Cannot close some of the output XML writers", e);
213         }
214     }
215
216     private static String writeNormalizedNode(final NormalizedNode<?, ?> errorsContainer,
217             final ByteArrayOutputStream outputStream, final StreamWriterWithDisabledValidation streamWriter) {
218         try (NormalizedNodeWriter nnWriter = NormalizedNodeWriter.forStreamWriter(streamWriter)) {
219             nnWriter.write(errorsContainer);
220         } catch (IOException e) {
221             throw new IllegalStateException("Cannot write error response body", e);
222         }
223         return outputStream.toString(StandardCharsets.UTF_8);
224     }
225
226     /**
227      * Deriving of the status code from the thrown exception. At the first step, status code is tried to be read using
228      * {@link RestconfDocumentedException#getStatus()}. If it is {@code null}, status code will be derived from status
229      * codes appended to error entries (the first that will be found). If there are not any error entries,
230      * {@link RestconfDocumentedExceptionMapper#DEFAULT_STATUS_CODE} will be used.
231      *
232      * @param exception Thrown exception.
233      * @return Derived status code.
234      */
235     private static Status getResponseStatusCode(final RestconfDocumentedException exception) {
236         final Status status = exception.getStatus();
237         if (status != null) {
238             // status code that is specified directly as field in exception has the precedence over error entries
239             return status;
240         }
241
242         final List<RestconfError> errors = exception.getErrors();
243         if (errors.isEmpty()) {
244             // if the module, that thrown exception, doesn't specify status code, it is treated as internal
245             // server error
246             return DEFAULT_STATUS_CODE;
247         }
248
249         final Set<Integer> allStatusCodesOfErrorEntries = errors.stream()
250                 .map(restconfError -> restconfError.getErrorTag().getStatusCode())
251                 // we would like to preserve iteration order in collected entries - hence usage of LinkedHashSet
252                 .collect(Collectors.toCollection(LinkedHashSet::new));
253         // choosing of the first status code from appended errors, if there are different status codes in error
254         // entries, we should create WARN message
255         if (allStatusCodesOfErrorEntries.size() > 1) {
256             LOG.warn("An unexpected error occurred during translation of exception {} to response: "
257                     + "Different status codes have been found in appended error entries: {}. The first error "
258                     + "entry status code is chosen for response.", exception, allStatusCodesOfErrorEntries);
259         }
260         return Status.fromStatusCode(allStatusCodesOfErrorEntries.iterator().next());
261     }
262
263     /**
264      * Selection of media type that will be used for creation suffix of 'application/yang-data'. Selection criteria
265      * is described in RFC 8040, section 7.1. At the first step, accepted media-type is analyzed and only supported
266      * media-types are filtered out. If both XML and JSON media-types are accepted, JSON is selected as a default one
267      * used in RESTCONF. If accepted-media type is not specified, the media-type used in request is chosen only if it
268      * is supported one. If it is not supported or it is not specified, again, the default one (JSON) is selected.
269      *
270      * @return Media type.
271      */
272     private MediaType getSupportedMediaType() {
273         final Set<MediaType> acceptableAndSupportedMediaTypes = headers.getAcceptableMediaTypes().stream()
274                 .filter(RestconfDocumentedExceptionMapper::isCompatibleMediaType)
275                 .collect(Collectors.toSet());
276         if (acceptableAndSupportedMediaTypes.isEmpty()) {
277             // check content type of the request
278             final MediaType requestMediaType = headers.getMediaType();
279             return requestMediaType == null ? DEFAULT_MEDIA_TYPE
280                     : chooseMediaType(Collections.singletonList(requestMediaType)).orElseGet(() -> {
281                         LOG.warn("Request doesn't specify accepted media-types and the media-type '{}' used by "
282                                 + "request is not supported - using of default '{}' media-type.",
283                                 requestMediaType, DEFAULT_MEDIA_TYPE);
284                         return DEFAULT_MEDIA_TYPE;
285                     });
286         }
287
288         // at first step, fully specified types without any wildcards are considered (for example, application/json)
289         final List<MediaType> fullySpecifiedMediaTypes = acceptableAndSupportedMediaTypes.stream()
290                 .filter(mediaType -> !mediaType.isWildcardType() && !mediaType.isWildcardSubtype())
291                 .collect(Collectors.toList());
292         if (!fullySpecifiedMediaTypes.isEmpty()) {
293             return chooseAndCheckMediaType(fullySpecifiedMediaTypes);
294         }
295
296         // at the second step, only types with specified subtype are considered (for example, */json)
297         final List<MediaType> mediaTypesWithSpecifiedSubtypes = acceptableAndSupportedMediaTypes.stream()
298                 .filter(mediaType -> !mediaType.isWildcardSubtype())
299                 .collect(Collectors.toList());
300         if (!mediaTypesWithSpecifiedSubtypes.isEmpty()) {
301             return chooseAndCheckMediaType(mediaTypesWithSpecifiedSubtypes);
302         }
303
304         // at the third step, only types with specified parent are considered (for example, application/*)
305         final List<MediaType> mediaTypesWithSpecifiedParent = acceptableAndSupportedMediaTypes.stream()
306                 .filter(mediaType -> !mediaType.isWildcardType())
307                 .collect(Collectors.toList());
308         if (!mediaTypesWithSpecifiedParent.isEmpty()) {
309             return chooseAndCheckMediaType(mediaTypesWithSpecifiedParent);
310         }
311
312         // it must be fully-wildcard-ed type - */*
313         return DEFAULT_MEDIA_TYPE;
314     }
315
316     private static MediaType chooseAndCheckMediaType(final List<MediaType> options) {
317         final Optional<MediaType> mediaTypeOpt = chooseMediaType(options);
318         checkState(mediaTypeOpt.isPresent());
319         return mediaTypeOpt.get();
320     }
321
322     /**
323      * This method is responsible for choosing of he media type from multiple options. At the first step,
324      * JSON-compatible types are considered, then, if there are not any JSON types, XML types are considered. The first
325      * compatible media-type is chosen.
326      *
327      * @param options Supported media types.
328      * @return Selected one media type or {@link Optional#empty()} if none of the provided options are compatible with
329      *     RESTCONF.
330      */
331     private static Optional<MediaType> chooseMediaType(final List<MediaType> options) {
332         return options.stream()
333                 .filter(RestconfDocumentedExceptionMapper::isJsonCompatibleMediaType)
334                 .findFirst()
335                 .map(Optional::of)
336                 .orElse(options.stream()
337                         .filter(RestconfDocumentedExceptionMapper::isXmlCompatibleMediaType)
338                         .findFirst());
339     }
340
341     /**
342      * Mapping of JSON-compatible type to {@link RestconfDocumentedExceptionMapper#YANG_DATA_JSON_TYPE}
343      * or XML-compatible type to {@link RestconfDocumentedExceptionMapper#YANG_DATA_XML_TYPE}.
344      *
345      * @param mediaTypeBase Base media type from which the response media-type is built.
346      * @return Derived media type.
347      */
348     private static MediaType transformToResponseMediaType(final MediaType mediaTypeBase) {
349         if (isJsonCompatibleMediaType(mediaTypeBase)) {
350             return YANG_DATA_JSON_TYPE;
351         } else if (isXmlCompatibleMediaType(mediaTypeBase)) {
352             return YANG_DATA_XML_TYPE;
353         } else {
354             throw new IllegalStateException(String.format("Unexpected input media-type %s "
355                     + "- it should be JSON/XML compatible type.", mediaTypeBase));
356         }
357     }
358
359     private static boolean isCompatibleMediaType(final MediaType mediaType) {
360         return isJsonCompatibleMediaType(mediaType) || isXmlCompatibleMediaType(mediaType);
361     }
362
363     private static boolean isJsonCompatibleMediaType(final MediaType mediaType) {
364         return mediaType.isCompatible(MediaType.APPLICATION_JSON_TYPE)
365                 || mediaType.isCompatible(YANG_DATA_JSON_TYPE)
366                 || mediaType.isCompatible(YANG_PATCH_RFC8072_JSON_TYPE)
367                 || mediaType.isCompatible(YANG_PATCH_JSON_TYPE);
368     }
369
370     private static boolean isXmlCompatibleMediaType(final MediaType mediaType) {
371         return mediaType.isCompatible(MediaType.APPLICATION_XML_TYPE)
372                 || mediaType.isCompatible(YANG_DATA_XML_TYPE)
373                 || mediaType.isCompatible(YANG_PATCH_RFC8072_XML_TYPE)
374                 || mediaType.isCompatible(YANG_PATCH_XML_TYPE);
375     }
376
377     /**
378      * Used just for testing purposes - simulation of HTTP headers with different accepted types and content type.
379      *
380      * @param httpHeaders Mocked HTTP headers.
381      */
382     @VisibleForTesting
383     void setHttpHeaders(final HttpHeaders httpHeaders) {
384         this.headers = httpHeaders;
385     }
386 }