diff --git a/docs/howto.md b/docs/howto.md index 3ff18dde75..232e64edae 100644 --- a/docs/howto.md +++ b/docs/howto.md @@ -40,7 +40,88 @@ Javadocs. Some rules of thumb: * Look for `@ConditionalOnExpression` annotations that switch features on and off in response to SpEL expressions, normally evaluated with placeholders resolved from the `Environment`. + + +## Write a JSON REST Service + +Any Spring `@RestController` in a Spring Boot application should +render JSON response by default as long as Jackson2 is on the +classpath. For example: + +```java +@RestController +public class MyController { + + @RequestMapping("/thing") + public MyThing thing() { + return new MyThing(); + } + +} +``` + +As long as `MyThing` can be serialized by Jackson2 (e.g. a normal POJO +or Groovy object) then `http://localhost:8080/thing` will serve a JSON +representation of it by default. Sometimes in a browser you might see +XML responses (but by default only if `MyThing` was a JAXB object) +because browsers tend to send accept headers that prefer XML. + +## Customize the Jackson ObjectMapper + +Spring MVC (client and server side) uses `HttpMessageConverters` to +negotiate content conversion in an HTTP exchange. If Jackson is on the +classpath you already get a default converter with a vanilla +`ObjectMapper`. Spring Boot has some features to make it easier to +customize this behaviour. + +The smallest change that might work is to just add beans of type +`Module` to your context. They will be registered with the default +`ObjectMapper` and then injected into the default message +converter. To replace the default `ObjectMapper` completely, define a +`@Bean` of that type and mark it as `@Primary`. + +In addition, if your context contains any beans of type `ObjectMapper` +then all of the `Module` beans will be registered with all of the +mappers. So there is a global mechanism for contributing custom +modules when you add new features to your application. +Finally, if you provide any `@Beans` of type +`MappingJackson2HttpMessageConverter` then they will replace the +default value in the MVC configuration. Also, a convenience bean is +provided of type `MessageConverters` (always available if you use the +default MVC configuration) which has some useful methods to access the +default and user-enhanced message converters. + +See also the [section on `HttpMessageConverters`](#message.converters) +and the `WebMvcAutoConfiguration` source code for more details. + + +## Customize the @ResponseBody Rendering + +Spring uses `HttpMessageConverters` to render `@ResponseBody` (or +responses from `@RestControllers`). You can contribute additional +converters by simply adding beans of that type in a Spring Boot +context. If a bean you add is of a type that would have been included +by default anyway (like `MappingJackson2HttpMessageConverter` for JSON +conversions) then it will replace the default value. A convenience +bean is provided of type `MessageConverters` (always available if you +use the default MVC configuration) which has some useful methods to +access the default and user-enhanced message converters (useful, for +example if you want to manually inject them into a custom +`RestTemplate`). + +As in normal MVC usage, any `WebMvcConfigurerAdapter` beans that you +provide can also contribute converters by overriding the +`configureMessageConverters` method, but unlike with normal MVC, you +can supply only additional converters that you need (because Spring +Boot uses the same mechanism to contribute its defaults). Finally, if +you opt out of the Spring Boot default MVC configuration by providing +your own `@EnableWebMvc` configuration, then you can take control +completely and do everything manually using `getMessageConverters` +from `WebMvcConfigurationSupport`. + +See the `WebMvcAutoConfiguration` source code for more details. + ## Add a Servlet, Filter or ServletContextListener to an Application `Servlet`, `Filter`, `ServletContextListener` and the other listeners diff --git a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/autoconfigure/EndpointWebMvcAutoConfiguration.java b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/autoconfigure/EndpointWebMvcAutoConfiguration.java index ee92f03890..e4e0d3c334 100644 --- a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/autoconfigure/EndpointWebMvcAutoConfiguration.java +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/autoconfigure/EndpointWebMvcAutoConfiguration.java @@ -40,6 +40,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.autoconfigure.web.DispatcherServletAutoConfiguration; import org.springframework.boot.autoconfigure.web.EmbeddedServletContainerAutoConfiguration; +import org.springframework.boot.autoconfigure.web.MessageConverters; import org.springframework.boot.autoconfigure.web.ServerProperties; import org.springframework.boot.autoconfigure.web.WebMvcAutoConfiguration; import org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext; @@ -92,8 +93,11 @@ public class EndpointWebMvcAutoConfiguration implements ApplicationContextAware, @Bean @ConditionalOnMissingBean - public EndpointHandlerAdapter endpointHandlerAdapter() { - return new EndpointHandlerAdapter(); + public EndpointHandlerAdapter endpointHandlerAdapter( + final MessageConverters messageConverters) { + EndpointHandlerAdapter adapter = new EndpointHandlerAdapter(); + adapter.setMessageConverters(messageConverters.getMessageConverters()); + return adapter; } @Override diff --git a/spring-boot-autoconfigure/pom.xml b/spring-boot-autoconfigure/pom.xml index 1cd27087eb..79a2ed601f 100644 --- a/spring-boot-autoconfigure/pom.xml +++ b/spring-boot-autoconfigure/pom.xml @@ -21,6 +21,11 @@ ${project.version} + + com.fasterxml.jackson.core + jackson-databind + true + commons-dbcp commons-dbcp diff --git a/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/MessageConverters.java b/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/MessageConverters.java new file mode 100644 index 0000000000..1f7f5374bd --- /dev/null +++ b/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/MessageConverters.java @@ -0,0 +1,106 @@ +/* + * Copyright 2012-2013 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.boot.autoconfigure.web; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.xml.AbstractXmlHttpMessageConverter; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport; + +/** + * Convenient utility for adding and merging additional {@link HttpMessageConverter} in an + * application context. It also modifies the default converters a bit (putting XML + * converters at the back of the list if they are present). + * + * @author Dave Syer + */ +public class MessageConverters { + + private List> defaults; + + private List> overrides; + + private Object lock = new Object(); + + private List> converters; + + public MessageConverters() { + this(Collections.> emptyList()); + } + + public MessageConverters(Collection> overrides) { + this.overrides = new ArrayList>(overrides); + } + + public List> getMessageConverters() { + if (this.converters == null) { + synchronized (this.lock) { + if (this.converters == null) { + this.converters = new ArrayList>(); + getDefaultMessageConverters(); // ensure they are available + for (HttpMessageConverter fallback : this.defaults) { + boolean overridden = false; + for (HttpMessageConverter converter : this.overrides) { + if (fallback.getClass() + .isAssignableFrom(converter.getClass())) { + if (!this.converters.contains(converter)) { + this.converters.add(converter); + overridden = true; + } + } + } + if (!overridden) { + this.converters.add(fallback); + } + } + } + } + } + return this.converters; + } + + public List> getDefaultMessageConverters() { + if (this.defaults == null) { + synchronized (this.lock) { + if (this.defaults == null) { + this.defaults = new ArrayList>(); + this.defaults.addAll(new WebMvcConfigurationSupport() { + public List> defaultMessageConverters() { + return super.getMessageConverters(); + } + }.defaultMessageConverters()); + List> xmls = new ArrayList>(); + for (HttpMessageConverter converter : this.defaults) { + // Shift XML converters to the back of the list so they only get + // used if nothing else works... + if (converter instanceof AbstractXmlHttpMessageConverter) { + xmls.add(converter); + } + } + this.defaults.removeAll(xmls); + this.defaults.addAll(xmls); + } + } + } + return Collections.unmodifiableList(this.defaults); + } + +} \ No newline at end of file diff --git a/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/WebMvcAutoConfiguration.java b/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/WebMvcAutoConfiguration.java index 622b2722d4..98c5b51944 100644 --- a/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/WebMvcAutoConfiguration.java +++ b/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/WebMvcAutoConfiguration.java @@ -17,15 +17,19 @@ package org.springframework.boot.autoconfigure.web; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.List; +import javax.annotation.PostConstruct; import javax.servlet.Servlet; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryUtils; import org.springframework.beans.factory.ListableBeanFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -37,6 +41,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.core.convert.converter.Converter; @@ -46,6 +51,8 @@ import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; import org.springframework.format.Formatter; import org.springframework.format.FormatterRegistry; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.web.accept.ContentNegotiationManager; import org.springframework.web.context.request.RequestContextListener; import org.springframework.web.filter.HiddenHttpMethodFilter; @@ -62,6 +69,9 @@ import org.springframework.web.servlet.view.BeanNameViewResolver; import org.springframework.web.servlet.view.ContentNegotiatingViewResolver; import org.springframework.web.servlet.view.InternalResourceViewResolver; +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.ObjectMapper; + /** * {@link EnableAutoConfiguration Auto-configuration} for {@link EnableWebMvc Web MVC}. * @@ -101,6 +111,17 @@ public class WebMvcAutoConfiguration { } } + @Autowired(required = false) + private List> converters = Collections.emptyList(); + + @Bean + @ConditionalOnMissingBean + public MessageConverters messageConverters() { + List> converters = new ArrayList>( + this.converters); + return new MessageConverters(converters); + } + @Bean @ConditionalOnMissingBean(HiddenHttpMethodFilter.class) public HiddenHttpMethodFilter hiddenHttpMethodFilter() { @@ -126,6 +147,53 @@ public class WebMvcAutoConfiguration { @Autowired private ResourceLoader resourceLoader; + @Autowired + private MessageConverters messageConverters; + + @Override + public void configureMessageConverters(List> converters) { + converters.addAll(this.messageConverters.getMessageConverters()); + } + + @Configuration + @ConditionalOnBean(ObjectMapper.class) + @ConditionalOnClass(ObjectMapper.class) + protected static class ObjectMappers { + + @Autowired + private ListableBeanFactory beanFactory; + + @PostConstruct + public void init() { + Collection mappers = BeanFactoryUtils + .beansOfTypeIncludingAncestors(this.beanFactory, + ObjectMapper.class).values(); + Collection modules = BeanFactoryUtils + .beansOfTypeIncludingAncestors(this.beanFactory, Module.class) + .values(); + for (ObjectMapper mapper : mappers) { + mapper.registerModules(modules); + } + } + + @Bean + @ConditionalOnMissingBean + @Primary + public ObjectMapper jacksonObjectMapper() { + return new ObjectMapper(); + } + + @Bean + @ConditionalOnMissingBean + public MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter( + ObjectMapper objectMapper) { + MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); + converter.setObjectMapper(objectMapper); + return converter; + } + + } + @Bean @ConditionalOnMissingBean(InternalResourceViewResolver.class) public InternalResourceViewResolver defaultViewResolver() { diff --git a/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jmx/JmxAutoConfigurationTests.java b/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jmx/JmxAutoConfigurationTests.java index 6df71f80ee..4a18095816 100644 --- a/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jmx/JmxAutoConfigurationTests.java +++ b/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/jmx/JmxAutoConfigurationTests.java @@ -29,7 +29,6 @@ import org.springframework.jmx.export.annotation.ManagedResource; import org.springframework.mock.env.MockEnvironment; import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.fail; /** * Tests for {@link JmxAutoConfiguration} @@ -66,7 +65,6 @@ public class JmxAutoConfigurationTests { this.context.refresh(); this.context.getBean(MBeanExporter.class); - fail(); } @Test diff --git a/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/WebMvcAutoConfigurationTests.java b/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/WebMvcAutoConfigurationTests.java index fa0fb09782..4f6a03635c 100644 --- a/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/WebMvcAutoConfigurationTests.java +++ b/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/WebMvcAutoConfigurationTests.java @@ -16,6 +16,7 @@ package org.springframework.boot.autoconfigure.web; +import java.io.IOException; import java.lang.reflect.Field; import java.util.LinkedHashMap; import java.util.List; @@ -28,14 +29,17 @@ import org.junit.After; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; +import org.mockito.Mockito; import org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext; import org.springframework.boot.context.embedded.EmbeddedServletContainerCustomizerBeanPostProcessor; import org.springframework.boot.context.embedded.EmbeddedServletContainerFactory; import org.springframework.boot.context.embedded.MockEmbeddedServletContainerFactory; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.util.ReflectionUtils; import org.springframework.web.servlet.HandlerAdapter; import org.springframework.web.servlet.HandlerMapping; @@ -43,12 +47,26 @@ import org.springframework.web.servlet.View; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter; import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter; import org.springframework.web.servlet.resource.ResourceHttpRequestHandler; import org.springframework.web.servlet.view.AbstractView; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; + import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItem; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.mockito.Matchers.argThat; +import static org.mockito.Mockito.verify; /** * Tests for {@link WebMvcAutoConfiguration}. @@ -86,6 +104,8 @@ public class WebMvcAutoConfigurationTests { this.context.register(Config.class, WebMvcAutoConfiguration.class); this.context.refresh(); assertEquals(6, this.context.getBeanNamesForType(HandlerMapping.class).length); + assertFalse(this.context.getBean(RequestMappingHandlerAdapter.class) + .getMessageConverters().isEmpty()); } @Test @@ -123,6 +143,46 @@ public class WebMvcAutoConfigurationTests { equalTo((Resource) new ClassPathResource("/foo/"))); } + @Test + public void customJacksonConverter() throws Exception { + this.context = new AnnotationConfigEmbeddedWebApplicationContext(); + this.context.register(JacksonConfig.class, Config.class, + WebMvcAutoConfiguration.class); + this.context.refresh(); + MappingJackson2HttpMessageConverter converter = this.context + .getBean(MappingJackson2HttpMessageConverter.class); + assertEquals(this.context.getBean(ObjectMapper.class), + converter.getObjectMapper()); + MessageConverters converters = this.context.getBean(MessageConverters.class); + assertTrue(converters.getMessageConverters().contains(converter)); + assertEquals(converters.getMessageConverters(), + this.context.getBean(RequestMappingHandlerAdapter.class) + .getMessageConverters()); + } + + @Test + public void customJacksonModules() throws Exception { + this.context = new AnnotationConfigEmbeddedWebApplicationContext(); + this.context.register(ModulesConfig.class, Config.class, + WebMvcAutoConfiguration.class); + this.context.refresh(); + ObjectMapper mapper = this.context.getBean(ObjectMapper.class); + + @SuppressWarnings({ "unchecked", "unused" }) + ObjectMapper result = verify(mapper).registerModules( + (Iterable) argThat(hasItem(this.context.getBean(Module.class)))); + } + + @Test + public void doubleModuleRegistration() throws Exception { + this.context = new AnnotationConfigEmbeddedWebApplicationContext(); + this.context.register(DoubleModulesConfig.class, Config.class, + WebMvcAutoConfiguration.class); + this.context.refresh(); + ObjectMapper mapper = this.context.getBean(ObjectMapper.class); + assertEquals("{\"foo\":\"bar\"}", mapper.writeValueAsString(new Foo())); + } + @SuppressWarnings("unchecked") protected Map> getMappingLocations() throws IllegalAccessException { @@ -180,6 +240,91 @@ public class WebMvcAutoConfigurationTests { } + @Configuration + protected static class ModulesConfig { + + @Bean + public Module jacksonModule() { + return new SimpleModule(); + } + + @Bean + @Primary + public ObjectMapper objectMapper() { + return Mockito.mock(ObjectMapper.class); + } + + } + + @Configuration + protected static class DoubleModulesConfig { + + @Bean + public Module jacksonModule() { + SimpleModule module = new SimpleModule(); + module.addSerializer(Foo.class, new JsonSerializer() { + + @Override + public void serialize(Foo value, JsonGenerator jgen, + SerializerProvider provider) throws IOException, + JsonProcessingException { + jgen.writeStartObject(); + jgen.writeStringField("foo", "bar"); + jgen.writeEndObject(); + } + }); + return module; + } + + @Bean + @Primary + public ObjectMapper objectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(jacksonModule()); + return mapper; + } + + } + + protected static class Foo { + + private String name; + + private Foo() { + + } + + static Foo create() { + return new Foo(); + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + } + + @Configuration + protected static class JacksonConfig { + + @Bean + public MappingJackson2HttpMessageConverter jacksonMessaegConverter() { + MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); + converter.setObjectMapper(objectMapper()); + return converter; + } + + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper(); + } + + } + @Configuration protected static class Config {