diff --git a/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/AbstractErrorController.java b/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/AbstractErrorController.java index 0c42240fb60..20fa52d83e8 100644 --- a/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/AbstractErrorController.java +++ b/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/AbstractErrorController.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2014 the original author or authors. + * Copyright 2012-2016 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. @@ -16,15 +16,20 @@ package org.springframework.boot.autoconfigure.web; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.springframework.core.annotation.AnnotationAwareOrderComparator; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Controller; import org.springframework.util.Assert; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.servlet.ModelAndView; /** * Abstract base class for error {@link Controller} implementations. @@ -38,9 +43,27 @@ public abstract class AbstractErrorController implements ErrorController { private final ErrorAttributes errorAttributes; + private final List errorViewResolvers; + public AbstractErrorController(ErrorAttributes errorAttributes) { + this(errorAttributes, null); + } + + public AbstractErrorController(ErrorAttributes errorAttributes, + List errorViewResolvers) { Assert.notNull(errorAttributes, "ErrorAttributes must not be null"); this.errorAttributes = errorAttributes; + this.errorViewResolvers = sortErrorViewResolvers(errorViewResolvers); + } + + private List sortErrorViewResolvers( + List resolvers) { + List sorted = new ArrayList(); + if (resolvers != null) { + sorted.addAll(resolvers); + AnnotationAwareOrderComparator.sortIfNecessary(sorted); + } + return sorted; } protected Map getErrorAttributes(HttpServletRequest request, @@ -72,4 +95,26 @@ public abstract class AbstractErrorController implements ErrorController { } } + /** + * Resolve any specific error views. By default this method delegates to + * {@link ErrorViewResolver ErrorViewResolvers}. + * @param request the request + * @param response the response + * @param status the HTTP status + * @param model the suggested model + * @return a specific {@link ModelAndView} or {@code null} if the default should be + * used + * @since 1.4.0 + */ + protected ModelAndView resolveErrorView(HttpServletRequest request, + HttpServletResponse response, HttpStatus status, Map model) { + for (ErrorViewResolver resolver : this.errorViewResolvers) { + ModelAndView modelAndView = resolver.resolveErrorView(request, status, model); + if (modelAndView != null) { + return modelAndView; + } + } + return null; + } + } diff --git a/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/BasicErrorController.java b/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/BasicErrorController.java index 5126f3244f8..41363125d15 100644 --- a/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/BasicErrorController.java +++ b/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/BasicErrorController.java @@ -16,6 +16,8 @@ package org.springframework.boot.autoconfigure.web; +import java.util.Collections; +import java.util.List; import java.util.Map; import javax.servlet.http.HttpServletRequest; @@ -58,7 +60,19 @@ public class BasicErrorController extends AbstractErrorController { */ public BasicErrorController(ErrorAttributes errorAttributes, ErrorProperties errorProperties) { - super(errorAttributes); + this(errorAttributes, errorProperties, + Collections.emptyList()); + } + + /** + * Create a new {@link BasicErrorController} instance. + * @param errorAttributes the error attributes + * @param errorProperties configuration properties + * @param errorViewResolvers error view resolvers + */ + public BasicErrorController(ErrorAttributes errorAttributes, + ErrorProperties errorProperties, List errorViewResolvers) { + super(errorAttributes, errorViewResolvers); Assert.notNull(errorProperties, "ErrorProperties must not be null"); this.errorProperties = errorProperties; } @@ -71,10 +85,12 @@ public class BasicErrorController extends AbstractErrorController { @RequestMapping(produces = "text/html") public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) { - response.setStatus(getStatus(request).value()); - Map model = getErrorAttributes(request, - isIncludeStackTrace(request, MediaType.TEXT_HTML)); - return new ModelAndView("error", model); + HttpStatus status = getStatus(request); + Map model = Collections.unmodifiableMap(getErrorAttributes( + request, isIncludeStackTrace(request, MediaType.TEXT_HTML))); + response.setStatus(status.value()); + ModelAndView modelAndView = resolveErrorView(request, response, status, model); + return (modelAndView == null ? new ModelAndView("error", model) : modelAndView); } @RequestMapping diff --git a/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/DefaultErrorViewResolver.java b/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/DefaultErrorViewResolver.java new file mode 100644 index 00000000000..095425cdc6a --- /dev/null +++ b/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/DefaultErrorViewResolver.java @@ -0,0 +1,180 @@ +/* + * Copyright 2012-2016 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.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.boot.autoconfigure.template.TemplateAvailabilityProvider; +import org.springframework.context.ApplicationContext; +import org.springframework.core.Ordered; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.SpringFactoriesLoader; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatus.Series; +import org.springframework.http.MediaType; +import org.springframework.util.Assert; +import org.springframework.util.FileCopyUtils; +import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.servlet.View; + +/** + * Default {@link ErrorViewResolver} implementation that attempts to resolve error views + * using well known conventions. Will search for templates and static assets under + * {@code '/error'} using the {@link HttpStatus status code} and the + * {@link HttpStatus#series() status series}. + *

+ * For example, an {@code HTTP 404} will search (in the specific order): + *

    + *
  • {@code '//error/404.'}
  • + *
  • {@code '//error/404.html'}
  • + *
  • {@code '//error/4xx.'}
  • + *
  • {@code '//error/4xx.html'}
  • + *
+ * + * @author Phillip Webb + * @since 1.4.0 + */ +public class DefaultErrorViewResolver implements ErrorViewResolver, Ordered { + + private static final Map SERIES_VIEWS; + + static { + Map views = new HashMap(); + views.put(Series.CLIENT_ERROR, "4xx"); + views.put(Series.SERVER_ERROR, "5xx"); + SERIES_VIEWS = Collections.unmodifiableMap(views); + } + + private ApplicationContext applicationContext; + + private final ResourceProperties resourceProperties; + + private final List templateAvailabilityProviders; + + private int order = Ordered.LOWEST_PRECEDENCE; + + /** + * Create a new {@link DefaultErrorViewResolver} instance. + * @param applicationContext the source application context + * @param resourceProperties resource properties + */ + public DefaultErrorViewResolver(ApplicationContext applicationContext, + ResourceProperties resourceProperties) { + this(applicationContext, resourceProperties, + loadTemplateAvailabilityProviders(applicationContext)); + } + + private static List loadTemplateAvailabilityProviders( + ApplicationContext applicationContext) { + return SpringFactoriesLoader.loadFactories(TemplateAvailabilityProvider.class, + applicationContext == null ? null : applicationContext.getClassLoader()); + } + + DefaultErrorViewResolver(ApplicationContext applicationContext, + ResourceProperties resourceProperties, + List templateAvailabilityProviders) { + Assert.notNull(applicationContext, "ApplicationContext must not be null"); + Assert.notNull(resourceProperties, "ResourceProperties must not be null"); + this.applicationContext = applicationContext; + this.resourceProperties = resourceProperties; + this.templateAvailabilityProviders = templateAvailabilityProviders; + } + + @Override + public ModelAndView resolveErrorView(HttpServletRequest request, HttpStatus status, + Map model) { + ModelAndView modelAndView = resolve(String.valueOf(status), model); + if (modelAndView == null && SERIES_VIEWS.containsKey(status.series())) { + modelAndView = resolve(SERIES_VIEWS.get(status.series()), model); + } + return modelAndView; + } + + private ModelAndView resolve(String viewName, Map model) { + ModelAndView modelAndView = resolveTemplate(viewName, model); + if (modelAndView == null) { + modelAndView = resolveResource(viewName, model); + } + return modelAndView; + } + + private ModelAndView resolveTemplate(String viewName, Map model) { + for (TemplateAvailabilityProvider templateAvailabilityProvider : this.templateAvailabilityProviders) { + if (templateAvailabilityProvider.isTemplateAvailable("error/" + viewName, + this.applicationContext.getEnvironment(), + this.applicationContext.getClassLoader(), this.applicationContext)) { + return new ModelAndView("error/" + viewName, model); + } + } + return null; + } + + private ModelAndView resolveResource(String viewName, Map model) { + for (String location : this.resourceProperties.getStaticLocations()) { + try { + Resource resource = this.applicationContext.getResource(location); + resource = resource.createRelative("error/" + viewName + ".html"); + if (resource.exists()) { + return new ModelAndView(new HtmlResourceView(resource), model); + } + } + catch (Exception ex) { + } + } + return null; + } + + @Override + public int getOrder() { + return this.order; + } + + public void setOrder(int order) { + this.order = order; + } + + /** + * {@link View} backed by a HTML resource. + */ + private static class HtmlResourceView implements View { + + private Resource resource; + + HtmlResourceView(Resource resource) { + this.resource = resource; + } + + @Override + public String getContentType() { + return MediaType.TEXT_HTML_VALUE; + } + + @Override + public void render(Map model, HttpServletRequest request, + HttpServletResponse response) throws Exception { + FileCopyUtils.copy(this.resource.getInputStream(), + response.getOutputStream()); + } + + } +} diff --git a/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ErrorMvcAutoConfiguration.java b/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ErrorMvcAutoConfiguration.java index 821ab0c7acd..c8e31dc7482 100644 --- a/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ErrorMvcAutoConfiguration.java +++ b/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ErrorMvcAutoConfiguration.java @@ -27,11 +27,13 @@ import javax.servlet.http.HttpServletResponse; import org.springframework.aop.framework.autoproxy.AutoProxyUtils; import org.springframework.beans.BeansException; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.config.BeanFactoryPostProcessor; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.boot.autoconfigure.AutoConfigureBefore; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionOutcome; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -40,9 +42,11 @@ import org.springframework.boot.autoconfigure.condition.SearchStrategy; import org.springframework.boot.autoconfigure.condition.SpringBootCondition; import org.springframework.boot.autoconfigure.template.TemplateAvailabilityProvider; import org.springframework.boot.context.embedded.EmbeddedServletContainerCustomizer; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.web.servlet.ErrorPage; import org.springframework.boot.web.servlet.ErrorPageRegistrar; import org.springframework.boot.web.servlet.ErrorPageRegistry; +import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ConditionContext; import org.springframework.context.annotation.Conditional; @@ -69,18 +73,28 @@ import org.springframework.web.util.HtmlUtils; * @author Andy Wilkinson * @author Stephane Nicoll */ -@ConditionalOnClass({ Servlet.class, DispatcherServlet.class }) -@ConditionalOnWebApplication -// Ensure this loads before the main WebMvcAutoConfiguration so that the error View is -// available -@AutoConfigureBefore(WebMvcAutoConfiguration.class) @Configuration +@ConditionalOnWebApplication +@ConditionalOnClass({ Servlet.class, DispatcherServlet.class }) +// Load before the main WebMvcAutoConfiguration so that the error View is available +@AutoConfigureBefore(WebMvcAutoConfiguration.class) +@EnableConfigurationProperties(ResourceProperties.class) public class ErrorMvcAutoConfiguration { - private final ServerProperties properties; + private final ApplicationContext applicationContext; - public ErrorMvcAutoConfiguration(ServerProperties properties) { - this.properties = properties; + private final ServerProperties serverProperties; + + private final ResourceProperties resourceProperties; + + @Autowired(required = false) + private List errorViewResolvers; + + public ErrorMvcAutoConfiguration(ApplicationContext applicationContext, + ServerProperties serverProperties, ResourceProperties resourceProperties) { + this.applicationContext = applicationContext; + this.serverProperties = serverProperties; + this.resourceProperties = resourceProperties; } @Bean @@ -92,12 +106,21 @@ public class ErrorMvcAutoConfiguration { @Bean @ConditionalOnMissingBean(value = ErrorController.class, search = SearchStrategy.CURRENT) public BasicErrorController basicErrorController(ErrorAttributes errorAttributes) { - return new BasicErrorController(errorAttributes, this.properties.getError()); + return new BasicErrorController(errorAttributes, this.serverProperties.getError(), + this.errorViewResolvers); } @Bean public ErrorPageCustomizer errorPageCustomizer() { - return new ErrorPageCustomizer(this.properties); + return new ErrorPageCustomizer(this.serverProperties); + } + + @Bean + @ConditionalOnBean(DispatcherServlet.class) + @ConditionalOnMissingBean + public DefaultErrorViewResolver conventionErrorViewResolver() { + return new DefaultErrorViewResolver(this.applicationContext, + this.resourceProperties); } @Bean diff --git a/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ErrorViewResolver.java b/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ErrorViewResolver.java new file mode 100644 index 00000000000..d2fdaf75fc2 --- /dev/null +++ b/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ErrorViewResolver.java @@ -0,0 +1,44 @@ +/* + * Copyright 2012-2016 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.Map; + +import javax.servlet.http.HttpServletRequest; + +import org.springframework.http.HttpStatus; +import org.springframework.web.servlet.ModelAndView; + +/** + * Interface that can be implemented by beans that resolve error views. + * + * @author Phillip Webb + * @since 1.4.0 + */ +public interface ErrorViewResolver { + + /** + * Resolve an error view for the specified details. + * @param request the source request + * @param status the http status of the error + * @param model the suggested model to be used with the view + * @return a resolved {@link ModelAndView} or {@code null} + */ + ModelAndView resolveErrorView(HttpServletRequest request, HttpStatus status, + Map model); + +} diff --git a/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/BasicErrorControllerIntegrationTests.java b/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/BasicErrorControllerIntegrationTests.java index ccf982b2ad4..3fce29961b8 100644 --- a/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/BasicErrorControllerIntegrationTests.java +++ b/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/BasicErrorControllerIntegrationTests.java @@ -32,6 +32,7 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.freemarker.FreeMarkerAutoConfiguration; import org.springframework.boot.autoconfigure.web.BasicErrorControllerMockMvcTests.MinimalWebConfiguration; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; @@ -39,6 +40,7 @@ import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.RequestEntity; @@ -173,6 +175,17 @@ public class BasicErrorControllerIntegrationTests { assertThat(resp).contains(MethodArgumentNotValidException.class.getName()); } + @Test + public void testConventionTemplateMapping() throws Exception { + load(); + RequestEntity request = RequestEntity.get(URI.create(createUrl("/noStorage"))) + .accept(MediaType.TEXT_HTML).build(); + ResponseEntity entity = new TestRestTemplate().exchange(request, + String.class); + String resp = entity.getBody().toString(); + assertThat(resp).contains("We are out of storage"); + } + private void assertErrorAttributes(Map content, String status, String error, Class exception, String message, String path) { assertThat(content.get("status")).as("Wrong status").isEqualTo(status); @@ -201,6 +214,7 @@ public class BasicErrorControllerIntegrationTests { @Configuration @MinimalWebConfiguration + @Import(FreeMarkerAutoConfiguration.class) public static class TestConfiguration { // For manual testing @@ -254,12 +268,22 @@ public class BasicErrorControllerIntegrationTests { return body.content; } + @RequestMapping(path = "/noStorage") + public String noStorage() { + throw new InsufficientStorageException(); + } + @ResponseStatus(value = HttpStatus.BAD_REQUEST, reason = "Expected!") @SuppressWarnings("serial") private static class ExpectedException extends RuntimeException { } + @ResponseStatus(HttpStatus.INSUFFICIENT_STORAGE) + private static class InsufficientStorageException extends RuntimeException { + + } + @ResponseStatus(HttpStatus.NOT_ACCEPTABLE) @SuppressWarnings("serial") private static class NoReasonExpectedException extends RuntimeException { diff --git a/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/DefaultErrorViewResolverTests.java b/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/DefaultErrorViewResolverTests.java new file mode 100644 index 00000000000..8e76abe00e8 --- /dev/null +++ b/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/DefaultErrorViewResolverTests.java @@ -0,0 +1,214 @@ +/* + * Copyright 2012-2016 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.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.servlet.http.HttpServletRequest; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import org.springframework.boot.autoconfigure.template.TemplateAvailabilityProvider; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.core.Ordered; +import org.springframework.core.env.Environment; +import org.springframework.core.io.ResourceLoader; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.servlet.ModelAndView; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +/** + * Tests for {@link DefaultErrorViewResolver}. + * + * @author Phillip Webb + */ +public class DefaultErrorViewResolverTests { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + private DefaultErrorViewResolver resolver; + + @Mock + private TemplateAvailabilityProvider templateAvailabilityProvider; + + private ResourceProperties resourceProperties; + + private Map model = new HashMap(); + + private HttpServletRequest request = new MockHttpServletRequest(); + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext(); + applicationContext.refresh(); + this.resourceProperties = new ResourceProperties(); + List templateAvailabilityProviders = Collections + .singletonList(this.templateAvailabilityProvider); + this.resolver = new DefaultErrorViewResolver(applicationContext, + this.resourceProperties, templateAvailabilityProviders); + } + + @Test + public void createWhenApplicationContextIsNullShouldThrowException() + throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("ApplicationContext must not be null"); + new DefaultErrorViewResolver(null, new ResourceProperties()); + } + + @Test + public void createWhenResourcePropertiesIsNullShouldThrowException() + throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("ResourceProperties must not be null"); + new DefaultErrorViewResolver(mock(ApplicationContext.class), null); + } + + @Test + public void resolveWhenNoMatchShouldReturnNull() throws Exception { + ModelAndView resolved = this.resolver.resolveErrorView(this.request, + HttpStatus.NOT_FOUND, this.model); + assertThat(resolved).isNull(); + } + + @Test + public void resolveWhenExactTemplateMatchShouldReturnTemplate() throws Exception { + given(this.templateAvailabilityProvider.isTemplateAvailable(eq("error/404"), + any(Environment.class), any(ClassLoader.class), + any(ResourceLoader.class))).willReturn(true); + ModelAndView resolved = this.resolver.resolveErrorView(this.request, + HttpStatus.NOT_FOUND, this.model); + assertThat(resolved).isNotNull(); + assertThat(resolved.getViewName()).isEqualTo("error/404"); + verify(this.templateAvailabilityProvider).isTemplateAvailable(eq("error/404"), + any(Environment.class), any(ClassLoader.class), + any(ResourceLoader.class)); + verifyNoMoreInteractions(this.templateAvailabilityProvider); + } + + @Test + public void resolveWhenSeries5xxTemplateMatchShouldReturnTemplate() throws Exception { + given(this.templateAvailabilityProvider.isTemplateAvailable(eq("error/5xx"), + any(Environment.class), any(ClassLoader.class), + any(ResourceLoader.class))).willReturn(true); + ModelAndView resolved = this.resolver.resolveErrorView(this.request, + HttpStatus.SERVICE_UNAVAILABLE, this.model); + assertThat(resolved.getViewName()).isEqualTo("error/5xx"); + } + + @Test + public void resolveWhenSeries4xxTemplateMatchShouldReturnTemplate() throws Exception { + given(this.templateAvailabilityProvider.isTemplateAvailable(eq("error/4xx"), + any(Environment.class), any(ClassLoader.class), + any(ResourceLoader.class))).willReturn(true); + ModelAndView resolved = this.resolver.resolveErrorView(this.request, + HttpStatus.NOT_FOUND, this.model); + assertThat(resolved.getViewName()).isEqualTo("error/4xx"); + } + + @Test + public void resolveWhenExactResourceMatchShouldReturnResource() throws Exception { + setResourceLocation("/exact"); + ModelAndView resolved = this.resolver.resolveErrorView(this.request, + HttpStatus.NOT_FOUND, this.model); + assertThat(render(resolved)).isEqualTo("exact/404"); + } + + @Test + public void resolveWhenSeries4xxResourceMatchShouldReturnResource() throws Exception { + setResourceLocation("/4xx"); + ModelAndView resolved = this.resolver.resolveErrorView(this.request, + HttpStatus.NOT_FOUND, this.model); + assertThat(render(resolved)).isEqualTo("4xx/4xx"); + } + + @Test + public void resolveWhenSeries5xxResourceMatchShouldReturnResource() throws Exception { + setResourceLocation("/5xx"); + ModelAndView resolved = this.resolver.resolveErrorView(this.request, + HttpStatus.INTERNAL_SERVER_ERROR, this.model); + assertThat(render(resolved)).isEqualTo("5xx/5xx"); + } + + @Test + public void resolveWhenTemplateAndResourceMatchShouldFavorTemplate() + throws Exception { + setResourceLocation("/exact"); + given(this.templateAvailabilityProvider.isTemplateAvailable(eq("error/404"), + any(Environment.class), any(ClassLoader.class), + any(ResourceLoader.class))).willReturn(true); + ModelAndView resolved = this.resolver.resolveErrorView(this.request, + HttpStatus.NOT_FOUND, this.model); + assertThat(resolved.getViewName()).isEqualTo("error/404"); + } + + @Test + public void resolveWhenExactResourceMatchAndSeriesTemplateMatchShouldFavorResource() + throws Exception { + setResourceLocation("/exact"); + given(this.templateAvailabilityProvider.isTemplateAvailable(eq("error/4xx"), + any(Environment.class), any(ClassLoader.class), + any(ResourceLoader.class))).willReturn(true); + ModelAndView resolved = this.resolver.resolveErrorView(this.request, + HttpStatus.NOT_FOUND, this.model); + assertThat(render(resolved)).isEqualTo("exact/404"); + } + + @Test + public void orderShouldBeLowest() throws Exception { + assertThat(this.resolver.getOrder()).isEqualTo(Ordered.LOWEST_PRECEDENCE); + } + + @Test + public void setOrderShouldChangeOrder() throws Exception { + this.resolver.setOrder(123); + assertThat(this.resolver.getOrder()).isEqualTo(123); + } + + private void setResourceLocation(String path) { + String packageName = getClass().getPackage().getName(); + this.resourceProperties.setStaticLocations(new String[] { + "classpath:" + packageName.replace(".", "/") + path + "/" }); + } + + private String render(ModelAndView modelAndView) throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + modelAndView.getView().render(this.model, this.request, response); + return response.getContentAsString().trim(); + } + +} diff --git a/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/web/4xx/error/402.html b/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/web/4xx/error/402.html new file mode 100644 index 00000000000..d18e8a2e09a --- /dev/null +++ b/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/web/4xx/error/402.html @@ -0,0 +1 @@ +4xx/402 diff --git a/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/web/4xx/error/4xx.html b/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/web/4xx/error/4xx.html new file mode 100644 index 00000000000..95728709187 --- /dev/null +++ b/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/web/4xx/error/4xx.html @@ -0,0 +1 @@ +4xx/4xx diff --git a/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/web/5xx/error/4xx.html b/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/web/5xx/error/4xx.html new file mode 100644 index 00000000000..6d8f11f9292 --- /dev/null +++ b/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/web/5xx/error/4xx.html @@ -0,0 +1 @@ +5xx/4xx diff --git a/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/web/5xx/error/5xx.html b/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/web/5xx/error/5xx.html new file mode 100644 index 00000000000..37aa905aad4 --- /dev/null +++ b/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/web/5xx/error/5xx.html @@ -0,0 +1 @@ +5xx/5xx diff --git a/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/web/exact/error/404.html b/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/web/exact/error/404.html new file mode 100644 index 00000000000..790a2dd1cd3 --- /dev/null +++ b/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/web/exact/error/404.html @@ -0,0 +1 @@ +exact/404 diff --git a/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/web/exact/error/4xx.html b/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/web/exact/error/4xx.html new file mode 100644 index 00000000000..b9a82ced611 --- /dev/null +++ b/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/web/exact/error/4xx.html @@ -0,0 +1 @@ +exact/4xx diff --git a/spring-boot-autoconfigure/src/test/resources/templates/error/507.ftl b/spring-boot-autoconfigure/src/test/resources/templates/error/507.ftl new file mode 100644 index 00000000000..a562b8fe9cf --- /dev/null +++ b/spring-boot-autoconfigure/src/test/resources/templates/error/507.ftl @@ -0,0 +1 @@ +We are out of storage diff --git a/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc b/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc index b1e6aeb2730..ea500f111ae 100644 --- a/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc +++ b/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc @@ -1713,34 +1713,98 @@ In the example above, if `YourException` is thrown by a controller defined in th package as `FooController`, a json representation of the `CustomerErrorType` POJO will be used instead of the `ErrorAttributes` representation. -If you want more specific error pages for some conditions, the embedded servlet containers -support a uniform Java DSL for customizing the error handling. Assuming that you have a -mapping for `/400`: + + +[[boot-features-error-handling-custom-error-pages]] +===== Custom error pages +If you want to display a custom HTML error page for a given status code, you add a file to +an `/error` folder. Error pages can either be static HTML (i.e. added under any of the +static resource folders) or built using templates. The name of the file should be the +exact status code or a series mask. + +For example, to map `404` to a static HTML file, your folder structure would look like +this: + +[source,indent=0,subs="verbatim,quotes,attributes"] +---- + src/ + +- main/ + +- java/ + | + + +- resources/ + +- public/ + +- error/ + | +- 404.html + +- +---- + +To map all `5xx` errors using a freemarker template, you'd have a structure like this: + +[source,indent=0,subs="verbatim,quotes,attributes"] +---- + src/ + +- main/ + +- java/ + | + + +- resources/ + +- template/ + +- error/ + | +- 5xx.ftl + +- +---- + +For more complex mappings you can also add beans that implement the `ErrorViewResolver` +interface. [source,java,indent=0,subs="verbatim,quotes,attributes"] ---- - @Bean - public EmbeddedServletContainerCustomizer containerCustomizer(){ - return new MyCustomizer(); - } - - // ... - - private static class MyCustomizer implements EmbeddedServletContainerCustomizer { + public class MyErrorViewResolver implements ErrorViewResolver { @Override - public void customize(ConfigurableEmbeddedServletContainer container) { - container.addErrorPages(new ErrorPage(HttpStatus.BAD_REQUEST, "/400")); + public ModelAndView resolveErrorView(HttpServletRequest request, + HttpStatus status, Map model) { + // Use the request or status to optionally return a ModelAndView + return ... } } ---- + You can also use regular Spring MVC features like {spring-reference}/#mvc-exceptionhandlers[`@ExceptionHandler` methods] and {spring-reference}/#mvc-ann-controller-advice[`@ControllerAdvice`]. The `ErrorController` will then pick up any unhandled exceptions. + + +[[boot-features-error-handling-mapping-error-pages-without-mvc]] +===== Mapping error pages outside of Spring MVC +For applications that aren't using Spring MVC, you can use the `ErrorPageRegistrar` +interface to directly register `ErrorPages`. This abstraction works directly with the +underlying embedded servlet container and will work even if you don't have a Spring MVC +`DispatcherServlet` + + +[source,java,indent=0,subs="verbatim,quotes,attributes"] +---- + @Bean + public ErrorPageRegistrar errorPageRegistrar(){ + return new MyErrorPageRegistrar(); + } + + // ... + + private static class MyErrorPageRegistrar implements ErrorPageRegistrar { + + @Override + public void registerErrorPages(ErrorPageRegistry registry) { + registry.addErrorPages(new ErrorPage(HttpStatus.BAD_REQUEST, "/400")); + } + + } +---- + N.B. if you register an `ErrorPage` with a path that will end up being handled by a `Filter` (e.g. as is common with some non-Spring web frameworks, like Jersey and Wicket), then the `Filter` has to be explicitly registered as an `ERROR` dispatcher, e.g.