Skip to content

Commit 1cbdb7d

Browse files
garyrussellartembilan
authored andcommitted
GH-1251: Jackson2JsonMessageConverter Improvements
Resolves #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
1 parent ad17638 commit 1cbdb7d

File tree

4 files changed

+170
-16
lines changed

4 files changed

+170
-16
lines changed

spring-amqp/src/main/java/org/springframework/amqp/support/converter/AbstractJackson2MessageConverter.java

Lines changed: 79 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2018-2020 the original author or authors.
2+
* Copyright 2018-2022 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -33,6 +33,7 @@
3333
import org.springframework.util.Assert;
3434
import org.springframework.util.ClassUtils;
3535
import org.springframework.util.MimeType;
36+
import org.springframework.util.MimeTypeUtils;
3637

3738
import com.fasterxml.jackson.databind.JavaType;
3839
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -61,13 +62,18 @@ public abstract class AbstractJackson2MessageConverter extends AbstractMessageCo
6162
*/
6263
public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
6364

65+
protected final ObjectMapper objectMapper; // NOSONAR protected
66+
6467
/**
65-
* The supported content type; only the subtype is checked, e.g. */json,
66-
* */xml.
68+
* The supported content type; only the subtype is checked when decoding, e.g.
69+
* */json, */xml. If this contains a charset parameter, when encoding, the
70+
* contentType header will not be set, when decoding, the raw bytes are passed to
71+
* Jackson which can dynamically determine the encoding; otherwise the contentEncoding
72+
* or default charset is used.
6773
*/
68-
private final MimeType supportedContentType;
74+
private MimeType supportedContentType;
6975

70-
protected final ObjectMapper objectMapper; // NOSONAR protected
76+
private String supportedCTCharset;
7177

7278
@Nullable
7379
private ClassMapper classMapper = null;
@@ -93,8 +99,11 @@ public abstract class AbstractJackson2MessageConverter extends AbstractMessageCo
9399
/**
94100
* Construct with the provided {@link ObjectMapper} instance.
95101
* @param objectMapper the {@link ObjectMapper} to use.
96-
* @param contentType supported content type when decoding messages, only the subtype
97-
* is checked, e.g. */json, */xml.
102+
* @param contentType the supported content type; only the subtype is checked when
103+
* decoding, e.g. */json, */xml. If this contains a charset parameter, when
104+
* encoding, the contentType header will not be set, when decoding, the raw bytes are
105+
* passed to Jackson which can dynamically determine the encoding; otherwise the
106+
* contentEncoding or default charset is used.
98107
* @param trustedPackages the trusted Java packages for deserialization
99108
* @see DefaultJackson2JavaTypeMapper#setTrustedPackages(String...)
100109
*/
@@ -105,9 +114,41 @@ protected AbstractJackson2MessageConverter(ObjectMapper objectMapper, MimeType c
105114
Assert.notNull(contentType, "'contentType' must not be null");
106115
this.objectMapper = objectMapper;
107116
this.supportedContentType = contentType;
117+
this.supportedCTCharset = this.supportedContentType.getParameter("charset");
108118
((DefaultJackson2JavaTypeMapper) this.javaTypeMapper).setTrustedPackages(trustedPackages);
109119
}
110120

121+
122+
/**
123+
* Get the supported content type; only the subtype is checked when decoding, e.g.
124+
* */json, */xml. If this contains a charset parameter, when encoding, the
125+
* contentType header will not be set, when decoding, the raw bytes are passed to
126+
* Jackson which can dynamically determine the encoding; otherwise the contentEncoding
127+
* or default charset is used.
128+
* @return the supportedContentType
129+
* @since 2.4.3
130+
*/
131+
protected MimeType getSupportedContentType() {
132+
return this.supportedContentType;
133+
}
134+
135+
136+
/**
137+
* Set the supported content type; only the subtype is checked when decoding, e.g.
138+
* */json, */xml. If this contains a charset parameter, when encoding, the
139+
* contentType header will not be set, when decoding, the raw bytes are passed to
140+
* Jackson which can dynamically determine the encoding; otherwise the contentEncoding
141+
* or default charset is used.
142+
* @param supportedContentType the supportedContentType to set.
143+
* @since 2.4.3
144+
*/
145+
public void setSupportedContentType(MimeType supportedContentType) {
146+
Assert.notNull(supportedContentType, "'supportedContentType' cannot be null");
147+
this.supportedContentType = supportedContentType;
148+
this.supportedCTCharset = this.supportedContentType.getParameter("charset");
149+
}
150+
151+
111152
@Nullable
112153
public ClassMapper getClassMapper() {
113154
return this.classMapper;
@@ -264,10 +305,7 @@ public Object fromMessage(Message message, @Nullable Object conversionHint) thro
264305
if ((this.assumeSupportedContentType // NOSONAR Boolean complexity
265306
&& (contentType == null || contentType.equals(MessageProperties.DEFAULT_CONTENT_TYPE)))
266307
|| (contentType != null && contentType.contains(this.supportedContentType.getSubtype()))) {
267-
String encoding = properties.getContentEncoding();
268-
if (encoding == null) {
269-
encoding = getDefaultCharset();
270-
}
308+
String encoding = determineEncoding(properties, contentType);
271309
content = doFromMessage(message, conversionHint, properties, encoding);
272310
}
273311
else {
@@ -283,6 +321,24 @@ public Object fromMessage(Message message, @Nullable Object conversionHint) thro
283321
return content;
284322
}
285323

324+
private String determineEncoding(MessageProperties properties, @Nullable String contentType) {
325+
String encoding = properties.getContentEncoding();
326+
if (encoding == null && contentType != null) {
327+
try {
328+
MimeType mimeType = MimeTypeUtils.parseMimeType(contentType);
329+
if (mimeType != null) {
330+
encoding = mimeType.getParameter("charset");
331+
}
332+
}
333+
catch (RuntimeException e) {
334+
}
335+
}
336+
if (encoding == null) {
337+
encoding = this.supportedCTCharset != null ? this.supportedCTCharset : getDefaultCharset();
338+
}
339+
return encoding;
340+
}
341+
286342
private Object doFromMessage(Message message, Object conversionHint, MessageProperties properties,
287343
String encoding) {
288344

@@ -348,11 +404,17 @@ private Object tryConverType(Message message, String encoding, JavaType inferred
348404
}
349405

350406
private Object convertBytesToObject(byte[] body, String encoding, JavaType targetJavaType) throws IOException {
407+
if (this.supportedCTCharset != null) { // Jackson will determine encoding
408+
return this.objectMapper.readValue(body, targetJavaType);
409+
}
351410
String contentAsString = new String(body, encoding);
352411
return this.objectMapper.readValue(contentAsString, targetJavaType);
353412
}
354413

355414
private Object convertBytesToObject(byte[] body, String encoding, Class<?> targetClass) throws IOException {
415+
if (this.supportedCTCharset != null) { // Jackson will determine encoding
416+
return this.objectMapper.readValue(body, this.objectMapper.constructType(targetClass));
417+
}
356418
String contentAsString = new String(body, encoding);
357419
return this.objectMapper.readValue(contentAsString, this.objectMapper.constructType(targetClass));
358420
}
@@ -370,20 +432,23 @@ protected Message createMessage(Object objectToConvert, MessageProperties messag
370432

371433
byte[] bytes;
372434
try {
373-
if (this.charsetIsUtf8) {
435+
if (this.charsetIsUtf8 && this.supportedCTCharset == null) {
374436
bytes = this.objectMapper.writeValueAsBytes(objectToConvert);
375437
}
376438
else {
377439
String jsonString = this.objectMapper
378440
.writeValueAsString(objectToConvert);
379-
bytes = jsonString.getBytes(getDefaultCharset());
441+
String encoding = this.supportedCTCharset != null ? this.supportedCTCharset : getDefaultCharset();
442+
bytes = jsonString.getBytes(encoding);
380443
}
381444
}
382445
catch (IOException e) {
383446
throw new MessageConversionException("Failed to convert Message content", e);
384447
}
385448
messageProperties.setContentType(this.supportedContentType.toString());
386-
messageProperties.setContentEncoding(getDefaultCharset());
449+
if (this.supportedCTCharset == null) {
450+
messageProperties.setContentEncoding(getDefaultCharset());
451+
}
387452
messageProperties.setContentLength(bytes.length);
388453

389454
if (getClassMapper() == null) {

spring-amqp/src/test/java/org/springframework/amqp/support/converter/Jackson2JsonMessageConverterTests.java

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2020 the original author or authors.
2+
* Copyright 2002-2022 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -17,6 +17,7 @@
1717
package org.springframework.amqp.support.converter;
1818

1919
import static org.assertj.core.api.Assertions.assertThat;
20+
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
2021

2122
import java.io.IOException;
2223
import java.math.BigDecimal;
@@ -34,6 +35,7 @@
3435
import org.springframework.core.ParameterizedTypeReference;
3536
import org.springframework.data.web.JsonPath;
3637
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
38+
import org.springframework.util.MimeTypeUtils;
3739

3840
import com.fasterxml.jackson.core.JsonParser;
3941
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -399,6 +401,67 @@ void concreteInMapRegression() throws Exception {
399401
assertThat(foos.values().iterator().next().getField()).isEqualTo("baz");
400402
}
401403

404+
@Test
405+
void charsetInContentType() {
406+
trade.setUserName("John Doe ∫");
407+
Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter();
408+
String utf8 = "application/json;charset=utf-8";
409+
converter.setSupportedContentType(MimeTypeUtils.parseMimeType(utf8));
410+
Message message = converter.toMessage(trade, new MessageProperties());
411+
int bodyLength8 = message.getBody().length;
412+
assertThat(message.getMessageProperties().getContentEncoding()).isNull();
413+
assertThat(message.getMessageProperties().getContentType()).isEqualTo(utf8);
414+
SimpleTrade marshalledTrade = (SimpleTrade) converter.fromMessage(message);
415+
assertThat(marshalledTrade).isEqualTo(trade);
416+
417+
// use content type property
418+
String utf16 = "application/json;charset=utf-16";
419+
converter.setSupportedContentType(MimeTypeUtils.parseMimeType(utf16));
420+
message = converter.toMessage(trade, new MessageProperties());
421+
assertThat(message.getBody().length).isNotEqualTo(bodyLength8);
422+
assertThat(message.getMessageProperties().getContentEncoding()).isNull();
423+
assertThat(message.getMessageProperties().getContentType()).isEqualTo(utf16);
424+
marshalledTrade = (SimpleTrade) converter.fromMessage(message);
425+
assertThat(marshalledTrade).isEqualTo(trade);
426+
427+
// no encoding in message, use configured default
428+
converter.setSupportedContentType(MimeTypeUtils.parseMimeType("application/json"));
429+
converter.setDefaultCharset("UTF-16");
430+
message = converter.toMessage(trade, new MessageProperties());
431+
assertThat(message.getBody().length).isNotEqualTo(bodyLength8);
432+
assertThat(message.getMessageProperties().getContentEncoding()).isNotNull();
433+
message.getMessageProperties().setContentEncoding(null);
434+
marshalledTrade = (SimpleTrade) converter.fromMessage(message);
435+
assertThat(marshalledTrade).isEqualTo(trade);
436+
437+
}
438+
439+
@Test
440+
void noConfigForCharsetInContentType() {
441+
trade.setUserName("John Doe ∫");
442+
Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter();
443+
Message message = converter.toMessage(trade, new MessageProperties());
444+
int bodyLength8 = message.getBody().length;
445+
SimpleTrade marshalledTrade = (SimpleTrade) converter.fromMessage(message);
446+
assertThat(marshalledTrade).isEqualTo(trade);
447+
448+
// no encoding in message; use configured default
449+
message = converter.toMessage(trade, new MessageProperties());
450+
assertThat(message.getMessageProperties().getContentEncoding()).isNotNull();
451+
message.getMessageProperties().setContentEncoding(null);
452+
marshalledTrade = (SimpleTrade) converter.fromMessage(message);
453+
assertThat(marshalledTrade).isEqualTo(trade);
454+
455+
converter.setDefaultCharset("UTF-16");
456+
Message message2 = converter.toMessage(trade, new MessageProperties());
457+
message2.getMessageProperties().setContentEncoding(null);
458+
assertThat(message2.getBody().length).isNotEqualTo(bodyLength8);
459+
converter.setDefaultCharset("UTF-8");
460+
461+
assertThatExceptionOfType(MessageConversionException.class).isThrownBy(
462+
() -> converter.fromMessage(message2));
463+
}
464+
402465
public List<Foo> fooLister() {
403466
return null;
404467
}

src/reference/asciidoc/amqp.adoc

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3938,11 +3938,34 @@ public DefaultClassMapper classMapper() {
39383938
Now, if the sending system sets the header to `thing1`, the converter creates a `Thing1` object, and so on.
39393939
See the <<spring-rabbit-json>> sample application for a complete discussion about converting messages from non-Spring applications.
39403940

3941+
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.
3942+
A new method `setSupportedMediaType` has been added:
3943+
3944+
====
3945+
[source, java]
3946+
----
3947+
String utf16 = "application/json; charset=utf-16";
3948+
converter.setSupportedContentType(MimeTypeUtils.parseMimeType(utf16));
3949+
----
3950+
====
3951+
39413952
[[Jackson2JsonMessageConverter-from-message]]
39423953
====== Converting from a `Message`
39433954

39443955
Inbound messages are converted to objects according to the type information added to headers by the sending system.
39453956

3957+
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.
3958+
If neither exist, if the `supportedMediaType` has a `charset` parameter, it will be used for decoding, with a final fallback to the `defaultCharset` property.
3959+
A new method `setSupportedMediaType` has been added:
3960+
3961+
====
3962+
[source, java]
3963+
----
3964+
String utf16 = "application/json; charset=utf-16";
3965+
converter.setSupportedContentType(MimeTypeUtils.parseMimeType(utf16));
3966+
----
3967+
====
3968+
39463969
In versions prior to 1.6, if type information is not present, conversion would fail.
39473970
Starting with version 1.6, if type information is missing, the converter converts the JSON by using Jackson defaults (usually a map).
39483971

src/reference/asciidoc/whats-new.adoc

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,7 @@ This version requires Spring Framework 6.0 and Java 17
1111

1212
The remoting feature (using RMI) is no longer supported.
1313

14-
See <<remoting>> for alternatives.
14+
==== Message Converter Changes
15+
16+
The `Jackson2JsonMessageConverter` can now determine the charset from the `contentEncoding` header.
17+
See <<json-message-converter>> for more information.

0 commit comments

Comments
 (0)