Bump upstreams
[netconf.git] / restconf / restconf-nb / 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 java.util.Objects.requireNonNull;
11 import static org.opendaylight.yang.svc.v1.urn.ietf.params.xml.ns.yang.ietf.restconf.rev170126.YangModuleInfoImpl.qnameOf;
12
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.io.StringReader;
19 import java.io.StringWriter;
20 import java.nio.charset.StandardCharsets;
21 import java.util.Collections;
22 import java.util.LinkedHashSet;
23 import java.util.List;
24 import java.util.Optional;
25 import java.util.Set;
26 import java.util.stream.Collectors;
27 import javax.ws.rs.core.Context;
28 import javax.ws.rs.core.HttpHeaders;
29 import javax.ws.rs.core.MediaType;
30 import javax.ws.rs.core.Response;
31 import javax.ws.rs.core.Response.Status;
32 import javax.ws.rs.ext.ExceptionMapper;
33 import javax.ws.rs.ext.Provider;
34 import javax.xml.stream.XMLOutputFactory;
35 import javax.xml.stream.XMLStreamException;
36 import javax.xml.transform.OutputKeys;
37 import javax.xml.transform.TransformerException;
38 import javax.xml.transform.TransformerFactory;
39 import javax.xml.transform.stream.StreamResult;
40 import javax.xml.transform.stream.StreamSource;
41 import org.opendaylight.restconf.common.errors.RestconfDocumentedException;
42 import org.opendaylight.restconf.nb.jaxrs.JaxRsMediaTypes;
43 import org.opendaylight.restconf.nb.rfc8040.legacy.ErrorTags;
44 import org.opendaylight.restconf.server.api.DatabindContext;
45 import org.opendaylight.restconf.server.spi.DatabindProvider;
46 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.restconf.rev170126.errors.Errors;
47 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.restconf.rev170126.errors.errors.Error;
48 import org.opendaylight.yangtools.yang.common.QName;
49 import org.opendaylight.yangtools.yang.data.codec.gson.JsonWriterFactory;
50 import org.slf4j.Logger;
51 import org.slf4j.LoggerFactory;
52
53 /**
54  * An {@link ExceptionMapper} that is responsible for transformation of thrown {@link RestconfDocumentedException} to
55  * {@code errors} structure that is modelled by RESTCONF module (see section 8 of RFC-8040).
56  *
57  * @see Errors
58  */
59 // FIXME: NETCONF-1188: eliminate the need for this class by having a separate exception which a has a HTTP status and
60 //                      optionally holds an ErrorsBody -- i.e. the equivalent of Errors, perhaps as NormalizedNode,
61 //                      with sufficient context to send it to JSON or XML -- very similar to a NormalizedNodePayload
62 @Deprecated
63 @Provider
64 public final class RestconfDocumentedExceptionMapper implements ExceptionMapper<RestconfDocumentedException> {
65     private static final Logger LOG = LoggerFactory.getLogger(RestconfDocumentedExceptionMapper.class);
66     private static final MediaType DEFAULT_MEDIA_TYPE = MediaType.APPLICATION_JSON_TYPE;
67     private static final Status DEFAULT_STATUS_CODE = Status.INTERNAL_SERVER_ERROR;
68     private static final QName ERROR_TYPE_QNAME = qnameOf("error-type");
69     private static final QName ERROR_TAG_QNAME = qnameOf("error-tag");
70     private static final QName ERROR_APP_TAG_QNAME = qnameOf("error-app-tag");
71     private static final QName ERROR_MESSAGE_QNAME = qnameOf("error-message");
72     private static final QName ERROR_INFO_QNAME = qnameOf("error-info");
73     private static final QName ERROR_PATH_QNAME = qnameOf("error-path");
74     private static final int DEFAULT_INDENT_SPACES_NUM = 2;
75     private static final XMLOutputFactory XML_OUTPUT_FACTORY;
76
77     static {
78         XML_OUTPUT_FACTORY = XMLOutputFactory.newFactory();
79         XML_OUTPUT_FACTORY.setProperty(XMLOutputFactory.IS_REPAIRING_NAMESPACES, true);
80     }
81
82     private final DatabindProvider databindProvider;
83
84     @Context
85     private HttpHeaders headers;
86
87     /**
88      * Initialization of the exception mapper.
89      *
90      * @param databindProvider A {@link DatabindProvider}
91      */
92     public RestconfDocumentedExceptionMapper(final DatabindProvider databindProvider) {
93         this.databindProvider = requireNonNull(databindProvider);
94     }
95
96     @Override
97     @SuppressFBWarnings(value = "SLF4J_MANUALLY_PROVIDED_MESSAGE", justification = "In the debug messages "
98             + "we don't to have full stack trace - getMessage(..) method provides finer output.")
99     public Response toResponse(final RestconfDocumentedException exception) {
100         LOG.debug("Starting to map received exception to error response: {}", exception.getMessage());
101         final Status responseStatus = getResponseStatusCode(exception);
102         if (responseStatus != Response.Status.FORBIDDEN
103                 && responseStatus.getFamily() == Response.Status.Family.CLIENT_ERROR
104                 && exception.getErrors().isEmpty()) {
105             // There should be at least one error entry for 4xx errors except 409 according to RFC8040, but we do not
106             // have it. Issue a warning with the call trace so we can fix whoever was the originator.
107             LOG.warn("Input exception has a family of 4xx but does not contain any descriptive errors", exception);
108         }
109
110         final String serializedResponseBody;
111         final MediaType responseMediaType = transformToResponseMediaType(getSupportedMediaType());
112         if (JaxRsMediaTypes.APPLICATION_YANG_DATA_JSON.equals(responseMediaType)) {
113             serializedResponseBody = serializeExceptionToJson(exception, databindProvider);
114         } else {
115             serializedResponseBody = serializeExceptionToXml(exception, databindProvider);
116         }
117
118         final Response preparedResponse = Response.status(responseStatus)
119                 .type(responseMediaType)
120                 .entity(serializedResponseBody)
121                 .build();
122         LOG.debug("Exception {} has been successfully mapped to response: {}",
123                 exception.getMessage(), preparedResponse);
124         return preparedResponse;
125     }
126
127     /**
128      * Serialization exceptions into JSON representation.
129      *
130      * @param exception To be serialized exception.
131      * @param databindProvider Holder of current {@code DatabindContext}.
132      * @return JSON representation of the exception.
133      */
134     private static String serializeExceptionToJson(final RestconfDocumentedException exception,
135             final DatabindProvider databindProvider) {
136         try (var outputStream = new ByteArrayOutputStream();
137              var streamWriter = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8);
138              var jsonWriter = JsonWriterFactory.createJsonWriter(streamWriter, DEFAULT_INDENT_SPACES_NUM)) {
139             final var currentDatabindContext = exception.modelContext() != null
140                 ? DatabindContext.ofModel(exception.modelContext()) : databindProvider.currentDatabind();
141             jsonWriter.beginObject();
142             final var errors = exception.getErrors();
143             if (errors != null && !errors.isEmpty()) {
144                 jsonWriter.name(Errors.QNAME.getLocalName()).beginObject();
145                 jsonWriter.name(Error.QNAME.getLocalName()).beginArray();
146                 for (final var error : errors) {
147                     jsonWriter.beginObject()
148                         .name(ERROR_TAG_QNAME.getLocalName()).value(error.getErrorTag().elementBody());
149                     final var errorAppTag = error.getErrorAppTag();
150                     if (errorAppTag != null) {
151                         jsonWriter.name(ERROR_APP_TAG_QNAME.getLocalName()).value(errorAppTag);
152                     }
153                     final var errorInfo = error.getErrorInfo();
154                     if (errorInfo != null) {
155                         jsonWriter.name(ERROR_INFO_QNAME.getLocalName()).value(errorInfo);
156                     }
157                     final var errorMessage = error.getErrorMessage();
158                     if (errorMessage != null) {
159                         jsonWriter.name(ERROR_MESSAGE_QNAME.getLocalName()).value(errorMessage);
160                     }
161                     final var errorPath = error.getErrorPath();
162                     if (errorPath != null) {
163                         jsonWriter.name(ERROR_PATH_QNAME.getLocalName());
164                         currentDatabindContext.jsonCodecs().instanceIdentifierCodec()
165                             .writeValue(jsonWriter, errorPath);
166                     }
167                     jsonWriter.name(ERROR_TYPE_QNAME.getLocalName()).value(error.getErrorType().elementBody());
168                     jsonWriter.endObject();
169                 }
170                 jsonWriter.endArray().endObject();
171             }
172             jsonWriter.endObject();
173             streamWriter.flush();
174             return outputStream.toString(StandardCharsets.UTF_8);
175         } catch (IOException e) {
176             throw new IllegalStateException("Error while serializing restconf exception into JSON", e);
177         }
178     }
179
180     /**
181      * Serialization exceptions into XML representation.
182      *
183      * @param exception To be serialized exception.
184      * @param databindProvider Holder of current {@code DatabindContext}.
185      * @return XML representation of the exception.
186      */
187     private static String serializeExceptionToXml(final RestconfDocumentedException exception,
188             final DatabindProvider databindProvider) {
189         try (var outputStream = new ByteArrayOutputStream()) {
190             final var xmlWriter = XML_OUTPUT_FACTORY.createXMLStreamWriter(outputStream,
191                 StandardCharsets.UTF_8.name());
192             final var currentDatabindContext = exception.modelContext() != null
193                 ? DatabindContext.ofModel(exception.modelContext()) : databindProvider.currentDatabind();
194             xmlWriter.writeStartDocument();
195             xmlWriter.writeStartElement(Errors.QNAME.getLocalName());
196             xmlWriter.writeDefaultNamespace(Errors.QNAME.getNamespace().toString());
197             if (exception.getErrors() != null && !exception.getErrors().isEmpty()) {
198                 for (final var error : exception.getErrors()) {
199                     xmlWriter.writeStartElement(Error.QNAME.getLocalName());
200                     // Write error-type element
201                     xmlWriter.writeStartElement(ERROR_TYPE_QNAME.getLocalName());
202                     xmlWriter.writeCharacters(error.getErrorType().elementBody());
203                     xmlWriter.writeEndElement();
204
205                     if (error.getErrorPath() != null) {
206                         xmlWriter.writeStartElement(ERROR_PATH_QNAME.getLocalName());
207                         currentDatabindContext.xmlCodecs().instanceIdentifierCodec()
208                             .writeValue(xmlWriter, error.getErrorPath());
209                         xmlWriter.writeEndElement();
210                     }
211                     if (error.getErrorMessage() != null) {
212                         xmlWriter.writeStartElement(ERROR_MESSAGE_QNAME.getLocalName());
213                         xmlWriter.writeCharacters(error.getErrorMessage());
214                         xmlWriter.writeEndElement();
215                     }
216
217                     // Write error-tag element
218                     xmlWriter.writeStartElement(ERROR_TAG_QNAME.getLocalName());
219                     xmlWriter.writeCharacters(error.getErrorTag().elementBody());
220                     xmlWriter.writeEndElement();
221
222                     if (error.getErrorAppTag() != null) {
223                         xmlWriter.writeStartElement(ERROR_APP_TAG_QNAME.getLocalName());
224                         xmlWriter.writeCharacters(error.getErrorAppTag());
225                         xmlWriter.writeEndElement();
226                     }
227                     if (error.getErrorInfo() != null) {
228                         xmlWriter.writeStartElement(ERROR_INFO_QNAME.getLocalName());
229                         xmlWriter.writeCharacters(error.getErrorInfo());
230                         xmlWriter.writeEndElement();
231                     }
232                     xmlWriter.writeEndElement();
233                 }
234             }
235             xmlWriter.writeEndElement();
236             xmlWriter.writeEndDocument();
237             xmlWriter.close();
238
239             final var transformerFactory = TransformerFactory.newInstance();
240             final var transformer = transformerFactory.newTransformer();
241             transformer.setOutputProperty(OutputKeys.INDENT, "yes");
242             // 2 spaces for indentation
243             transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount",
244                 String.valueOf(DEFAULT_INDENT_SPACES_NUM));
245             transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes");
246             final var xmlSource = new StreamSource(new StringReader(outputStream.toString(StandardCharsets.UTF_8)));
247             final var stringWriter = new StringWriter();
248             final var streamResult = new StreamResult(stringWriter);
249             transformer.transform(xmlSource, streamResult);
250             return stringWriter.toString();
251         } catch (IOException | XMLStreamException | TransformerException e) {
252             throw new IllegalStateException("Error while serializing restconf exception into XML", e);
253         }
254     }
255
256     /**
257      * Deriving of the status code from the thrown exception. At the first step, status code is tried to be read using
258      * {@link RestconfDocumentedException#getStatus()}. If it is {@code null}, status code will be derived from status
259      * codes appended to error entries (the first that will be found). If there are not any error entries,
260      * {@link RestconfDocumentedExceptionMapper#DEFAULT_STATUS_CODE} will be used.
261      *
262      * @param exception Thrown exception.
263      * @return Derived status code.
264      */
265     private static Status getResponseStatusCode(final RestconfDocumentedException exception) {
266         final var errors = exception.getErrors();
267         if (errors.isEmpty()) {
268             // if the module, that thrown exception, doesn't specify status code, it is treated as internal
269             // server error
270             return DEFAULT_STATUS_CODE;
271         }
272
273         final var allStatusCodesOfErrorEntries = errors.stream()
274                 .map(restconfError -> ErrorTags.statusOf(restconfError.getErrorTag()))
275                 // we would like to preserve iteration order in collected entries - hence usage of LinkedHashSet
276                 .collect(Collectors.toCollection(LinkedHashSet::new));
277         // choosing of the first status code from appended errors, if there are different status codes in error
278         // entries, we should create WARN message
279         if (allStatusCodesOfErrorEntries.size() > 1) {
280             LOG.warn("""
281                 An unexpected error occurred during translation of exception {} to response: Different status codes
282                 have been found in appended error entries: {}. The first error entry status code is chosen for
283                 response.""", exception, allStatusCodesOfErrorEntries);
284         }
285         return allStatusCodesOfErrorEntries.iterator().next();
286     }
287
288     /**
289      * Selection of media type that will be used for creation suffix of 'application/yang-data'. Selection criteria
290      * is described in RFC 8040, section 7.1. At the first step, accepted media-type is analyzed and only supported
291      * media-types are filtered out. If both XML and JSON media-types are accepted, JSON is selected as a default one
292      * used in RESTCONF. If accepted-media type is not specified, the media-type used in request is chosen only if it
293      * is supported one. If it is not supported or it is not specified, again, the default one (JSON) is selected.
294      *
295      * @return Media type.
296      */
297     private MediaType getSupportedMediaType() {
298         final Set<MediaType> acceptableAndSupportedMediaTypes = headers.getAcceptableMediaTypes().stream()
299                 .filter(RestconfDocumentedExceptionMapper::isCompatibleMediaType)
300                 .collect(Collectors.toSet());
301         if (acceptableAndSupportedMediaTypes.isEmpty()) {
302             // check content type of the request
303             final MediaType requestMediaType = headers.getMediaType();
304             return requestMediaType == null ? DEFAULT_MEDIA_TYPE
305                     : chooseMediaType(Collections.singletonList(requestMediaType)).orElseGet(() -> {
306                         LOG.warn("Request doesn't specify accepted media-types and the media-type '{}' used by "
307                                 + "request is not supported - using of default '{}' media-type.",
308                                 requestMediaType, DEFAULT_MEDIA_TYPE);
309                         return DEFAULT_MEDIA_TYPE;
310                     });
311         }
312
313         // at first step, fully specified types without any wildcards are considered (for example, application/json)
314         final List<MediaType> fullySpecifiedMediaTypes = acceptableAndSupportedMediaTypes.stream()
315                 .filter(mediaType -> !mediaType.isWildcardType() && !mediaType.isWildcardSubtype())
316                 .collect(Collectors.toList());
317         if (!fullySpecifiedMediaTypes.isEmpty()) {
318             return chooseAndCheckMediaType(fullySpecifiedMediaTypes);
319         }
320
321         // at the second step, only types with specified subtype are considered (for example, */json)
322         final List<MediaType> mediaTypesWithSpecifiedSubtypes = acceptableAndSupportedMediaTypes.stream()
323                 .filter(mediaType -> !mediaType.isWildcardSubtype())
324                 .collect(Collectors.toList());
325         if (!mediaTypesWithSpecifiedSubtypes.isEmpty()) {
326             return chooseAndCheckMediaType(mediaTypesWithSpecifiedSubtypes);
327         }
328
329         // at the third step, only types with specified parent are considered (for example, application/*)
330         final List<MediaType> mediaTypesWithSpecifiedParent = acceptableAndSupportedMediaTypes.stream()
331                 .filter(mediaType -> !mediaType.isWildcardType())
332                 .collect(Collectors.toList());
333         if (!mediaTypesWithSpecifiedParent.isEmpty()) {
334             return chooseAndCheckMediaType(mediaTypesWithSpecifiedParent);
335         }
336
337         // it must be fully-wildcard-ed type - */*
338         return DEFAULT_MEDIA_TYPE;
339     }
340
341     private static MediaType chooseAndCheckMediaType(final List<MediaType> options) {
342         return chooseMediaType(options).orElseThrow(IllegalStateException::new);
343     }
344
345     /**
346      * This method is responsible for choosing of he media type from multiple options. At the first step,
347      * JSON-compatible types are considered, then, if there are not any JSON types, XML types are considered. The first
348      * compatible media-type is chosen.
349      *
350      * @param options Supported media types.
351      * @return Selected one media type or {@link Optional#empty()} if none of the provided options are compatible with
352      *     RESTCONF.
353      */
354     private static Optional<MediaType> chooseMediaType(final List<MediaType> options) {
355         return options.stream()
356                 .filter(RestconfDocumentedExceptionMapper::isJsonCompatibleMediaType)
357                 .findFirst()
358                 .map(Optional::of)
359                 .orElse(options.stream()
360                         .filter(RestconfDocumentedExceptionMapper::isXmlCompatibleMediaType)
361                         .findFirst());
362     }
363
364     /**
365      * Mapping of JSON-compatible type to {@link RestconfDocumentedExceptionMapper#YANG_DATA_JSON_TYPE}
366      * or XML-compatible type to {@link RestconfDocumentedExceptionMapper#YANG_DATA_XML_TYPE}.
367      *
368      * @param mediaTypeBase Base media type from which the response media-type is built.
369      * @return Derived media type.
370      */
371     private static MediaType transformToResponseMediaType(final MediaType mediaTypeBase) {
372         if (isJsonCompatibleMediaType(mediaTypeBase)) {
373             return JaxRsMediaTypes.APPLICATION_YANG_DATA_JSON;
374         } else if (isXmlCompatibleMediaType(mediaTypeBase)) {
375             return JaxRsMediaTypes.APPLICATION_YANG_DATA_XML;
376         } else {
377             throw new IllegalStateException(String.format("Unexpected input media-type %s "
378                     + "- it should be JSON/XML compatible type.", mediaTypeBase));
379         }
380     }
381
382     private static boolean isCompatibleMediaType(final MediaType mediaType) {
383         return isJsonCompatibleMediaType(mediaType) || isXmlCompatibleMediaType(mediaType);
384     }
385
386     private static boolean isJsonCompatibleMediaType(final MediaType mediaType) {
387         return mediaType.isCompatible(MediaType.APPLICATION_JSON_TYPE)
388                 || mediaType.isCompatible(JaxRsMediaTypes.APPLICATION_YANG_DATA_JSON)
389                 || mediaType.isCompatible(JaxRsMediaTypes.APPLICATION_YANG_PATCH_JSON);
390     }
391
392     private static boolean isXmlCompatibleMediaType(final MediaType mediaType) {
393         return mediaType.isCompatible(MediaType.APPLICATION_XML_TYPE)
394                 || mediaType.isCompatible(JaxRsMediaTypes.APPLICATION_YANG_DATA_XML)
395                 || mediaType.isCompatible(JaxRsMediaTypes.APPLICATION_YANG_PATCH_XML);
396     }
397
398     /**
399      * Used just for testing purposes - simulation of HTTP headers with different accepted types and content type.
400      *
401      * @param httpHeaders Mocked HTTP headers.
402      */
403     @VisibleForTesting
404     void setHttpHeaders(final HttpHeaders httpHeaders) {
405         headers = httpHeaders;
406     }
407 }