diff --git a/build.gradle b/build.gradle index fd89bdc6e5..9974eff3b9 100644 --- a/build.gradle +++ b/build.gradle @@ -735,6 +735,7 @@ project("spring-web") { optional("com.fasterxml.jackson.dataformat:jackson-dataformat-smile:${jackson2Version}") optional("com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:${jackson2Version}") optional("com.google.code.gson:gson:${gsonVersion}") + optional("javax.json.bind:javax.json.bind-api:1.0.0-M1") optional("com.rometools:rome:${romeVersion}") optional("org.eclipse.jetty:jetty-servlet:${jettyVersion}") { exclude group: "javax.servlet", module: "javax.servlet-api" diff --git a/spring-web/src/main/java/org/springframework/http/converter/json/JsonbHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/json/JsonbHttpMessageConverter.java new file mode 100644 index 0000000000..0dddf1e9fe --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/converter/json/JsonbHttpMessageConverter.java @@ -0,0 +1,109 @@ +/* + * Copyright 2002-2017 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.converter.json; + +import java.io.Reader; +import java.io.Writer; +import java.lang.reflect.Type; +import javax.json.bind.Jsonb; +import javax.json.bind.JsonbBuilder; +import javax.json.bind.JsonbConfig; + +import org.springframework.util.Assert; + +/** + * Implementation of {@link org.springframework.http.converter.HttpMessageConverter} + * that can read and write JSON using the + * JSON Binding API. + * + *

This converter can be used to bind to typed beans or untyped {@code HashMap}s. + * By default, it supports {@code application/json} and {@code application/*+json} with + * {@code UTF-8} character set. + * + * @author Juergen Hoeller + * @since 5.0 + * @see javax.json.bind.Jsonb + * @see javax.json.bind.JsonbBuilder + * @see #setJsonb + */ +public class JsonbHttpMessageConverter extends AbstractJsonHttpMessageConverter { + + private Jsonb jsonb; + + + /** + * Construct a new {@code JsonbHttpMessageConverter} with default configuration. + */ + public JsonbHttpMessageConverter() { + this(JsonbBuilder.create()); + } + + /** + * Construct a new {@code JsonbHttpMessageConverter} with the given configuration. + * @param config the {@code JsonbConfig} for the underlying delegate + */ + public JsonbHttpMessageConverter(JsonbConfig config) { + this(JsonbBuilder.create(config)); + } + + /** + * Construct a new {@code JsonbHttpMessageConverter} with the given delegate. + * @param jsonb the Jsonb instance to use + */ + public JsonbHttpMessageConverter(Jsonb jsonb) { + setJsonb(jsonb); + } + + + /** + * Set the {@code Jsonb} instance to use. + * If not set, a default {@code Jsonb} instance will be created. + *

Setting a custom-configured {@code Jsonb} is one way to take further + * control of the JSON serialization process. + * @see #JsonbHttpMessageConverter(Jsonb) + * @see #JsonbHttpMessageConverter(JsonbConfig) + * @see JsonbBuilder + */ + public void setJsonb(Jsonb jsonb) { + Assert.notNull(jsonb, "A Jsonb instance is required"); + this.jsonb = jsonb; + } + + /** + * Return the configured {@code Jsonb} instance for this converter. + */ + public Jsonb getJsonb() { + return this.jsonb; + } + + + @Override + protected Object readInternal(Type resolvedType, Reader reader) throws Exception { + return getJsonb().fromJson(reader, resolvedType); + } + + @Override + protected void writeInternal(Object o, Type type, Writer writer) throws Exception { + if (type != null) { + getJsonb().toJson(o, type, writer); + } + else { + getJsonb().toJson(o, writer); + } + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/converter/support/AllEncompassingFormHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/support/AllEncompassingFormHttpMessageConverter.java index 4583daceb7..6e4291cdbf 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/support/AllEncompassingFormHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/support/AllEncompassingFormHttpMessageConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2017 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. @@ -18,6 +18,7 @@ package org.springframework.http.converter.support; import org.springframework.http.converter.FormHttpMessageConverter; import org.springframework.http.converter.json.GsonHttpMessageConverter; +import org.springframework.http.converter.json.JsonbHttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.http.converter.smile.MappingJackson2SmileHttpMessageConverter; import org.springframework.http.converter.xml.Jaxb2RootElementHttpMessageConverter; @@ -51,6 +52,9 @@ public class AllEncompassingFormHttpMessageConverter extends FormHttpMessageConv private static final boolean gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", AllEncompassingFormHttpMessageConverter.class.getClassLoader()); + private static final boolean jsonbPresent = + ClassUtils.isPresent("javax.json.bind.Jsonb", AllEncompassingFormHttpMessageConverter.class.getClassLoader()); + public AllEncompassingFormHttpMessageConverter() { addPartConverter(new SourceHttpMessageConverter<>()); @@ -65,6 +69,9 @@ public class AllEncompassingFormHttpMessageConverter extends FormHttpMessageConv else if (gsonPresent) { addPartConverter(new GsonHttpMessageConverter()); } + else if (jsonbPresent) { + addPartConverter(new JsonbHttpMessageConverter()); + } if (jackson2XmlPresent) { addPartConverter(new MappingJackson2XmlHttpMessageConverter()); diff --git a/spring-web/src/main/java/org/springframework/web/client/RestTemplate.java b/spring-web/src/main/java/org/springframework/web/client/RestTemplate.java index ea94a66c83..71d9a28627 100644 --- a/spring-web/src/main/java/org/springframework/web/client/RestTemplate.java +++ b/spring-web/src/main/java/org/springframework/web/client/RestTemplate.java @@ -44,6 +44,7 @@ import org.springframework.http.converter.cbor.MappingJackson2CborHttpMessageCon import org.springframework.http.converter.feed.AtomFeedHttpMessageConverter; import org.springframework.http.converter.feed.RssChannelHttpMessageConverter; import org.springframework.http.converter.json.GsonHttpMessageConverter; +import org.springframework.http.converter.json.JsonbHttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.http.converter.smile.MappingJackson2SmileHttpMessageConverter; import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter; @@ -143,6 +144,9 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat private static final boolean gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", RestTemplate.class.getClassLoader()); + private static final boolean jsonbPresent = + ClassUtils.isPresent("javax.json.bind.Jsonb", RestTemplate.class.getClassLoader()); + private final List> messageConverters = new ArrayList<>(); @@ -182,6 +186,9 @@ public class RestTemplate extends InterceptingHttpAccessor implements RestOperat else if (gsonPresent) { this.messageConverters.add(new GsonHttpMessageConverter()); } + else if (jsonbPresent) { + this.messageConverters.add(new JsonbHttpMessageConverter()); + } if (jackson2SmilePresent) { this.messageConverters.add(new MappingJackson2SmileHttpMessageConverter()); diff --git a/spring-web/src/test/java/org/springframework/http/converter/json/JsonbHttpMessageConverterTests.java b/spring-web/src/test/java/org/springframework/http/converter/json/JsonbHttpMessageConverterTests.java new file mode 100644 index 0000000000..e7e35989f6 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/converter/json/JsonbHttpMessageConverterTests.java @@ -0,0 +1,234 @@ +/* + * Copyright 2002-2017 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.converter.json; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Ignore; +import org.junit.Test; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.MediaType; +import org.springframework.http.MockHttpInputMessage; +import org.springframework.http.MockHttpOutputMessage; +import org.springframework.http.converter.HttpMessageNotReadableException; + +import static org.junit.Assert.*; + +/** + * @author Juergen Hoeller + */ +@Ignore // until we are able to include Eclipse Yasson (the JSONB RI) in our build setup +public class JsonbHttpMessageConverterTests { + + private final JsonbHttpMessageConverter converter = new JsonbHttpMessageConverter(); + + + @Test + public void canRead() { + assertTrue(this.converter.canRead(MyBean.class, new MediaType("application", "json"))); + assertTrue(this.converter.canRead(Map.class, new MediaType("application", "json"))); + } + + @Test + public void canWrite() { + assertTrue(this.converter.canWrite(MyBean.class, new MediaType("application", "json"))); + assertTrue(this.converter.canWrite(Map.class, new MediaType("application", "json"))); + } + + @Test + public void canReadAndWriteMicroformats() { + assertTrue(this.converter.canRead(MyBean.class, new MediaType("application", "vnd.test-micro-type+json"))); + assertTrue(this.converter.canWrite(MyBean.class, new MediaType("application", "vnd.test-micro-type+json"))); + } + + @Test + public void readTyped() throws IOException { + String body = "{\"bytes\":[1,2],\"array\":[\"Foo\",\"Bar\"]," + + "\"number\":42,\"string\":\"Foo\",\"bool\":true,\"fraction\":42.0}"; + MockHttpInputMessage inputMessage = new MockHttpInputMessage(body.getBytes("UTF-8")); + inputMessage.getHeaders().setContentType(new MediaType("application", "json")); + MyBean result = (MyBean) this.converter.read(MyBean.class, inputMessage); + + assertEquals("Foo", result.getString()); + assertEquals(42, result.getNumber()); + assertEquals(42F, result.getFraction(), 0F); + assertArrayEquals(new String[] {"Foo", "Bar"}, result.getArray()); + assertTrue(result.isBool()); + assertArrayEquals(new byte[] {0x1, 0x2}, result.getBytes()); + } + + @Test + @SuppressWarnings("unchecked") + public void readUntyped() throws IOException { + String body = "{\"bytes\":[1,2],\"array\":[\"Foo\",\"Bar\"]," + + "\"number\":42,\"string\":\"Foo\",\"bool\":true,\"fraction\":42.0}"; + MockHttpInputMessage inputMessage = new MockHttpInputMessage(body.getBytes("UTF-8")); + inputMessage.getHeaders().setContentType(new MediaType("application", "json")); + HashMap result = (HashMap) this.converter.read(HashMap.class, inputMessage); + assertEquals("Foo", result.get("string")); + Number n = (Number) result.get("number"); + assertEquals(42, n.longValue()); + n = (Number) result.get("fraction"); + assertEquals(42D, n.doubleValue(), 0D); + List array = new ArrayList<>(); + array.add("Foo"); + array.add("Bar"); + assertEquals(array, result.get("array")); + assertEquals(Boolean.TRUE, result.get("bool")); + byte[] bytes = new byte[2]; + List resultBytes = (ArrayList)result.get("bytes"); + for (int i = 0; i < 2; i++) { + bytes[i] = resultBytes.get(i).byteValue(); + } + assertArrayEquals(new byte[] {0x1, 0x2}, bytes); + } + + @Test + public void write() throws IOException { + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + MyBean body = new MyBean(); + body.setString("Foo"); + body.setNumber(42); + body.setFraction(42F); + body.setArray(new String[] {"Foo", "Bar"}); + body.setBool(true); + body.setBytes(new byte[] {0x1, 0x2}); + this.converter.write(body, null, outputMessage); + Charset utf8 = StandardCharsets.UTF_8; + String result = outputMessage.getBodyAsString(utf8); + assertTrue(result.contains("\"string\":\"Foo\"")); + assertTrue(result.contains("\"number\":42")); + assertTrue(result.contains("fraction\":42.0")); + assertTrue(result.contains("\"array\":[\"Foo\",\"Bar\"]")); + assertTrue(result.contains("\"bool\":true")); + assertTrue(result.contains("\"bytes\":[1,2]")); + assertEquals("Invalid content-type", new MediaType("application", "json", utf8), + outputMessage.getHeaders().getContentType()); + } + + @Test + public void writeUTF16() throws IOException { + MediaType contentType = new MediaType("application", "json", StandardCharsets.UTF_16BE); + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + String body = "H\u00e9llo W\u00f6rld"; + this.converter.write(body, contentType, outputMessage); + assertEquals("Invalid result", "\"" + body + "\"", outputMessage.getBodyAsString(StandardCharsets.UTF_16BE)); + assertEquals("Invalid content-type", contentType, outputMessage.getHeaders().getContentType()); + } + + @Test(expected = HttpMessageNotReadableException.class) + public void readInvalidJson() throws IOException { + String body = "FooBar"; + MockHttpInputMessage inputMessage = new MockHttpInputMessage(body.getBytes("UTF-8")); + inputMessage.getHeaders().setContentType(new MediaType("application", "json")); + this.converter.read(MyBean.class, inputMessage); + } + + @Test + @SuppressWarnings("unchecked") + public void readParameterizedType() throws IOException { + ParameterizedTypeReference> beansList = new ParameterizedTypeReference>() { + }; + + String body = "[{\"bytes\":[1,2],\"array\":[\"Foo\",\"Bar\"]," + + "\"number\":42,\"string\":\"Foo\",\"bool\":true,\"fraction\":42.0}]"; + MockHttpInputMessage inputMessage = new MockHttpInputMessage(body.getBytes(StandardCharsets.UTF_8)); + inputMessage.getHeaders().setContentType(new MediaType("application", "json")); + + List results = (List) converter.read(beansList.getType(), null, inputMessage); + assertEquals(1, results.size()); + MyBean result = results.get(0); + assertEquals("Foo", result.getString()); + assertEquals(42, result.getNumber()); + assertEquals(42F, result.getFraction(), 0F); + assertArrayEquals(new String[] { "Foo", "Bar" }, result.getArray()); + assertTrue(result.isBool()); + assertArrayEquals(new byte[] {0x1, 0x2}, result.getBytes()); + } + + + public static class MyBean { + + private String string; + + private int number; + + private float fraction; + + private String[] array; + + private boolean bool; + + private byte[] bytes; + + public byte[] getBytes() { + return bytes; + } + + public void setBytes(byte[] bytes) { + this.bytes = bytes; + } + + public boolean isBool() { + return bool; + } + + public void setBool(boolean bool) { + this.bool = bool; + } + + public String getString() { + return string; + } + + public void setString(String string) { + this.string = string; + } + + public int getNumber() { + return number; + } + + public void setNumber(int number) { + this.number = number; + } + + public float getFraction() { + return fraction; + } + + public void setFraction(float fraction) { + this.fraction = fraction; + } + + public String[] getArray() { + return array; + } + + public void setArray(String[] array) { + this.array = array; + } + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java index 9ac1891058..49abd8574a 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java @@ -49,6 +49,7 @@ import org.springframework.http.converter.feed.AtomFeedHttpMessageConverter; import org.springframework.http.converter.feed.RssChannelHttpMessageConverter; import org.springframework.http.converter.json.GsonHttpMessageConverter; import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.http.converter.json.JsonbHttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.http.converter.smile.MappingJackson2SmileHttpMessageConverter; import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter; @@ -193,6 +194,9 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv private static final boolean gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", WebMvcConfigurationSupport.class.getClassLoader()); + private static final boolean jsonbPresent = + ClassUtils.isPresent("javax.json.bind.Jsonb", WebMvcConfigurationSupport.class.getClassLoader()); + private ApplicationContext applicationContext; @@ -390,7 +394,7 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv if (jaxb2Present || jackson2XmlPresent) { map.put("xml", MediaType.APPLICATION_XML); } - if (jackson2Present || gsonPresent) { + if (jackson2Present || gsonPresent || jsonbPresent) { map.put("json", MediaType.APPLICATION_JSON); } if (jackson2SmilePresent) { @@ -785,6 +789,9 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv else if (gsonPresent) { messageConverters.add(new GsonHttpMessageConverter()); } + else if (jsonbPresent) { + messageConverters.add(new JsonbHttpMessageConverter()); + } if (jackson2SmilePresent) { ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.smile().applicationContext(this.applicationContext).build();