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