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