diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/NoResourceFoundException.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/NoResourceFoundException.java new file mode 100644 index 0000000000..d4cef7ee62 --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/NoResourceFoundException.java @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2023 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 + * + * https://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.web.reactive.resource; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +/** + * Raised when {@link ResourceWebHandler} is mapped to the request but can not + * find a matching resource. + * + * @author Rossen Stoyanchev + * @since 6.1 + */ +@SuppressWarnings("serial") +public class NoResourceFoundException extends ResponseStatusException { + + + public NoResourceFoundException(String resourcePath) { + super(HttpStatus.NOT_FOUND, "No static resource " + resourcePath + "."); + setDetail(getReason()); + } + +} diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceWebHandler.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceWebHandler.java index e665a68bbd..60f7feabc2 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceWebHandler.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceWebHandler.java @@ -41,7 +41,6 @@ import org.springframework.core.log.LogFormatUtils; import org.springframework.http.CacheControl; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.MediaTypeFactory; import org.springframework.http.codec.ResourceHttpMessageWriter; @@ -54,7 +53,6 @@ import org.springframework.util.ResourceUtils; import org.springframework.util.StringUtils; import org.springframework.web.reactive.HandlerMapping; import org.springframework.web.server.MethodNotAllowedException; -import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebHandler; import org.springframework.web.util.pattern.PathPattern; @@ -403,7 +401,7 @@ public class ResourceWebHandler implements WebHandler, InitializingBean { return getResource(exchange) .switchIfEmpty(Mono.defer(() -> { logger.debug(exchange.getLogPrefix() + "Resource not found"); - return Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND)); + return Mono.error(new NoResourceFoundException(getResourcePath(exchange))); })) .flatMap(resource -> { try { diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java index 7087cde65e..800c22e5bf 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2023 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. @@ -19,6 +19,7 @@ package org.springframework.web.reactive; import java.time.Duration; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.BeforeEach; @@ -32,15 +33,23 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.codec.CharSequenceEncoder; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.codec.EncoderHttpMessageWriter; +import org.springframework.http.codec.ServerCodecConfigurer; import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.reactive.accept.HeaderContentTypeResolver; +import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping; +import org.springframework.web.reactive.resource.ResourceWebHandler; +import org.springframework.web.reactive.result.SimpleHandlerAdapter; import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerAdapter; import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping; import org.springframework.web.reactive.result.method.annotation.ResponseBodyResultHandler; +import org.springframework.web.reactive.result.method.annotation.ResponseEntityExceptionHandler; +import org.springframework.web.reactive.result.method.annotation.ResponseEntityResultHandler; import org.springframework.web.server.NotAcceptableStatusException; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ServerWebExchange; @@ -48,6 +57,7 @@ import org.springframework.web.server.WebExceptionHandler; import org.springframework.web.server.WebHandler; import org.springframework.web.server.handler.ExceptionHandlingWebHandler; import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest; +import org.springframework.web.testfixture.http.server.reactive.MockServerHttpResponse; import org.springframework.web.testfixture.server.MockServerWebExchange; import static org.assertj.core.api.Assertions.assertThat; @@ -69,10 +79,11 @@ public class DispatcherHandlerErrorTests { @BeforeEach public void setup() { - AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(); - ctx.register(TestConfig.class); - ctx.refresh(); - this.dispatcherHandler = new DispatcherHandler(ctx); + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.register(TestConfig.class); + context.refresh(); + + this.dispatcherHandler = new DispatcherHandler(context); } @@ -94,6 +105,28 @@ public class DispatcherHandlerErrorTests { StepVerifier.create(mono).consumeErrorWith(ex -> assertThat(ex).isNotSameAs(exceptionRef.get())).verify(); } + @Test + public void noStaticResource() { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.register(StaticResourceConfig.class); + context.refresh(); + + MockServerHttpRequest request = MockServerHttpRequest.get("/resources/non-existing").build(); + MockServerWebExchange exchange = MockServerWebExchange.from(request); + new DispatcherHandler(context).handle(exchange).block(); + + MockServerHttpResponse response = exchange.getResponse(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_PROBLEM_JSON); + assertThat(response.getBodyAsString().block()).isEqualTo(""" + {"type":"about:blank",\ + "title":"Not Found",\ + "status":404,\ + "detail":"No static resource non-existing.",\ + "instance":"/resources/non-existing"}\ + """); + } + @Test public void controllerReturnsMonoError() { MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/error-signal")); @@ -223,6 +256,50 @@ public class DispatcherHandlerErrorTests { } + @Configuration + @SuppressWarnings({"unused", "WeakerAccess"}) + static class StaticResourceConfig { + + @Bean + public SimpleUrlHandlerMapping resourceMapping(ResourceWebHandler resourceWebHandler) { + SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping(); + mapping.setUrlMap(Map.of("/resources/**", resourceWebHandler)); + return mapping; + } + + @Bean + public RequestMappingHandlerAdapter requestMappingHandlerAdapter() { + return new RequestMappingHandlerAdapter(); + } + + @Bean + public SimpleHandlerAdapter simpleHandlerAdapter() { + return new SimpleHandlerAdapter(); + } + + @Bean + public ResourceWebHandler resourceWebHandler() { + return new ResourceWebHandler(); + } + + @Bean + public ResponseEntityResultHandler responseEntityResultHandler() { + ServerCodecConfigurer configurer = ServerCodecConfigurer.create(); + return new ResponseEntityResultHandler(configurer.getWriters(), new HeaderContentTypeResolver()); + } + + @Bean + GlobalExceptionHandler globalExceptionHandler() { + return new GlobalExceptionHandler(); + } + } + + + @ControllerAdvice + private static class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + } + + private static class ServerError500ExceptionHandler implements WebExceptionHandler { @Override diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerExceptionResolver.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerExceptionResolver.java index 511769a94b..93a527ca7d 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerExceptionResolver.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerExceptionResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2023 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. @@ -26,6 +26,7 @@ import org.apache.commons.logging.LogFactory; import org.springframework.core.Ordered; import org.springframework.core.log.LogFormatUtils; import org.springframework.lang.Nullable; +import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; import org.springframework.web.servlet.HandlerExceptionResolver; import org.springframework.web.servlet.ModelAndView; @@ -98,6 +99,25 @@ public abstract class AbstractHandlerExceptionResolver implements HandlerExcepti this.mappedHandlerClasses = mappedHandlerClasses; } + /** + * Add a mapped handler class. + * @since 6.1 + */ + public void addMappedHandlerClass(Class mappedHandlerClass) { + this.mappedHandlerClasses = (this.mappedHandlerClasses != null ? + ObjectUtils.addObjectToArray(this.mappedHandlerClasses, mappedHandlerClass) : + new Class[] {mappedHandlerClass}); + } + + /** + * Return the {@link #setMappedHandlerClasses(Class[]) configured} mapped + * handler classes. + */ + @Nullable + protected Class[] getMappedHandlerClasses() { + return this.mappedHandlerClasses; + } + /** * Set the log category for warn logging. The name will be passed to the underlying logger * implementation through Commons Logging, getting interpreted as a log category according diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ExceptionHandlerExceptionResolver.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ExceptionHandlerExceptionResolver.java index b296609588..067fb5af9c 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ExceptionHandlerExceptionResolver.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ExceptionHandlerExceptionResolver.java @@ -56,6 +56,7 @@ import org.springframework.web.servlet.ModelAndView; import org.springframework.web.servlet.View; import org.springframework.web.servlet.handler.AbstractHandlerMethodExceptionResolver; import org.springframework.web.servlet.mvc.support.RedirectAttributes; +import org.springframework.web.servlet.resource.ResourceHttpRequestHandler; import org.springframework.web.servlet.support.RequestContextUtils; /** @@ -372,6 +373,12 @@ public class ExceptionHandlerExceptionResolver extends AbstractHandlerMethodExce return !this.exceptionHandlerAdviceCache.isEmpty(); } + @Override + protected boolean shouldApplyTo(HttpServletRequest request, @Nullable Object handler) { + return (handler instanceof ResourceHttpRequestHandler ? + hasGlobalExceptionHandlers() : super.shouldApplyTo(request, handler)); + } + /** * Find an {@code @ExceptionHandler} method and invoke it to handle the raised exception. */ diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerIntegrationTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerIntegrationTests.java index e5890e2c1b..9dc6c4ffb5 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerIntegrationTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2023 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. @@ -19,9 +19,11 @@ package org.springframework.web.servlet.resource; import java.io.IOException; import java.net.MalformedURLException; import java.nio.charset.StandardCharsets; +import java.util.List; import java.util.stream.Stream; import jakarta.servlet.ServletException; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -29,12 +31,16 @@ import org.junit.jupiter.params.provider.MethodSource; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.UrlResource; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; import org.springframework.web.servlet.DispatcherServlet; import org.springframework.web.servlet.config.annotation.EnableWebMvc; import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; import org.springframework.web.testfixture.servlet.MockHttpServletRequest; import org.springframework.web.testfixture.servlet.MockHttpServletResponse; import org.springframework.web.testfixture.servlet.MockServletConfig; @@ -114,6 +120,34 @@ public class ResourceHttpRequestHandlerIntegrationTests { assertThat(response.getContentAsString()).as(description).isEqualTo("h1 { color:red; }"); } + @Test + void testNoResourceFoundException() throws Exception { + AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext(); + context.setServletConfig(this.servletConfig); + context.register(WebConfig.class); + context.register(GlobalExceptionHandler.class); + context.refresh(); + + DispatcherServlet servlet = new DispatcherServlet(); + servlet.setApplicationContext(context); + servlet.init(this.servletConfig); + + MockHttpServletRequest request = initRequest("/cp/non-existing"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + servlet.service(request, response); + + assertThat(response.getStatus()).isEqualTo(404); + assertThat(response.getContentType()).isEqualTo("application/problem+json"); + assertThat(response.getContentAsString()).isEqualTo(""" + {"type":"about:blank",\ + "title":"Not Found",\ + "status":404,\ + "detail":"No static resource non-existing.",\ + "instance":"/cp/non-existing"}\ + """); + } + private DispatcherServlet initDispatcherServlet( boolean usePathPatterns, boolean decodingUrlPathHelper, Class... configClasses) throws ServletException { @@ -176,6 +210,11 @@ public class ResourceHttpRequestHandlerIntegrationTests { } return urlResource; } + + @Override + public void configureMessageConverters(List> converters) { + converters.add(new MappingJackson2HttpMessageConverter()); + } } @@ -209,4 +248,9 @@ public class ResourceHttpRequestHandlerIntegrationTests { } } + + @ControllerAdvice + private static class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + } + }