JSON: Resolve 500 response from device exception
[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.gen.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.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.jaxrs.JaxRsMediaTypes;
35 import org.opendaylight.restconf.nb.rfc8040.legacy.ErrorTags;
36 import org.opendaylight.restconf.server.api.DatabindContext;
37 import org.opendaylight.restconf.server.spi.DatabindProvider;
38 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.restconf.rev170126.errors.Errors;
39 import org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.restconf.rev170126.errors.errors.Error;
40 import org.opendaylight.yangtools.yang.common.QName;
41 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeIdentifier;
42 import org.opendaylight.yangtools.yang.data.api.schema.ContainerNode;
43 import org.opendaylight.yangtools.yang.data.api.schema.NormalizedNode;
44 import org.opendaylight.yangtools.yang.data.api.schema.UnkeyedListEntryNode;
45 import org.opendaylight.yangtools.yang.data.api.schema.stream.NormalizedNodeWriter;
46 import org.opendaylight.yangtools.yang.data.codec.gson.JsonWriterFactory;
47 import org.opendaylight.yangtools.yang.data.impl.schema.Builders;
48 import org.opendaylight.yangtools.yang.data.impl.schema.ImmutableNodes;
49 import org.slf4j.Logger;
50 import org.slf4j.LoggerFactory;
51
52 /**
53  * An {@link ExceptionMapper} that is responsible for transformation of thrown {@link RestconfDocumentedException} to
54  * {@code errors} structure that is modelled by RESTCONF module (see section 8 of RFC-8040).
55  *
56  * @see Errors
57  */
58 // FIXME: NETCONF-1188: eliminate the need for this class by having a separate exception which a has a HTTP status and
59 //                      optionally holds an ErrorsBody -- i.e. the equivalent of Errors, perhaps as NormalizedNode,
60 //                      with sufficient context to send it to JSON or XML -- very similar to a NormalizedNodePayload
61 @Deprecated
62 @Provider
63 public final class RestconfDocumentedExceptionMapper implements ExceptionMapper<RestconfDocumentedException> {
64     private static final Logger LOG = LoggerFactory.getLogger(RestconfDocumentedExceptionMapper.class);
65     private static final MediaType DEFAULT_MEDIA_TYPE = MediaType.APPLICATION_JSON_TYPE;
66     private static final Status DEFAULT_STATUS_CODE = Status.INTERNAL_SERVER_ERROR;
67     private static final QName ERROR_TYPE_QNAME = qnameOf("error-type");
68     private static final QName ERROR_TAG_QNAME = qnameOf("error-tag");
69     private static final QName ERROR_APP_TAG_QNAME = qnameOf("error-app-tag");
70     private static final QName ERROR_MESSAGE_QNAME = qnameOf("error-message");
71     // FIXME make this private
72     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
76     private final DatabindProvider databindProvider;
77
78     @Context
79     private HttpHeaders headers;
80
81     /**
82      * Initialization of the exception mapper.
83      *
84      * @param databindProvider A {@link DatabindProvider}
85      */
86     public RestconfDocumentedExceptionMapper(final DatabindProvider databindProvider) {
87         this.databindProvider = requireNonNull(databindProvider);
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 (JaxRsMediaTypes.APPLICATION_YANG_DATA_JSON.equals(responseMediaType)) {
108             serializedResponseBody = serializeExceptionToJson(exception, databindProvider);
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 static ContainerNode buildErrorsContainer(final RestconfDocumentedException exception) {
129         return Builders.containerBuilder()
130             .withNodeIdentifier(NodeIdentifier.create(Errors.QNAME))
131             .withChild(Builders.unkeyedListBuilder()
132                 .withNodeIdentifier(NodeIdentifier.create(Error.QNAME))
133                 .withValue(exception.getErrors().stream()
134                     .map(RestconfDocumentedExceptionMapper::createErrorEntry)
135                     .collect(Collectors.toList()))
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 static UnkeyedListEntryNode createErrorEntry(final RestconfError restconfError) {
147         // filling in mandatory leafs
148         final var entryBuilder = Builders.unkeyedListEntryBuilder()
149             .withNodeIdentifier(NodeIdentifier.create(Error.QNAME))
150             .withChild(ImmutableNodes.leafNode(ERROR_TYPE_QNAME, restconfError.getErrorType().elementBody()))
151             .withChild(ImmutableNodes.leafNode(ERROR_TAG_QNAME, restconfError.getErrorTag().elementBody()));
152
153         // filling in optional fields
154         if (restconfError.getErrorMessage() != null) {
155             entryBuilder.withChild(ImmutableNodes.leafNode(ERROR_MESSAGE_QNAME, restconfError.getErrorMessage()));
156         }
157         if (restconfError.getErrorAppTag() != null) {
158             entryBuilder.withChild(ImmutableNodes.leafNode(ERROR_APP_TAG_QNAME, restconfError.getErrorAppTag()));
159         }
160         if (restconfError.getErrorInfo() != null) {
161             // Oddly, error-info is defined as an empty container in the restconf yang. Apparently the
162             // intention is for implementors to define their own data content so we'll just treat it as a leaf
163             // with string data.
164             entryBuilder.withChild(ImmutableNodes.leafNode(ERROR_INFO_QNAME, restconfError.getErrorInfo()));
165         }
166
167         if (restconfError.getErrorPath() != null) {
168             entryBuilder.withChild(ImmutableNodes.leafNode(ERROR_PATH_QNAME, restconfError.getErrorPath()));
169         }
170         return entryBuilder.build();
171     }
172
173     /**
174      * Serialization exceptions into JSON representation.
175      *
176      * @param exception To be serialized exception.
177      * @param databindProvider Holder of current {@code DatabindContext}.
178      * @return JSON representation of the exception.
179      */
180     private static String serializeExceptionToJson(final RestconfDocumentedException exception,
181             final DatabindProvider databindProvider) {
182         try (var outputStream = new ByteArrayOutputStream();
183              var streamWriter = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8);
184              var jsonWriter = JsonWriterFactory.createJsonWriter(streamWriter, DEFAULT_INDENT_SPACES_NUM)) {
185             final var currentDatabindContext = exception.modelContext() != null
186                 ? DatabindContext.ofModel(exception.modelContext()) : databindProvider.currentDatabind();
187             jsonWriter.beginObject();
188             final var errors = exception.getErrors();
189             if (errors != null && !errors.isEmpty()) {
190                 jsonWriter.name(Errors.QNAME.getLocalName()).beginObject();
191                 jsonWriter.name(Error.QNAME.getLocalName()).beginArray();
192                 for (final var error : errors) {
193                     jsonWriter.beginObject()
194                         .name(ERROR_TAG_QNAME.getLocalName()).value(error.getErrorTag().elementBody());
195                     final var errorAppTag = error.getErrorAppTag();
196                     if (errorAppTag != null) {
197                         jsonWriter.name(ERROR_APP_TAG_QNAME.getLocalName()).value(errorAppTag);
198                     }
199                     final var errorInfo = error.getErrorInfo();
200                     if (errorInfo != null) {
201                         jsonWriter.name(ERROR_INFO_QNAME.getLocalName()).value(errorInfo);
202                     }
203                     final var errorMessage = error.getErrorMessage();
204                     if (errorMessage != null) {
205                         jsonWriter.name(ERROR_MESSAGE_QNAME.getLocalName()).value(errorMessage);
206                     }
207                     final var errorPath = error.getErrorPath();
208                     if (errorPath != null) {
209                         jsonWriter.name(ERROR_PATH_QNAME.getLocalName());
210                         currentDatabindContext.jsonCodecs().instanceIdentifierCodec()
211                             .writeValue(jsonWriter, errorPath);
212                     }
213                     jsonWriter.name(ERROR_TYPE_QNAME.getLocalName()).value(error.getErrorType().elementBody());
214                     jsonWriter.endObject();
215                 }
216                 jsonWriter.endArray().endObject();
217             }
218             jsonWriter.endObject();
219             streamWriter.flush();
220             return outputStream.toString(StandardCharsets.UTF_8);
221         } catch (IOException e) {
222             throw new IllegalStateException("Error while serializing restconf exception into JSON", e);
223         }
224     }
225
226     /**
227      * Serialization of the errors container into XML representation.
228      *
229      * @param errorsContainer To be serialized errors container.
230      * @return XML representation of the errors container.
231      */
232     private String serializeErrorsContainerToXml(final ContainerNode errorsContainer) {
233         try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
234             return writeNormalizedNode(errorsContainer, outputStream,
235                 new XmlStreamWriterWithDisabledValidation(databindProvider.currentDatabind(), outputStream));
236         } catch (IOException e) {
237             throw new IllegalStateException("Cannot close some of the output XML writers", e);
238         }
239     }
240
241     private static String writeNormalizedNode(final NormalizedNode errorsContainer,
242             final ByteArrayOutputStream outputStream, final StreamWriterWithDisabledValidation streamWriter) {
243         try (NormalizedNodeWriter nnWriter = NormalizedNodeWriter.forStreamWriter(streamWriter)) {
244             nnWriter.write(errorsContainer);
245         } catch (IOException e) {
246             throw new IllegalStateException("Cannot write error response body", e);
247         }
248         return outputStream.toString(StandardCharsets.UTF_8);
249     }
250
251     /**
252      * Deriving of the status code from the thrown exception. At the first step, status code is tried to be read using
253      * {@link RestconfDocumentedException#getStatus()}. If it is {@code null}, status code will be derived from status
254      * codes appended to error entries (the first that will be found). If there are not any error entries,
255      * {@link RestconfDocumentedExceptionMapper#DEFAULT_STATUS_CODE} will be used.
256      *
257      * @param exception Thrown exception.
258      * @return Derived status code.
259      */
260     private static Status getResponseStatusCode(final RestconfDocumentedException exception) {
261         final var errors = exception.getErrors();
262         if (errors.isEmpty()) {
263             // if the module, that thrown exception, doesn't specify status code, it is treated as internal
264             // server error
265             return DEFAULT_STATUS_CODE;
266         }
267
268         final var allStatusCodesOfErrorEntries = errors.stream()
269                 .map(restconfError -> ErrorTags.statusOf(restconfError.getErrorTag()))
270                 // we would like to preserve iteration order in collected entries - hence usage of LinkedHashSet
271                 .collect(Collectors.toCollection(LinkedHashSet::new));
272         // choosing of the first status code from appended errors, if there are different status codes in error
273         // entries, we should create WARN message
274         if (allStatusCodesOfErrorEntries.size() > 1) {
275             LOG.warn("""
276                 An unexpected error occurred during translation of exception {} to response: Different status codes
277                 have been found in appended error entries: {}. The first error entry status code is chosen for
278                 response.""", exception, allStatusCodesOfErrorEntries);
279         }
280         return allStatusCodesOfErrorEntries.iterator().next();
281     }
282
283     /**
284      * Selection of media type that will be used for creation suffix of 'application/yang-data'. Selection criteria
285      * is described in RFC 8040, section 7.1. At the first step, accepted media-type is analyzed and only supported
286      * media-types are filtered out. If both XML and JSON media-types are accepted, JSON is selected as a default one
287      * used in RESTCONF. If accepted-media type is not specified, the media-type used in request is chosen only if it
288      * is supported one. If it is not supported or it is not specified, again, the default one (JSON) is selected.
289      *
290      * @return Media type.
291      */
292     private MediaType getSupportedMediaType() {
293         final Set<MediaType> acceptableAndSupportedMediaTypes = headers.getAcceptableMediaTypes().stream()
294                 .filter(RestconfDocumentedExceptionMapper::isCompatibleMediaType)
295                 .collect(Collectors.toSet());
296         if (acceptableAndSupportedMediaTypes.isEmpty()) {
297             // check content type of the request
298             final MediaType requestMediaType = headers.getMediaType();
299             return requestMediaType == null ? DEFAULT_MEDIA_TYPE
300                     : chooseMediaType(Collections.singletonList(requestMediaType)).orElseGet(() -> {
301                         LOG.warn("Request doesn't specify accepted media-types and the media-type '{}' used by "
302                                 + "request is not supported - using of default '{}' media-type.",
303                                 requestMediaType, DEFAULT_MEDIA_TYPE);
304                         return DEFAULT_MEDIA_TYPE;
305                     });
306         }
307
308         // at first step, fully specified types without any wildcards are considered (for example, application/json)
309         final List<MediaType> fullySpecifiedMediaTypes = acceptableAndSupportedMediaTypes.stream()
310                 .filter(mediaType -> !mediaType.isWildcardType() && !mediaType.isWildcardSubtype())
311                 .collect(Collectors.toList());
312         if (!fullySpecifiedMediaTypes.isEmpty()) {
313             return chooseAndCheckMediaType(fullySpecifiedMediaTypes);
314         }
315
316         // at the second step, only types with specified subtype are considered (for example, */json)
317         final List<MediaType> mediaTypesWithSpecifiedSubtypes = acceptableAndSupportedMediaTypes.stream()
318                 .filter(mediaType -> !mediaType.isWildcardSubtype())
319                 .collect(Collectors.toList());
320         if (!mediaTypesWithSpecifiedSubtypes.isEmpty()) {
321             return chooseAndCheckMediaType(mediaTypesWithSpecifiedSubtypes);
322         }
323
324         // at the third step, only types with specified parent are considered (for example, application/*)
325         final List<MediaType> mediaTypesWithSpecifiedParent = acceptableAndSupportedMediaTypes.stream()
326                 .filter(mediaType -> !mediaType.isWildcardType())
327                 .collect(Collectors.toList());
328         if (!mediaTypesWithSpecifiedParent.isEmpty()) {
329             return chooseAndCheckMediaType(mediaTypesWithSpecifiedParent);
330         }
331
332         // it must be fully-wildcard-ed type - */*
333         return DEFAULT_MEDIA_TYPE;
334     }
335
336     private static MediaType chooseAndCheckMediaType(final List<MediaType> options) {
337         return chooseMediaType(options).orElseThrow(IllegalStateException::new);
338     }
339
340     /**
341      * This method is responsible for choosing of he media type from multiple options. At the first step,
342      * JSON-compatible types are considered, then, if there are not any JSON types, XML types are considered. The first
343      * compatible media-type is chosen.
344      *
345      * @param options Supported media types.
346      * @return Selected one media type or {@link Optional#empty()} if none of the provided options are compatible with
347      *     RESTCONF.
348      */
349     private static Optional<MediaType> chooseMediaType(final List<MediaType> options) {
350         return options.stream()
351                 .filter(RestconfDocumentedExceptionMapper::isJsonCompatibleMediaType)
352                 .findFirst()
353                 .map(Optional::of)
354                 .orElse(options.stream()
355                         .filter(RestconfDocumentedExceptionMapper::isXmlCompatibleMediaType)
356                         .findFirst());
357     }
358
359     /**
360      * Mapping of JSON-compatible type to {@link RestconfDocumentedExceptionMapper#YANG_DATA_JSON_TYPE}
361      * or XML-compatible type to {@link RestconfDocumentedExceptionMapper#YANG_DATA_XML_TYPE}.
362      *
363      * @param mediaTypeBase Base media type from which the response media-type is built.
364      * @return Derived media type.
365      */
366     private static MediaType transformToResponseMediaType(final MediaType mediaTypeBase) {
367         if (isJsonCompatibleMediaType(mediaTypeBase)) {
368             return JaxRsMediaTypes.APPLICATION_YANG_DATA_JSON;
369         } else if (isXmlCompatibleMediaType(mediaTypeBase)) {
370             return JaxRsMediaTypes.APPLICATION_YANG_DATA_XML;
371         } else {
372             throw new IllegalStateException(String.format("Unexpected input media-type %s "
373                     + "- it should be JSON/XML compatible type.", mediaTypeBase));
374         }
375     }
376
377     private static boolean isCompatibleMediaType(final MediaType mediaType) {
378         return isJsonCompatibleMediaType(mediaType) || isXmlCompatibleMediaType(mediaType);
379     }
380
381     private static boolean isJsonCompatibleMediaType(final MediaType mediaType) {
382         return mediaType.isCompatible(MediaType.APPLICATION_JSON_TYPE)
383                 || mediaType.isCompatible(JaxRsMediaTypes.APPLICATION_YANG_DATA_JSON)
384                 || mediaType.isCompatible(JaxRsMediaTypes.APPLICATION_YANG_PATCH_JSON);
385     }
386
387     private static boolean isXmlCompatibleMediaType(final MediaType mediaType) {
388         return mediaType.isCompatible(MediaType.APPLICATION_XML_TYPE)
389                 || mediaType.isCompatible(JaxRsMediaTypes.APPLICATION_YANG_DATA_XML)
390                 || mediaType.isCompatible(JaxRsMediaTypes.APPLICATION_YANG_PATCH_XML);
391     }
392
393     /**
394      * Used just for testing purposes - simulation of HTTP headers with different accepted types and content type.
395      *
396      * @param httpHeaders Mocked HTTP headers.
397      */
398     @VisibleForTesting
399     void setHttpHeaders(final HttpHeaders httpHeaders) {
400         headers = httpHeaders;
401     }
402 }