From 9c7b5cb3f5cc4aac3b4e8ae9499b2753b85696f7 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Wed, 14 Jun 2023 15:57:04 +0100 Subject: [PATCH] Add ProblemDetail "type" message code See gh-30566 --- .../web/webflux/ann-rest-exceptions.adoc | 28 +++++++-------- .../web/webmvc/mvc-ann-rest-exceptions.adoc | 27 +++++++------- .../springframework/web/ErrorResponse.java | 35 ++++++++++++++++--- .../ResponseEntityExceptionHandlerTests.java | 17 +++++---- .../ResponseEntityExceptionHandlerTests.java | 15 +++++--- 5 files changed, 78 insertions(+), 44 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/web/webflux/ann-rest-exceptions.adoc b/framework-docs/modules/ROOT/pages/web/webflux/ann-rest-exceptions.adoc index 188d58a1ad7..5be40fd19a0 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/ann-rest-exceptions.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/ann-rest-exceptions.adoc @@ -71,20 +71,21 @@ from an existing `ProblemDetail`. This could be done centrally, e.g. from an [.small]#xref:web/webmvc/mvc-ann-rest-exceptions.adoc#mvc-ann-rest-exceptions-i18n[See equivalent in the Servlet stack]# It is a common requirement to internationalize error response details, and good practice -to customize the problem details for Spring WebFlux exceptions. This is supported as follows: +to customize the problem details for Spring WebFlux exceptions. This section describes the +support for that. -- Each `ErrorResponse` exposes a message code and arguments to resolve the "detail" field -through a xref:core/beans/context-introduction.adoc#context-functionality-messagesource[MessageSource]. -The actual message code value is parameterized with placeholders, e.g. -`+"HTTP method {0} not supported"+` to be expanded from the arguments. -- Each `ErrorResponse` also exposes a message code to resolve the "title" field. -- `ResponseEntityExceptionHandler` uses the message code and arguments to resolve the -"detail" and the "title" fields. +`ErrorResponse` exposes message codes for "type", "title", and "detail", in addition to +message code arguments for the "detail" field. `ResponseEntityExceptionHandler` resolves +these through a xref:core/beans/context-introduction.adoc#context-functionality-messagesource[MessageSource] +and updates the `ProblemDetail` accordingly. -By default, the message code for the "detail" field is "problemDetail." + the fully -qualified exception class name. Some exceptions may expose additional message codes in -which case a suffix is added to the default message code. The table below lists message -arguments and codes for Spring WebFlux exceptions: +The default strategy for message codes follows the pattern: + +`problemDetail.[type|title|detail].[fully qualified exception class name]` + +Some `ErrorResponse` may expose more than one message code, typically adding a suffix +to the default message code. The table below lists message codes, and arguments for +Spring WebFlux exceptions: [[webflux-ann-rest-exceptions-codes]] [cols="1,1,2", options="header"] @@ -131,9 +132,6 @@ via `MessageSource`. |=== -By default, the message code for the "title" field is "problemDetail.title." + the fully -qualified exception class name. - diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-ann-rest-exceptions.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-ann-rest-exceptions.adoc index 183e49b0aeb..cbbdd951ac8 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-ann-rest-exceptions.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-ann-rest-exceptions.adoc @@ -71,20 +71,21 @@ from an existing `ProblemDetail`. This could be done centrally, e.g. from an [.small]#xref:web/webflux/ann-rest-exceptions.adoc#webflux-ann-rest-exceptions-i18n[See equivalent in the Reactive stack]# It is a common requirement to internationalize error response details, and good practice -to customize the problem details for Spring MVC exceptions. This is supported as follows: +to customize the problem details for Spring WebFlux exceptions. This section describes the +support for that. -- Each `ErrorResponse` exposes a message code and arguments to resolve the "detail" field -through a xref:core/beans/context-introduction.adoc#context-functionality-messagesource[MessageSource]. -The actual message code value is parameterized with placeholders, e.g. -`+"HTTP method {0} not supported"+` to be expanded from the arguments. -- Each `ErrorResponse` also exposes a message code to resolve the "title" field. -- `ResponseEntityExceptionHandler` uses the message code and arguments to resolve the -"detail" and the "title" fields. +`ErrorResponse` exposes message codes for "type", "title", and "detail", in addition to +message code arguments for the "detail" field. `ResponseEntityExceptionHandler` resolves +these through a xref:core/beans/context-introduction.adoc#context-functionality-messagesource[MessageSource] +and updates the `ProblemDetail` accordingly. -By default, the message code for the "detail" field is "problemDetail." + the fully -qualified exception class name. Some exceptions may expose additional message codes in -which case a suffix is added to the default message code. The table below lists message -arguments and codes for Spring MVC exceptions: +The default strategy for message codes follows the pattern: + +`problemDetail.[type|title|detail].[fully qualified exception class name]` + +Some `ErrorResponse` may expose more than one message code, typically adding a suffix +to the default message code. The table below lists message codes, and arguments for +Spring MVC exceptions: [[mvc-ann-rest-exceptions-codes]] [cols="1,1,2", options="header"] @@ -171,8 +172,6 @@ arguments and codes for Spring MVC exceptions: |=== -By default, the message code for the "title" field is "problemDetail.title." + the fully -qualified exception class name. diff --git a/spring-web/src/main/java/org/springframework/web/ErrorResponse.java b/spring-web/src/main/java/org/springframework/web/ErrorResponse.java index 2ae0187d705..7ea1f06e459 100644 --- a/spring-web/src/main/java/org/springframework/web/ErrorResponse.java +++ b/spring-web/src/main/java/org/springframework/web/ErrorResponse.java @@ -64,6 +64,18 @@ public interface ErrorResponse { */ ProblemDetail getBody(); + /** + * Return a code to use to resolve the problem "type" for this exception + * through a {@link MessageSource}. The type resolved through the + * {@code MessageSource} will be passed into {@link URI#create(String)} + * and therefore must be an encoded URI String. + *

