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 java.util.Objects.requireNonNull;
11 import static org.opendaylight.yang.gen.v1.urn.ietf.params.xml.ns.yang.ietf.restconf.rev170126.$YangModuleInfoImpl.qnameOf;
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;
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;
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).
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
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 // FIXME make this private
73 static final QName ERROR_INFO_QNAME = qnameOf("error-info");
74 private static final QName ERROR_PATH_QNAME = qnameOf("error-path");
75 private static final int DEFAULT_INDENT_SPACES_NUM = 2;
76 private static final XMLOutputFactory XML_OUTPUT_FACTORY;
79 XML_OUTPUT_FACTORY = XMLOutputFactory.newFactory();
80 XML_OUTPUT_FACTORY.setProperty(XMLOutputFactory.IS_REPAIRING_NAMESPACES, true);
83 private final DatabindProvider databindProvider;
86 private HttpHeaders headers;
89 * Initialization of the exception mapper.
91 * @param databindProvider A {@link DatabindProvider}
93 public RestconfDocumentedExceptionMapper(final DatabindProvider databindProvider) {
94 this.databindProvider = requireNonNull(databindProvider);
98 @SuppressFBWarnings(value = "SLF4J_MANUALLY_PROVIDED_MESSAGE", justification = "In the debug messages "
99 + "we don't to have full stack trace - getMessage(..) method provides finer output.")
100 public Response toResponse(final RestconfDocumentedException exception) {
101 LOG.debug("Starting to map received exception to error response: {}", exception.getMessage());
102 final Status responseStatus = getResponseStatusCode(exception);
103 if (responseStatus != Response.Status.FORBIDDEN
104 && responseStatus.getFamily() == Response.Status.Family.CLIENT_ERROR
105 && exception.getErrors().isEmpty()) {
106 // There should be at least one error entry for 4xx errors except 409 according to RFC8040, but we do not
107 // have it. Issue a warning with the call trace so we can fix whoever was the originator.
108 LOG.warn("Input exception has a family of 4xx but does not contain any descriptive errors", exception);
111 final String serializedResponseBody;
112 final MediaType responseMediaType = transformToResponseMediaType(getSupportedMediaType());
113 if (JaxRsMediaTypes.APPLICATION_YANG_DATA_JSON.equals(responseMediaType)) {
114 serializedResponseBody = serializeExceptionToJson(exception, databindProvider);
116 serializedResponseBody = serializeExceptionToXml(exception, databindProvider);
119 final Response preparedResponse = Response.status(responseStatus)
120 .type(responseMediaType)
121 .entity(serializedResponseBody)
123 LOG.debug("Exception {} has been successfully mapped to response: {}",
124 exception.getMessage(), preparedResponse);
125 return preparedResponse;
129 * Serialization exceptions into JSON representation.
131 * @param exception To be serialized exception.
132 * @param databindProvider Holder of current {@code DatabindContext}.
133 * @return JSON representation of the exception.
135 private static String serializeExceptionToJson(final RestconfDocumentedException exception,
136 final DatabindProvider databindProvider) {
137 try (var outputStream = new ByteArrayOutputStream();
138 var streamWriter = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8);
139 var jsonWriter = JsonWriterFactory.createJsonWriter(streamWriter, DEFAULT_INDENT_SPACES_NUM)) {
140 final var currentDatabindContext = exception.modelContext() != null
141 ? DatabindContext.ofModel(exception.modelContext()) : databindProvider.currentDatabind();
142 jsonWriter.beginObject();
143 final var errors = exception.getErrors();
144 if (errors != null && !errors.isEmpty()) {
145 jsonWriter.name(Errors.QNAME.getLocalName()).beginObject();
146 jsonWriter.name(Error.QNAME.getLocalName()).beginArray();
147 for (final var error : errors) {
148 jsonWriter.beginObject()
149 .name(ERROR_TAG_QNAME.getLocalName()).value(error.getErrorTag().elementBody());
150 final var errorAppTag = error.getErrorAppTag();
151 if (errorAppTag != null) {
152 jsonWriter.name(ERROR_APP_TAG_QNAME.getLocalName()).value(errorAppTag);
154 final var errorInfo = error.getErrorInfo();
155 if (errorInfo != null) {
156 jsonWriter.name(ERROR_INFO_QNAME.getLocalName()).value(errorInfo);
158 final var errorMessage = error.getErrorMessage();
159 if (errorMessage != null) {
160 jsonWriter.name(ERROR_MESSAGE_QNAME.getLocalName()).value(errorMessage);
162 final var errorPath = error.getErrorPath();
163 if (errorPath != null) {
164 jsonWriter.name(ERROR_PATH_QNAME.getLocalName());
165 currentDatabindContext.jsonCodecs().instanceIdentifierCodec()
166 .writeValue(jsonWriter, errorPath);
168 jsonWriter.name(ERROR_TYPE_QNAME.getLocalName()).value(error.getErrorType().elementBody());
169 jsonWriter.endObject();
171 jsonWriter.endArray().endObject();
173 jsonWriter.endObject();
174 streamWriter.flush();
175 return outputStream.toString(StandardCharsets.UTF_8);
176 } catch (IOException e) {
177 throw new IllegalStateException("Error while serializing restconf exception into JSON", e);
182 * Serialization exceptions into XML representation.
184 * @param exception To be serialized exception.
185 * @param databindProvider Holder of current {@code DatabindContext}.
186 * @return XML representation of the exception.
188 private static String serializeExceptionToXml(final RestconfDocumentedException exception,
189 final DatabindProvider databindProvider) {
190 try (var outputStream = new ByteArrayOutputStream()) {
191 final var xmlWriter = XML_OUTPUT_FACTORY.createXMLStreamWriter(outputStream,
192 StandardCharsets.UTF_8.name());
193 xmlWriter.writeStartDocument();
194 xmlWriter.writeStartElement(Errors.QNAME.getLocalName());
195 xmlWriter.writeNamespace("xmlns", Errors.QNAME.getNamespace().toString());
196 if (exception.getErrors() != null && !exception.getErrors().isEmpty()) {
197 for (final var error : exception.getErrors()) {
198 xmlWriter.writeStartElement(Error.QNAME.getLocalName());
199 // Write error-type element
200 xmlWriter.writeStartElement(ERROR_TYPE_QNAME.getLocalName());
201 xmlWriter.writeCharacters(error.getErrorType().elementBody());
202 xmlWriter.writeEndElement();
204 if (error.getErrorPath() != null) {
205 xmlWriter.writeStartElement(ERROR_PATH_QNAME.getLocalName());
206 databindProvider.currentDatabind().xmlCodecs().instanceIdentifierCodec()
207 .writeValue(xmlWriter, error.getErrorPath());
208 xmlWriter.writeEndElement();
210 if (error.getErrorMessage() != null) {
211 xmlWriter.writeStartElement(ERROR_MESSAGE_QNAME.getLocalName());
212 xmlWriter.writeCharacters(error.getErrorMessage());
213 xmlWriter.writeEndElement();
216 // Write error-tag element
217 xmlWriter.writeStartElement(ERROR_TAG_QNAME.getLocalName());
218 xmlWriter.writeCharacters(error.getErrorTag().elementBody());
219 xmlWriter.writeEndElement();
221 if (error.getErrorAppTag() != null) {
222 xmlWriter.writeStartElement(ERROR_APP_TAG_QNAME.getLocalName());
223 xmlWriter.writeCharacters(error.getErrorAppTag());
224 xmlWriter.writeEndElement();
226 if (error.getErrorInfo() != null) {
227 xmlWriter.writeStartElement(ERROR_INFO_QNAME.getLocalName());
228 xmlWriter.writeCharacters(error.getErrorInfo());
229 xmlWriter.writeEndElement();
231 xmlWriter.writeEndElement();
234 xmlWriter.writeEndElement();
235 xmlWriter.writeEndDocument();
238 final var transformerFactory = TransformerFactory.newInstance();
239 final var transformer = transformerFactory.newTransformer();
240 transformer.setOutputProperty(OutputKeys.INDENT, "yes");
241 // 2 spaces for indentation
242 transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount",
243 String.valueOf(DEFAULT_INDENT_SPACES_NUM));
244 transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes");
245 final var xmlSource = new StreamSource(new StringReader(outputStream.toString(StandardCharsets.UTF_8)));
246 final var stringWriter = new StringWriter();
247 final var streamResult = new StreamResult(stringWriter);
248 transformer.transform(xmlSource, streamResult);
249 return stringWriter.toString();
250 } catch (IOException | XMLStreamException | TransformerException e) {
251 throw new IllegalStateException("Error while serializing restconf exception into XML", e);
256 * Deriving of the status code from the thrown exception. At the first step, status code is tried to be read using
257 * {@link RestconfDocumentedException#getStatus()}. If it is {@code null}, status code will be derived from status
258 * codes appended to error entries (the first that will be found). If there are not any error entries,
259 * {@link RestconfDocumentedExceptionMapper#DEFAULT_STATUS_CODE} will be used.
261 * @param exception Thrown exception.
262 * @return Derived status code.
264 private static Status getResponseStatusCode(final RestconfDocumentedException exception) {
265 final var errors = exception.getErrors();
266 if (errors.isEmpty()) {
267 // if the module, that thrown exception, doesn't specify status code, it is treated as internal
269 return DEFAULT_STATUS_CODE;
272 final var allStatusCodesOfErrorEntries = errors.stream()
273 .map(restconfError -> ErrorTags.statusOf(restconfError.getErrorTag()))
274 // we would like to preserve iteration order in collected entries - hence usage of LinkedHashSet
275 .collect(Collectors.toCollection(LinkedHashSet::new));
276 // choosing of the first status code from appended errors, if there are different status codes in error
277 // entries, we should create WARN message
278 if (allStatusCodesOfErrorEntries.size() > 1) {
280 An unexpected error occurred during translation of exception {} to response: Different status codes
281 have been found in appended error entries: {}. The first error entry status code is chosen for
282 response.""", exception, allStatusCodesOfErrorEntries);
284 return allStatusCodesOfErrorEntries.iterator().next();
288 * Selection of media type that will be used for creation suffix of 'application/yang-data'. Selection criteria
289 * is described in RFC 8040, section 7.1. At the first step, accepted media-type is analyzed and only supported
290 * media-types are filtered out. If both XML and JSON media-types are accepted, JSON is selected as a default one
291 * used in RESTCONF. If accepted-media type is not specified, the media-type used in request is chosen only if it
292 * is supported one. If it is not supported or it is not specified, again, the default one (JSON) is selected.
294 * @return Media type.
296 private MediaType getSupportedMediaType() {
297 final Set<MediaType> acceptableAndSupportedMediaTypes = headers.getAcceptableMediaTypes().stream()
298 .filter(RestconfDocumentedExceptionMapper::isCompatibleMediaType)
299 .collect(Collectors.toSet());
300 if (acceptableAndSupportedMediaTypes.isEmpty()) {
301 // check content type of the request
302 final MediaType requestMediaType = headers.getMediaType();
303 return requestMediaType == null ? DEFAULT_MEDIA_TYPE
304 : chooseMediaType(Collections.singletonList(requestMediaType)).orElseGet(() -> {
305 LOG.warn("Request doesn't specify accepted media-types and the media-type '{}' used by "
306 + "request is not supported - using of default '{}' media-type.",
307 requestMediaType, DEFAULT_MEDIA_TYPE);
308 return DEFAULT_MEDIA_TYPE;
312 // at first step, fully specified types without any wildcards are considered (for example, application/json)
313 final List<MediaType> fullySpecifiedMediaTypes = acceptableAndSupportedMediaTypes.stream()
314 .filter(mediaType -> !mediaType.isWildcardType() && !mediaType.isWildcardSubtype())
315 .collect(Collectors.toList());
316 if (!fullySpecifiedMediaTypes.isEmpty()) {
317 return chooseAndCheckMediaType(fullySpecifiedMediaTypes);
320 // at the second step, only types with specified subtype are considered (for example, */json)
321 final List<MediaType> mediaTypesWithSpecifiedSubtypes = acceptableAndSupportedMediaTypes.stream()
322 .filter(mediaType -> !mediaType.isWildcardSubtype())
323 .collect(Collectors.toList());
324 if (!mediaTypesWithSpecifiedSubtypes.isEmpty()) {
325 return chooseAndCheckMediaType(mediaTypesWithSpecifiedSubtypes);
328 // at the third step, only types with specified parent are considered (for example, application/*)
329 final List<MediaType> mediaTypesWithSpecifiedParent = acceptableAndSupportedMediaTypes.stream()
330 .filter(mediaType -> !mediaType.isWildcardType())
331 .collect(Collectors.toList());
332 if (!mediaTypesWithSpecifiedParent.isEmpty()) {
333 return chooseAndCheckMediaType(mediaTypesWithSpecifiedParent);
336 // it must be fully-wildcard-ed type - */*
337 return DEFAULT_MEDIA_TYPE;
340 private static MediaType chooseAndCheckMediaType(final List<MediaType> options) {
341 return chooseMediaType(options).orElseThrow(IllegalStateException::new);
345 * This method is responsible for choosing of he media type from multiple options. At the first step,
346 * JSON-compatible types are considered, then, if there are not any JSON types, XML types are considered. The first
347 * compatible media-type is chosen.
349 * @param options Supported media types.
350 * @return Selected one media type or {@link Optional#empty()} if none of the provided options are compatible with
353 private static Optional<MediaType> chooseMediaType(final List<MediaType> options) {
354 return options.stream()
355 .filter(RestconfDocumentedExceptionMapper::isJsonCompatibleMediaType)
358 .orElse(options.stream()
359 .filter(RestconfDocumentedExceptionMapper::isXmlCompatibleMediaType)
364 * Mapping of JSON-compatible type to {@link RestconfDocumentedExceptionMapper#YANG_DATA_JSON_TYPE}
365 * or XML-compatible type to {@link RestconfDocumentedExceptionMapper#YANG_DATA_XML_TYPE}.
367 * @param mediaTypeBase Base media type from which the response media-type is built.
368 * @return Derived media type.
370 private static MediaType transformToResponseMediaType(final MediaType mediaTypeBase) {
371 if (isJsonCompatibleMediaType(mediaTypeBase)) {
372 return JaxRsMediaTypes.APPLICATION_YANG_DATA_JSON;
373 } else if (isXmlCompatibleMediaType(mediaTypeBase)) {
374 return JaxRsMediaTypes.APPLICATION_YANG_DATA_XML;
376 throw new IllegalStateException(String.format("Unexpected input media-type %s "
377 + "- it should be JSON/XML compatible type.", mediaTypeBase));
381 private static boolean isCompatibleMediaType(final MediaType mediaType) {
382 return isJsonCompatibleMediaType(mediaType) || isXmlCompatibleMediaType(mediaType);
385 private static boolean isJsonCompatibleMediaType(final MediaType mediaType) {
386 return mediaType.isCompatible(MediaType.APPLICATION_JSON_TYPE)
387 || mediaType.isCompatible(JaxRsMediaTypes.APPLICATION_YANG_DATA_JSON)
388 || mediaType.isCompatible(JaxRsMediaTypes.APPLICATION_YANG_PATCH_JSON);
391 private static boolean isXmlCompatibleMediaType(final MediaType mediaType) {
392 return mediaType.isCompatible(MediaType.APPLICATION_XML_TYPE)
393 || mediaType.isCompatible(JaxRsMediaTypes.APPLICATION_YANG_DATA_XML)
394 || mediaType.isCompatible(JaxRsMediaTypes.APPLICATION_YANG_PATCH_XML);
398 * Used just for testing purposes - simulation of HTTP headers with different accepted types and content type.
400 * @param httpHeaders Mocked HTTP headers.
403 void setHttpHeaders(final HttpHeaders httpHeaders) {
404 headers = httpHeaders;