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