By default this is initialized via {@link #getDefaultTypeMessageCode(Class)}. + * @since 6.1 + */ + default String getTypeMessageCode() { + return getDefaultTypeMessageCode(getClass()); + } + /** * Return a code to use to resolve the problem "detail" for this exception * through a {@link MessageSource}. @@ -109,15 +121,19 @@ public interface ErrorResponse { } /** - * Resolve the {@link #getDetailMessageCode() detailMessageCode} and the - * {@link #getTitleMessageCode() titleMessageCode} through the given - * {@link MessageSource}, and if found, update the "detail" and "title" - * fields respectively. + * Use the given {@link MessageSource} to resolve the + * {@link #getTypeMessageCode() type}, {@link #getTitleMessageCode() title}, + * and {@link #getDetailMessageCode() detail} message codes, and then use the + * resolved values to update the corresponding fields in {@link #getBody()}. * @param messageSource the {@code MessageSource} to use for the lookup * @param locale the {@code Locale} to use for the lookup */ default ProblemDetail updateAndGetBody(@Nullable MessageSource messageSource, Locale locale) { if (messageSource != null) { + String type = messageSource.getMessage(getTypeMessageCode(), null, null, locale); + if (type != null) { + getBody().setType(URI.create(type)); + } Object[] arguments = getDetailMessageArguments(messageSource, locale); String detail = messageSource.getMessage(getDetailMessageCode(), arguments, null, locale); if (detail != null) { @@ -132,6 +148,17 @@ public interface ErrorResponse { } + /** + * Build a message code for the "type" field, for the given exception type. + * @param exceptionType the exception type associated with the problem + * @return {@code "problemDetail.type."} followed by the fully qualified + * {@link Class#getName() class name} + * @since 6.1 + */ + static String getDefaultTypeMessageCode(Class exceptionType) { + return "problemDetail.type." + exceptionType.getName(); + } + /** * Build a message code for the "detail" field, for the given exception type. * @param exceptionType the exception type associated with the problem diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandlerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandlerTests.java index 9394443518f..bb5ef4fb962 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandlerTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityExceptionHandlerTests.java @@ -18,6 +18,7 @@ package org.springframework.web.reactive.result.method.annotation; import java.lang.reflect.Method; +import java.net.URI; import java.util.Collections; import java.util.List; import java.util.Locale; @@ -130,29 +131,33 @@ public class ResponseEntityExceptionHandlerTests { Locale locale = Locale.UK; LocaleContextHolder.setLocale(locale); + String type = "https://example.com/probs/unsupported-content"; + String title = "Media type is not valid or not supported"; + StaticMessageSource messageSource = new StaticMessageSource(); messageSource.addMessage( ErrorResponse.getDefaultDetailMessageCode(UnsupportedMediaTypeStatusException.class, null), locale, "Content-Type {0} not supported. Supported: {1}"); messageSource.addMessage( - ErrorResponse.getDefaultTitleMessageCode(UnsupportedMediaTypeStatusException.class), locale, - "Media type is not valid or not supported"); + ErrorResponse.getDefaultTitleMessageCode(UnsupportedMediaTypeStatusException.class), locale, title); + messageSource.addMessage( + ErrorResponse.getDefaultTypeMessageCode(UnsupportedMediaTypeStatusException.class), locale, type); this.exceptionHandler.setMessageSource(messageSource); Exception ex = new UnsupportedMediaTypeStatusException(MediaType.APPLICATION_JSON, List.of(MediaType.APPLICATION_ATOM_XML, MediaType.APPLICATION_XML)); - MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/") - .acceptLanguageAsLocales(locale).build()); + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.get("/").acceptLanguageAsLocales(locale).build()); ResponseEntity responseEntity = this.exceptionHandler.handleException(ex, exchange).block(); ProblemDetail body = (ProblemDetail) responseEntity.getBody(); assertThat(body.getDetail()).isEqualTo( "Content-Type application/json not supported. Supported: [application/atom+xml, application/xml]"); - assertThat(body.getTitle()).isEqualTo( - "Media type is not valid or not supported"); + assertThat(body.getTitle()).isEqualTo(title); + assertThat(body.getType()).isEqualTo(URI.create(type)); } @Test diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.java index 12529ef528a..daaf53e6336 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.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. @@ -17,6 +17,7 @@ package org.springframework.web.servlet.mvc.method.annotation; import java.beans.PropertyChangeEvent; +import java.net.URI; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -164,14 +165,18 @@ public class ResponseEntityExceptionHandlerTests { Locale locale = Locale.UK; LocaleContextHolder.setLocale(locale); + String type = "https://example.com/probs/unsupported-content"; + String title = "Media type is not valid or not supported"; + try { StaticMessageSource messageSource = new StaticMessageSource(); messageSource.addMessage( ErrorResponse.getDefaultDetailMessageCode(HttpMediaTypeNotSupportedException.class, null), locale, "Content-Type {0} not supported. Supported: {1}"); messageSource.addMessage( - ErrorResponse.getDefaultTitleMessageCode(HttpMediaTypeNotSupportedException.class), locale, - "Media type is not valid or not supported"); + ErrorResponse.getDefaultTitleMessageCode(HttpMediaTypeNotSupportedException.class), locale, title); + messageSource.addMessage( + ErrorResponse.getDefaultTypeMessageCode(HttpMediaTypeNotSupportedException.class), locale, type); this.exceptionHandler.setMessageSource(messageSource); @@ -181,8 +186,8 @@ public class ResponseEntityExceptionHandlerTests { ProblemDetail body = (ProblemDetail) entity.getBody(); assertThat(body.getDetail()).isEqualTo( "Content-Type application/json not supported. Supported: [application/atom+xml, application/xml]"); - assertThat(body.getTitle()).isEqualTo( - "Media type is not valid or not supported"); + assertThat(body.getTitle()).isEqualTo(title); + assertThat(body.getType()).isEqualTo(URI.create(type)); } finally { LocaleContextHolder.resetLocaleContext();