2 * Copyright © 2019 FRINX s.r.o. All rights reserved.
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
8 package org.opendaylight.restconf.nb.rfc8040.jersey.providers.errors;
10 import static com.google.common.base.Preconditions.checkState;
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.nio.charset.StandardCharsets;
18 import java.util.Collections;
19 import java.util.LinkedHashSet;
20 import java.util.List;
21 import java.util.Optional;
23 import java.util.stream.Collectors;
24 import javax.ws.rs.core.Context;
25 import javax.ws.rs.core.HttpHeaders;
26 import javax.ws.rs.core.MediaType;
27 import javax.ws.rs.core.Response;
28 import javax.ws.rs.core.Response.Status;
29 import javax.ws.rs.ext.ExceptionMapper;
30 import javax.ws.rs.ext.Provider;
31 import org.opendaylight.restconf.common.errors.RestconfDocumentedException;
32 import org.opendaylight.restconf.common.errors.RestconfError;
33 import org.opendaylight.restconf.nb.rfc8040.Rfc8040.MediaTypes;
34 import org.opendaylight.restconf.nb.rfc8040.Rfc8040.RestconfModule;
35 import org.opendaylight.restconf.nb.rfc8040.handlers.SchemaContextHandler;
36 import org.opendaylight.restconf.nb.rfc8040.utils.RestconfConstants;
37 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier;
38 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeIdentifier;
39 import org.opendaylight.yangtools.yang.data.api.schema.ContainerNode;
40 import org.opendaylight.yangtools.yang.data.api.schema.NormalizedNode;
41 import org.opendaylight.yangtools.yang.data.api.schema.UnkeyedListEntryNode;
42 import org.opendaylight.yangtools.yang.data.api.schema.stream.NormalizedNodeWriter;
43 import org.opendaylight.yangtools.yang.data.impl.schema.ImmutableNodes;
44 import org.opendaylight.yangtools.yang.data.impl.schema.builder.api.DataContainerNodeBuilder;
45 import org.opendaylight.yangtools.yang.data.impl.schema.builder.impl.ImmutableContainerNodeBuilder;
46 import org.opendaylight.yangtools.yang.data.impl.schema.builder.impl.ImmutableUnkeyedListEntryNodeBuilder;
47 import org.opendaylight.yangtools.yang.data.impl.schema.builder.impl.ImmutableUnkeyedListNodeBuilder;
48 import org.opendaylight.yangtools.yang.model.api.SchemaPath;
49 import org.slf4j.Logger;
50 import org.slf4j.LoggerFactory;
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).
57 public final class RestconfDocumentedExceptionMapper implements ExceptionMapper<RestconfDocumentedException> {
59 static final MediaType YANG_DATA_JSON_TYPE = MediaType.valueOf(MediaTypes.DATA + RestconfConstants.JSON);
61 static final MediaType YANG_DATA_XML_TYPE = MediaType.valueOf(MediaTypes.DATA + RestconfConstants.XML);
63 static final MediaType YANG_PATCH_JSON_TYPE = MediaType.valueOf(MediaTypes.YANG_PATCH + RestconfConstants.JSON);
65 static final MediaType YANG_PATCH_XML_TYPE = MediaType.valueOf(MediaTypes.YANG_PATCH + RestconfConstants.XML);
66 private static final MediaType YANG_PATCH_RFC8072_JSON_TYPE =
67 MediaType.valueOf(MediaTypes.YANG_PATCH_RFC8072 + RestconfConstants.JSON);
68 private static final MediaType YANG_PATCH_RFC8072_XML_TYPE =
69 MediaType.valueOf(MediaTypes.YANG_PATCH_RFC8072 + RestconfConstants.XML);
71 private static final Logger LOG = LoggerFactory.getLogger(RestconfDocumentedExceptionMapper.class);
72 private static final MediaType DEFAULT_MEDIA_TYPE = MediaType.APPLICATION_JSON_TYPE;
73 private static final Status DEFAULT_STATUS_CODE = Status.INTERNAL_SERVER_ERROR;
74 private static final SchemaPath ERRORS_GROUPING_PATH = SchemaPath.create(true,
75 RestconfModule.ERRORS_GROUPING_QNAME);
78 private HttpHeaders headers;
79 private final SchemaContextHandler schemaContextHandler;
82 * Initialization of the exception mapper.
84 * @param schemaContextHandler Handler that provides actual schema context.
86 public RestconfDocumentedExceptionMapper(final SchemaContextHandler schemaContextHandler) {
87 this.schemaContextHandler = schemaContextHandler;
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 the RFC 8040
100 // - creation of WARN log that something went wrong way on the server side
101 LOG.warn("Input exception has a family of 4xx but doesn't contain any descriptive errors: {}",
102 exception.getMessage());
105 final ContainerNode errorsContainer = buildErrorsContainer(exception);
106 final String serializedResponseBody;
107 final MediaType responseMediaType = transformToResponseMediaType(getSupportedMediaType());
108 if (YANG_DATA_JSON_TYPE.equals(responseMediaType)) {
109 serializedResponseBody = serializeErrorsContainerToJson(errorsContainer);
111 serializedResponseBody = serializeErrorsContainerToXml(errorsContainer);
114 final Response preparedResponse = Response.status(responseStatus)
115 .type(responseMediaType)
116 .entity(serializedResponseBody)
118 LOG.debug("Exception {} has been successfully mapped to response: {}",
119 exception.getMessage(), preparedResponse);
120 return preparedResponse;
124 * Filling up of the errors container with data from input {@link RestconfDocumentedException}.
126 * @param exception Thrown exception.
127 * @return Built errors container.
129 private ContainerNode buildErrorsContainer(final RestconfDocumentedException exception) {
130 final List<UnkeyedListEntryNode> errorEntries = exception.getErrors().stream()
131 .map(this::createErrorEntry)
132 .collect(Collectors.toList());
133 return ImmutableContainerNodeBuilder.create()
134 .withNodeIdentifier(YangInstanceIdentifier.NodeIdentifier.create(
135 RestconfModule.ERRORS_CONTAINER_QNAME))
136 .withChild(ImmutableUnkeyedListNodeBuilder.create()
137 .withNodeIdentifier(YangInstanceIdentifier.NodeIdentifier.create(
138 RestconfModule.ERROR_LIST_QNAME))
139 .withValue(errorEntries)
145 * Building of one error entry using provided {@link RestconfError}.
147 * @param restconfError Error details.
148 * @return Built list entry.
150 private UnkeyedListEntryNode createErrorEntry(final RestconfError restconfError) {
151 // filling in mandatory leafs
152 final DataContainerNodeBuilder<NodeIdentifier, UnkeyedListEntryNode> entryBuilder
153 = ImmutableUnkeyedListEntryNodeBuilder.create()
154 .withNodeIdentifier(NodeIdentifier.create(RestconfModule.ERROR_LIST_QNAME))
155 .withChild(ImmutableNodes.leafNode(RestconfModule.ERROR_TYPE_QNAME,
156 restconfError.getErrorType().getErrorTypeTag()))
157 .withChild(ImmutableNodes.leafNode(RestconfModule.ERROR_TAG_QNAME,
158 restconfError.getErrorTag().getTagValue()));
160 // filling in optional fields
161 if (restconfError.getErrorMessage() != null) {
162 entryBuilder.withChild(ImmutableNodes.leafNode(
163 RestconfModule.ERROR_MESSAGE_QNAME, restconfError.getErrorMessage()));
165 if (restconfError.getErrorAppTag() != null) {
166 entryBuilder.withChild(ImmutableNodes.leafNode(
167 RestconfModule.ERROR_APP_TAG_QNAME, restconfError.getErrorAppTag()));
169 if (restconfError.getErrorInfo() != null) {
170 // Oddly, error-info is defined as an empty container in the restconf yang. Apparently the
171 // intention is for implementors to define their own data content so we'll just treat it as a leaf
173 entryBuilder.withChild(ImmutableNodes.leafNode(
174 RestconfModule.ERROR_INFO_QNAME, restconfError.getErrorInfo()));
177 if (restconfError.getErrorPath() != null) {
178 entryBuilder.withChild(ImmutableNodes.leafNode(
179 RestconfModule.ERROR_PATH_QNAME, restconfError.getErrorPath()));
181 return entryBuilder.build();
185 * Serialization of the errors container into JSON representation.
187 * @param errorsContainer To be serialized errors container.
188 * @return JSON representation of the errors container.
190 private String serializeErrorsContainerToJson(final ContainerNode errorsContainer) {
191 try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
192 OutputStreamWriter streamStreamWriter = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8);
194 return writeNormalizedNode(errorsContainer, outputStream, new JsonStreamWriterWithDisabledValidation(
195 RestconfModule.ERROR_INFO_QNAME, streamStreamWriter, ERRORS_GROUPING_PATH,
196 RestconfModule.URI_MODULE, schemaContextHandler));
197 } catch (IOException e) {
198 throw new IllegalStateException("Cannot close some of the output JSON writers", e);
203 * Serialization of the errors container into XML representation.
205 * @param errorsContainer To be serialized errors container.
206 * @return XML representation of the errors container.
208 private String serializeErrorsContainerToXml(final ContainerNode errorsContainer) {
209 try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
210 return writeNormalizedNode(errorsContainer, outputStream, new XmlStreamWriterWithDisabledValidation(
211 RestconfModule.ERROR_INFO_QNAME, outputStream, ERRORS_GROUPING_PATH, schemaContextHandler));
212 } catch (IOException e) {
213 throw new IllegalStateException("Cannot close some of the output XML writers", e);
217 private static String writeNormalizedNode(final NormalizedNode<?, ?> errorsContainer,
218 final ByteArrayOutputStream outputStream, final StreamWriterWithDisabledValidation streamWriter) {
219 try (NormalizedNodeWriter nnWriter = NormalizedNodeWriter.forStreamWriter(streamWriter)) {
220 nnWriter.write(errorsContainer);
221 } catch (IOException e) {
222 throw new IllegalStateException("Cannot write error response body", e);
224 return outputStream.toString(StandardCharsets.UTF_8);
228 * Deriving of the status code from the thrown exception. At the first step, status code is tried to be read using
229 * {@link RestconfDocumentedException#getStatus()}. If it is {@code null}, status code will be derived from status
230 * codes appended to error entries (the first that will be found). If there are not any error entries,
231 * {@link RestconfDocumentedExceptionMapper#DEFAULT_STATUS_CODE} will be used.
233 * @param exception Thrown exception.
234 * @return Derived status code.
236 private static Status getResponseStatusCode(final RestconfDocumentedException exception) {
237 final Status status = exception.getStatus();
238 if (status != null) {
239 // status code that is specified directly as field in exception has the precedence over error entries
243 final List<RestconfError> errors = exception.getErrors();
244 if (errors.isEmpty()) {
245 // if the module, that thrown exception, doesn't specify status code, it is treated as internal
247 return DEFAULT_STATUS_CODE;
250 final Set<Integer> allStatusCodesOfErrorEntries = errors.stream()
251 .map(restconfError -> restconfError.getErrorTag().getStatusCode())
252 // we would like to preserve iteration order in collected entries - hence usage of LinkedHashSet
253 .collect(Collectors.toCollection(LinkedHashSet::new));
254 // choosing of the first status code from appended errors, if there are different status codes in error
255 // entries, we should create WARN message
256 if (allStatusCodesOfErrorEntries.size() > 1) {
257 LOG.warn("An unexpected error occurred during translation of exception {} to response: "
258 + "Different status codes have been found in appended error entries: {}. The first error "
259 + "entry status code is chosen for response.", exception, allStatusCodesOfErrorEntries);
261 return Status.fromStatusCode(allStatusCodesOfErrorEntries.iterator().next());
265 * Selection of media type that will be used for creation suffix of 'application/yang-data'. Selection criteria
266 * is described in RFC 8040, section 7.1. At the first step, accepted media-type is analyzed and only supported
267 * media-types are filtered out. If both XML and JSON media-types are accepted, JSON is selected as a default one
268 * used in RESTCONF. If accepted-media type is not specified, the media-type used in request is chosen only if it
269 * is supported one. If it is not supported or it is not specified, again, the default one (JSON) is selected.
271 * @return Media type.
273 private MediaType getSupportedMediaType() {
274 final Set<MediaType> acceptableAndSupportedMediaTypes = headers.getAcceptableMediaTypes().stream()
275 .filter(RestconfDocumentedExceptionMapper::isCompatibleMediaType)
276 .collect(Collectors.toSet());
277 if (acceptableAndSupportedMediaTypes.isEmpty()) {
278 // check content type of the request
279 final MediaType requestMediaType = headers.getMediaType();
280 return requestMediaType == null ? DEFAULT_MEDIA_TYPE
281 : chooseMediaType(Collections.singletonList(requestMediaType)).orElseGet(() -> {
282 LOG.warn("Request doesn't specify accepted media-types and the media-type '{}' used by "
283 + "request is not supported - using of default '{}' media-type.",
284 requestMediaType, DEFAULT_MEDIA_TYPE);
285 return DEFAULT_MEDIA_TYPE;
289 // at first step, fully specified types without any wildcards are considered (for example, application/json)
290 final List<MediaType> fullySpecifiedMediaTypes = acceptableAndSupportedMediaTypes.stream()
291 .filter(mediaType -> !mediaType.isWildcardType() && !mediaType.isWildcardSubtype())
292 .collect(Collectors.toList());
293 if (!fullySpecifiedMediaTypes.isEmpty()) {
294 return chooseAndCheckMediaType(fullySpecifiedMediaTypes);
297 // at the second step, only types with specified subtype are considered (for example, */json)
298 final List<MediaType> mediaTypesWithSpecifiedSubtypes = acceptableAndSupportedMediaTypes.stream()
299 .filter(mediaType -> !mediaType.isWildcardSubtype())
300 .collect(Collectors.toList());
301 if (!mediaTypesWithSpecifiedSubtypes.isEmpty()) {
302 return chooseAndCheckMediaType(mediaTypesWithSpecifiedSubtypes);
305 // at the third step, only types with specified parent are considered (for example, application/*)
306 final List<MediaType> mediaTypesWithSpecifiedParent = acceptableAndSupportedMediaTypes.stream()
307 .filter(mediaType -> !mediaType.isWildcardType())
308 .collect(Collectors.toList());
309 if (!mediaTypesWithSpecifiedParent.isEmpty()) {
310 return chooseAndCheckMediaType(mediaTypesWithSpecifiedParent);
313 // it must be fully-wildcard-ed type - */*
314 return DEFAULT_MEDIA_TYPE;
317 private static MediaType chooseAndCheckMediaType(final List<MediaType> options) {
318 final Optional<MediaType> mediaTypeOpt = chooseMediaType(options);
319 checkState(mediaTypeOpt.isPresent());
320 return mediaTypeOpt.get();
324 * This method is responsible for choosing of he media type from multiple options. At the first step,
325 * JSON-compatible types are considered, then, if there are not any JSON types, XML types are considered. The first
326 * compatible media-type is chosen.
328 * @param options Supported media types.
329 * @return Selected one media type or {@link Optional#empty()} if none of the provided options are compatible with
332 private static Optional<MediaType> chooseMediaType(final List<MediaType> options) {
333 return options.stream()
334 .filter(RestconfDocumentedExceptionMapper::isJsonCompatibleMediaType)
337 .orElse(options.stream()
338 .filter(RestconfDocumentedExceptionMapper::isXmlCompatibleMediaType)
343 * Mapping of JSON-compatible type to {@link RestconfDocumentedExceptionMapper#YANG_DATA_JSON_TYPE}
344 * or XML-compatible type to {@link RestconfDocumentedExceptionMapper#YANG_DATA_XML_TYPE}.
346 * @param mediaTypeBase Base media type from which the response media-type is built.
347 * @return Derived media type.
349 private static MediaType transformToResponseMediaType(final MediaType mediaTypeBase) {
350 if (isJsonCompatibleMediaType(mediaTypeBase)) {
351 return YANG_DATA_JSON_TYPE;
352 } else if (isXmlCompatibleMediaType(mediaTypeBase)) {
353 return YANG_DATA_XML_TYPE;
355 throw new IllegalStateException(String.format("Unexpected input media-type %s "
356 + "- it should be JSON/XML compatible type.", mediaTypeBase));
360 private static boolean isCompatibleMediaType(final MediaType mediaType) {
361 return isJsonCompatibleMediaType(mediaType) || isXmlCompatibleMediaType(mediaType);
364 private static boolean isJsonCompatibleMediaType(final MediaType mediaType) {
365 return mediaType.isCompatible(MediaType.APPLICATION_JSON_TYPE)
366 || mediaType.isCompatible(YANG_DATA_JSON_TYPE)
367 || mediaType.isCompatible(YANG_PATCH_RFC8072_JSON_TYPE)
368 || mediaType.isCompatible(YANG_PATCH_JSON_TYPE);
371 private static boolean isXmlCompatibleMediaType(final MediaType mediaType) {
372 return mediaType.isCompatible(MediaType.APPLICATION_XML_TYPE)
373 || mediaType.isCompatible(YANG_DATA_XML_TYPE)
374 || mediaType.isCompatible(YANG_PATCH_RFC8072_XML_TYPE)
375 || mediaType.isCompatible(YANG_PATCH_XML_TYPE);
379 * Used just for testing purposes - simulation of HTTP headers with different accepted types and content type.
381 * @param httpHeaders Mocked HTTP headers.
384 void setHttpHeaders(final HttpHeaders httpHeaders) {
385 this.headers = httpHeaders;