diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-exceptions.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-exceptions.adoc index c62341d365..a4435e11d5 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-exceptions.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-exceptions.adoc @@ -7,43 +7,8 @@ `@ExceptionHandler` methods to handle exceptions from controller methods. The following example includes such a handler method: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @Controller - public class SimpleController { - - // ... - - @ExceptionHandler // <1> - public ResponseEntity handle(IOException ex) { - // ... - } - } ----- -<1> Declaring an `@ExceptionHandler`. - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @Controller - class SimpleController { - - // ... - - @ExceptionHandler // <1> - fun handle(ex: IOException): ResponseEntity { - // ... - } - } ----- -<1> Declaring an `@ExceptionHandler`. -====== +include-code::./SimpleController[indent=0] The exception can match against a top-level exception being propagated (that is, a direct @@ -65,6 +30,22 @@ Support for `@ExceptionHandler` methods in Spring WebFlux is provided by the `HandlerAdapter` for `@RequestMapping` methods. See xref:web/webflux/dispatcher-handler.adoc[`DispatcherHandler`] for more detail. +[[webflux-ann-exceptionhandler-media]] +== Media Type Mapping +[.small]#xref:web/webmvc/mvc-controller/ann-exceptionhandler.adoc#mvc-ann-exceptionhandler-media[See equivalent in the Servlet stack]# + +In addition to exception types, `@ExceptionHandler` methods can also declare producible media types. +This allows to refine error responses depending on the media types requested by HTTP clients, typically in the "Accept" HTTP request header. + +Applications can declare producible media types directly on annotations, for the same exception type: + + +include-code::./MediaTypeController[tag=mediatype,indent=0] + +Here, methods handle the same exception type but will not be rejected as duplicates. +Instead, API clients requesting "application/json" will receive a JSON error, and browsers will get an HTML error view. +Each `@ExceptionHandler` annotation can declare several producible media types, +the content negotiation during the error handling phase will decide which content type will be used. [[webflux-ann-exceptionhandler-args]] diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-exceptionhandler.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-exceptionhandler.adoc index f3fe7f2be8..08c7961c15 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-exceptionhandler.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-exceptionhandler.adoc @@ -6,40 +6,12 @@ `@Controller` and xref:web/webmvc/mvc-controller/ann-advice.adoc[@ControllerAdvice] classes can have `@ExceptionHandler` methods to handle exceptions from controller methods, as the following example shows: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @Controller - public class SimpleController { - // ... +include-code::./SimpleController[indent=0] - @ExceptionHandler - public ResponseEntity handle(IOException ex) { - // ... - } - } ----- -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @Controller - class SimpleController { - - // ... - - @ExceptionHandler - fun handle(ex: IOException): ResponseEntity { - // ... - } - } ----- -====== +[[mvc-ann-exceptionhandler-exc]] +== Exception Mapping The exception may match against a top-level exception being propagated (e.g. a direct `IOException` being thrown) or against a nested cause within a wrapper exception (e.g. @@ -54,54 +26,13 @@ is used to sort exceptions based on their depth from the thrown exception type. Alternatively, the annotation declaration may narrow the exception types to match, as the following example shows: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @ExceptionHandler({FileSystemException.class, RemoteException.class}) - public ResponseEntity handle(IOException ex) { - // ... - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @ExceptionHandler(FileSystemException::class, RemoteException::class) - fun handle(ex: IOException): ResponseEntity { - // ... - } ----- -====== +include-code::./ExceptionController[tag=narrow,indent=0] You can even use a list of specific exception types with a very generic argument signature, as the following example shows: -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes",role="primary"] ----- - @ExceptionHandler({FileSystemException.class, RemoteException.class}) - public ResponseEntity handle(Exception ex) { - // ... - } ----- +include-code::./ExceptionController[tag=general,indent=0] -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] ----- - @ExceptionHandler(FileSystemException::class, RemoteException::class) - fun handle(ex: Exception): ResponseEntity { - // ... - } ----- -====== [NOTE] ==== @@ -143,6 +74,25 @@ Support for `@ExceptionHandler` methods in Spring MVC is built on the `Dispatche level, xref:web/webmvc/mvc-servlet/exceptionhandlers.adoc[HandlerExceptionResolver] mechanism. + +[[mvc-ann-exceptionhandler-media]] +== Media Type Mapping +[.small]#xref:web/webflux/controller/ann-exceptions.adoc#webflux-ann-exceptionhandler-media[See equivalent in the Reactive stack]# + +In addition to exception types, `@ExceptionHandler` methods can also declare producible media types. +This allows to refine error responses depending on the media types requested by HTTP clients, typically in the "Accept" HTTP request header. + +Applications can declare producible media types directly on annotations, for the same exception type: + + +include-code::./MediaTypeController[tag=mediatype,indent=0] + +Here, methods handle the same exception type but will not be rejected as duplicates. +Instead, API clients requesting "application/json" will receive a JSON error, and browsers will get an HTML error view. +Each `@ExceptionHandler` annotation can declare several producible media types, +the content negotiation during the error handling phase will decide which content type will be used. + + [[mvc-ann-exceptionhandler-args]] == Method Arguments [.small]#xref:web/webflux/controller/ann-exceptions.adoc#webflux-ann-exceptionhandler-args[See equivalent in the Reactive stack]# diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webflux/controller/webfluxanncontrollerexceptions/SimpleController.java b/framework-docs/src/main/java/org/springframework/docs/web/webflux/controller/webfluxanncontrollerexceptions/SimpleController.java new file mode 100644 index 0000000000..423f47a567 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webflux/controller/webfluxanncontrollerexceptions/SimpleController.java @@ -0,0 +1,33 @@ +/* + * Copyright 2002-2024 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.docs.web.webflux.controller.webfluxanncontrollerexceptions; + +import java.io.IOException; + +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@Controller +public class SimpleController { + + @ExceptionHandler(IOException.class) + public ResponseEntity handle() { + return ResponseEntity.internalServerError().body("Could not read file storage"); + } + +} \ No newline at end of file diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webflux/controller/webfluxannexceptionhandlermedia/MediaTypeController.java b/framework-docs/src/main/java/org/springframework/docs/web/webflux/controller/webfluxannexceptionhandlermedia/MediaTypeController.java new file mode 100644 index 0000000000..08e7003604 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webflux/controller/webfluxannexceptionhandlermedia/MediaTypeController.java @@ -0,0 +1,43 @@ +/* + * Copyright 2002-2024 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.docs.web.webflux.controller.webfluxannexceptionhandlermedia; + +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@Controller +public class MediaTypeController { + + // tag::mediatype[] + @ExceptionHandler(produces = "application/json") + public ResponseEntity handleJson(IllegalArgumentException exc) { + return ResponseEntity.badRequest().body(new ErrorMessage(exc.getMessage(), 42)); + } + + @ExceptionHandler(produces = "text/html") + public String handle(IllegalArgumentException exc, Model model) { + model.addAttribute("error", new ErrorMessage(exc.getMessage(), 42)); + return "errorView"; + } + // end::mediatype[] + + static record ErrorMessage(String message, int code) { + + } +} diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandler/SimpleController.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandler/SimpleController.java new file mode 100644 index 0000000000..8da9c174e1 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandler/SimpleController.java @@ -0,0 +1,33 @@ +/* + * Copyright 2002-2024 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.docs.web.webmvc.mvccontroller.mvcannexceptionhandler; + +import java.io.IOException; + +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@Controller +public class SimpleController { + + @ExceptionHandler(IOException.class) + public ResponseEntity handle() { + return ResponseEntity.internalServerError().body("Could not read file storage"); + } + +} \ No newline at end of file diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandlerexc/ExceptionController.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandlerexc/ExceptionController.java new file mode 100644 index 0000000000..c11617638d --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandlerexc/ExceptionController.java @@ -0,0 +1,45 @@ +/* + * Copyright 2002-2024 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.docs.web.webmvc.mvccontroller.mvcannexceptionhandlerexc; + +import java.io.IOException; +import java.nio.file.FileSystemException; +import java.rmi.RemoteException; + +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@Controller +public class ExceptionController { + + // tag::narrow[] + @ExceptionHandler({FileSystemException.class, RemoteException.class}) + public ResponseEntity handleIoException(IOException ex) { + return ResponseEntity.internalServerError().body(ex.getMessage()); + } + // end::narrow[] + + + // tag::general[] + @ExceptionHandler({FileSystemException.class, RemoteException.class}) + public ResponseEntity handleExceptions(Exception ex) { + return ResponseEntity.internalServerError().body(ex.getMessage()); + } + // end::general[] + +} diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandlermedia/MediaTypeController.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandlermedia/MediaTypeController.java new file mode 100644 index 0000000000..feecf5f677 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandlermedia/MediaTypeController.java @@ -0,0 +1,43 @@ +/* + * Copyright 2002-2024 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.docs.web.webmvc.mvccontroller.mvcannexceptionhandlermedia; + +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@Controller +public class MediaTypeController { + + // tag::mediatype[] + @ExceptionHandler(produces = "application/json") + public ResponseEntity handleJson(IllegalArgumentException exc) { + return ResponseEntity.badRequest().body(new ErrorMessage(exc.getMessage(), 42)); + } + + @ExceptionHandler(produces = "text/html") + public String handle(IllegalArgumentException exc, Model model) { + model.addAttribute("error", new ErrorMessage(exc.getMessage(), 42)); + return "errorView"; + } + // end::mediatype[] + + static record ErrorMessage(String message, int code) { + + } +} diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webflux/controller/webfluxanncontrollerexceptions/SimpleController.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webflux/controller/webfluxanncontrollerexceptions/SimpleController.kt new file mode 100644 index 0000000000..645aa1de0b --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webflux/controller/webfluxanncontrollerexceptions/SimpleController.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2002-2024 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.docs.web.webflux.controller.webfluxanncontrollerexceptions; + +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.ExceptionHandler +import java.io.IOException + +@Controller +class SimpleController { + + @ExceptionHandler(IOException::class) + fun handle() : ResponseEntity { + return ResponseEntity.internalServerError().body("Could not read file storage") + } + +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webflux/controller/webfluxannexceptionhandlermedia/MediaTypeController.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webflux/controller/webfluxannexceptionhandlermedia/MediaTypeController.kt new file mode 100644 index 0000000000..3bddcc33f2 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webflux/controller/webfluxannexceptionhandlermedia/MediaTypeController.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2002-2024 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.docs.web.webflux.controller.webfluxannexceptionhandlermedia + +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Controller +import org.springframework.ui.Model +import org.springframework.web.bind.annotation.ExceptionHandler + +@Controller +class MediaTypeController { + + // tag::mediatype[] + @ExceptionHandler(produces = ["application/json"]) + fun handleJson(exc: IllegalArgumentException): ResponseEntity { + return ResponseEntity.badRequest().body(ErrorMessage(exc.message, 42)) + } + + @ExceptionHandler(produces = ["text/html"]) + fun handle(exc: IllegalArgumentException, model: Model): String { + model.addAttribute("error", ErrorMessage(exc.message, 42)) + return "errorView" + } + // end::mediatype[] + + @JvmRecord + data class ErrorMessage(val message: String?, val code: Int) +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandler/SimpleController.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandler/SimpleController.kt new file mode 100644 index 0000000000..f89f36d059 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandler/SimpleController.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2002-2024 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.docs.web.webmvc.mvccontroller.mvcannexceptionhandler; + +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.ExceptionHandler +import java.io.IOException + +@Controller +class SimpleController { + + @ExceptionHandler(IOException::class) + fun handle() : ResponseEntity { + return ResponseEntity.internalServerError().body("Could not read file storage") + } + +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandlerexc/ExceptionController.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandlerexc/ExceptionController.kt new file mode 100644 index 0000000000..548ed6e568 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandlerexc/ExceptionController.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2002-2024 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.docs.web.webmvc.mvccontroller.mvcannexceptionhandlerexc + +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.ExceptionHandler +import java.io.IOException +import java.rmi.RemoteException + +@Controller +class ExceptionController { + + // tag::narrow[] + @ExceptionHandler(FileSystemException::class, RemoteException::class) + fun handleIoException(ex: IOException): ResponseEntity { + return ResponseEntity.internalServerError().body(ex.message) + } + // end::narrow[] + + + // tag::general[] + @ExceptionHandler(FileSystemException::class, RemoteException::class) + fun handleExceptions(ex: Exception): ResponseEntity { + return ResponseEntity.internalServerError().body(ex.message) + } + // end::general[] +} \ No newline at end of file diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandlermedia/MediaTypeController.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandlermedia/MediaTypeController.kt new file mode 100644 index 0000000000..e1311de33e --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvccontroller/mvcannexceptionhandlermedia/MediaTypeController.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2002-2024 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.docs.web.webmvc.mvccontroller.mvcannexceptionhandlermedia + +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Controller +import org.springframework.ui.Model +import org.springframework.web.bind.annotation.ExceptionHandler + +@Controller +class MediaTypeController { + + // tag::mediatype[] + @ExceptionHandler(produces = ["application/json"]) + fun handleJson(exc: IllegalArgumentException): ResponseEntity { + return ResponseEntity.badRequest().body(ErrorMessage(exc.message, 42)) + } + + @ExceptionHandler(produces = ["text/html"]) + fun handle(exc: IllegalArgumentException, model: Model): String { + model.addAttribute("error", ErrorMessage(exc.message, 42)) + return "errorView" + } + // end::mediatype[] + + @JvmRecord + data class ErrorMessage(val message: String?, val code: Int) +} \ No newline at end of file diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/ExceptionHandler.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/ExceptionHandler.java index bb80146627..4f058a05be 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/ExceptionHandler.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/ExceptionHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 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. @@ -23,6 +23,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import org.springframework.aot.hint.annotation.Reflective; +import org.springframework.core.annotation.AliasFor; /** * Annotation for handling exceptions in specific handler classes and/or @@ -101,6 +102,7 @@ import org.springframework.aot.hint.annotation.Reflective; * * @author Arjen Poutsma * @author Juergen Hoeller + * @author Brian Clozel * @since 3.0 * @see ControllerAdvice * @see org.springframework.web.context.request.WebRequest @@ -112,9 +114,24 @@ import org.springframework.aot.hint.annotation.Reflective; public @interface ExceptionHandler { /** - * Exceptions handled by the annotated method. If empty, will default to any - * exceptions listed in the method argument list. + * Exceptions handled by the annotated method. + *

This is an alias for {@link #exception}. */ + @AliasFor("exception") Class[] value() default {}; + /** + * Exceptions handled by the annotated method. If empty, will default to any + * exceptions listed in the method argument list. + * @since 6.2.0 + */ + @AliasFor("value") + Class[] exception() default {}; + + /** + * Media Types that can be produced by the annotated method. + * @since 6.2.0 + */ + String[] produces() default {}; + } diff --git a/spring-web/src/main/java/org/springframework/web/method/annotation/ExceptionHandlerMappingInfo.java b/spring-web/src/main/java/org/springframework/web/method/annotation/ExceptionHandlerMappingInfo.java new file mode 100644 index 0000000000..4dce05c4fb --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/method/annotation/ExceptionHandlerMappingInfo.java @@ -0,0 +1,73 @@ +/* + * Copyright 2002-2024 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.method.annotation; + +import java.lang.reflect.Method; +import java.util.Set; + +import org.springframework.http.MediaType; +import org.springframework.util.Assert; + +/** + * {@code @ExceptionHandler} mapping information. It contains: + *

    + *
  • the supported exception types + *
  • the producible media types, if any + *
  • the method in charge of handling the exception + *
+ * @author Brian Clozel + * @since 6.2.0 + */ +public class ExceptionHandlerMappingInfo { + + private final Set> exceptionTypes; + + private final Set producibleTypes; + + private final Method handlerMethod; + + ExceptionHandlerMappingInfo(Set> exceptionTypes, Set producibleMediaTypes, Method handlerMethod) { + Assert.notNull(exceptionTypes, "exceptionTypes should not be null"); + Assert.notNull(producibleMediaTypes, "producibleMediaTypes should not be null"); + Assert.notNull(handlerMethod, "handlerMethod should not be null"); + this.exceptionTypes = exceptionTypes; + this.producibleTypes = producibleMediaTypes; + this.handlerMethod = handlerMethod; + } + + /** + * Return the method responsible for handling the exception. + */ + public Method getHandlerMethod() { + return this.handlerMethod; + } + + /** + * Return the exception types supported by this handler. + */ + public Set> getExceptionTypes() { + return this.exceptionTypes; + } + + /** + * Return the producible media types by this handler. Can be empty. + */ + public Set getProducibleTypes() { + return this.producibleTypes; + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/method/annotation/ExceptionHandlerMethodResolver.java b/spring-web/src/main/java/org/springframework/web/method/annotation/ExceptionHandlerMethodResolver.java index 36673d2ece..597ce8cdf5 100644 --- a/spring-web/src/main/java/org/springframework/web/method/annotation/ExceptionHandlerMethodResolver.java +++ b/spring-web/src/main/java/org/springframework/web/method/annotation/ExceptionHandlerMethodResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2024 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,27 +19,42 @@ package org.springframework.web.method.annotation; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Arrays; +import java.util.Comparator; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import org.springframework.core.ExceptionDepthComparator; import org.springframework.core.MethodIntrospector; import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.http.InvalidMediaTypeException; +import org.springframework.http.MediaType; import org.springframework.lang.Nullable; import org.springframework.util.Assert; -import org.springframework.util.ConcurrentReferenceHashMap; +import org.springframework.util.ConcurrentLruCache; +import org.springframework.util.MimeType; import org.springframework.util.ReflectionUtils.MethodFilter; import org.springframework.web.bind.annotation.ExceptionHandler; /** * Discovers {@linkplain ExceptionHandler @ExceptionHandler} methods in a given class, * including all of its superclasses, and helps to resolve a given {@link Exception} - * to the exception types supported by a given {@link Method}. + * and {@link MediaType} requested by clients to combinations supported by a given {@link Method}. + *

This resolver will use the exception information declared as {@code @ExceptionHandler} + * annotation attributes, or as a method argument as a fallback. This will throw + * {@code IllegalStateException} instances if: + *

    + *
  • No Exception information could be found for a method + *
  • An invalid {@link MediaType} has been declared as {@code @ExceptionHandler} attribute + *
  • Multiple handlers declare the same exception + media type mapping + *
* * @author Rossen Stoyanchev * @author Juergen Hoeller * @author Sam Brannen + * @author Brian Clozel * @since 3.1 */ public class ExceptionHandlerMethodResolver { @@ -47,35 +62,42 @@ public class ExceptionHandlerMethodResolver { /** * A filter for selecting {@code @ExceptionHandler} methods. */ - public static final MethodFilter EXCEPTION_HANDLER_METHODS = method -> + private static final MethodFilter EXCEPTION_HANDLER_METHODS = method -> AnnotatedElementUtils.hasAnnotation(method, ExceptionHandler.class); - private static final Method NO_MATCHING_EXCEPTION_HANDLER_METHOD; + private static final ExceptionHandlerMappingInfo NO_MATCHING_EXCEPTION_HANDLER; static { try { - NO_MATCHING_EXCEPTION_HANDLER_METHOD = - ExceptionHandlerMethodResolver.class.getDeclaredMethod("noMatchingExceptionHandler"); + NO_MATCHING_EXCEPTION_HANDLER = new ExceptionHandlerMappingInfo(Set.of(), Set.of(), + ExceptionHandlerMethodResolver.class.getDeclaredMethod("noMatchingExceptionHandler")); } catch (NoSuchMethodException ex) { throw new IllegalStateException("Expected method not found: " + ex); } } + private final Map mappedMethods = new HashMap<>(16); - private final Map, Method> mappedMethods = new HashMap<>(16); - - private final Map, Method> exceptionLookupCache = new ConcurrentReferenceHashMap<>(16); + private final ConcurrentLruCache lookupCache = new ConcurrentLruCache<>(24, + cacheKey -> getMappedMethod(cacheKey.exceptionType(), cacheKey.mediaType())); /** * A constructor that finds {@link ExceptionHandler} methods in the given type. * @param handlerType the type to introspect + * @throws IllegalStateException in case of invalid or ambiguous exception mapping declarations */ public ExceptionHandlerMethodResolver(Class handlerType) { for (Method method : MethodIntrospector.selectMethods(handlerType, EXCEPTION_HANDLER_METHODS)) { - for (Class exceptionType : detectExceptionMappings(method)) { - addExceptionMapping(exceptionType, method); + ExceptionHandlerMappingInfo mappingInfo = detectExceptionMappings(method); + for (Class exceptionType : mappingInfo.getExceptionTypes()) { + for (MediaType producibleType : mappingInfo.getProducibleTypes()) { + addExceptionMapping(new ExceptionMapping(exceptionType, producibleType), mappingInfo); + } + if (mappingInfo.getProducibleTypes().isEmpty()) { + addExceptionMapping(new ExceptionMapping(exceptionType, MediaType.ALL), mappingInfo); + } } } } @@ -86,33 +108,42 @@ public class ExceptionHandlerMethodResolver { * and then as a fallback from the method signature itself. */ @SuppressWarnings("unchecked") - private List> detectExceptionMappings(Method method) { - List> result = new ArrayList<>(); - detectAnnotationExceptionMappings(method, result); - if (result.isEmpty()) { + private ExceptionHandlerMappingInfo detectExceptionMappings(Method method) { + ExceptionHandler exceptionHandler = readExceptionHandlerAnnotation(method); + List> exceptions = new ArrayList<>(Arrays.asList(exceptionHandler.exception())); + if (exceptions.isEmpty()) { for (Class paramType : method.getParameterTypes()) { if (Throwable.class.isAssignableFrom(paramType)) { - result.add((Class) paramType); + exceptions.add((Class) paramType); } } } - if (result.isEmpty()) { + if (exceptions.isEmpty()) { throw new IllegalStateException("No exception types mapped to " + method); } - return result; + Set mediaTypes = new HashSet<>(); + for (String mediaType : exceptionHandler.produces()) { + try { + mediaTypes.add(MediaType.parseMediaType(mediaType)); + } + catch (InvalidMediaTypeException exc) { + throw new IllegalStateException("Invalid media type [" + mediaType + "] declared on @ExceptionHandler for " + method, exc); + } + } + return new ExceptionHandlerMappingInfo(Set.copyOf(exceptions), mediaTypes, method); } - private void detectAnnotationExceptionMappings(Method method, List> result) { + private ExceptionHandler readExceptionHandlerAnnotation(Method method) { ExceptionHandler ann = AnnotatedElementUtils.findMergedAnnotation(method, ExceptionHandler.class); Assert.state(ann != null, "No ExceptionHandler annotation"); - result.addAll(Arrays.asList(ann.value())); + return ann; } - private void addExceptionMapping(Class exceptionType, Method method) { - Method oldMethod = this.mappedMethods.put(exceptionType, method); - if (oldMethod != null && !oldMethod.equals(method)) { + private void addExceptionMapping(ExceptionMapping mapping, ExceptionHandlerMappingInfo mappingInfo) { + ExceptionHandlerMappingInfo oldMapping = this.mappedMethods.put(mapping, mappingInfo); + if (oldMapping != null && !oldMapping.getHandlerMethod().equals(mappingInfo.getHandlerMethod())) { throw new IllegalStateException("Ambiguous @ExceptionHandler method mapped for [" + - exceptionType + "]: {" + oldMethod + ", " + method + "}"); + mapping + "]: {" + oldMapping.getHandlerMethod() + ", " + mappingInfo.getHandlerMethod() + "}"); } } @@ -131,7 +162,8 @@ public class ExceptionHandlerMethodResolver { */ @Nullable public Method resolveMethod(Exception exception) { - return resolveMethodByThrowable(exception); + ExceptionHandlerMappingInfo mappingInfo = resolveExceptionMapping(exception, MediaType.ALL); + return (mappingInfo != null) ? mappingInfo.getHandlerMethod() : null; } /** @@ -143,14 +175,29 @@ public class ExceptionHandlerMethodResolver { */ @Nullable public Method resolveMethodByThrowable(Throwable exception) { - Method method = resolveMethodByExceptionType(exception.getClass()); - if (method == null) { + ExceptionHandlerMappingInfo mappingInfo = resolveExceptionMapping(exception, MediaType.ALL); + return (mappingInfo != null) ? mappingInfo.getHandlerMethod() : null; + } + + /** + * Find a {@link Method} to handle the given Throwable for the requested {@link MediaType}. + *

Uses {@link ExceptionDepthComparator} and {@link MediaType#isMoreSpecific(MimeType)} + * if more than one match is found. + * @param exception the exception + * @param mediaType the media type requested by the HTTP client + * @return a Method to handle the exception, or {@code null} if none found + * @since 6.2.0 + */ + @Nullable + public ExceptionHandlerMappingInfo resolveExceptionMapping(Throwable exception, MediaType mediaType) { + ExceptionHandlerMappingInfo mappingInfo = resolveExceptionMappingByExceptionType(exception.getClass(), mediaType); + if (mappingInfo == null) { Throwable cause = exception.getCause(); if (cause != null) { - method = resolveMethodByThrowable(cause); + mappingInfo = resolveExceptionMapping(cause, mediaType); } } - return method; + return mappingInfo; } /** @@ -162,42 +209,92 @@ public class ExceptionHandlerMethodResolver { */ @Nullable public Method resolveMethodByExceptionType(Class exceptionType) { - Method method = this.exceptionLookupCache.get(exceptionType); - if (method == null) { - method = getMappedMethod(exceptionType); - this.exceptionLookupCache.put(exceptionType, method); - } - return (method != NO_MATCHING_EXCEPTION_HANDLER_METHOD ? method : null); + ExceptionHandlerMappingInfo mappingInfo = resolveExceptionMappingByExceptionType(exceptionType, MediaType.ALL); + return (mappingInfo != null) ? mappingInfo.getHandlerMethod() : null; + } + + /** + * Find a {@link Method} to handle the given exception type and media type. + * This can be useful if an {@link Exception} instance is not available (e.g. for tools). + * @param exceptionType the exception type + * @param mediaType the media type requested by the HTTP client + * @return a Method to handle the exception, or {@code null} if none found + */ + @Nullable + public ExceptionHandlerMappingInfo resolveExceptionMappingByExceptionType(Class exceptionType, MediaType mediaType) { + ExceptionHandlerMappingInfo mappingInfo = this.lookupCache.get(new ExceptionMapping(exceptionType, mediaType)); + return (mappingInfo != NO_MATCHING_EXCEPTION_HANDLER ? mappingInfo : null); } /** * Return the {@link Method} mapped to the given exception type, or - * {@link #NO_MATCHING_EXCEPTION_HANDLER_METHOD} if none. + * {@link #NO_MATCHING_EXCEPTION_HANDLER} if none. */ @Nullable - private Method getMappedMethod(Class exceptionType) { - List> matches = new ArrayList<>(); - for (Class mappedException : this.mappedMethods.keySet()) { - if (mappedException.isAssignableFrom(exceptionType)) { - matches.add(mappedException); + private ExceptionHandlerMappingInfo getMappedMethod(Class exceptionType, MediaType mediaType) { + List matches = new ArrayList<>(); + for (ExceptionMapping mappingInfo : this.mappedMethods.keySet()) { + if (mappingInfo.exceptionType().isAssignableFrom(exceptionType) && mappingInfo.mediaType().isCompatibleWith(mediaType)) { + matches.add(mappingInfo); } } if (!matches.isEmpty()) { if (matches.size() > 1) { - matches.sort(new ExceptionDepthComparator(exceptionType)); + matches.sort(new ExceptionMapingComparator(exceptionType, mediaType)); } return this.mappedMethods.get(matches.get(0)); } else { - return NO_MATCHING_EXCEPTION_HANDLER_METHOD; + return NO_MATCHING_EXCEPTION_HANDLER; } } /** - * For the {@link #NO_MATCHING_EXCEPTION_HANDLER_METHOD} constant. + * For the {@link #NO_MATCHING_EXCEPTION_HANDLER} constant. */ @SuppressWarnings("unused") private void noMatchingExceptionHandler() { } + private record ExceptionMapping(Class exceptionType, MediaType mediaType) { + + @Override + public String toString() { + return "ExceptionHandler{" + + "exceptionType=" + this.exceptionType.getCanonicalName() + + ", mediaType=" + this.mediaType + + '}'; + } + } + + private static class ExceptionMapingComparator implements Comparator { + + private final ExceptionDepthComparator exceptionDepthComparator; + + private final MediaType mediaType; + + public ExceptionMapingComparator(Class exceptionType, MediaType mediaType) { + this.exceptionDepthComparator = new ExceptionDepthComparator(exceptionType); + this.mediaType = mediaType; + } + + @Override + public int compare(ExceptionMapping o1, ExceptionMapping o2) { + int result = this.exceptionDepthComparator.compare(o1.exceptionType(), o2.exceptionType()); + if (result != 0) { + return result; + } + if(o1.mediaType.equals(this.mediaType)) { + return -1; + } + if(o2.mediaType.equals(this.mediaType)) { + return 1; + } + if (o1.mediaType.equals(o2.mediaType)) { + return 0; + } + return (o1.mediaType.isMoreSpecific(o2.mediaType)) ? -1 : 1; + } + } + } diff --git a/spring-web/src/test/java/org/springframework/web/method/annotation/ExceptionHandlerMethodResolverTests.java b/spring-web/src/test/java/org/springframework/web/method/annotation/ExceptionHandlerMethodResolverTests.java index eeccbd1ca6..d70dc9f7d5 100644 --- a/spring-web/src/test/java/org/springframework/web/method/annotation/ExceptionHandlerMethodResolverTests.java +++ b/spring-web/src/test/java/org/springframework/web/method/annotation/ExceptionHandlerMethodResolverTests.java @@ -25,6 +25,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; import org.springframework.stereotype.Controller; import org.springframework.util.ClassUtils; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -33,28 +34,29 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalStateException; /** - * Test fixture for {@link ExceptionHandlerMethodResolver} tests. + * Tests for {@link ExceptionHandlerMethodResolver}. * * @author Rossen Stoyanchev + * @author Brian Clozel */ class ExceptionHandlerMethodResolverTests { @Test - void resolveMethodFromAnnotation() { + void shouldResolveMethodFromAnnotationAttribute() { ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(ExceptionController.class); IOException exception = new IOException(); assertThat(resolver.resolveMethod(exception).getName()).isEqualTo("handleIOException"); } @Test - void resolveMethodFromArgument() { + void shouldResolveMethodFromMethodArgument() { ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(ExceptionController.class); IllegalArgumentException exception = new IllegalArgumentException(); assertThat(resolver.resolveMethod(exception).getName()).isEqualTo("handleIllegalArgumentException"); } @Test - void resolveMethodExceptionSubType() { + void shouldResolveMethodWithExceptionSubType() { ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(ExceptionController.class); IOException ioException = new FileNotFoundException(); assertThat(resolver.resolveMethod(ioException).getName()).isEqualTo("handleIOException"); @@ -63,14 +65,14 @@ class ExceptionHandlerMethodResolverTests { } @Test - void resolveMethodBestMatch() { + void shouldResolveMethodWithExceptionBestMatch() { ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(ExceptionController.class); SocketException exception = new SocketException(); assertThat(resolver.resolveMethod(exception).getName()).isEqualTo("handleSocketException"); } @Test - void resolveMethodNoMatch() { + void shouldNotResolveMethodWhenExceptionNoMatch() { ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(ExceptionController.class); Exception exception = new Exception(); assertThat(resolver.resolveMethod(exception)).as("1st lookup").isNull(); @@ -78,7 +80,7 @@ class ExceptionHandlerMethodResolverTests { } @Test - void resolveMethodExceptionCause() { + void ShouldResolveMethodWithExceptionCause() { ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(ExceptionController.class); SocketException bindException = new BindException(); @@ -90,24 +92,65 @@ class ExceptionHandlerMethodResolverTests { } @Test - void resolveMethodInherited() { + void shouldResolveMethodFromSuperClass() { ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(InheritedController.class); IOException exception = new IOException(); assertThat(resolver.resolveMethod(exception).getName()).isEqualTo("handleIOException"); } @Test - void ambiguousExceptionMapping() { + void shouldThrowExceptionWhenAmbiguousExceptionMapping() { assertThatIllegalStateException().isThrownBy(() -> new ExceptionHandlerMethodResolver(AmbiguousController.class)); } @Test - void noExceptionMapping() { + void shouldThrowExceptionWhenNoExceptionMapping() { assertThatIllegalStateException().isThrownBy(() -> new ExceptionHandlerMethodResolver(NoExceptionController.class)); } + @Test + void shouldResolveMethodWithMediaType() { + ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(MediaTypeController.class); + assertThat(resolver.resolveExceptionMapping(new IllegalArgumentException(), MediaType.APPLICATION_JSON).getHandlerMethod().getName()).isEqualTo("handleJson"); + assertThat(resolver.resolveExceptionMapping(new IllegalArgumentException(), MediaType.TEXT_HTML).getHandlerMethod().getName()).isEqualTo("handleHtml"); + } + + @Test + void shouldResolveMethodWithCompatibleMediaType() { + ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(MediaTypeController.class); + assertThat(resolver.resolveExceptionMapping(new IllegalArgumentException(), MediaType.parseMediaType("application/*")).getHandlerMethod().getName()).isEqualTo("handleJson"); + } + + @Test + void shouldFavorMethodWithExplicitAcceptAll() { + ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(MediaTypeController.class); + assertThat(resolver.resolveExceptionMapping(new IllegalArgumentException(), MediaType.ALL).getHandlerMethod().getName()).isEqualTo("handleHtml"); + } + + @Test + void shouldThrowExceptionWhenInvalidMediaTypeMapping() { + assertThatIllegalStateException().isThrownBy(() -> + new ExceptionHandlerMethodResolver(InvalidMediaTypeController.class)) + .withMessageContaining("Invalid media type [invalid-mediatype] declared on @ExceptionHandler"); + } + + @Test + void shouldThrowExceptionWhenAmbiguousMediaTypeMapping() { + assertThatIllegalStateException().isThrownBy(() -> + new ExceptionHandlerMethodResolver(AmbiguousMediaTypeController.class)) + .withMessageContaining("Ambiguous @ExceptionHandler method mapped for [ExceptionHandler{exceptionType=java.lang.IllegalArgumentException, mediaType=application/json}]") + .withMessageContaining("AmbiguousMediaTypeController.handleJson()") + .withMessageContaining("AmbiguousMediaTypeController.handleJsonToo()"); + } + + @Test + void shouldResolveMethodWithMediaTypeFallback() { + ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(MixedController.class); + assertThat(resolver.resolveExceptionMapping(new IllegalArgumentException(), MediaType.TEXT_HTML).getHandlerMethod().getName()).isEqualTo("handleOther"); + } + @Controller static class ExceptionController { @@ -162,4 +205,58 @@ class ExceptionHandlerMethodResolverTests { } } + @Controller + static class MediaTypeController { + + @ExceptionHandler(exception = {IllegalArgumentException.class}, produces = "application/json") + public void handleJson() { + + } + + @ExceptionHandler(exception = {IllegalArgumentException.class}, produces = {"text/html", "*/*"}) + public void handleHtml() { + + } + + } + + @Controller + static class AmbiguousMediaTypeController { + + @ExceptionHandler(exception = {IllegalArgumentException.class}, produces = "application/json") + public void handleJson() { + + } + + @ExceptionHandler(exception = {IllegalArgumentException.class}, produces = "application/json") + public void handleJsonToo() { + + } + + } + + @Controller + static class MixedController { + + @ExceptionHandler(exception = {IllegalArgumentException.class}, produces = "application/json") + public void handleJson() { + + } + + @ExceptionHandler(IllegalArgumentException.class) + public void handleOther() { + + } + + } + + @Controller + static class InvalidMediaTypeController { + + @ExceptionHandler(exception = {IllegalArgumentException.class}, produces = "invalid-mediatype") + public void handle() { + + } + } + } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurationSupport.java b/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurationSupport.java index ae451e4d75..7fb54fc02e 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurationSupport.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/config/WebFluxConfigurationSupport.java @@ -283,12 +283,14 @@ public class WebFluxConfigurationSupport implements ApplicationContextAware { @Qualifier("webFluxAdapterRegistry") ReactiveAdapterRegistry reactiveAdapterRegistry, ServerCodecConfigurer serverCodecConfigurer, @Qualifier("webFluxConversionService") FormattingConversionService conversionService, + @Qualifier("webFluxContentTypeResolver") RequestedContentTypeResolver contentTypeResolver, @Qualifier("webFluxValidator") Validator validator) { RequestMappingHandlerAdapter adapter = createRequestMappingHandlerAdapter(); adapter.setMessageReaders(serverCodecConfigurer.getReaders()); adapter.setWebBindingInitializer(getConfigurableWebBindingInitializer(conversionService, validator)); adapter.setReactiveAdapterRegistry(reactiveAdapterRegistry); + adapter.setContentTypeResolver(contentTypeResolver); BlockingExecutionConfigurer executorConfigurer = getBlockingExecutionConfigurer(); if (executorConfigurer.getExecutor() != null) { diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ControllerMethodResolver.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ControllerMethodResolver.java index a4524e3577..aac9d48ea3 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ControllerMethodResolver.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ControllerMethodResolver.java @@ -38,6 +38,7 @@ import org.springframework.core.MethodIntrospector; import org.springframework.core.MethodParameter; import org.springframework.core.ReactiveAdapterRegistry; import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.http.MediaType; import org.springframework.http.codec.HttpMessageReader; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -50,12 +51,16 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.support.WebBindingInitializer; import org.springframework.web.method.ControllerAdviceBean; import org.springframework.web.method.HandlerMethod; +import org.springframework.web.method.annotation.ExceptionHandlerMappingInfo; import org.springframework.web.method.annotation.ExceptionHandlerMethodResolver; import org.springframework.web.method.annotation.HandlerMethodValidator; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.reactive.accept.RequestedContentTypeResolver; import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver; import org.springframework.web.reactive.result.method.InvocableHandlerMethod; import org.springframework.web.reactive.result.method.SyncHandlerMethodArgumentResolver; import org.springframework.web.reactive.result.method.SyncInvocableHandlerMethod; +import org.springframework.web.server.ServerWebExchange; /** * Package-private class to assist {@link RequestMappingHandlerAdapter} with @@ -104,6 +109,8 @@ class ControllerMethodResolver { private final ReactiveAdapterRegistry reactiveAdapterRegistry; + private final RequestedContentTypeResolver contentTypeResolver; + @Nullable private final Scheduler invocationScheduler; @@ -129,16 +136,17 @@ class ControllerMethodResolver { private final Map, SessionAttributesHandler> sessionAttributesHandlerCache = new ConcurrentHashMap<>(64); + ControllerMethodResolver( ArgumentResolverConfigurer customResolvers, ReactiveAdapterRegistry adapterRegistry, - ConfigurableApplicationContext context, List> readers, - @Nullable WebBindingInitializer webBindingInitializer, - @Nullable Scheduler invocationScheduler, - @Nullable Predicate blockingMethodPredicate) { + ConfigurableApplicationContext context, RequestedContentTypeResolver contentTypeResolver, + List> readers, @Nullable WebBindingInitializer webBindingInitializer, + @Nullable Scheduler invocationScheduler, @Nullable Predicate blockingMethodPredicate) { Assert.notNull(customResolvers, "ArgumentResolverConfigurer is required"); Assert.notNull(adapterRegistry, "ReactiveAdapterRegistry is required"); Assert.notNull(context, "ApplicationContext is required"); + Assert.notNull(contentTypeResolver, "RequestedContentTypeResolver is required"); Assert.notNull(readers, "HttpMessageReader List is required"); this.initBinderResolvers = initBinderResolvers(customResolvers, adapterRegistry, context); @@ -146,6 +154,7 @@ class ControllerMethodResolver { this.requestMappingResolvers = requestMappingResolvers(customResolvers, adapterRegistry, context, readers); this.exceptionHandlerResolvers = exceptionHandlerResolvers(customResolvers, adapterRegistry, context); this.reactiveAdapterRegistry = adapterRegistry; + this.contentTypeResolver = contentTypeResolver; this.invocationScheduler = invocationScheduler; this.blockingMethodPredicate = blockingMethodPredicate; @@ -398,44 +407,53 @@ class ControllerMethodResolver { * controller method, and also within {@code @ControllerAdvice} classes that * are applicable to the class of the given controller method. * @param ex the exception to find a handler for - * @param handlerMethod the controller method that raised the exception, or - * if {@code null}, check only {@code @ControllerAdvice} classes. + * @param exchange the current HTTP exchange + * @param handlerMethod the controller method that raised the exception, + * or if {@code null}, check only {@code @ControllerAdvice} classes. */ @Nullable @SuppressWarnings("NullAway") - public InvocableHandlerMethod getExceptionHandlerMethod(Throwable ex, @Nullable HandlerMethod handlerMethod) { + public InvocableHandlerMethod getExceptionHandlerMethod(Throwable ex, ServerWebExchange exchange, @Nullable HandlerMethod handlerMethod) { Class handlerType = (handlerMethod != null ? handlerMethod.getBeanType() : null); - Object exceptionHandlerObject = null; - Method exceptionHandlerMethod = null; + List requestedMediaTypes = this.contentTypeResolver.resolveMediaTypes(exchange); + // Controller-local first if (handlerType != null) { - // Controller-local first... - exceptionHandlerObject = handlerMethod.getBean(); - exceptionHandlerMethod = this.exceptionHandlerCache - .computeIfAbsent(handlerType, ExceptionHandlerMethodResolver::new) - .resolveMethodByThrowable(ex); + for (MediaType mediaType : requestedMediaTypes) { + ExceptionHandlerMappingInfo mappingInfo = this.exceptionHandlerCache + .computeIfAbsent(handlerType, ExceptionHandlerMethodResolver::new) + .resolveExceptionMapping(ex, mediaType); + if (mappingInfo != null) { + if (!mappingInfo.getProducibleTypes().isEmpty()) { + exchange.getAttributes().put(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE, mappingInfo.getProducibleTypes()); + } + return createInvocableHandlerMethod(handlerMethod.getBean(), mappingInfo.getHandlerMethod()); + } + } } - if (exceptionHandlerMethod == null) { - // Global exception handlers... + // Global exception handlers + for (MediaType mediaType : requestedMediaTypes) { for (Map.Entry entry : this.exceptionHandlerAdviceCache.entrySet()) { ControllerAdviceBean advice = entry.getKey(); if (advice.isApplicableToBeanType(handlerType)) { - exceptionHandlerMethod = entry.getValue().resolveMethodByThrowable(ex); - if (exceptionHandlerMethod != null) { - exceptionHandlerObject = advice.resolveBean(); - break; + ExceptionHandlerMappingInfo mappingInfo = entry.getValue().resolveExceptionMapping(ex, mediaType); + if (mappingInfo != null) { + if (!mappingInfo.getProducibleTypes().isEmpty()) { + exchange.getAttributes().put(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE, mappingInfo.getProducibleTypes()); + } + return createInvocableHandlerMethod(advice.resolveBean(), mappingInfo.getHandlerMethod()); } } } } - if (exceptionHandlerObject == null || exceptionHandlerMethod == null) { - return null; - } + return null; + } - InvocableHandlerMethod invocable = new InvocableHandlerMethod(exceptionHandlerObject, exceptionHandlerMethod); + private InvocableHandlerMethod createInvocableHandlerMethod(Object bean, Method method) { + InvocableHandlerMethod invocable = new InvocableHandlerMethod(bean, method); invocable.setArgumentResolvers(this.exceptionHandlerResolvers); return invocable; } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerAdapter.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerAdapter.java index 7098dcd96e..26c7ed5f33 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerAdapter.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerAdapter.java @@ -46,6 +46,8 @@ import org.springframework.web.reactive.DispatchExceptionHandler; import org.springframework.web.reactive.HandlerAdapter; import org.springframework.web.reactive.HandlerMapping; import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.reactive.accept.RequestedContentTypeResolver; +import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder; import org.springframework.web.reactive.result.method.InvocableHandlerMethod; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.util.DisconnectedClientHelper; @@ -56,6 +58,7 @@ import org.springframework.web.util.DisconnectedClientHelper; * handler methods. * * @author Rossen Stoyanchev + * @author Brian Clozel * @since 5.0 */ public class RequestMappingHandlerAdapter @@ -82,6 +85,8 @@ public class RequestMappingHandlerAdapter @Nullable private ArgumentResolverConfigurer argumentResolverConfigurer; + private RequestedContentTypeResolver contentTypeResolver = new RequestedContentTypeResolverBuilder().build(); + @Nullable private Scheduler scheduler; @@ -148,6 +153,24 @@ public class RequestMappingHandlerAdapter return this.argumentResolverConfigurer; } + /** + * Set the {@link RequestedContentTypeResolver} to use to determine requested + * media types. If not set, the default constructor is used. + * @since 6.2.0 + */ + public void setContentTypeResolver(RequestedContentTypeResolver contentTypeResolver) { + Assert.notNull(contentTypeResolver, "'contentTypeResolver' must not be null"); + this.contentTypeResolver = contentTypeResolver; + } + + /** + * Return the configured {@link RequestedContentTypeResolver}. + * @since 6.2.0 + */ + public RequestedContentTypeResolver getContentTypeResolver() { + return this.contentTypeResolver; + } + /** * Configure an executor to invoke blocking controller methods with. *

By default, this is not set in which case controller methods are @@ -225,7 +248,7 @@ public class RequestMappingHandlerAdapter this.methodResolver = new ControllerMethodResolver( this.argumentResolverConfigurer, this.reactiveAdapterRegistry, this.applicationContext, - this.messageReaders, this.webBindingInitializer, + this.contentTypeResolver, this.messageReaders, this.webBindingInitializer, this.scheduler, this.blockingMethodPredicate); this.modelInitializer = new ModelInitializer(this.methodResolver, this.reactiveAdapterRegistry); @@ -280,7 +303,7 @@ public class RequestMappingHandlerAdapter exchange.getResponse().getHeaders().clearContentHeaders(); InvocableHandlerMethod invocable = - this.methodResolver.getExceptionHandlerMethod(exception, handlerMethod); + this.methodResolver.getExceptionHandlerMethod(exception, exchange, handlerMethod); if (invocable != null) { ArrayList exceptions = new ArrayList<>(); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/config/DelegatingWebFluxConfigurationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/config/DelegatingWebFluxConfigurationTests.java index bb50ecaa84..df78c06092 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/config/DelegatingWebFluxConfigurationTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/config/DelegatingWebFluxConfigurationTests.java @@ -37,6 +37,7 @@ import org.springframework.validation.Validator; import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; import org.springframework.web.ErrorResponse; import org.springframework.web.bind.support.ConfigurableWebBindingInitializer; +import org.springframework.web.reactive.accept.RequestedContentTypeResolver; import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder; import org.springframework.web.reactive.result.method.annotation.ResponseBodyResultHandler; import org.springframework.web.reactive.socket.server.WebSocketService; @@ -98,11 +99,12 @@ public class DelegatingWebFluxConfigurationTests { ReactiveAdapterRegistry reactiveAdapterRegistry = delegatingConfig.webFluxAdapterRegistry(); ServerCodecConfigurer serverCodecConfigurer = delegatingConfig.serverCodecConfigurer(); FormattingConversionService formattingConversionService = delegatingConfig.webFluxConversionService(); + RequestedContentTypeResolver requestedContentTypeResolver = delegatingConfig.webFluxContentTypeResolver(); Validator validator = delegatingConfig.webFluxValidator(); ConfigurableWebBindingInitializer initializer = (ConfigurableWebBindingInitializer) this.delegatingConfig.requestMappingHandlerAdapter(reactiveAdapterRegistry, serverCodecConfigurer, - formattingConversionService, validator).getWebBindingInitializer(); + formattingConversionService, requestedContentTypeResolver, validator).getWebBindingInitializer(); verify(webFluxConfigurer).configureHttpMessageCodecs(codecsConfigurer.capture()); verify(webFluxConfigurer).getValidator(); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ControllerMethodResolverTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ControllerMethodResolverTests.java index 1df47ab627..7d582a95d3 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ControllerMethodResolverTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ControllerMethodResolverTests.java @@ -18,6 +18,7 @@ package org.springframework.web.reactive.result.method.annotation; import java.lang.reflect.Method; import java.util.List; +import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.BeforeEach; @@ -30,6 +31,7 @@ import org.springframework.core.ReactiveAdapterRegistry; import org.springframework.core.codec.ByteArrayDecoder; import org.springframework.core.codec.ByteBufferDecoder; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.codec.ServerCodecConfigurer; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.ControllerAdvice; @@ -39,13 +41,17 @@ import org.springframework.web.bind.annotation.InitBinder; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.method.HandlerMethod; import org.springframework.web.reactive.BindingContext; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder; import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver; import org.springframework.web.reactive.result.method.InvocableHandlerMethod; import org.springframework.web.reactive.result.method.SyncHandlerMethodArgumentResolver; import org.springframework.web.reactive.result.method.SyncInvocableHandlerMethod; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest; import org.springframework.web.testfixture.method.ResolvableMethod; +import org.springframework.web.testfixture.server.MockServerWebExchange; import static org.assertj.core.api.Assertions.assertThat; @@ -77,7 +83,8 @@ class ControllerMethodResolverTests { this.methodResolver = new ControllerMethodResolver( resolvers, ReactiveAdapterRegistry.getSharedInstance(), applicationContext, - codecs.getReaders(), null, null, null); + new RequestedContentTypeResolverBuilder().build(), codecs.getReaders(), + null, null, null); Method method = ResolvableMethod.on(TestController.class).mockCall(TestController::handle).method(); this.handlerMethod = new HandlerMethod(new TestController(), method); @@ -191,8 +198,9 @@ class ControllerMethodResolverTests { @Test void exceptionHandlerArgumentResolvers() { + MockServerWebExchange serverWebExchange = MockServerWebExchange.builder(MockServerHttpRequest.get("/test").build()).build(); InvocableHandlerMethod invocable = this.methodResolver.getExceptionHandlerMethod( - new ResponseStatusException(HttpStatus.BAD_REQUEST, "reason"), this.handlerMethod); + new ResponseStatusException(HttpStatus.BAD_REQUEST, "reason"), serverWebExchange, this.handlerMethod); assertThat(invocable).as("No match").isNotNull(); assertThat(invocable.getBeanType()).isEqualTo(TestController.class); @@ -226,13 +234,30 @@ class ControllerMethodResolverTests { @Test void exceptionHandlerFromControllerAdvice() { + MockServerWebExchange serverWebExchange = MockServerWebExchange.builder(MockServerHttpRequest.get("/test").build()).build(); InvocableHandlerMethod invocable = this.methodResolver.getExceptionHandlerMethod( - new IllegalStateException("reason"), this.handlerMethod); + new IllegalStateException("reason"), serverWebExchange, this.handlerMethod); assertThat(invocable).isNotNull(); assertThat(invocable.getBeanType()).isEqualTo(TestControllerAdvice.class); } + @Test + void exceptionHandlerWithMediaType() { + Method method = ResolvableMethod.on(ExceptionHandlerController.class).mockCall(ExceptionHandlerController::handle).method(); + this.handlerMethod = new HandlerMethod(new ExceptionHandlerController(), method); + MockServerHttpRequest httpRequest = MockServerHttpRequest.get("/test").accept(MediaType.APPLICATION_JSON).build(); + MockServerWebExchange serverWebExchange = MockServerWebExchange.builder(httpRequest).build(); + InvocableHandlerMethod invocable = this.methodResolver.getExceptionHandlerMethod( + new ResponseStatusException(HttpStatus.BAD_REQUEST, "reason"), serverWebExchange, this.handlerMethod); + + assertThat(invocable).as("No match").isNotNull(); + assertThat(invocable.getBeanType()).isEqualTo(ExceptionHandlerController.class); + assertThat(invocable.getMethod().getName()).isEqualTo("handleExceptionJson"); + Set producibleMediaTypes = serverWebExchange.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE); + assertThat(producibleMediaTypes).isNotEmpty().contains(MediaType.APPLICATION_JSON); + } + private static HandlerMethodArgumentResolver next( List resolvers, AtomicInteger index) { @@ -273,6 +298,20 @@ class ControllerMethodResolverTests { } + @Controller + static class ExceptionHandlerController { + + @GetMapping + void handle() {} + + @ExceptionHandler(produces = "text/html") + void handleExceptionHtml(ResponseStatusException ex) {} + + @ExceptionHandler(produces = "application/json") + void handleExceptionJson(ResponseStatusException ex) {} + + } + static class CustomArgumentResolver implements HandlerMethodArgumentResolver { diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ModelInitializerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ModelInitializerTests.java index 6f55870375..7cf05e598f 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ModelInitializerTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ModelInitializerTests.java @@ -45,6 +45,7 @@ import org.springframework.web.bind.support.ConfigurableWebBindingInitializer; import org.springframework.web.bind.support.WebBindingInitializer; import org.springframework.web.bind.support.WebExchangeDataBinder; import org.springframework.web.method.HandlerMethod; +import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder; import org.springframework.web.reactive.result.method.SyncInvocableHandlerMethod; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebSession; @@ -80,7 +81,8 @@ class ModelInitializerTests { ControllerMethodResolver methodResolver = new ControllerMethodResolver( resolverConfigurer, adapterRegistry, new StaticApplicationContext(), - Collections.emptyList(), null, null, null); + new RequestedContentTypeResolverBuilder().build(), Collections.emptyList(), + null, null, null); this.modelInitializer = new ModelInitializer(methodResolver, adapterRegistry); } diff --git a/spring-webflux/src/test/kotlin/org/springframework/web/reactive/result/method/annotation/ModelInitializerKotlinTests.kt b/spring-webflux/src/test/kotlin/org/springframework/web/reactive/result/method/annotation/ModelInitializerKotlinTests.kt index 9ece488448..5424ee8903 100644 --- a/spring-webflux/src/test/kotlin/org/springframework/web/reactive/result/method/annotation/ModelInitializerKotlinTests.kt +++ b/spring-webflux/src/test/kotlin/org/springframework/web/reactive/result/method/annotation/ModelInitializerKotlinTests.kt @@ -27,6 +27,7 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.ModelAttribute import org.springframework.web.bind.support.ConfigurableWebBindingInitializer import org.springframework.web.method.HandlerMethod +import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder import org.springframework.web.server.ServerWebExchange import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest import org.springframework.web.testfixture.method.ResolvableMethod @@ -52,8 +53,10 @@ class ModelInitializerKotlinTests { val adapterRegistry = ReactiveAdapterRegistry.getSharedInstance() val resolverConfigurer = ArgumentResolverConfigurer() resolverConfigurer.addCustomResolver(ModelMethodArgumentResolver(adapterRegistry)) - val methodResolver = ControllerMethodResolver(resolverConfigurer, adapterRegistry, StaticApplicationContext(), - emptyList(), null, null, null) + val methodResolver = ControllerMethodResolver( + resolverConfigurer, adapterRegistry, StaticApplicationContext(), + RequestedContentTypeResolverBuilder().build(), emptyList(), null, null, null + ) modelInitializer = ModelInitializer(methodResolver, adapterRegistry) } 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 ccccc43a4f..9e201d4a8a 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 @@ -16,7 +16,6 @@ package org.springframework.web.servlet.mvc.method.annotation; -import java.lang.reflect.Method; import java.lang.reflect.Proxy; import java.util.ArrayList; import java.util.Collections; @@ -32,7 +31,9 @@ import org.springframework.aop.support.AopUtils; import org.springframework.beans.factory.InitializingBean; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; import org.springframework.http.converter.ByteArrayHttpMessageConverter; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.StringHttpMessageConverter; @@ -40,11 +41,14 @@ import org.springframework.http.converter.support.AllEncompassingFormHttpMessage import org.springframework.lang.Nullable; import org.springframework.ui.ModelMap; import org.springframework.web.ErrorResponse; +import org.springframework.web.HttpMediaTypeNotAcceptableException; import org.springframework.web.accept.ContentNegotiationManager; import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.ServletWebRequest; import org.springframework.web.method.ControllerAdviceBean; import org.springframework.web.method.HandlerMethod; +import org.springframework.web.method.annotation.ExceptionHandlerMappingInfo; import org.springframework.web.method.annotation.ExceptionHandlerMethodResolver; import org.springframework.web.method.annotation.MapMethodProcessor; import org.springframework.web.method.annotation.ModelMethodProcessor; @@ -53,6 +57,7 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolverCompo import org.springframework.web.method.support.HandlerMethodReturnValueHandler; import org.springframework.web.method.support.HandlerMethodReturnValueHandlerComposite; import org.springframework.web.method.support.ModelAndViewContainer; +import org.springframework.web.servlet.HandlerMapping; import org.springframework.web.servlet.ModelAndView; import org.springframework.web.servlet.View; import org.springframework.web.servlet.handler.AbstractHandlerMethodExceptionResolver; @@ -73,6 +78,7 @@ import org.springframework.web.util.DisconnectedClientHelper; * @author Rossen Stoyanchev * @author Juergen Hoeller * @author Sebastien Deleuze + * @author Brian Clozel * @since 3.1 */ public class ExceptionHandlerExceptionResolver extends AbstractHandlerMethodExceptionResolver @@ -425,7 +431,9 @@ public class ExceptionHandlerExceptionResolver extends AbstractHandlerMethodExce protected ModelAndView doResolveHandlerMethodException(HttpServletRequest request, HttpServletResponse response, @Nullable HandlerMethod handlerMethod, Exception exception) { - ServletInvocableHandlerMethod exceptionHandlerMethod = getExceptionHandlerMethod(handlerMethod, exception); + ServletWebRequest webRequest = new ServletWebRequest(request, response); + ServletInvocableHandlerMethod exceptionHandlerMethod = getExceptionHandlerMethod(handlerMethod, exception, webRequest); + if (exceptionHandlerMethod == null) { return null; } @@ -437,7 +445,6 @@ public class ExceptionHandlerExceptionResolver extends AbstractHandlerMethodExce exceptionHandlerMethod.setHandlerMethodReturnValueHandlers(this.returnValueHandlers); } - ServletWebRequest webRequest = new ServletWebRequest(request, response); ModelAndViewContainer mavContainer = new ModelAndViewContainer(); ArrayList exceptions = new ArrayList<>(); @@ -497,11 +504,22 @@ public class ExceptionHandlerExceptionResolver extends AbstractHandlerMethodExce * Spring-managed beans were detected. * @param handlerMethod the method where the exception was raised (may be {@code null}) * @param exception the raised exception + * @param webRequest the original web request that resulted in a handler error * @return a method to handle the exception, or {@code null} if none */ @Nullable protected ServletInvocableHandlerMethod getExceptionHandlerMethod( - @Nullable HandlerMethod handlerMethod, Exception exception) { + @Nullable HandlerMethod handlerMethod, Exception exception, ServletWebRequest webRequest) { + + List acceptedMediaTypes = List.of(MediaType.ALL); + try { + acceptedMediaTypes = this.contentNegotiationManager.resolveMediaTypes(webRequest); + } + catch (HttpMediaTypeNotAcceptableException mediaTypeExc) { + if (logger.isDebugEnabled()) { + logger.debug("Could not resolve accepted media types for @ExceptionHandler [" + webRequest.getHeader(HttpHeaders.ACCEPT) + "]", mediaTypeExc); + } + } Class handlerType = null; @@ -511,9 +529,15 @@ public class ExceptionHandlerExceptionResolver extends AbstractHandlerMethodExce handlerType = handlerMethod.getBeanType(); ExceptionHandlerMethodResolver resolver = this.exceptionHandlerCache.computeIfAbsent( handlerType, ExceptionHandlerMethodResolver::new); - Method method = resolver.resolveMethod(exception); - if (method != null) { - return new ServletInvocableHandlerMethod(handlerMethod.getBean(), method, this.applicationContext); + + for (MediaType mediaType : acceptedMediaTypes) { + ExceptionHandlerMappingInfo mappingInfo = resolver.resolveExceptionMapping(exception, mediaType); + if (mappingInfo != null) { + if (!mappingInfo.getProducibleTypes().isEmpty()) { + webRequest.setAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE, mappingInfo.getProducibleTypes(), RequestAttributes.SCOPE_REQUEST); + } + return new ServletInvocableHandlerMethod(handlerMethod.getBean(), mappingInfo.getHandlerMethod(), this.applicationContext); + } } // For advice applicability check below (involving base packages, assignable types // and annotation presence), use target class instead of interface-based proxy. @@ -526,9 +550,14 @@ public class ExceptionHandlerExceptionResolver extends AbstractHandlerMethodExce ControllerAdviceBean advice = entry.getKey(); if (advice.isApplicableToBeanType(handlerType)) { ExceptionHandlerMethodResolver resolver = entry.getValue(); - Method method = resolver.resolveMethod(exception); - if (method != null) { - return new ServletInvocableHandlerMethod(advice.resolveBean(), method, this.applicationContext); + for (MediaType mediaType : acceptedMediaTypes) { + ExceptionHandlerMappingInfo mappingInfo = resolver.resolveExceptionMapping(exception, mediaType); + if (mappingInfo != null) { + if (!mappingInfo.getProducibleTypes().isEmpty()) { + webRequest.setAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE, mappingInfo.getProducibleTypes(), RequestAttributes.SCOPE_REQUEST); + } + return new ServletInvocableHandlerMethod(advice.resolveBean(), mappingInfo.getHandlerMethod(), this.applicationContext); + } } } } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ExceptionHandlerExceptionResolverTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ExceptionHandlerExceptionResolverTests.java index 2373a3cdbd..917294c997 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ExceptionHandlerExceptionResolverTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ExceptionHandlerExceptionResolverTests.java @@ -71,7 +71,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; /** - * Test fixture with {@link ExceptionHandlerExceptionResolver}. + * Tests for {@link ExceptionHandlerExceptionResolver}. * * @author Rossen Stoyanchev * @author Arjen Poutsma @@ -419,6 +419,41 @@ class ExceptionHandlerExceptionResolverTests { assertThat(mav.isEmpty()).isTrue(); } + @Test + void resolveExceptionJsonMediaType() throws UnsupportedEncodingException, NoSuchMethodException { + IllegalArgumentException ex = new IllegalArgumentException(); + HandlerMethod handlerMethod = new HandlerMethod(new MediaTypeController(), "handle"); + this.resolver.afterPropertiesSet(); + this.request.addHeader("Accept", "application/json"); + ModelAndView mav = this.resolver.resolveException(this.request, this.response, handlerMethod, ex); + + assertExceptionHandledAsBody(mav, "jsonBody"); + } + + @Test + void resolveExceptionHtmlMediaType() throws NoSuchMethodException { + IllegalArgumentException ex = new IllegalArgumentException(); + HandlerMethod handlerMethod = new HandlerMethod(new MediaTypeController(), "handle"); + this.resolver.afterPropertiesSet(); + this.request.addHeader("Accept", "text/html"); + ModelAndView mav = this.resolver.resolveException(this.request, this.response, handlerMethod, ex); + + assertThat(mav).isNotNull(); + assertThat(mav.getViewName()).isEqualTo("htmlView"); + } + + @Test + void resolveExceptionDefaultMediaType() throws NoSuchMethodException { + IllegalArgumentException ex = new IllegalArgumentException(); + HandlerMethod handlerMethod = new HandlerMethod(new MediaTypeController(), "handle"); + this.resolver.afterPropertiesSet(); + this.request.addHeader("Accept", "*/*"); + ModelAndView mav = this.resolver.resolveException(this.request, this.response, handlerMethod, ex); + + assertThat(mav).isNotNull(); + assertThat(mav.getViewName()).isEqualTo("htmlView"); + } + private void assertMethodProcessorCount(int resolverCount, int handlerCount) { assertThat(this.resolver.getArgumentResolvers().getResolvers()).hasSize(resolverCount); @@ -650,4 +685,21 @@ class ExceptionHandlerExceptionResolverTests { } } + @Controller + static class MediaTypeController { + + public void handle() {} + + @ExceptionHandler(exception = IllegalArgumentException.class, produces = "application/json") + @ResponseBody + public String handleExceptionJson() { + return "jsonBody"; + } + + @ExceptionHandler(exception = IllegalArgumentException.class, produces = {"text/html", "*/*"}) + public String handleExceptionHtml() { + return "htmlView"; + } + } + }