GH-1251: Jackson2JsonMessageConverter Improvements

Resolves https://github.com/spring-projects/spring-amqp/issues/1420

- detect and use `charset` in `contentType` when present
- allow Jackson to determine the decode `charset` via `ByteSourceJsonBootstrapper.detectEncoding()`
- allow configuration of the `MimeType` to use, which can include a `charset` parameter

**cherry-pick to main - will require what's new fix**

* Fix typo in doc.
# Conflicts:
#	src/reference/asciidoc/whats-new.adoc
This commit is contained in:
Gary Russell
2022-03-09 13:03:39 -05:00
committed by Artem Bilan
parent ad17638479
commit 1cbdb7de47
4 changed files with 172 additions and 18 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2018-2020 the original author or authors.
* Copyright 2018-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -33,6 +33,7 @@ import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.MimeType;
import org.springframework.util.MimeTypeUtils;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -61,14 +62,19 @@ public abstract class AbstractJackson2MessageConverter extends AbstractMessageCo
*/
public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
/**
* The supported content type; only the subtype is checked, e.g. */json,
* */xml.
*/
private final MimeType supportedContentType;
protected final ObjectMapper objectMapper; // NOSONAR protected
/**
* The supported content type; only the subtype is checked when decoding, e.g.
* */json, */xml. If this contains a charset parameter, when encoding, the
* contentType header will not be set, when decoding, the raw bytes are passed to
* Jackson which can dynamically determine the encoding; otherwise the contentEncoding
* or default charset is used.
*/
private MimeType supportedContentType;
private String supportedCTCharset;
@Nullable
private ClassMapper classMapper = null;
@@ -93,8 +99,11 @@ public abstract class AbstractJackson2MessageConverter extends AbstractMessageCo
/**
* Construct with the provided {@link ObjectMapper} instance.
* @param objectMapper the {@link ObjectMapper} to use.
* @param contentType supported content type when decoding messages, only the subtype
* is checked, e.g. */json, */xml.
* @param contentType the supported content type; only the subtype is checked when
* decoding, e.g. */json, */xml. If this contains a charset parameter, when
* encoding, the contentType header will not be set, when decoding, the raw bytes are
* passed to Jackson which can dynamically determine the encoding; otherwise the
* contentEncoding or default charset is used.
* @param trustedPackages the trusted Java packages for deserialization
* @see DefaultJackson2JavaTypeMapper#setTrustedPackages(String...)
*/
@@ -105,9 +114,41 @@ public abstract class AbstractJackson2MessageConverter extends AbstractMessageCo
Assert.notNull(contentType, "'contentType' must not be null");
this.objectMapper = objectMapper;
this.supportedContentType = contentType;
this.supportedCTCharset = this.supportedContentType.getParameter("charset");
((DefaultJackson2JavaTypeMapper) this.javaTypeMapper).setTrustedPackages(trustedPackages);
}
/**
* Get the supported content type; only the subtype is checked when decoding, e.g.
* */json, */xml. If this contains a charset parameter, when encoding, the
* contentType header will not be set, when decoding, the raw bytes are passed to
* Jackson which can dynamically determine the encoding; otherwise the contentEncoding
* or default charset is used.
* @return the supportedContentType
* @since 2.4.3
*/
protected MimeType getSupportedContentType() {
return this.supportedContentType;
}
/**
* Set the supported content type; only the subtype is checked when decoding, e.g.
* */json, */xml. If this contains a charset parameter, when encoding, the
* contentType header will not be set, when decoding, the raw bytes are passed to
* Jackson which can dynamically determine the encoding; otherwise the contentEncoding
* or default charset is used.
* @param supportedContentType the supportedContentType to set.
* @since 2.4.3
*/
public void setSupportedContentType(MimeType supportedContentType) {
Assert.notNull(supportedContentType, "'supportedContentType' cannot be null");
this.supportedContentType = supportedContentType;
this.supportedCTCharset = this.supportedContentType.getParameter("charset");
}
@Nullable
public ClassMapper getClassMapper() {
return this.classMapper;
@@ -264,10 +305,7 @@ public abstract class AbstractJackson2MessageConverter extends AbstractMessageCo
if ((this.assumeSupportedContentType // NOSONAR Boolean complexity
&& (contentType == null || contentType.equals(MessageProperties.DEFAULT_CONTENT_TYPE)))
|| (contentType != null && contentType.contains(this.supportedContentType.getSubtype()))) {
String encoding = properties.getContentEncoding();
if (encoding == null) {
encoding = getDefaultCharset();
}
String encoding = determineEncoding(properties, contentType);
content = doFromMessage(message, conversionHint, properties, encoding);
}
else {
@@ -283,6 +321,24 @@ public abstract class AbstractJackson2MessageConverter extends AbstractMessageCo
return content;
}
private String determineEncoding(MessageProperties properties, @Nullable String contentType) {
String encoding = properties.getContentEncoding();
if (encoding == null && contentType != null) {
try {
MimeType mimeType = MimeTypeUtils.parseMimeType(contentType);
if (mimeType != null) {
encoding = mimeType.getParameter("charset");
}
}
catch (RuntimeException e) {
}
}
if (encoding == null) {
encoding = this.supportedCTCharset != null ? this.supportedCTCharset : getDefaultCharset();
}
return encoding;
}
private Object doFromMessage(Message message, Object conversionHint, MessageProperties properties,
String encoding) {
@@ -348,11 +404,17 @@ public abstract class AbstractJackson2MessageConverter extends AbstractMessageCo
}
private Object convertBytesToObject(byte[] body, String encoding, JavaType targetJavaType) throws IOException {
if (this.supportedCTCharset != null) { // Jackson will determine encoding
return this.objectMapper.readValue(body, targetJavaType);
}
String contentAsString = new String(body, encoding);
return this.objectMapper.readValue(contentAsString, targetJavaType);
}
private Object convertBytesToObject(byte[] body, String encoding, Class<?> targetClass) throws IOException {
if (this.supportedCTCharset != null) { // Jackson will determine encoding
return this.objectMapper.readValue(body, this.objectMapper.constructType(targetClass));
}
String contentAsString = new String(body, encoding);
return this.objectMapper.readValue(contentAsString, this.objectMapper.constructType(targetClass));
}
@@ -370,20 +432,23 @@ public abstract class AbstractJackson2MessageConverter extends AbstractMessageCo
byte[] bytes;
try {
if (this.charsetIsUtf8) {
if (this.charsetIsUtf8 && this.supportedCTCharset == null) {
bytes = this.objectMapper.writeValueAsBytes(objectToConvert);
}
else {
String jsonString = this.objectMapper
.writeValueAsString(objectToConvert);
bytes = jsonString.getBytes(getDefaultCharset());
String encoding = this.supportedCTCharset != null ? this.supportedCTCharset : getDefaultCharset();
bytes = jsonString.getBytes(encoding);
}
}
catch (IOException e) {
throw new MessageConversionException("Failed to convert Message content", e);
}
messageProperties.setContentType(this.supportedContentType.toString());
messageProperties.setContentEncoding(getDefaultCharset());
if (this.supportedCTCharset == null) {
messageProperties.setContentEncoding(getDefaultCharset());
}
messageProperties.setContentLength(bytes.length);
if (getClassMapper() == null) {

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2020 the original author or authors.
* Copyright 2002-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -17,6 +17,7 @@
package org.springframework.amqp.support.converter;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import java.io.IOException;
import java.math.BigDecimal;
@@ -34,6 +35,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.data.web.JsonPath;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.util.MimeTypeUtils;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -399,6 +401,67 @@ public class Jackson2JsonMessageConverterTests {
assertThat(foos.values().iterator().next().getField()).isEqualTo("baz");
}
@Test
void charsetInContentType() {
trade.setUserName("John Doe ∫");
Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter();
String utf8 = "application/json;charset=utf-8";
converter.setSupportedContentType(MimeTypeUtils.parseMimeType(utf8));
Message message = converter.toMessage(trade, new MessageProperties());
int bodyLength8 = message.getBody().length;
assertThat(message.getMessageProperties().getContentEncoding()).isNull();
assertThat(message.getMessageProperties().getContentType()).isEqualTo(utf8);
SimpleTrade marshalledTrade = (SimpleTrade) converter.fromMessage(message);
assertThat(marshalledTrade).isEqualTo(trade);
// use content type property
String utf16 = "application/json;charset=utf-16";
converter.setSupportedContentType(MimeTypeUtils.parseMimeType(utf16));
message = converter.toMessage(trade, new MessageProperties());
assertThat(message.getBody().length).isNotEqualTo(bodyLength8);
assertThat(message.getMessageProperties().getContentEncoding()).isNull();
assertThat(message.getMessageProperties().getContentType()).isEqualTo(utf16);
marshalledTrade = (SimpleTrade) converter.fromMessage(message);
assertThat(marshalledTrade).isEqualTo(trade);
// no encoding in message, use configured default
converter.setSupportedContentType(MimeTypeUtils.parseMimeType("application/json"));
converter.setDefaultCharset("UTF-16");
message = converter.toMessage(trade, new MessageProperties());
assertThat(message.getBody().length).isNotEqualTo(bodyLength8);
assertThat(message.getMessageProperties().getContentEncoding()).isNotNull();
message.getMessageProperties().setContentEncoding(null);
marshalledTrade = (SimpleTrade) converter.fromMessage(message);
assertThat(marshalledTrade).isEqualTo(trade);
}
@Test
void noConfigForCharsetInContentType() {
trade.setUserName("John Doe ∫");
Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter();
Message message = converter.toMessage(trade, new MessageProperties());
int bodyLength8 = message.getBody().length;
SimpleTrade marshalledTrade = (SimpleTrade) converter.fromMessage(message);
assertThat(marshalledTrade).isEqualTo(trade);
// no encoding in message; use configured default
message = converter.toMessage(trade, new MessageProperties());
assertThat(message.getMessageProperties().getContentEncoding()).isNotNull();
message.getMessageProperties().setContentEncoding(null);
marshalledTrade = (SimpleTrade) converter.fromMessage(message);
assertThat(marshalledTrade).isEqualTo(trade);
converter.setDefaultCharset("UTF-16");
Message message2 = converter.toMessage(trade, new MessageProperties());
message2.getMessageProperties().setContentEncoding(null);
assertThat(message2.getBody().length).isNotEqualTo(bodyLength8);
converter.setDefaultCharset("UTF-8");
assertThatExceptionOfType(MessageConversionException.class).isThrownBy(
() -> converter.fromMessage(message2));
}
public List<Foo> fooLister() {
return null;
}

View File

@@ -3938,11 +3938,34 @@ public DefaultClassMapper classMapper() {
Now, if the sending system sets the header to `thing1`, the converter creates a `Thing1` object, and so on.
See the <<spring-rabbit-json>> sample application for a complete discussion about converting messages from non-Spring applications.
Starting with version 2.4.3, the converter will not add a `contentEncoding` message property if the `supportedMediaType` has a `charset` parameter; this is also used for the encoding.
A new method `setSupportedMediaType` has been added:
====
[source, java]
----
String utf16 = "application/json; charset=utf-16";
converter.setSupportedContentType(MimeTypeUtils.parseMimeType(utf16));
----
====
[[Jackson2JsonMessageConverter-from-message]]
====== Converting from a `Message`
Inbound messages are converted to objects according to the type information added to headers by the sending system.
Starting with version 2.4.3, if there is no `contentEncoding` message property, the converter will attempt to detect a `charset` parameter in the `contentType` message property and use that.
If neither exist, if the `supportedMediaType` has a `charset` parameter, it will be used for decoding, with a final fallback to the `defaultCharset` property.
A new method `setSupportedMediaType` has been added:
====
[source, java]
----
String utf16 = "application/json; charset=utf-16";
converter.setSupportedContentType(MimeTypeUtils.parseMimeType(utf16));
----
====
In versions prior to 1.6, if type information is not present, conversion would fail.
Starting with version 1.6, if type information is missing, the converter converts the JSON by using Jackson defaults (usually a map).

View File

@@ -11,4 +11,7 @@ This version requires Spring Framework 6.0 and Java 17
The remoting feature (using RMI) is no longer supported.
See <<remoting>> for alternatives.
==== Message Converter Changes
The `Jackson2JsonMessageConverter` can now determine the charset from the `contentEncoding` header.
See <<json-message-converter>> for more information.