From 1ef6c6398a7abf45cbfc996c5c7c021328ac69a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Mon, 21 Jul 2025 11:15:18 +0200 Subject: [PATCH 001/591] Upgrade to Jackson 3.0.0-rc6 and 2.19.2 Closes gh-35228 --- framework-platform/framework-platform.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index 83a2031f5c64..fde721cb02f2 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -7,7 +7,7 @@ javaPlatform { } dependencies { - api(platform("com.fasterxml.jackson:jackson-bom:2.18.4")) + api(platform("com.fasterxml.jackson:jackson-bom:2.19.2")) api(platform("io.micrometer:micrometer-bom:1.16.0-M1")) api(platform("io.netty:netty-bom:4.2.3.Final")) api(platform("io.projectreactor:reactor-bom:2025.0.0-M5")) @@ -21,7 +21,7 @@ dependencies { api(platform("org.jetbrains.kotlinx:kotlinx-serialization-bom:1.9.0")) api(platform("org.junit:junit-bom:5.13.3")) api(platform("org.mockito:mockito-bom:5.18.0")) - api(platform("tools.jackson:jackson-bom:3.0.0-rc5")) + api(platform("tools.jackson:jackson-bom:3.0.0-rc6")) constraints { api("com.fasterxml:aalto-xml:1.3.2") From 445da246317086742512e511ca65fef265f7af87 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:17:38 +0300 Subject: [PATCH 002/591] Upgrade to JUnit 5.13.4 Closes gh-35229 --- build.gradle | 2 +- framework-platform/framework-platform.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 372b2d8328a4..1dae7ba3d3a9 100644 --- a/build.gradle +++ b/build.gradle @@ -97,7 +97,7 @@ configure([rootProject] + javaProjects) { project -> // TODO Uncomment link to JUnit 5 docs once we execute Gradle with Java 18+. // See https://github.com/spring-projects/spring-framework/issues/27497 // - // "https://junit.org/junit5/docs/5.13.3/api/", + // "https://junit.org/junit5/docs/5.13.4/api/", "https://www.reactive-streams.org/reactive-streams-1.0.3-javadoc/", //"https://javadoc.io/static/io.rsocket/rsocket-core/1.1.1/", "https://r2dbc.io/spec/1.0.0.RELEASE/api/", diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index ecc9e1f149b5..e83712dbedd7 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -20,7 +20,7 @@ dependencies { api(platform("org.eclipse.jetty.ee10:jetty-ee10-bom:12.0.23")) api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.1")) api(platform("org.jetbrains.kotlinx:kotlinx-serialization-bom:1.6.3")) - api(platform("org.junit:junit-bom:5.13.3")) + api(platform("org.junit:junit-bom:5.13.4")) api(platform("org.mockito:mockito-bom:5.18.0")) constraints { From 4bb191d51cec994a5618ebac14750baada304750 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Wed, 23 Jul 2025 11:19:35 +0200 Subject: [PATCH 003/591] Upgrade to Jetty 12.1.0.beta2 Closes gh-35233 --- framework-platform/framework-platform.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index b988157e60bf..a554d8325a79 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -15,7 +15,7 @@ dependencies { api(platform("org.apache.groovy:groovy-bom:4.0.27")) api(platform("org.apache.logging.log4j:log4j-bom:3.0.0-beta3")) api(platform("org.assertj:assertj-bom:3.27.3")) - api(platform("org.eclipse.jetty:jetty-bom:12.1.0.beta1")) + api(platform("org.eclipse.jetty:jetty-bom:12.1.0.beta2")) api(platform("org.eclipse.jetty.ee11:jetty-ee11-bom:12.1.0.beta1")) api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2")) api(platform("org.jetbrains.kotlinx:kotlinx-serialization-bom:1.9.0")) From 5e338ef1b873e931c14774f655963215a970f181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Tue, 22 Jul 2025 12:04:24 +0200 Subject: [PATCH 004/591] Make MessageSource locale parameter nullable Closes gh-35230 --- .../context/MessageSource.java | 6 ++--- .../support/AbstractApplicationContext.java | 6 ++--- .../support/AbstractMessageSource.java | 25 +++++++++++++------ .../support/DelegatingMessageSource.java | 20 +++++++++++---- .../context/support/MessageSourceSupport.java | 8 +++--- .../setup/StubWebApplicationContext.java | 6 ++--- .../web/util/BindErrorUtils.java | 2 +- 7 files changed, 47 insertions(+), 26 deletions(-) diff --git a/spring-context/src/main/java/org/springframework/context/MessageSource.java b/spring-context/src/main/java/org/springframework/context/MessageSource.java index 509aed07124a..df6218d3d5b4 100644 --- a/spring-context/src/main/java/org/springframework/context/MessageSource.java +++ b/spring-context/src/main/java/org/springframework/context/MessageSource.java @@ -55,7 +55,7 @@ public interface MessageSource { * @see java.text.MessageFormat */ @Nullable - String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale); + String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, @Nullable Locale locale); /** * Try to resolve the message. Treat as an error if the message can't be found. @@ -71,7 +71,7 @@ public interface MessageSource { * @see #getMessage(MessageSourceResolvable, Locale) * @see java.text.MessageFormat */ - String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException; + String getMessage(String code, @Nullable Object[] args, @Nullable Locale locale) throws NoSuchMessageException; /** * Try to resolve the message using all the attributes contained within the @@ -91,6 +91,6 @@ public interface MessageSource { * @see MessageSourceResolvable#getDefaultMessage() * @see java.text.MessageFormat */ - String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException; + String getMessage(MessageSourceResolvable resolvable, @Nullable Locale locale) throws NoSuchMessageException; } diff --git a/spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java b/spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java index 9756570a924e..ee8fd7dddadb 100644 --- a/spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java +++ b/spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java @@ -1502,17 +1502,17 @@ protected BeanFactory getInternalParentBeanFactory() { @Override @Nullable - public String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale) { + public String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, @Nullable Locale locale) { return getMessageSource().getMessage(code, args, defaultMessage, locale); } @Override - public String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException { + public String getMessage(String code, @Nullable Object[] args, @Nullable Locale locale) throws NoSuchMessageException { return getMessageSource().getMessage(code, args, locale); } @Override - public String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException { + public String getMessage(MessageSourceResolvable resolvable, @Nullable Locale locale) throws NoSuchMessageException { return getMessageSource().getMessage(resolvable, locale); } diff --git a/spring-context/src/main/java/org/springframework/context/support/AbstractMessageSource.java b/spring-context/src/main/java/org/springframework/context/support/AbstractMessageSource.java index ae42985a4ab3..9abd0e44da00 100644 --- a/spring-context/src/main/java/org/springframework/context/support/AbstractMessageSource.java +++ b/spring-context/src/main/java/org/springframework/context/support/AbstractMessageSource.java @@ -138,7 +138,7 @@ protected boolean isUseCodeAsDefaultMessage() { @Override @Nullable - public final String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale) { + public final String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, @Nullable Locale locale) { String msg = getMessageInternal(code, args, locale); if (msg != null) { return msg; @@ -150,7 +150,7 @@ public final String getMessage(String code, @Nullable Object[] args, @Nullable S } @Override - public final String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException { + public final String getMessage(String code, @Nullable Object[] args, @Nullable Locale locale) throws NoSuchMessageException { String msg = getMessageInternal(code, args, locale); if (msg != null) { return msg; @@ -159,11 +159,16 @@ public final String getMessage(String code, @Nullable Object[] args, Locale loca if (fallback != null) { return fallback; } - throw new NoSuchMessageException(code, locale); + if (locale == null ) { + throw new NoSuchMessageException(code); + } + else { + throw new NoSuchMessageException(code, locale); + } } @Override - public final String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException { + public final String getMessage(MessageSourceResolvable resolvable, @Nullable Locale locale) throws NoSuchMessageException { String[] codes = resolvable.getCodes(); if (codes != null) { for (String code : codes) { @@ -177,7 +182,13 @@ public final String getMessage(MessageSourceResolvable resolvable, Locale locale if (defaultMessage != null) { return defaultMessage; } - throw new NoSuchMessageException(!ObjectUtils.isEmpty(codes) ? codes[codes.length - 1] : "", locale); + String code = !ObjectUtils.isEmpty(codes) ? codes[codes.length - 1] : ""; + if (locale == null ) { + throw new NoSuchMessageException(code); + } + else { + throw new NoSuchMessageException(code, locale); + } } @@ -284,7 +295,7 @@ protected String getMessageFromParent(String code, @Nullable Object[] args, Loca * @see #getDefaultMessage(String) */ @Nullable - protected String getDefaultMessage(MessageSourceResolvable resolvable, Locale locale) { + protected String getDefaultMessage(MessageSourceResolvable resolvable, @Nullable Locale locale) { String defaultMessage = resolvable.getDefaultMessage(); String[] codes = resolvable.getCodes(); if (defaultMessage != null) { @@ -331,7 +342,7 @@ protected String getDefaultMessage(String code) { * @return an array of arguments with any MessageSourceResolvables resolved */ @Override - protected Object[] resolveArguments(@Nullable Object[] args, Locale locale) { + protected Object[] resolveArguments(@Nullable Object[] args, @Nullable Locale locale) { if (ObjectUtils.isEmpty(args)) { return super.resolveArguments(args, locale); } diff --git a/spring-context/src/main/java/org/springframework/context/support/DelegatingMessageSource.java b/spring-context/src/main/java/org/springframework/context/support/DelegatingMessageSource.java index 6d15a713e0ab..952990a9e3f8 100644 --- a/spring-context/src/main/java/org/springframework/context/support/DelegatingMessageSource.java +++ b/spring-context/src/main/java/org/springframework/context/support/DelegatingMessageSource.java @@ -55,7 +55,7 @@ public MessageSource getParentMessageSource() { @Override @Nullable - public String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale) { + public String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, @Nullable Locale locale) { if (this.parentMessageSource != null) { return this.parentMessageSource.getMessage(code, args, defaultMessage, locale); } @@ -68,17 +68,22 @@ else if (defaultMessage != null) { } @Override - public String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException { + public String getMessage(String code, @Nullable Object[] args, @Nullable Locale locale) throws NoSuchMessageException { if (this.parentMessageSource != null) { return this.parentMessageSource.getMessage(code, args, locale); } else { - throw new NoSuchMessageException(code, locale); + if (locale == null) { + throw new NoSuchMessageException(code); + } + else { + throw new NoSuchMessageException(code, locale); + } } } @Override - public String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException { + public String getMessage(MessageSourceResolvable resolvable, @Nullable Locale locale) throws NoSuchMessageException { if (this.parentMessageSource != null) { return this.parentMessageSource.getMessage(resolvable, locale); } @@ -88,7 +93,12 @@ public String getMessage(MessageSourceResolvable resolvable, Locale locale) thro } String[] codes = resolvable.getCodes(); String code = (codes != null && codes.length > 0 ? codes[0] : ""); - throw new NoSuchMessageException(code, locale); + if (locale == null) { + throw new NoSuchMessageException(code); + } + else { + throw new NoSuchMessageException(code, locale); + } } } diff --git a/spring-context/src/main/java/org/springframework/context/support/MessageSourceSupport.java b/spring-context/src/main/java/org/springframework/context/support/MessageSourceSupport.java index 6f1bba4f6fb1..5ce2c329feae 100644 --- a/spring-context/src/main/java/org/springframework/context/support/MessageSourceSupport.java +++ b/spring-context/src/main/java/org/springframework/context/support/MessageSourceSupport.java @@ -98,7 +98,7 @@ protected boolean isAlwaysUseMessageFormat() { * @return the rendered default message (with resolved arguments) * @see #formatMessage(String, Object[], java.util.Locale) */ - protected String renderDefaultMessage(String defaultMessage, @Nullable Object[] args, Locale locale) { + protected String renderDefaultMessage(String defaultMessage, @Nullable Object[] args, @Nullable Locale locale) { return formatMessage(defaultMessage, args, locale); } @@ -112,7 +112,7 @@ protected String renderDefaultMessage(String defaultMessage, @Nullable Object[] * @param locale the Locale used for formatting * @return the formatted message (with resolved arguments) */ - protected String formatMessage(String msg, @Nullable Object[] args, Locale locale) { + protected String formatMessage(String msg, @Nullable Object[] args, @Nullable Locale locale) { if (!isAlwaysUseMessageFormat() && ObjectUtils.isEmpty(args)) { return msg; } @@ -146,7 +146,7 @@ protected String formatMessage(String msg, @Nullable Object[] args, Locale local * @param locale the Locale to create a {@code MessageFormat} for * @return the {@code MessageFormat} instance */ - protected MessageFormat createMessageFormat(String msg, Locale locale) { + protected MessageFormat createMessageFormat(String msg, @Nullable Locale locale) { return new MessageFormat(msg, locale); } @@ -158,7 +158,7 @@ protected MessageFormat createMessageFormat(String msg, Locale locale) { * @param locale the Locale to resolve against * @return the resolved argument array */ - protected Object[] resolveArguments(@Nullable Object[] args, Locale locale) { + protected Object[] resolveArguments(@Nullable Object[] args, @Nullable Locale locale) { return (args != null ? args : new Object[0]); } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/setup/StubWebApplicationContext.java b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/StubWebApplicationContext.java index e1bdca74cd94..f3661f093aa0 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/setup/StubWebApplicationContext.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/StubWebApplicationContext.java @@ -357,17 +357,17 @@ public boolean containsLocalBean(String name) { @Override @Nullable - public String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale) { + public String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, @Nullable Locale locale) { return this.messageSource.getMessage(code, args, defaultMessage, locale); } @Override - public String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException { + public String getMessage(String code, @Nullable Object[] args, @Nullable Locale locale) throws NoSuchMessageException { return this.messageSource.getMessage(code, args, locale); } @Override - public String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException { + public String getMessage(MessageSourceResolvable resolvable, @Nullable Locale locale) throws NoSuchMessageException { return this.messageSource.getMessage(resolvable, locale); } diff --git a/spring-web/src/main/java/org/springframework/web/util/BindErrorUtils.java b/spring-web/src/main/java/org/springframework/web/util/BindErrorUtils.java index b8722a2e1f3e..aca6d7c63583 100644 --- a/spring-web/src/main/java/org/springframework/web/util/BindErrorUtils.java +++ b/spring-web/src/main/java/org/springframework/web/util/BindErrorUtils.java @@ -116,7 +116,7 @@ private static class MethodArgumentErrorMessageSource extends StaticMessageSourc @Override @Nullable - protected String getDefaultMessage(MessageSourceResolvable resolvable, Locale locale) { + protected String getDefaultMessage(MessageSourceResolvable resolvable, @Nullable Locale locale) { String message = super.getDefaultMessage(resolvable, locale); return (resolvable instanceof FieldError error ? error.getField() + ": " + message : message); } From d06255214eee2de8e62812ffe7ee206e842c80ec Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Fri, 25 Jul 2025 12:34:40 +0200 Subject: [PATCH 005/591] Support wildcard path elements at the start of path patterns Prior to this commit, the `PathPattern` and `PathPatternParser` would allow multiple-segments matching and capturing with the following: * "/files/**" (matching 0-N segments until the end) * "/files/{*path}" (matching 0-N segments until the end and capturing the value as the "path" variable) This would be only allowed as the last path element in the pattern and the parser would reject other combinations. This commit expands the support and allows multiple segments matching at the beginning of the path: * "/**/index.html" (matching 0-N segments from the start) * "/{*path}/index.html" (matching 0-N segments until the end and capturing the value as the "path" variable) This does come with additional restrictions: 1. "/files/**/file.txt" and "/files/{*path}/file.txt" are invalid, as multiple segment matching is not allowed in the middle of the pattern. 2. "/{*path}/files/**" is not allowed, as a single "{*path}" or "/**" element is allowed in a pattern 3. "/{*path}/{folder}/file.txt" "/**/{folder:[a-z]+}/file.txt" are invalid because only a literal pattern is allowed right after multiple segments path elements. Closes gh-35213 --- .../controller/ann-requestmapping.adoc | 28 +- .../mvc-controller/ann-requestmapping.adoc | 92 ++- ...t.java => CaptureSegmentsPathElement.java} | 59 +- .../pattern/InternalPathPatternParser.java | 94 ++- .../web/util/pattern/PathElement.java | 2 +- .../web/util/pattern/PathPattern.java | 6 +- .../util/pattern/PatternParseException.java | 7 +- ....java => WildcardSegmentsPathElement.java} | 32 +- .../util/pattern/PathPatternParserTests.java | 580 +++++++++-------- .../web/util/pattern/PathPatternTests.java | 612 ++++++++++-------- 10 files changed, 868 insertions(+), 644 deletions(-) rename spring-web/src/main/java/org/springframework/web/util/pattern/{CaptureTheRestPathElement.java => CaptureSegmentsPathElement.java} (59%) rename spring-web/src/main/java/org/springframework/web/util/pattern/{WildcardTheRestPathElement.java => WildcardSegmentsPathElement.java} (51%) diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc index 5b4e756899ad..67874085f08b 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc @@ -93,6 +93,10 @@ You can map requests by using glob patterns and wildcards: |=== |Pattern |Description |Example +| `spring` +| Literal pattern +| `+"/spring"+` matches `+"/spring"+` + | `+?+` | Matches one character | `+"/pages/t?st.html"+` matches `+"/pages/test.html"+` and `+"/pages/t3st.html"+` @@ -104,23 +108,41 @@ You can map requests by using glob patterns and wildcards: `+"/projects/*/versions"+` matches `+"/projects/spring/versions"+` but does not match `+"/projects/spring/boot/versions"+` | `+**+` -| Matches zero or more path segments until the end of the path +| Matches zero or more path segments | `+"/resources/**"+` matches `+"/resources/file.png"+` and `+"/resources/images/file.png"+` -`+"/resources/**/file.png"+` is invalid as `+**+` is only allowed at the end of the path. +`+"/**/resources"+` matches `+"/spring/resources"+` and `+"/spring/framework/resources"+` + +`+"/resources/**/file.png"+` is invalid as `+**+` is not allowed in the middle of the path. + +`+"/**/{name}/resources"+` is invalid as only a literal pattern is allowed right after `+**+`. +`+"/**/project/{project}/resources"+` is allowed. + +`+"/**/spring/**"+` is not allowed, as only a single `+**+`/`+{*path}+` instance is allowed per pattern. | `+{name}+` | Matches a path segment and captures it as a variable named "name" | `+"/projects/{project}/versions"+` matches `+"/projects/spring/versions"+` and captures `+project=spring+` +`+"/projects/{project}/versions"+` does not match `+"/projects/spring/framework/versions"+` as it captures a single path segment. + | `+{name:[a-z]+}+` | Matches the regexp `+"[a-z]+"+` as a path variable named "name" | `+"/projects/{project:[a-z]+}/versions"+` matches `+"/projects/spring/versions"+` but not `+"/projects/spring1/versions"+` | `+{*path}+` -| Matches zero or more path segments until the end of the path and captures it as a variable named "path" +| Matches zero or more path segments and captures it as a variable named "path" | `+"/resources/{*file}"+` matches `+"/resources/images/file.png"+` and captures `+file=/images/file.png+` +`+"{*path}/resources"+` matches `+"/spring/framework/resources"+` and captures `+path=/spring/framework+` + +`+"/resources/{*path}/file.png"+` is invalid as `{*path}` is not allowed in the middle of the path. + +`+"/{*path}/{name}/resources"+` is invalid as only a literal pattern is allowed right after `{*path}`. +`+"/{*path}/project/{project}/resources"+` is allowed. + +`+"/{*path}/spring/**"+` is not allowed, as only a single `+**+`/`+{*path}+` instance is allowed per pattern. + |=== Captured URI variables can be accessed with `@PathVariable`, as the following example shows: diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc index 94ff1b4f420b..cd15384c34e8 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc @@ -88,37 +88,71 @@ Kotlin:: == URI patterns [.small]#xref:web/webflux/controller/ann-requestmapping.adoc#webflux-ann-requestmapping-uri-templates[See equivalent in the Reactive stack]# -`@RequestMapping` methods can be mapped using URL patterns. There are two alternatives: - -* `PathPattern` -- a pre-parsed pattern matched against the URL path also pre-parsed as -`PathContainer`. Designed for web use, this solution deals effectively with encoding and -path parameters, and matches efficiently. -* `AntPathMatcher` -- match String patterns against a String path. This is the original -solution also used in Spring configuration to select resources on the classpath, on the -filesystem, and other locations. It is less efficient and the String path input is a +`@RequestMapping` methods can be mapped using URL patterns. +Spring MVC is using `PathPattern` -- a pre-parsed pattern matched against the URL path also pre-parsed as `PathContainer`. +Designed for web use, this solution deals effectively with encoding and path parameters, and matches efficiently. +See xref:web/webmvc/mvc-config/path-matching.adoc[MVC config] for customizations of path matching options. + +NOTE: the `AntPathMatcher` variant is now deprecated because it is less efficient and the String path input is a challenge for dealing effectively with encoding and other issues with URLs. -`PathPattern` is the recommended solution for web applications and it is the only choice in -Spring WebFlux. It was enabled for use in Spring MVC from version 5.3 and is enabled by -default from version 6.0. See xref:web/webmvc/mvc-config/path-matching.adoc[MVC config] for -customizations of path matching options. - -`PathPattern` supports the same pattern syntax as `AntPathMatcher`. In addition, it also -supports the capturing pattern, for example, `+{*spring}+`, for matching 0 or more path segments -at the end of a path. `PathPattern` also restricts the use of `+**+` for matching multiple -path segments such that it's only allowed at the end of a pattern. This eliminates many -cases of ambiguity when choosing the best matching pattern for a given request. -For full pattern syntax please refer to -{spring-framework-api}/web/util/pattern/PathPattern.html[PathPattern] and -{spring-framework-api}/util/AntPathMatcher.html[AntPathMatcher]. - -Some example patterns: - -* `+"/resources/ima?e.png"+` - match one character in a path segment -* `+"/resources/*.png"+` - match zero or more characters in a path segment -* `+"/resources/**"+` - match multiple path segments -* `+"/projects/{project}/versions"+` - match a path segment and capture it as a variable -* `++"/projects/{project:[a-z]+}/versions"++` - match and capture a variable with a regex +You can map requests by using glob patterns and wildcards: + +[cols="2,3,5"] +|=== +|Pattern |Description |Example + +| `spring` +| Literal pattern +| `+"/spring"+` matches `+"/spring"+` + +| `+?+` +| Matches one character +| `+"/pages/t?st.html"+` matches `+"/pages/test.html"+` and `+"/pages/t3st.html"+` + +| `+*+` +| Matches zero or more characters within a path segment +| `+"/resources/*.png"+` matches `+"/resources/file.png"+` + +`+"/projects/*/versions"+` matches `+"/projects/spring/versions"+` but does not match `+"/projects/spring/boot/versions"+` + +| `+**+` +| Matches zero or more path segments +| `+"/resources/**"+` matches `+"/resources/file.png"+` and `+"/resources/images/file.png"+` + +`+"/**/resources"+` matches `+"/spring/resources"+` and `+"/spring/framework/resources"+` + +`+"/resources/**/file.png"+` is invalid as `+**+` is not allowed in the middle of the path. + +`+"/**/{name}/resources"+` is invalid as only a literal pattern is allowed right after `+**+`. +`+"/**/project/{project}/resources"+` is allowed. + +`+"/**/spring/**"+` is not allowed, as only a single `+**+`/`+{*path}+` instance is allowed per pattern. + +| `+{name}+` +| Matches a path segment and captures it as a variable named "name" +| `+"/projects/{project}/versions"+` matches `+"/projects/spring/versions"+` and captures `+project=spring+` + +`+"/projects/{project}/versions"+` does not match `+"/projects/spring/framework/versions"+` as it captures a single path segment. + +| `+{name:[a-z]+}+` +| Matches the regexp `+"[a-z]+"+` as a path variable named "name" +| `+"/projects/{project:[a-z]+}/versions"+` matches `+"/projects/spring/versions"+` but not `+"/projects/spring1/versions"+` + +| `+{*path}+` +| Matches zero or more path segments and captures it as a variable named "path" +| `+"/resources/{*file}"+` matches `+"/resources/images/file.png"+` and captures `+file=/images/file.png+` + +`+"{*path}/resources"+` matches `+"/spring/framework/resources"+` and captures `+path=/spring/framework+` + +`+"/resources/{*path}/file.png"+` is invalid as `{*path}` is not allowed in the middle of the path. + +`+"/{*path}/{name}/resources"+` is invalid as only a literal pattern is allowed right after `{*path}`. +`+"/{*path}/project/{project}/resources"+` is allowed. + +`+"/{*path}/spring/**"+` is not allowed, as only a single `+**+`/`+{*path}+` instance is allowed per pattern. + +|=== Captured URI variables can be accessed with `@PathVariable`. For example: diff --git a/spring-web/src/main/java/org/springframework/web/util/pattern/CaptureTheRestPathElement.java b/spring-web/src/main/java/org/springframework/web/util/pattern/CaptureSegmentsPathElement.java similarity index 59% rename from spring-web/src/main/java/org/springframework/web/util/pattern/CaptureTheRestPathElement.java rename to spring-web/src/main/java/org/springframework/web/util/pattern/CaptureSegmentsPathElement.java index 11cefc0c2491..7133cebd8e76 100644 --- a/spring-web/src/main/java/org/springframework/web/util/pattern/CaptureTheRestPathElement.java +++ b/spring-web/src/main/java/org/springframework/web/util/pattern/CaptureSegmentsPathElement.java @@ -25,24 +25,31 @@ import org.springframework.web.util.pattern.PathPattern.MatchingContext; /** - * A path element representing capturing the rest of a path. In the pattern - * '/foo/{*foobar}' the /{*foobar} is represented as a {@link CaptureTheRestPathElement}. + * A path element that captures multiple path segments. + * This element is only allowed in two situations: + *
    + *
  1. At the start of a path, immediately followed by a {@link LiteralPathElement} like '/{*foobar}/foo/{bar}' + *
  2. At the end of a path, like '/foo/{*foobar}' + *
+ *

Only a single {@link WildcardSegmentsPathElement} or {@link CaptureSegmentsPathElement} element is allowed + * * in a pattern. In the pattern '/foo/{*foobar}' the /{*foobar} is represented as a {@link CaptureSegmentsPathElement}. * * @author Andy Clement + * @author Brian Clozel * @since 5.0 */ -class CaptureTheRestPathElement extends PathElement { +class CaptureSegmentsPathElement extends PathElement { private final String variableName; /** - * Create a new {@link CaptureTheRestPathElement} instance. + * Create a new {@link CaptureSegmentsPathElement} instance. * @param pos position of the path element within the path pattern text * @param captureDescriptor a character array containing contents like '{' '*' 'a' 'b' '}' * @param separator the separator used in the path pattern */ - CaptureTheRestPathElement(int pos, char[] captureDescriptor, char separator) { + CaptureSegmentsPathElement(int pos, char[] captureDescriptor, char separator) { super(pos, separator); this.variableName = new String(captureDescriptor, 2, captureDescriptor.length - 3); } @@ -50,41 +57,53 @@ class CaptureTheRestPathElement extends PathElement { @Override public boolean matches(int pathIndex, MatchingContext matchingContext) { - // No need to handle 'match start' checking as this captures everything - // anyway and cannot be followed by anything else - // assert next == null - - // If there is more data, it must start with the separator - if (pathIndex < matchingContext.pathLength && !matchingContext.isSeparator(pathIndex)) { + // wildcard segments at the start of the pattern + if (pathIndex == 0 && this.next != null) { + int endPathIndex = pathIndex; + while (endPathIndex < matchingContext.pathLength) { + if (this.next.matches(endPathIndex, matchingContext)) { + collectParameters(matchingContext, pathIndex, endPathIndex); + return true; + } + endPathIndex++; + } + return false; + } + // match until the end of the path + else if (pathIndex < matchingContext.pathLength && !matchingContext.isSeparator(pathIndex)) { return false; } if (matchingContext.determineRemainingPath) { matchingContext.remainingPathIndex = matchingContext.pathLength; } + collectParameters(matchingContext, pathIndex, matchingContext.pathLength); + return true; + } + + private void collectParameters(MatchingContext matchingContext, int pathIndex, int endPathIndex) { if (matchingContext.extractingVariables) { // Collect the parameters from all the remaining segments - MultiValueMap parametersCollector = null; - for (int i = pathIndex; i < matchingContext.pathLength; i++) { + MultiValueMap parametersCollector = NO_PARAMETERS; + for (int i = pathIndex; i < endPathIndex; i++) { Element element = matchingContext.pathElements.get(i); if (element instanceof PathSegment pathSegment) { MultiValueMap parameters = pathSegment.parameters(); if (!parameters.isEmpty()) { - if (parametersCollector == null) { + if (parametersCollector == NO_PARAMETERS) { parametersCollector = new LinkedMultiValueMap<>(); } parametersCollector.addAll(parameters); } } } - matchingContext.set(this.variableName, pathToString(pathIndex, matchingContext.pathElements), - parametersCollector == null?NO_PARAMETERS:parametersCollector); + matchingContext.set(this.variableName, pathToString(pathIndex, endPathIndex, matchingContext.pathElements), + parametersCollector); } - return true; } - private String pathToString(int fromSegment, List pathElements) { + private String pathToString(int fromSegment, int toSegment, List pathElements) { StringBuilder sb = new StringBuilder(); - for (int i = fromSegment, max = pathElements.size(); i < max; i++) { + for (int i = fromSegment, max = toSegment; i < max; i++) { Element element = pathElements.get(i); if (element instanceof PathSegment pathSegment) { sb.append(pathSegment.valueToMatch()); @@ -119,7 +138,7 @@ public int getCaptureCount() { @Override public String toString() { - return "CaptureTheRest(/{*" + this.variableName + "})"; + return "CaptureSegments(/{*" + this.variableName + "})"; } } diff --git a/spring-web/src/main/java/org/springframework/web/util/pattern/InternalPathPatternParser.java b/spring-web/src/main/java/org/springframework/web/util/pattern/InternalPathPatternParser.java index 0f9579a0289e..7011545ed3fa 100644 --- a/spring-web/src/main/java/org/springframework/web/util/pattern/InternalPathPatternParser.java +++ b/spring-web/src/main/java/org/springframework/web/util/pattern/InternalPathPatternParser.java @@ -30,6 +30,7 @@ * {@link PathElement PathElements} in a linked list. Instances are reusable but are not thread-safe. * * @author Andy Clement + * @author Brian Clozel * @since 5.0 */ class InternalPathPatternParser { @@ -52,7 +53,7 @@ class InternalPathPatternParser { private boolean wildcard = false; // Is the construct {*...} being used in a particular path element - private boolean isCaptureTheRestVariable = false; + private boolean isCaptureSegmentsVariable = false; // Has the parser entered a {...} variable capture block in a particular // path element @@ -67,6 +68,9 @@ class InternalPathPatternParser { // Start of the most recent variable capture in a particular path element private int variableCaptureStart; + // Did we parse a WildcardSegments(**) or CaptureSegments({*foo}) PathElement already? + private boolean hasMultipleSegmentsElement = false; + // Variables captures in this path pattern private @Nullable List capturedVariableNames; @@ -108,13 +112,7 @@ public PathPattern parse(String pathPattern) throws PatternParseException { if (this.pathElementStart != -1) { pushPathElement(createPathElement()); } - if (peekDoubleWildcard()) { - pushPathElement(new WildcardTheRestPathElement(this.pos, separator)); - this.pos += 2; - } - else { - pushPathElement(new SeparatorPathElement(this.pos, separator)); - } + pushPathElement(new SeparatorPathElement(this.pos, separator)); } else { if (this.pathElementStart == -1) { @@ -142,35 +140,37 @@ else if (ch == '}') { PatternMessage.MISSING_OPEN_CAPTURE); } this.insideVariableCapture = false; - if (this.isCaptureTheRestVariable && (this.pos + 1) < this.pathPatternLength) { - throw new PatternParseException(this.pos + 1, this.pathPatternData, - PatternMessage.NO_MORE_DATA_EXPECTED_AFTER_CAPTURE_THE_REST); - } this.variableCaptureCount++; } else if (ch == ':') { - if (this.insideVariableCapture && !this.isCaptureTheRestVariable) { + if (this.insideVariableCapture && !this.isCaptureSegmentsVariable) { skipCaptureRegex(); this.insideVariableCapture = false; this.variableCaptureCount++; } } + else if (isDoubleWildcard(separator)) { + checkValidMultipleSegmentsElements(this.pos, this.pos + 1); + pushPathElement(new WildcardSegmentsPathElement(this.pos, separator)); + this.hasMultipleSegmentsElement = true; + this.pos++; + } else if (ch == '*') { if (this.insideVariableCapture && this.variableCaptureStart == this.pos - 1) { - this.isCaptureTheRestVariable = true; + this.isCaptureSegmentsVariable = true; } this.wildcard = true; } // Check that the characters used for captured variable names are like java identifiers if (this.insideVariableCapture) { - if ((this.variableCaptureStart + 1 + (this.isCaptureTheRestVariable ? 1 : 0)) == this.pos && + if ((this.variableCaptureStart + 1 + (this.isCaptureSegmentsVariable ? 1 : 0)) == this.pos && !Character.isJavaIdentifierStart(ch)) { throw new PatternParseException(this.pos, this.pathPatternData, PatternMessage.ILLEGAL_CHARACTER_AT_START_OF_CAPTURE_DESCRIPTOR, Character.toString(ch)); } - else if ((this.pos > (this.variableCaptureStart + 1 + (this.isCaptureTheRestVariable ? 1 : 0)) && + else if ((this.pos > (this.variableCaptureStart + 1 + (this.isCaptureSegmentsVariable ? 1 : 0)) && !Character.isJavaIdentifierPart(ch) && ch != '-')) { throw new PatternParseException(this.pos, this.pathPatternData, PatternMessage.ILLEGAL_CHARACTER_IN_CAPTURE_DESCRIPTOR, @@ -183,6 +183,7 @@ else if ((this.pos > (this.variableCaptureStart + 1 + (this.isCaptureTheRestVari if (this.pathElementStart != -1) { pushPathElement(createPathElement()); } + verifyPatternElements(this.headPE); return new PathPattern(pathPattern, this.parser, this.headPE); } @@ -232,23 +233,28 @@ else if (ch == '}' && !previousBackslash) { PatternMessage.MISSING_CLOSE_CAPTURE); } - /** - * After processing a separator, a quick peek whether it is followed by - * a double wildcard (and only as the last path element). - */ - private boolean peekDoubleWildcard() { - if ((this.pos + 2) >= this.pathPatternLength) { + private boolean isDoubleWildcard(char separator) { + if ((this.pos + 1) >= this.pathPatternLength) { return false; } - if (this.pathPatternData[this.pos + 1] != '*' || this.pathPatternData[this.pos + 2] != '*') { + if (this.pathPatternData[this.pos] != '*' || this.pathPatternData[this.pos + 1] != '*') { return false; } - char separator = this.parser.getPathOptions().separator(); - if ((this.pos + 3) < this.pathPatternLength && this.pathPatternData[this.pos + 3] == separator) { + if ((this.pos + 2) < this.pathPatternLength) { + return this.pathPatternData[this.pos + 2] == separator; + } + return true; + } + + private void checkValidMultipleSegmentsElements(int startPosition, int endPosition) { + if (this.hasMultipleSegmentsElement) { throw new PatternParseException(this.pos, this.pathPatternData, - PatternMessage.NO_MORE_DATA_EXPECTED_AFTER_CAPTURE_THE_REST); + PatternMessage.CANNOT_HAVE_MANY_MULTISEGMENT_PATHELEMENTS); + } + if (startPosition > 1 && endPosition != this.pathPatternLength - 1) { + throw new PatternParseException(this.pos, this.pathPatternData, + PatternMessage.INVALID_LOCATION_FOR_MULTISEGMENT_PATHELEMENT); } - return (this.pos + 3 == this.pathPatternLength); } /** @@ -256,7 +262,8 @@ private boolean peekDoubleWildcard() { * @param newPathElement the new path element to add */ private void pushPathElement(PathElement newPathElement) { - if (newPathElement instanceof CaptureTheRestPathElement) { + if (newPathElement instanceof CaptureSegmentsPathElement || + newPathElement instanceof WildcardSegmentsPathElement) { // There must be a separator ahead of this thing // currentPE SHOULD be a SeparatorPathElement if (this.currentPE == null) { @@ -277,7 +284,8 @@ else if (this.currentPE instanceof SeparatorPathElement) { this.currentPE = newPathElement; } else { - throw new IllegalStateException("Expected SeparatorPathElement but was " + this.currentPE); + throw new IllegalStateException("Expected SeparatorPathElement before " + + newPathElement.getClass().getName() +" but was " + this.currentPE); } } else { @@ -318,9 +326,11 @@ private PathElement createPathElement() { if (this.variableCaptureCount > 0) { if (this.variableCaptureCount == 1 && this.pathElementStart == this.variableCaptureStart && this.pathPatternData[this.pos - 1] == '}') { - if (this.isCaptureTheRestVariable) { + if (this.isCaptureSegmentsVariable) { // It is {*....} - newPE = new CaptureTheRestPathElement( + checkValidMultipleSegmentsElements(this.pathElementStart, this.pos -1); + this.hasMultipleSegmentsElement = true; + newPE = new CaptureSegmentsPathElement( this.pathElementStart, getPathElementText(), separator); } else { @@ -339,7 +349,7 @@ private PathElement createPathElement() { } } else { - if (this.isCaptureTheRestVariable) { + if (this.isCaptureSegmentsVariable) { throw new PatternParseException(this.pathElementStart, this.pathPatternData, PatternMessage.CAPTURE_ALL_IS_STANDALONE_CONSTRUCT); } @@ -403,7 +413,7 @@ private void resetPathElementState() { this.insideVariableCapture = false; this.variableCaptureCount = 0; this.wildcard = false; - this.isCaptureTheRestVariable = false; + this.isCaptureSegmentsVariable = false; this.variableCaptureStart = -1; } @@ -421,4 +431,22 @@ private void recordCapturedVariable(int pos, String variableName) { this.capturedVariableNames.add(variableName); } + private void verifyPatternElements(@Nullable PathElement headPE) { + PathElement currentElement = headPE; + while (currentElement != null) { + if (currentElement instanceof CaptureSegmentsPathElement || + currentElement instanceof WildcardSegmentsPathElement) { + PathElement nextElement = currentElement.next; + while (nextElement instanceof SeparatorPathElement) { + nextElement = nextElement.next; + } + if (nextElement != null && !(nextElement instanceof LiteralPathElement)) { + throw new PatternParseException(nextElement.pos, this.pathPatternData, + PatternMessage.MULTISEGMENT_PATHELEMENT_NOT_FOLLOWED_BY_LITERAL); + } + } + currentElement = currentElement.next; + } + } + } diff --git a/spring-web/src/main/java/org/springframework/web/util/pattern/PathElement.java b/spring-web/src/main/java/org/springframework/web/util/pattern/PathElement.java index 2cc9c24ff5a4..a154cd59628a 100644 --- a/spring-web/src/main/java/org/springframework/web/util/pattern/PathElement.java +++ b/spring-web/src/main/java/org/springframework/web/util/pattern/PathElement.java @@ -108,7 +108,7 @@ public boolean isLiteral() { } /** - * Return if the there are no more PathElements in the pattern. + * Return if there are no more PathElements in the pattern. * @return {@code true} if the there are no more elements */ protected final boolean isNoMorePattern() { diff --git a/spring-web/src/main/java/org/springframework/web/util/pattern/PathPattern.java b/spring-web/src/main/java/org/springframework/web/util/pattern/PathPattern.java index e3aa9d28ee17..ddd8ce9fc8a1 100644 --- a/spring-web/src/main/java/org/springframework/web/util/pattern/PathPattern.java +++ b/spring-web/src/main/java/org/springframework/web/util/pattern/PathPattern.java @@ -162,7 +162,7 @@ public class PathPattern implements Comparable { this.capturedVariableCount += elem.getCaptureCount(); this.normalizedLength += elem.getNormalizedLength(); this.score += elem.getScore(); - if (elem instanceof CaptureTheRestPathElement || elem instanceof WildcardTheRestPathElement) { + if (elem instanceof CaptureSegmentsPathElement || elem instanceof WildcardSegmentsPathElement) { this.catchAll = true; } if (elem instanceof SeparatorPathElement && elem.next instanceof WildcardPathElement && elem.next.next == null) { @@ -200,7 +200,7 @@ public boolean matches(PathContainer pathContainer) { return !hasLength(pathContainer); } else if (!hasLength(pathContainer)) { - if (this.head instanceof WildcardTheRestPathElement || this.head instanceof CaptureTheRestPathElement) { + if (this.head instanceof WildcardSegmentsPathElement || this.head instanceof CaptureSegmentsPathElement) { pathContainer = EMPTY_PATH; // Will allow CaptureTheRest to bind the variable to empty } else { @@ -222,7 +222,7 @@ else if (!hasLength(pathContainer)) { return (hasLength(pathContainer) && !pathContainerIsJustSeparator(pathContainer) ? null : PathMatchInfo.EMPTY); } else if (!hasLength(pathContainer)) { - if (this.head instanceof WildcardTheRestPathElement || this.head instanceof CaptureTheRestPathElement) { + if (this.head instanceof WildcardSegmentsPathElement || this.head instanceof CaptureSegmentsPathElement) { pathContainer = EMPTY_PATH; // Will allow CaptureTheRest to bind the variable to empty } else { diff --git a/spring-web/src/main/java/org/springframework/web/util/pattern/PatternParseException.java b/spring-web/src/main/java/org/springframework/web/util/pattern/PatternParseException.java index 9cd3583092d1..d9dee2780d47 100644 --- a/spring-web/src/main/java/org/springframework/web/util/pattern/PatternParseException.java +++ b/spring-web/src/main/java/org/springframework/web/util/pattern/PatternParseException.java @@ -22,6 +22,7 @@ * Exception that is thrown when there is a problem with the pattern being parsed. * * @author Andy Clement + * @author Brian Clozel * @since 5.0 */ @SuppressWarnings("serial") @@ -98,12 +99,14 @@ public enum PatternMessage { CANNOT_HAVE_ADJACENT_CAPTURES("Adjacent captures are not allowed"), ILLEGAL_CHARACTER_AT_START_OF_CAPTURE_DESCRIPTOR("Char ''{0}'' not allowed at start of captured variable name"), ILLEGAL_CHARACTER_IN_CAPTURE_DESCRIPTOR("Char ''{0}'' is not allowed in a captured variable name"), - NO_MORE_DATA_EXPECTED_AFTER_CAPTURE_THE_REST("No more pattern data allowed after '{*...}' or '**' pattern element"), + CANNOT_HAVE_MANY_MULTISEGMENT_PATHELEMENTS("Multiple '{*...}' or '**' pattern elements are not allowed"), + INVALID_LOCATION_FOR_MULTISEGMENT_PATHELEMENT("'{*...}' or '**' pattern elements should be placed at the start or end of the pattern"), + MULTISEGMENT_PATHELEMENT_NOT_FOLLOWED_BY_LITERAL("'{*...}' or '**' pattern elements should be followed by a literal path element"), BADLY_FORMED_CAPTURE_THE_REST("Expected form when capturing the rest of the path is simply '{*...}'"), MISSING_REGEX_CONSTRAINT("Missing regex constraint on capture"), ILLEGAL_DOUBLE_CAPTURE("Not allowed to capture ''{0}'' twice in the same pattern"), REGEX_PATTERN_SYNTAX_EXCEPTION("Exception occurred in regex pattern compilation"), - CAPTURE_ALL_IS_STANDALONE_CONSTRUCT("'{*...}' can only be preceded by a path separator"); + CAPTURE_ALL_IS_STANDALONE_CONSTRUCT("'{*...}' cannot be mixed with other path elements in the same path segment"); private final String message; diff --git a/spring-web/src/main/java/org/springframework/web/util/pattern/WildcardTheRestPathElement.java b/spring-web/src/main/java/org/springframework/web/util/pattern/WildcardSegmentsPathElement.java similarity index 51% rename from spring-web/src/main/java/org/springframework/web/util/pattern/WildcardTheRestPathElement.java rename to spring-web/src/main/java/org/springframework/web/util/pattern/WildcardSegmentsPathElement.java index 65ae88dee5ee..80c3413ed714 100644 --- a/spring-web/src/main/java/org/springframework/web/util/pattern/WildcardTheRestPathElement.java +++ b/spring-web/src/main/java/org/springframework/web/util/pattern/WildcardSegmentsPathElement.java @@ -17,23 +17,41 @@ package org.springframework.web.util.pattern; /** - * A path element representing wildcarding the rest of a path. In the pattern - * '/foo/**' the /** is represented as a {@link WildcardTheRestPathElement}. + * A path element representing wildcarding multiple segments in a path. + * This element is only allowed in two situations: + *

    + *
  1. At the start of a path, immediately followed by a {@link LiteralPathElement} like '/**/foo/{bar}' + *
  2. At the end of a path, like '/foo/**' + *
+ *

Only a single {@link WildcardSegmentsPathElement} or {@link CaptureSegmentsPathElement} element is allowed + * in a pattern. In the pattern '/foo/**' the '/**' is represented as a {@link WildcardSegmentsPathElement}. * * @author Andy Clement + * @author Brian Clozel * @since 5.0 */ -class WildcardTheRestPathElement extends PathElement { +class WildcardSegmentsPathElement extends PathElement { - WildcardTheRestPathElement(int pos, char separator) { + WildcardSegmentsPathElement(int pos, char separator) { super(pos, separator); } @Override public boolean matches(int pathIndex, PathPattern.MatchingContext matchingContext) { - // If there is more data, it must start with the separator - if (pathIndex < matchingContext.pathLength && !matchingContext.isSeparator(pathIndex)) { + // wildcard segments at the start of the pattern + if (pathIndex == 0 && this.next != null) { + int endPathIndex = pathIndex; + while (endPathIndex < matchingContext.pathLength) { + if (this.next.matches(endPathIndex, matchingContext)) { + return true; + } + endPathIndex++; + } + return false; + } + // match until the end of the path + else if (pathIndex < matchingContext.pathLength && !matchingContext.isSeparator(pathIndex)) { return false; } if (matchingContext.determineRemainingPath) { @@ -60,7 +78,7 @@ public int getWildcardCount() { @Override public String toString() { - return "WildcardTheRest(" + this.separator + "**)"; + return "WildcardSegments(" + this.separator + "**)"; } } diff --git a/spring-web/src/test/java/org/springframework/web/util/pattern/PathPatternParserTests.java b/spring-web/src/test/java/org/springframework/web/util/pattern/PathPatternParserTests.java index 4abc53cd1764..99c3db01549b 100644 --- a/spring-web/src/test/java/org/springframework/web/util/pattern/PathPatternParserTests.java +++ b/spring-web/src/test/java/org/springframework/web/util/pattern/PathPatternParserTests.java @@ -20,6 +20,7 @@ import java.util.Collections; import java.util.List; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.http.server.PathContainer; @@ -36,66 +37,303 @@ * * @author Andy Clement * @author Sam Brannen + * @author Brian Clozel */ class PathPatternParserTests { private PathPattern pathPattern; - @Test - void basicPatterns() { - checkStructure("/"); - checkStructure("/foo"); - checkStructure("foo"); - checkStructure("foo/"); - checkStructure("/foo/"); - checkStructure(""); - } + /** + * Verify that the parsed pattern matches + * the text and path elements of the original pattern. + */ + @Nested + class StructureTests { + + @Test + void literalPatterns() { + checkStructure("/"); + checkStructure("/foo"); + checkStructure("foo"); + checkStructure("foo/"); + checkStructure("/foo/"); + checkStructure(""); + } - @Test - void singleCharWildcardPatterns() { - pathPattern = checkStructure("?"); - assertPathElements(pathPattern, SingleCharWildcardedPathElement.class); - checkStructure("/?/"); - checkStructure("/?abc?/"); - } + @Test + void singleCharWildcardPatterns() { + pathPattern = checkStructure("?"); + assertPathElements(pathPattern, SingleCharWildcardedPathElement.class); + checkStructure("/?/"); + checkStructure("/?abc?/"); + } + + @Test + void wildcardSegmentsStartOfPathPatterns() { + pathPattern = checkStructure("/**/foo"); + assertPathElements(pathPattern, WildcardSegmentsPathElement.class, SeparatorPathElement.class, LiteralPathElement.class); + } + + @Test + void wildcardSegmentEndOfPathPatterns() { + pathPattern = checkStructure("/**"); + assertPathElements(pathPattern, WildcardSegmentsPathElement.class); + pathPattern = checkStructure("/foo/**"); + assertPathElements(pathPattern, SeparatorPathElement.class, LiteralPathElement.class, WildcardSegmentsPathElement.class); + + } + + @Test + void regexpSegmentIsNotWildcardSegment() { + // this is not double wildcard, it's / then **acb (an odd, unnecessary use of double *) + pathPattern = checkStructure("/**acb"); + assertPathElements(pathPattern, SeparatorPathElement.class, RegexPathElement.class); + } + + @Test + void partialCapturingPatterns() { + pathPattern = checkStructure("{foo}abc"); + assertPathElements(pathPattern, RegexPathElement.class); + checkStructure("abc{foo}"); + checkStructure("/abc{foo}"); + checkStructure("{foo}def/"); + checkStructure("/abc{foo}def/"); + checkStructure("{foo}abc{bar}"); + checkStructure("{foo}abc{bar}/"); + checkStructure("/{foo}abc{bar}/"); + } + + @Test + void completeCapturingPatterns() { + pathPattern = checkStructure("{foo}"); + assertPathElements(pathPattern, CaptureVariablePathElement.class); + checkStructure("/{foo}"); + checkStructure("/{f}/"); + checkStructure("/{foo}/{bar}/{wibble}"); + checkStructure("/{mobile-number}"); // gh-23101 + } + + @Test + void completeCaptureWithConstraints() { + pathPattern = checkStructure("{foo:...}"); + assertPathElements(pathPattern, CaptureVariablePathElement.class); + pathPattern = checkStructure("{foo:[0-9]*}"); + assertPathElements(pathPattern, CaptureVariablePathElement.class); + } + + @Test + void captureSegmentsStartOfPathPatterns() { + pathPattern = checkStructure("/{*foobar}"); + assertPathElements(pathPattern, CaptureSegmentsPathElement.class); + pathPattern = checkStructure("/{*foobar}/foo"); + assertPathElements(pathPattern, CaptureSegmentsPathElement.class, SeparatorPathElement.class, LiteralPathElement.class); + } + + @Test + void captureSegmentsEndOfPathPatterns() { + pathPattern = parse("{*foobar}"); + assertThat(pathPattern.computePatternString()).isEqualTo("/{*foobar}"); + assertPathElements(pathPattern, CaptureSegmentsPathElement.class); + pathPattern = checkStructure("/{*foobar}"); + assertPathElements(pathPattern, CaptureSegmentsPathElement.class); + pathPattern = checkStructure("/foo/{*foobar}"); + assertPathElements(pathPattern, SeparatorPathElement.class, LiteralPathElement.class, CaptureSegmentsPathElement.class); + } + + @Test + void multipleSeparatorPatterns() { + pathPattern = checkStructure("///aaa"); + assertPathElements(pathPattern, SeparatorPathElement.class, SeparatorPathElement.class, + SeparatorPathElement.class, LiteralPathElement.class); + pathPattern = checkStructure("///aaa////aaa/b"); + assertPathElements(pathPattern, SeparatorPathElement.class, SeparatorPathElement.class, + SeparatorPathElement.class, LiteralPathElement.class, SeparatorPathElement.class, + SeparatorPathElement.class, SeparatorPathElement.class, SeparatorPathElement.class, + LiteralPathElement.class, SeparatorPathElement.class, LiteralPathElement.class); + pathPattern = checkStructure("/////**"); + assertPathElements(pathPattern, SeparatorPathElement.class, SeparatorPathElement.class, + SeparatorPathElement.class, SeparatorPathElement.class, WildcardSegmentsPathElement.class); + } + + @Test + void regexPathElementPatterns() { + pathPattern = checkStructure("/{var:\\\\}"); + assertPathElements(pathPattern, SeparatorPathElement.class, CaptureVariablePathElement.class); + + pathPattern = checkStructure("/{var:\\/}"); + assertPathElements(pathPattern, SeparatorPathElement.class, CaptureVariablePathElement.class); + + pathPattern = checkStructure("/{var:a{1,2}}"); + assertPathElements(pathPattern, SeparatorPathElement.class, CaptureVariablePathElement.class); + + pathPattern = checkStructure("/{var:[^\\/]*}"); + assertPathElements(pathPattern, SeparatorPathElement.class, CaptureVariablePathElement.class); + + pathPattern = checkStructure("/{var:\\[*}"); + assertPathElements(pathPattern, SeparatorPathElement.class, CaptureVariablePathElement.class); + + pathPattern = checkStructure("/{var:[\\{]*}"); + assertPathElements(pathPattern, SeparatorPathElement.class, CaptureVariablePathElement.class); + + pathPattern = checkStructure("/{var:[\\}]*}"); + assertPathElements(pathPattern, SeparatorPathElement.class, CaptureVariablePathElement.class); + + pathPattern = checkStructure("*"); + assertPathElements(pathPattern, WildcardPathElement.class); + checkStructure("/*"); + checkStructure("/*/"); + checkStructure("*/"); + checkStructure("/*/"); + pathPattern = checkStructure("/*a*/"); + assertPathElements(pathPattern, SeparatorPathElement.class, RegexPathElement.class, SeparatorPathElement.class); + pathPattern = checkStructure("*/"); + assertPathElements(pathPattern, WildcardPathElement.class, SeparatorPathElement.class); + + pathPattern = checkStructure("{symbolicName:[\\p{L}\\.]+}-sources-{version:[\\p{N}\\.]+}.jar"); + assertPathElements(pathPattern, RegexPathElement.class); + } + + private PathPattern checkStructure(String pattern) { + PathPatternParser patternParser = new PathPatternParser(); + PathPattern pp = patternParser.parse(pattern); + assertThat(pp.computePatternString()).isEqualTo(pattern); + return pp; + } + + @SafeVarargs + final void assertPathElements(PathPattern p, Class... sectionClasses) { + PathElement head = p.getHeadSection(); + for (Class sectionClass : sectionClasses) { + if (head == null) { + fail("Ran out of data in parsed pattern. Pattern is: " + p.toChainString()); + } + assertThat(head.getClass().getSimpleName()).as("Not expected section type. Pattern is: " + p.toChainString()).isEqualTo(sectionClass.getSimpleName()); + head = head.next; + } + } - @Test - void multiwildcardPattern() { - pathPattern = checkStructure("/**"); - assertPathElements(pathPattern, WildcardTheRestPathElement.class); - // this is not double wildcard, it's / then **acb (an odd, unnecessary use of double *) - pathPattern = checkStructure("/**acb"); - assertPathElements(pathPattern, SeparatorPathElement.class, RegexPathElement.class); } - @Test - void toStringTests() { - assertThat(checkStructure("/{*foobar}").toChainString()).isEqualTo("CaptureTheRest(/{*foobar})"); - assertThat(checkStructure("{foobar}").toChainString()).isEqualTo("CaptureVariable({foobar})"); - assertThat(checkStructure("abc").toChainString()).isEqualTo("Literal(abc)"); - assertThat(checkStructure("{a}_*_{b}").toChainString()).isEqualTo("Regex({a}_*_{b})"); - assertThat(checkStructure("/").toChainString()).isEqualTo("Separator(/)"); - assertThat(checkStructure("?a?b?c").toChainString()).isEqualTo("SingleCharWildcarded(?a?b?c)"); - assertThat(checkStructure("*").toChainString()).isEqualTo("Wildcard(*)"); - assertThat(checkStructure("/**").toChainString()).isEqualTo("WildcardTheRest(/**)"); + @Nested + class ParsingErrorTests { + + @Test + void captureSegmentsIllegalSyntax() { + checkError("/{*foobar}abc", 1, PatternMessage.CAPTURE_ALL_IS_STANDALONE_CONSTRUCT); + checkError("/{*f%obar}", 4, PatternMessage.ILLEGAL_CHARACTER_IN_CAPTURE_DESCRIPTOR); + checkError("/{*foobar}abc", 1, PatternMessage.CAPTURE_ALL_IS_STANDALONE_CONSTRUCT); + checkError("/{f*oobar}", 3, PatternMessage.ILLEGAL_CHARACTER_IN_CAPTURE_DESCRIPTOR); + checkError("/{*foobar:.*}/abc", 9, PatternMessage.ILLEGAL_CHARACTER_IN_CAPTURE_DESCRIPTOR); + checkError("/{abc}{*foobar}", 1, PatternMessage.CAPTURE_ALL_IS_STANDALONE_CONSTRUCT); + checkError("/{abc}{*foobar}{foo}", 1, PatternMessage.CAPTURE_ALL_IS_STANDALONE_CONSTRUCT); + checkError("/{*foo}/foo/{*bar}", 18, PatternMessage.CANNOT_HAVE_MANY_MULTISEGMENT_PATHELEMENTS); + checkError("/{*foo}/{bar}", 8, PatternMessage.MULTISEGMENT_PATHELEMENT_NOT_FOLLOWED_BY_LITERAL); + checkError("{foo:}", 5, PatternMessage.MISSING_REGEX_CONSTRAINT); + checkError("{foo}_{foo}", 0, PatternMessage.ILLEGAL_DOUBLE_CAPTURE, "foo"); + checkError("/{bar}/{bar}", 7, PatternMessage.ILLEGAL_DOUBLE_CAPTURE, "bar"); + checkError("/{bar}/{bar}_{foo}", 7, PatternMessage.ILLEGAL_DOUBLE_CAPTURE, "bar"); + } + + @Test + void regexpSegmentsIllegalSyntax() { + checkError("/{var:[^/]*}", 8, PatternMessage.MISSING_CLOSE_CAPTURE); + checkError("/{var:abc", 8, PatternMessage.MISSING_CLOSE_CAPTURE); + // Do not check the expected position due a change in RegEx parsing in JDK 13. + // See https://github.com/spring-projects/spring-framework/issues/23669 + checkError("/{var:a{{1,2}}}", PatternMessage.REGEX_PATTERN_SYNTAX_EXCEPTION); + } + + @Test + void illegalCapturePatterns() { + checkError("{abc/", 4, PatternMessage.MISSING_CLOSE_CAPTURE); + checkError("{abc:}/", 5, PatternMessage.MISSING_REGEX_CONSTRAINT); + checkError("{", 1, PatternMessage.MISSING_CLOSE_CAPTURE); + checkError("{abc", 4, PatternMessage.MISSING_CLOSE_CAPTURE); + checkError("{/}", 1, PatternMessage.MISSING_CLOSE_CAPTURE); + checkError("/{", 2, PatternMessage.MISSING_CLOSE_CAPTURE); + checkError("}", 0, PatternMessage.MISSING_OPEN_CAPTURE); + checkError("/}", 1, PatternMessage.MISSING_OPEN_CAPTURE); + checkError("def}", 3, PatternMessage.MISSING_OPEN_CAPTURE); + checkError("/{/}", 2, PatternMessage.MISSING_CLOSE_CAPTURE); + checkError("/{{/}", 2, PatternMessage.ILLEGAL_NESTED_CAPTURE); + checkError("/{abc{/}", 5, PatternMessage.ILLEGAL_NESTED_CAPTURE); + checkError("/{0abc}/abc", 2, PatternMessage.ILLEGAL_CHARACTER_AT_START_OF_CAPTURE_DESCRIPTOR); + checkError("/{a?bc}/abc", 3, PatternMessage.ILLEGAL_CHARACTER_IN_CAPTURE_DESCRIPTOR); + checkError("/{abc}_{abc}", 1, PatternMessage.ILLEGAL_DOUBLE_CAPTURE); + checkError("/foobar/{abc}_{abc}", 8, PatternMessage.ILLEGAL_DOUBLE_CAPTURE); + checkError("/foobar/{abc:..}_{abc:..}", 8, PatternMessage.ILLEGAL_DOUBLE_CAPTURE); + } + + @Test + void captureGroupInRegexpNotAllowed() { + PathPattern pp = parse("/{abc:foo(bar)}"); + assertThatIllegalArgumentException().isThrownBy(() -> + pp.matchAndExtract(PathContainer.parsePath("/foo"))) + .withMessage("No capture groups allowed in the constraint regex: foo(bar)"); + assertThatIllegalArgumentException().isThrownBy(() -> + pp.matchAndExtract(PathContainer.parsePath("/foobar"))) + .withMessage("No capture groups allowed in the constraint regex: foo(bar)"); + } + + @Test + void badPatterns() { + //checkError("/{foo}{bar}/",6,PatternMessage.CANNOT_HAVE_ADJACENT_CAPTURES); + checkError("/{?}/", 2, PatternMessage.ILLEGAL_CHARACTER_AT_START_OF_CAPTURE_DESCRIPTOR, "?"); + checkError("/{a?b}/", 3, PatternMessage.ILLEGAL_CHARACTER_IN_CAPTURE_DESCRIPTOR, "?"); + checkError("/{%%$}", 2, PatternMessage.ILLEGAL_CHARACTER_AT_START_OF_CAPTURE_DESCRIPTOR, "%"); + checkError("/{ }", 2, PatternMessage.ILLEGAL_CHARACTER_AT_START_OF_CAPTURE_DESCRIPTOR, " "); + checkError("/{%:[0-9]*}", 2, PatternMessage.ILLEGAL_CHARACTER_AT_START_OF_CAPTURE_DESCRIPTOR, "%"); + } + + @Test + void captureTheRestWithinPatternNotSupported() { + PathPatternParser parser = new PathPatternParser(); + assertThatThrownBy(() -> parser.parse("/resources/**/details")) + .isInstanceOf(PatternParseException.class) + .extracting("messageType").isEqualTo(PatternMessage.INVALID_LOCATION_FOR_MULTISEGMENT_PATHELEMENT); + } + + /** + * Delegates to {@link #checkError(String, int, PatternMessage, String...)}, + * passing {@code -1} as the {@code expectedPos}. + * @since 5.2 + */ + private void checkError(String pattern, PatternMessage expectedMessage, String... expectedInserts) { + checkError(pattern, -1, expectedMessage, expectedInserts); + } + + /** + * @param expectedPos the expected position, or {@code -1} if the position should not be checked + */ + private void checkError(String pattern, int expectedPos, PatternMessage expectedMessage, + String... expectedInserts) { + + assertThatExceptionOfType(PatternParseException.class) + .isThrownBy(() -> pathPattern = parse(pattern)) + .satisfies(ex -> { + if (expectedPos >= 0) { + assertThat(ex.getPosition()).as(ex.toDetailedString()).isEqualTo(expectedPos); + } + assertThat(ex.getMessageType()).as(ex.toDetailedString()).isEqualTo(expectedMessage); + if (expectedInserts.length != 0) { + assertThat(ex.getInserts()).isEqualTo(expectedInserts); + } + }); + } + } + @Test - void captureTheRestPatterns() { - pathPattern = parse("{*foobar}"); - assertThat(pathPattern.computePatternString()).isEqualTo("/{*foobar}"); - assertPathElements(pathPattern, CaptureTheRestPathElement.class); - pathPattern = checkStructure("/{*foobar}"); - assertPathElements(pathPattern, CaptureTheRestPathElement.class); - checkError("/{*foobar}/", 10, PatternMessage.NO_MORE_DATA_EXPECTED_AFTER_CAPTURE_THE_REST); - checkError("/{*foobar}abc", 10, PatternMessage.NO_MORE_DATA_EXPECTED_AFTER_CAPTURE_THE_REST); - checkError("/{*f%obar}", 4, PatternMessage.ILLEGAL_CHARACTER_IN_CAPTURE_DESCRIPTOR); - checkError("/{*foobar}abc", 10, PatternMessage.NO_MORE_DATA_EXPECTED_AFTER_CAPTURE_THE_REST); - checkError("/{f*oobar}", 3, PatternMessage.ILLEGAL_CHARACTER_IN_CAPTURE_DESCRIPTOR); - checkError("/{*foobar}/abc", 10, PatternMessage.NO_MORE_DATA_EXPECTED_AFTER_CAPTURE_THE_REST); - checkError("/{*foobar:.*}/abc", 9, PatternMessage.ILLEGAL_CHARACTER_IN_CAPTURE_DESCRIPTOR); - checkError("/{abc}{*foobar}", 1, PatternMessage.CAPTURE_ALL_IS_STANDALONE_CONSTRUCT); - checkError("/{abc}{*foobar}{foo}", 15, PatternMessage.NO_MORE_DATA_EXPECTED_AFTER_CAPTURE_THE_REST); + void toStringTests() { + assertThat(parse("/{*foobar}").toChainString()).isEqualTo("CaptureSegments(/{*foobar})"); + assertThat(parse("{foobar}").toChainString()).isEqualTo("CaptureVariable({foobar})"); + assertThat(parse("abc").toChainString()).isEqualTo("Literal(abc)"); + assertThat(parse("{a}_*_{b}").toChainString()).isEqualTo("Regex({a}_*_{b})"); + assertThat(parse("/").toChainString()).isEqualTo("Separator(/)"); + assertThat(parse("?a?b?c").toChainString()).isEqualTo("SingleCharWildcarded(?a?b?c)"); + assertThat(parse("*").toChainString()).isEqualTo("Wildcard(*)"); + assertThat(parse("/**").toChainString()).isEqualTo("WildcardSegments(/**)"); } @Test @@ -116,82 +354,6 @@ void equalsAndHashcode() { assertThat(pp2.hashCode()).isNotEqualTo(pp1.hashCode()); } - @Test - void regexPathElementPatterns() { - checkError("/{var:[^/]*}", 8, PatternMessage.MISSING_CLOSE_CAPTURE); - checkError("/{var:abc", 8, PatternMessage.MISSING_CLOSE_CAPTURE); - - // Do not check the expected position due a change in RegEx parsing in JDK 13. - // See https://github.com/spring-projects/spring-framework/issues/23669 - checkError("/{var:a{{1,2}}}", PatternMessage.REGEX_PATTERN_SYNTAX_EXCEPTION); - - pathPattern = checkStructure("/{var:\\\\}"); - PathElement next = pathPattern.getHeadSection().next; - assertThat(next.getClass().getName()).isEqualTo(CaptureVariablePathElement.class.getName()); - assertMatches(pathPattern, "/\\"); - - pathPattern = checkStructure("/{var:\\/}"); - next = pathPattern.getHeadSection().next; - assertThat(next.getClass().getName()).isEqualTo(CaptureVariablePathElement.class.getName()); - assertNoMatch(pathPattern, "/aaa"); - - pathPattern = checkStructure("/{var:a{1,2}}"); - next = pathPattern.getHeadSection().next; - assertThat(next.getClass().getName()).isEqualTo(CaptureVariablePathElement.class.getName()); - - pathPattern = checkStructure("/{var:[^\\/]*}"); - next = pathPattern.getHeadSection().next; - assertThat(next.getClass().getName()).isEqualTo(CaptureVariablePathElement.class.getName()); - PathPattern.PathMatchInfo result = matchAndExtract(pathPattern, "/foo"); - assertThat(result.getUriVariables().get("var")).isEqualTo("foo"); - - pathPattern = checkStructure("/{var:\\[*}"); - next = pathPattern.getHeadSection().next; - assertThat(next.getClass().getName()).isEqualTo(CaptureVariablePathElement.class.getName()); - result = matchAndExtract(pathPattern, "/[[["); - assertThat(result.getUriVariables().get("var")).isEqualTo("[[["); - - pathPattern = checkStructure("/{var:[\\{]*}"); - next = pathPattern.getHeadSection().next; - assertThat(next.getClass().getName()).isEqualTo(CaptureVariablePathElement.class.getName()); - result = matchAndExtract(pathPattern, "/{{{"); - assertThat(result.getUriVariables().get("var")).isEqualTo("{{{"); - - pathPattern = checkStructure("/{var:[\\}]*}"); - next = pathPattern.getHeadSection().next; - assertThat(next.getClass().getName()).isEqualTo(CaptureVariablePathElement.class.getName()); - result = matchAndExtract(pathPattern, "/}}}"); - assertThat(result.getUriVariables().get("var")).isEqualTo("}}}"); - - pathPattern = checkStructure("*"); - assertThat(pathPattern.getHeadSection().getClass().getName()).isEqualTo(WildcardPathElement.class.getName()); - checkStructure("/*"); - checkStructure("/*/"); - checkStructure("*/"); - checkStructure("/*/"); - pathPattern = checkStructure("/*a*/"); - next = pathPattern.getHeadSection().next; - assertThat(next.getClass().getName()).isEqualTo(RegexPathElement.class.getName()); - pathPattern = checkStructure("*/"); - assertThat(pathPattern.getHeadSection().getClass().getName()).isEqualTo(WildcardPathElement.class.getName()); - checkError("{foo}_{foo}", 0, PatternMessage.ILLEGAL_DOUBLE_CAPTURE, "foo"); - checkError("/{bar}/{bar}", 7, PatternMessage.ILLEGAL_DOUBLE_CAPTURE, "bar"); - checkError("/{bar}/{bar}_{foo}", 7, PatternMessage.ILLEGAL_DOUBLE_CAPTURE, "bar"); - - pathPattern = checkStructure("{symbolicName:[\\p{L}\\.]+}-sources-{version:[\\p{N}\\.]+}.jar"); - assertThat(pathPattern.getHeadSection().getClass().getName()).isEqualTo(RegexPathElement.class.getName()); - } - - @Test - void completeCapturingPatterns() { - pathPattern = checkStructure("{foo}"); - assertThat(pathPattern.getHeadSection().getClass().getName()).isEqualTo(CaptureVariablePathElement.class.getName()); - checkStructure("/{foo}"); - checkStructure("/{f}/"); - checkStructure("/{foo}/{bar}/{wibble}"); - checkStructure("/{mobile-number}"); // gh-23101 - } - @Test void noEncoding() { // Check no encoding of expressions or constraints @@ -205,66 +367,6 @@ void noEncoding() { assertThat(pp.toChainString()).isEqualTo("Regex({foo:f o}_ _{bar:b\\|o})"); } - @Test - void completeCaptureWithConstraints() { - pathPattern = checkStructure("{foo:...}"); - assertPathElements(pathPattern, CaptureVariablePathElement.class); - pathPattern = checkStructure("{foo:[0-9]*}"); - assertPathElements(pathPattern, CaptureVariablePathElement.class); - checkError("{foo:}", 5, PatternMessage.MISSING_REGEX_CONSTRAINT); - } - - @Test - void partialCapturingPatterns() { - pathPattern = checkStructure("{foo}abc"); - assertThat(pathPattern.getHeadSection().getClass().getName()).isEqualTo(RegexPathElement.class.getName()); - checkStructure("abc{foo}"); - checkStructure("/abc{foo}"); - checkStructure("{foo}def/"); - checkStructure("/abc{foo}def/"); - checkStructure("{foo}abc{bar}"); - checkStructure("{foo}abc{bar}/"); - checkStructure("/{foo}abc{bar}/"); - } - - @Test - void illegalCapturePatterns() { - checkError("{abc/", 4, PatternMessage.MISSING_CLOSE_CAPTURE); - checkError("{abc:}/", 5, PatternMessage.MISSING_REGEX_CONSTRAINT); - checkError("{", 1, PatternMessage.MISSING_CLOSE_CAPTURE); - checkError("{abc", 4, PatternMessage.MISSING_CLOSE_CAPTURE); - checkError("{/}", 1, PatternMessage.MISSING_CLOSE_CAPTURE); - checkError("/{", 2, PatternMessage.MISSING_CLOSE_CAPTURE); - checkError("}", 0, PatternMessage.MISSING_OPEN_CAPTURE); - checkError("/}", 1, PatternMessage.MISSING_OPEN_CAPTURE); - checkError("def}", 3, PatternMessage.MISSING_OPEN_CAPTURE); - checkError("/{/}", 2, PatternMessage.MISSING_CLOSE_CAPTURE); - checkError("/{{/}", 2, PatternMessage.ILLEGAL_NESTED_CAPTURE); - checkError("/{abc{/}", 5, PatternMessage.ILLEGAL_NESTED_CAPTURE); - checkError("/{0abc}/abc", 2, PatternMessage.ILLEGAL_CHARACTER_AT_START_OF_CAPTURE_DESCRIPTOR); - checkError("/{a?bc}/abc", 3, PatternMessage.ILLEGAL_CHARACTER_IN_CAPTURE_DESCRIPTOR); - checkError("/{abc}_{abc}", 1, PatternMessage.ILLEGAL_DOUBLE_CAPTURE); - checkError("/foobar/{abc}_{abc}", 8, PatternMessage.ILLEGAL_DOUBLE_CAPTURE); - checkError("/foobar/{abc:..}_{abc:..}", 8, PatternMessage.ILLEGAL_DOUBLE_CAPTURE); - PathPattern pp = parse("/{abc:foo(bar)}"); - assertThatIllegalArgumentException().isThrownBy(() -> - pp.matchAndExtract(toPSC("/foo"))) - .withMessage("No capture groups allowed in the constraint regex: foo(bar)"); - assertThatIllegalArgumentException().isThrownBy(() -> - pp.matchAndExtract(toPSC("/foobar"))) - .withMessage("No capture groups allowed in the constraint regex: foo(bar)"); - } - - @Test - void badPatterns() { -// checkError("/{foo}{bar}/",6,PatternMessage.CANNOT_HAVE_ADJACENT_CAPTURES); - checkError("/{?}/", 2, PatternMessage.ILLEGAL_CHARACTER_AT_START_OF_CAPTURE_DESCRIPTOR, "?"); - checkError("/{a?b}/", 3, PatternMessage.ILLEGAL_CHARACTER_IN_CAPTURE_DESCRIPTOR, "?"); - checkError("/{%%$}", 2, PatternMessage.ILLEGAL_CHARACTER_AT_START_OF_CAPTURE_DESCRIPTOR, "%"); - checkError("/{ }", 2, PatternMessage.ILLEGAL_CHARACTER_AT_START_OF_CAPTURE_DESCRIPTOR, " "); - checkError("/{%:[0-9]*}", 2, PatternMessage.ILLEGAL_CHARACTER_AT_START_OF_CAPTURE_DESCRIPTOR, "%"); - } - @Test void patternPropertyGetCaptureCountTests() { // Test all basic section types @@ -311,30 +413,23 @@ void patternPropertyGetWildcardCountTests() { } @Test - void multipleSeparatorPatterns() { - pathPattern = checkStructure("///aaa"); + void normalizedLengthWhenMultipleSeparator() { + pathPattern = parse("///aaa"); assertThat(pathPattern.getNormalizedLength()).isEqualTo(6); - assertPathElements(pathPattern, SeparatorPathElement.class, SeparatorPathElement.class, - SeparatorPathElement.class, LiteralPathElement.class); - pathPattern = checkStructure("///aaa////aaa/b"); + pathPattern = parse("///aaa////aaa/b"); assertThat(pathPattern.getNormalizedLength()).isEqualTo(15); - assertPathElements(pathPattern, SeparatorPathElement.class, SeparatorPathElement.class, - SeparatorPathElement.class, LiteralPathElement.class, SeparatorPathElement.class, - SeparatorPathElement.class, SeparatorPathElement.class, SeparatorPathElement.class, - LiteralPathElement.class, SeparatorPathElement.class, LiteralPathElement.class); - pathPattern = checkStructure("/////**"); + pathPattern = parse("/////**"); assertThat(pathPattern.getNormalizedLength()).isEqualTo(5); - assertPathElements(pathPattern, SeparatorPathElement.class, SeparatorPathElement.class, - SeparatorPathElement.class, SeparatorPathElement.class, WildcardTheRestPathElement.class); } @Test - void patternPropertyGetLengthTests() { + void normalizedLengthWhenVariable() { // Test all basic section types assertThat(parse("{foo}").getNormalizedLength()).isEqualTo(1); assertThat(parse("foo").getNormalizedLength()).isEqualTo(3); assertThat(parse("{*foobar}").getNormalizedLength()).isEqualTo(1); assertThat(parse("/{*foobar}").getNormalizedLength()).isEqualTo(1); + assertThat(parse("**").getNormalizedLength()).isEqualTo(1); assertThat(parse("/**").getNormalizedLength()).isEqualTo(1); assertThat(parse("{abc}asdf").getNormalizedLength()).isEqualTo(5); assertThat(parse("{abc}_*").getNormalizedLength()).isEqualTo(3); @@ -350,6 +445,15 @@ void patternPropertyGetLengthTests() { assertThat(parse("/{foo}/{bar}_{goo}_{wibble}/abc/bar").getNormalizedLength()).isEqualTo(16); } + @Test + void separatorTests() { + PathPatternParser parser = new PathPatternParser(); + parser.setPathOptions(PathContainer.Options.create('.', false)); + String rawPattern = "first.second.{last}"; + PathPattern pattern = parser.parse(rawPattern); + assertThat(pattern.computePatternString()).isEqualTo(rawPattern); + } + @Test void compareTests() { PathPattern p1, p2, p3; @@ -414,96 +518,14 @@ void compareTests() { assertThat(patterns).element(1).isEqualTo(p2); } - @Test - void captureTheRestWithinPatternNotSupported() { - PathPatternParser parser = new PathPatternParser(); - assertThatThrownBy(() -> parser.parse("/resources/**/details")) - .isInstanceOf(PatternParseException.class) - .extracting("messageType").isEqualTo(PatternMessage.NO_MORE_DATA_EXPECTED_AFTER_CAPTURE_THE_REST); - } - - @Test - void separatorTests() { - PathPatternParser parser = new PathPatternParser(); - parser.setPathOptions(PathContainer.Options.create('.', false)); - String rawPattern = "first.second.{last}"; - PathPattern pattern = parser.parse(rawPattern); - assertThat(pattern.computePatternString()).isEqualTo(rawPattern); - } - private PathPattern parse(String pattern) { PathPatternParser patternParser = new PathPatternParser(); return patternParser.parse(pattern); } - /** - * Verify the pattern string computed for a parsed pattern matches the original pattern text - */ - private PathPattern checkStructure(String pattern) { - PathPattern pp = parse(pattern); - assertThat(pp.computePatternString()).isEqualTo(pattern); - return pp; - } - - /** - * Delegates to {@link #checkError(String, int, PatternMessage, String...)}, - * passing {@code -1} as the {@code expectedPos}. - * @since 5.2 - */ - private void checkError(String pattern, PatternMessage expectedMessage, String... expectedInserts) { - checkError(pattern, -1, expectedMessage, expectedInserts); - } - - /** - * @param expectedPos the expected position, or {@code -1} if the position should not be checked - */ - private void checkError(String pattern, int expectedPos, PatternMessage expectedMessage, - String... expectedInserts) { - - assertThatExceptionOfType(PatternParseException.class) - .isThrownBy(() -> pathPattern = parse(pattern)) - .satisfies(ex -> { - if (expectedPos >= 0) { - assertThat(ex.getPosition()).as(ex.toDetailedString()).isEqualTo(expectedPos); - } - assertThat(ex.getMessageType()).as(ex.toDetailedString()).isEqualTo(expectedMessage); - if (expectedInserts.length != 0) { - assertThat(ex.getInserts()).isEqualTo(expectedInserts); - } - }); - } - - @SafeVarargs - private void assertPathElements(PathPattern p, Class... sectionClasses) { - PathElement head = p.getHeadSection(); - for (Class sectionClass : sectionClasses) { - if (head == null) { - fail("Ran out of data in parsed pattern. Pattern is: " + p.toChainString()); - } - assertThat(head.getClass().getSimpleName()).as("Not expected section type. Pattern is: " + p.toChainString()).isEqualTo(sectionClass.getSimpleName()); - head = head.next; - } - } - // Mirrors the score computation logic in PathPattern private int computeScore(int capturedVariableCount, int wildcardCount) { return capturedVariableCount + wildcardCount * 100; } - private void assertMatches(PathPattern pp, String path) { - assertThat(pp.matches(PathPatternTests.toPathContainer(path))).isTrue(); - } - - private void assertNoMatch(PathPattern pp, String path) { - assertThat(pp.matches(PathPatternTests.toPathContainer(path))).isFalse(); - } - - private PathPattern.PathMatchInfo matchAndExtract(PathPattern pp, String path) { - return pp.matchAndExtract(PathPatternTests.toPathContainer(path)); - } - - private PathContainer toPSC(String path) { - return PathPatternTests.toPathContainer(path); - } - } diff --git a/spring-web/src/test/java/org/springframework/web/util/pattern/PathPatternTests.java b/spring-web/src/test/java/org/springframework/web/util/pattern/PathPatternTests.java index aca89c268789..9238fcd77d10 100644 --- a/spring-web/src/test/java/org/springframework/web/util/pattern/PathPatternTests.java +++ b/spring-web/src/test/java/org/springframework/web/util/pattern/PathPatternTests.java @@ -23,6 +23,7 @@ import java.util.List; import java.util.Map; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.http.server.PathContainer; @@ -34,12 +35,303 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; /** - * Exercise matching of {@link PathPattern} objects. + * Tests for {@link PathPattern}. * * @author Andy Clement + * @author Brian Clozel */ class PathPatternTests { + @Nested + class MatchingTests { + + @Test + void basicMatching() { + checkMatches("", ""); + checkMatches("", null); + checkNoMatch("/abc", "/"); + checkMatches("/", "/"); + checkNoMatch("/", "/a"); + checkMatches("foo/bar/", "foo/bar/"); + checkNoMatch("foo", "foobar"); + checkMatches("/foo/bar", "/foo/bar"); + checkNoMatch("/foo/bar", "/foo/baz"); + } + + @Test + void literalPathElements() { + checkMatches("foo", "foo"); + checkNoMatch("foo", "bar"); + checkNoMatch("foo", "/foo"); + checkNoMatch("/foo", "foo"); + checkMatches("/f", "/f"); + checkMatches("/foo", "/foo"); + checkNoMatch("/foo", "/food"); + checkNoMatch("/food", "/foo"); + checkMatches("/foo/", "/foo/"); + checkMatches("/foo/bar/woo", "/foo/bar/woo"); + checkMatches("foo/bar/woo", "foo/bar/woo"); + } + + @Test + void questionMarks() { + checkNoMatch("a", "ab"); + checkMatches("/f?o/bar", "/foo/bar"); + checkNoMatch("/foo/b2r", "/foo/bar"); + checkNoMatch("?", "te"); + checkMatches("?", "a"); + checkMatches("???", "abc"); + checkNoMatch("tes?", "te"); + checkNoMatch("tes?", "tes"); + checkNoMatch("tes?", "testt"); + checkNoMatch("tes?", "tsst"); + checkMatches(".?.a", ".a.a"); + checkNoMatch(".?.a", ".aba"); + checkMatches("/f?o/bar","/f%20o/bar"); + } + + @Test + void multipleSeparatorsInPattern() { + PathPattern pp = parse("a//b//c"); + assertThat(pp.toChainString()).isEqualTo("Literal(a) Separator(/) Separator(/) Literal(b) Separator(/) Separator(/) Literal(c)"); + assertMatches(pp,"a//b//c"); + assertThat(parse("a//**").toChainString()).isEqualTo("Literal(a) Separator(/) WildcardSegments(/**)"); + checkMatches("///abc", "///abc"); + checkNoMatch("///abc", "/abc"); + checkNoMatch("//", "/"); + checkMatches("//", "//"); + checkNoMatch("///abc//d/e", "/abc/d/e"); + checkMatches("///abc//d/e", "///abc//d/e"); + checkNoMatch("///abc//{def}//////xyz", "/abc/foo/xyz"); + checkMatches("///abc//{def}//////xyz", "///abc//p//////xyz"); + } + + @Test + void multipleSelectorsInPath() { + checkNoMatch("/abc", "////abc"); + checkNoMatch("/", "//"); + checkNoMatch("/abc/def/ghi", "/abc//def///ghi"); + checkNoMatch("/abc", "////abc"); + checkMatches("////abc", "////abc"); + checkNoMatch("/", "//"); + checkNoMatch("/abc//def", "/abc/def"); + checkNoMatch("/abc//def///ghi", "/abc/def/ghi"); + checkMatches("/abc//def///ghi", "/abc//def///ghi"); + } + + @Test + void multipleSeparatorsInPatternAndPath() { + checkNoMatch("///one///two///three", "//one/////two///////three"); + checkMatches("//one/////two///////three", "//one/////two///////three"); + checkNoMatch("//one//two//three", "/one/////two/three"); + checkMatches("/one/////two/three", "/one/////two/three"); + checkCapture("///{foo}///bar", "///one///bar", "foo", "one"); + } + + @Test + void captureSegmentsAtStart() { + checkMatches("/{*foobar}/resource", "/resource"); + checkNoMatch("/{*foobar}/resource", "/resourceX"); + checkNoMatch("/{*foobar}/resource", "/foobar/resourceX"); + checkMatches("/{*foobar}/resource", "/foobar/resource"); + } + + @Test + void captureSegmentsAtEnd() { + checkMatches("/resource/{*foobar}", "/resource"); + checkNoMatch("/resource/{*foobar}", "/resourceX"); + checkNoMatch("/resource/{*foobar}", "/resourceX/foobar"); + checkMatches("/resource/{*foobar}", "/resource/foobar"); + } + + @Test + void wildcards() { + checkMatches("/*/bar", "/foo/bar"); + checkNoMatch("/*/bar", "/foo/baz"); + checkNoMatch("/*/bar", "//bar"); + checkMatches("/f*/bar", "/foo/bar"); + checkMatches("/*/bar", "/foo/bar"); + checkMatches("a/*","a/"); + checkMatches("/*","/"); + checkMatches("/*/bar", "/foo/bar"); + checkNoMatch("/*/bar", "/foo/baz"); + checkMatches("/f*/bar", "/foo/bar"); + checkMatches("/*/bar", "/foo/bar"); + checkMatches("/a*b*c*d/bar", "/abcd/bar"); + checkMatches("*a*", "testa"); + checkMatches("a/*", "a/"); + checkNoMatch("a/*", "a//"); // no data for * + PathPatternParser ppp = new PathPatternParser(); + assertThat(ppp.parse("a/*").matches(toPathContainer("a//"))).isFalse(); + checkMatches("a/*", "a/a"); + } + + @Test + void wildcardSegmentsStart() { + checkMatches("/**/resource", "/resource"); + checkNoMatch("/**/resource", "/Xresource"); + checkNoMatch("/**/resource", "/foobar/resourceX"); + checkMatches("/**/resource", "/foobar/resource"); + + checkMatches("/**/resource/test", "/foo/bar/resource/test"); + checkNoMatch("/**/resource/test", "/foo/bar/resource/t"); + } + + @Test + void wildcardSegmentsEnd() { + checkMatches("/resource/**", "/resource"); + checkNoMatch("/resource/**", "/resourceX"); + checkNoMatch("/resource/**", "/resourceX/foobar"); + checkMatches("/resource/**", "/resource/foobar"); + } + + @Test + void antPathMatcherTests() { + // test exact matching + checkMatches("test", "test"); + checkMatches("/test", "/test"); + checkMatches("https://example.org", "https://example.org"); + checkNoMatch("/test.jpg", "test.jpg"); + checkNoMatch("test", "/test"); + checkNoMatch("/test", "test"); + + // test matching with ?'s + checkMatches("t?st", "test"); + checkMatches("??st", "test"); + checkMatches("tes?", "test"); + checkMatches("te??", "test"); + checkMatches("?es?", "test"); + checkNoMatch("tes?", "tes"); + checkNoMatch("tes?", "testt"); + checkNoMatch("tes?", "tsst"); + + // test matching with *'s + checkMatches("*", "test"); + checkMatches("test*", "test"); + checkMatches("test*", "testTest"); + checkMatches("test/*", "test/Test"); + checkMatches("test/*", "test/t"); + checkMatches("test/*", "test/"); + checkMatches("*test*", "AnothertestTest"); + checkMatches("*test", "Anothertest"); + checkMatches("*.*", "test."); + checkMatches("*.*", "test.test"); + checkMatches("*.*", "test.test.test"); + checkMatches("test*aaa", "testblaaaa"); + checkNoMatch("test*", "tst"); + checkNoMatch("test*", "tsttest"); + checkMatches("test*", "test"); // trailing slash is optional + checkNoMatch("test*", "test/t"); + checkNoMatch("test/*", "test"); + checkNoMatch("*test*", "tsttst"); + checkNoMatch("*test", "tsttst"); + checkNoMatch("*.*", "tsttst"); + checkNoMatch("test*aaa", "test"); + checkNoMatch("test*aaa", "testblaaab"); + + // test matching with ?'s and /'s + checkMatches("/?", "/a"); + checkMatches("/?/a", "/a/a"); + checkMatches("/a/?", "/a/b"); + checkMatches("/??/a", "/aa/a"); + checkMatches("/a/??", "/a/bb"); + checkMatches("/?", "/a"); + + checkMatches("/**", ""); + checkMatches("/books/**", "/books"); + checkMatches("/**", "/testing/testing"); + checkMatches("/*/**", "/testing/testing"); + checkMatches("/bla*bla/test", "/blaXXXbla/test"); + checkMatches("/*bla/test", "/XXXbla/test"); + checkNoMatch("/bla*bla/test", "/blaXXXbl/test"); + checkNoMatch("/*bla/test", "XXXblab/test"); + checkNoMatch("/*bla/test", "XXXbl/test"); + checkNoMatch("/????", "/bala/bla"); + checkMatches("/foo/bar/**", "/foo/bar/"); + checkMatches("/{bla}.html", "/testing.html"); + checkCapture("/{bla}.*", "/testing.html", "bla", "testing"); + } + + } + + @Nested + class VariableCaptureTests { + + @Test + void constrainedMatches() { + checkCapture("{foo:[0-9]*}", "123", "foo", "123"); + checkNoMatch("{foo:[0-9]*}", "abc"); + checkNoMatch("/{foo:[0-9]*}", "abc"); + checkCapture("/*/{foo:....}/**", "/foo/barg/foo", "foo", "barg"); + checkCapture("/*/{foo:....}/**", "/foo/barg/abc/def/ghi", "foo", "barg"); + checkNoMatch("{foo:....}", "99"); + checkMatches("{foo:..}", "99"); + checkCapture("/{abc:\\{\\}}", "/{}", "abc", "{}"); + checkCapture("/{abc:\\[\\]}", "/[]", "abc", "[]"); + checkCapture("/{abc:\\\\\\\\}", "/\\\\"); // this is fun... + } + + @Test + void captureSegmentsAtStart() { + checkCapture("/{*foobar}/resource", "/foobar/resource", "foobar", "/foobar"); + checkCapture("/{*something}/customer", "/99/customer", "something", "/99"); + checkCapture("/{*something}/customer", "/aa/bb/cc/customer", "something", "/aa/bb/cc"); + checkCapture("/{*something}/customer", "/customer", "something", ""); + checkCapture("/{*something}/customer", "//////99/customer", "something", "//////99"); + } + + @Test + void captureSegmentsAtEnd() { + checkCapture("/resource/{*foobar}", "/resource/foobar", "foobar", "/foobar"); + checkCapture("/customer/{*something}", "/customer/99", "something", "/99"); + checkCapture("/customer/{*something}", "/customer/aa/bb/cc", "something", + "/aa/bb/cc"); + checkCapture("/customer/{*something}", "/customer/", "something", "/"); + checkCapture("/customer/////{*something}", "/customer/////", "something", "/"); + checkCapture("/customer/////{*something}", "/customer//////", "something", "//"); + checkCapture("/customer//////{*something}", "/customer//////99", "something", "/99"); + checkCapture("/customer//////{*something}", "/customer//////99", "something", "/99"); + checkCapture("/customer/{*something}", "/customer", "something", ""); + checkCapture("/{*something}", "", "something", ""); + checkCapture("/customer/{*something}", "/customer//////99", "something", "//////99"); + } + + @Test + void encodingAndBoundVariablesCapturePathElement() { + checkCapture("{var}","f%20o","var","f o"); + checkCapture("{var1}/{var2}","f%20o/f%7Co","var1","f o","var2","f|o"); + checkCapture("{var1}/{var2}","f%20o/f%7co","var1","f o","var2","f|o"); // lower case encoding + checkCapture("{var:foo}","foo","var","foo"); + checkCapture("{var:f o}","f%20o","var","f o"); // constraint is expressed in non encoded form + checkCapture("{var:f.o}","f%20o","var","f o"); + checkCapture("{var:f\\|o}","f%7co","var","f|o"); + checkCapture("{var:.*}","x\ny","var","x\ny"); + } + + @Test + void encodingAndBoundVariablesCaptureTheRestPathElement() { + checkCapture("/{*var}","/f%20o","var","/f o"); + checkCapture("{var1}/{*var2}","f%20o/f%7Co","var1","f o","var2","/f|o"); + checkCapture("/{*var}","/foo","var","/foo"); + checkCapture("/{*var}","/f%20o","var","/f o"); + checkCapture("/{*var}","/f%20o","var","/f o"); + checkCapture("/{*var}","/f%7co","var","/f|o"); + } + + @Test + void encodingAndBoundVariablesRegexPathElement() { + checkCapture("/{var1:f o}_ _{var2}","/f%20o_%20_f%7co","var1","f o","var2","f|o"); + checkCapture("/{var1}_{var2}","/f%20o_foo","var1","f o","var2","foo"); + checkCapture("/{var1}_ _{var2}","/f%20o_%20_f%7co","var1","f o","var2","f|o"); + checkCapture("/{var1}_ _{var2:f\\|o}","/f%20o_%20_f%7co","var1","f o","var2","f|o"); + checkCapture("/{var1:f o}_ _{var2:f\\|o}","/f%20o_%20_f%7co","var1","f o","var2","f|o"); + checkCapture("/{var1:f o}_ _{var2:f\\|o}","/f%20o_%20_f%7co","var1","f o","var2","f|o"); + checkCapture("/{var1}_{var2}","/f\noo_foo","var1","f\noo","var2","foo"); + } + + } + + @Test void pathContainer() { assertThat(elementsToString(toPathContainer("/abc/def").elements())).isEqualTo("[/][abc][/][def]"); @@ -62,44 +354,8 @@ void hasPatternSyntax() { assertThat(parser.parse("/foo/bar").hasPatternSyntax()).isFalse(); } - @Test - void matching_LiteralPathElement() { - checkMatches("foo", "foo"); - checkNoMatch("foo", "bar"); - checkNoMatch("foo", "/foo"); - checkNoMatch("/foo", "foo"); - checkMatches("/f", "/f"); - checkMatches("/foo", "/foo"); - checkNoMatch("/foo", "/food"); - checkNoMatch("/food", "/foo"); - checkMatches("/foo/", "/foo/"); - checkMatches("/foo/bar/woo", "/foo/bar/woo"); - checkMatches("foo/bar/woo", "foo/bar/woo"); - } - - @Test - void basicMatching() { - checkMatches("", ""); - checkMatches("", null); - checkNoMatch("/abc", "/"); - checkMatches("/", "/"); - checkNoMatch("/", "/a"); - checkMatches("foo/bar/", "foo/bar/"); - checkNoMatch("foo", "foobar"); - checkMatches("/foo/bar", "/foo/bar"); - checkNoMatch("/foo/bar", "/foo/baz"); - } - - private void assertMatches(PathPattern pp, String path) { - assertThat(pp.matches(toPathContainer(path))).isTrue(); - } - - private void assertNoMatch(PathPattern pp, String path) { - assertThat(pp.matches(toPathContainer(path))).isFalse(); - } - - @Test - void pathRemainderBasicCases_spr15336() { + @Test // SPR-15336 + void pathRemainderBasicCases() { // Cover all PathElement kinds assertThat(getPathRemaining("/foo", "/foo/bar").getPathRemaining().value()).isEqualTo("/bar"); assertThat(getPathRemaining("/foo", "/foo/").getPathRemaining().value()).isEqualTo("/"); @@ -119,41 +375,8 @@ void pathRemainderBasicCases_spr15336() { assertThat(getPathRemaining("/foo//", "/foo///bar").getPathRemaining().value()).isEqualTo("/bar"); } - @Test - void encodingAndBoundVariablesCapturePathElement() { - checkCapture("{var}","f%20o","var","f o"); - checkCapture("{var1}/{var2}","f%20o/f%7Co","var1","f o","var2","f|o"); - checkCapture("{var1}/{var2}","f%20o/f%7co","var1","f o","var2","f|o"); // lower case encoding - checkCapture("{var:foo}","foo","var","foo"); - checkCapture("{var:f o}","f%20o","var","f o"); // constraint is expressed in non encoded form - checkCapture("{var:f.o}","f%20o","var","f o"); - checkCapture("{var:f\\|o}","f%7co","var","f|o"); - checkCapture("{var:.*}","x\ny","var","x\ny"); - } - - @Test - void encodingAndBoundVariablesCaptureTheRestPathElement() { - checkCapture("/{*var}","/f%20o","var","/f o"); - checkCapture("{var1}/{*var2}","f%20o/f%7Co","var1","f o","var2","/f|o"); - checkCapture("/{*var}","/foo","var","/foo"); - checkCapture("/{*var}","/f%20o","var","/f o"); - checkCapture("/{*var}","/f%20o","var","/f o"); - checkCapture("/{*var}","/f%7co","var","/f|o"); - } - - @Test - void encodingAndBoundVariablesRegexPathElement() { - checkCapture("/{var1:f o}_ _{var2}","/f%20o_%20_f%7co","var1","f o","var2","f|o"); - checkCapture("/{var1}_{var2}","/f%20o_foo","var1","f o","var2","foo"); - checkCapture("/{var1}_ _{var2}","/f%20o_%20_f%7co","var1","f o","var2","f|o"); - checkCapture("/{var1}_ _{var2:f\\|o}","/f%20o_%20_f%7co","var1","f o","var2","f|o"); - checkCapture("/{var1:f o}_ _{var2:f\\|o}","/f%20o_%20_f%7co","var1","f o","var2","f|o"); - checkCapture("/{var1:f o}_ _{var2:f\\|o}","/f%20o_%20_f%7co","var1","f o","var2","f|o"); - checkCapture("/{var1}_{var2}","/f\noo_foo","var1","f\noo","var2","foo"); - } - - @Test - void pathRemainingCornerCases_spr15336() { + @Test // SPR-15336 + void pathRemainingCornerCases() { // No match when the literal path element is a longer form of the segment in the pattern assertThat(parse("/foo").matchStartOfPath(toPathContainer("/footastic/bar"))).isNull(); assertThat(parse("/f?o").matchStartOfPath(toPathContainer("/footastic/bar"))).isNull(); @@ -166,6 +389,11 @@ void pathRemainingCornerCases_spr15336() { assertThat(parse("/resource/**") .matchStartOfPath(toPathContainer("/resource")).getPathRemaining().value()).isEmpty(); + assertThat(parse("/**/resource") + .matchStartOfPath(toPathContainer("/test/resource")).getPathRemaining().value()).isEmpty(); + assertThat(parse("/**/resource") + .matchStartOfPath(toPathContainer("/test/resource/other")).getPathRemaining().value()).isEqualTo("/other"); + // Similar to above for the capture-the-rest variant assertThat(parse("/resource/{*foo}").matchStartOfPath(toPathContainer("/resourceX"))).isNull(); assertThat(parse("/resource/{*foo}") @@ -194,191 +422,8 @@ void pathRemainingCornerCases_spr15336() { assertThat(parse("").matchStartOfPath(toPathContainer("")).getPathRemaining().value()).isEmpty(); } - @Test - void questionMarks() { - checkNoMatch("a", "ab"); - checkMatches("/f?o/bar", "/foo/bar"); - checkNoMatch("/foo/b2r", "/foo/bar"); - checkNoMatch("?", "te"); - checkMatches("?", "a"); - checkMatches("???", "abc"); - checkNoMatch("tes?", "te"); - checkNoMatch("tes?", "tes"); - checkNoMatch("tes?", "testt"); - checkNoMatch("tes?", "tsst"); - checkMatches(".?.a", ".a.a"); - checkNoMatch(".?.a", ".aba"); - checkMatches("/f?o/bar","/f%20o/bar"); - } - - @Test - void captureTheRest() { - checkMatches("/resource/{*foobar}", "/resource"); - checkNoMatch("/resource/{*foobar}", "/resourceX"); - checkNoMatch("/resource/{*foobar}", "/resourceX/foobar"); - checkMatches("/resource/{*foobar}", "/resource/foobar"); - checkCapture("/resource/{*foobar}", "/resource/foobar", "foobar", "/foobar"); - checkCapture("/customer/{*something}", "/customer/99", "something", "/99"); - checkCapture("/customer/{*something}", "/customer/aa/bb/cc", "something", - "/aa/bb/cc"); - checkCapture("/customer/{*something}", "/customer/", "something", "/"); - checkCapture("/customer/////{*something}", "/customer/////", "something", "/"); - checkCapture("/customer/////{*something}", "/customer//////", "something", "//"); - checkCapture("/customer//////{*something}", "/customer//////99", "something", "/99"); - checkCapture("/customer//////{*something}", "/customer//////99", "something", "/99"); - checkCapture("/customer/{*something}", "/customer", "something", ""); - checkCapture("/{*something}", "", "something", ""); - checkCapture("/customer/{*something}", "/customer//////99", "something", "//////99"); - } - - @Test - void multipleSeparatorsInPattern() { - PathPattern pp = parse("a//b//c"); - assertThat(pp.toChainString()).isEqualTo("Literal(a) Separator(/) Separator(/) Literal(b) Separator(/) Separator(/) Literal(c)"); - assertMatches(pp,"a//b//c"); - assertThat(parse("a//**").toChainString()).isEqualTo("Literal(a) Separator(/) WildcardTheRest(/**)"); - checkMatches("///abc", "///abc"); - checkNoMatch("///abc", "/abc"); - checkNoMatch("//", "/"); - checkMatches("//", "//"); - checkNoMatch("///abc//d/e", "/abc/d/e"); - checkMatches("///abc//d/e", "///abc//d/e"); - checkNoMatch("///abc//{def}//////xyz", "/abc/foo/xyz"); - checkMatches("///abc//{def}//////xyz", "///abc//p//////xyz"); - } - - @Test - void multipleSelectorsInPath() { - checkNoMatch("/abc", "////abc"); - checkNoMatch("/", "//"); - checkNoMatch("/abc/def/ghi", "/abc//def///ghi"); - checkNoMatch("/abc", "////abc"); - checkMatches("////abc", "////abc"); - checkNoMatch("/", "//"); - checkNoMatch("/abc//def", "/abc/def"); - checkNoMatch("/abc//def///ghi", "/abc/def/ghi"); - checkMatches("/abc//def///ghi", "/abc//def///ghi"); - } - - @Test - void multipleSeparatorsInPatternAndPath() { - checkNoMatch("///one///two///three", "//one/////two///////three"); - checkMatches("//one/////two///////three", "//one/////two///////three"); - checkNoMatch("//one//two//three", "/one/////two/three"); - checkMatches("/one/////two/three", "/one/////two/three"); - checkCapture("///{foo}///bar", "///one///bar", "foo", "one"); - } - - @SuppressWarnings("deprecation") - @Test - void wildcards() { - checkMatches("/*/bar", "/foo/bar"); - checkNoMatch("/*/bar", "/foo/baz"); - checkNoMatch("/*/bar", "//bar"); - checkMatches("/f*/bar", "/foo/bar"); - checkMatches("/*/bar", "/foo/bar"); - checkMatches("a/*","a/"); - checkMatches("/*","/"); - checkMatches("/*/bar", "/foo/bar"); - checkNoMatch("/*/bar", "/foo/baz"); - checkMatches("/f*/bar", "/foo/bar"); - checkMatches("/*/bar", "/foo/bar"); - checkMatches("/a*b*c*d/bar", "/abcd/bar"); - checkMatches("*a*", "testa"); - checkMatches("a/*", "a/"); - checkNoMatch("a/*", "a//"); // no data for * - PathPatternParser ppp = new PathPatternParser(); - assertThat(ppp.parse("a/*").matches(toPathContainer("a//"))).isFalse(); - checkMatches("a/*", "a/a"); - checkMatches("/resource/**", "/resource"); - checkNoMatch("/resource/**", "/resourceX"); - checkNoMatch("/resource/**", "/resourceX/foobar"); - checkMatches("/resource/**", "/resource/foobar"); - } - - @Test - void constrainedMatches() { - checkCapture("{foo:[0-9]*}", "123", "foo", "123"); - checkNoMatch("{foo:[0-9]*}", "abc"); - checkNoMatch("/{foo:[0-9]*}", "abc"); - checkCapture("/*/{foo:....}/**", "/foo/barg/foo", "foo", "barg"); - checkCapture("/*/{foo:....}/**", "/foo/barg/abc/def/ghi", "foo", "barg"); - checkNoMatch("{foo:....}", "99"); - checkMatches("{foo:..}", "99"); - checkCapture("/{abc:\\{\\}}", "/{}", "abc", "{}"); - checkCapture("/{abc:\\[\\]}", "/[]", "abc", "[]"); - checkCapture("/{abc:\\\\\\\\}", "/\\\\"); // this is fun... - } - - @Test - void antPathMatcherTests() { - // test exact matching - checkMatches("test", "test"); - checkMatches("/test", "/test"); - checkMatches("https://example.org", "https://example.org"); - checkNoMatch("/test.jpg", "test.jpg"); - checkNoMatch("test", "/test"); - checkNoMatch("/test", "test"); - - // test matching with ?'s - checkMatches("t?st", "test"); - checkMatches("??st", "test"); - checkMatches("tes?", "test"); - checkMatches("te??", "test"); - checkMatches("?es?", "test"); - checkNoMatch("tes?", "tes"); - checkNoMatch("tes?", "testt"); - checkNoMatch("tes?", "tsst"); - - // test matching with *'s - checkMatches("*", "test"); - checkMatches("test*", "test"); - checkMatches("test*", "testTest"); - checkMatches("test/*", "test/Test"); - checkMatches("test/*", "test/t"); - checkMatches("test/*", "test/"); - checkMatches("*test*", "AnothertestTest"); - checkMatches("*test", "Anothertest"); - checkMatches("*.*", "test."); - checkMatches("*.*", "test.test"); - checkMatches("*.*", "test.test.test"); - checkMatches("test*aaa", "testblaaaa"); - checkNoMatch("test*", "tst"); - checkNoMatch("test*", "tsttest"); - checkMatches("test*", "test"); // trailing slash is optional - checkNoMatch("test*", "test/t"); - checkNoMatch("test/*", "test"); - checkNoMatch("*test*", "tsttst"); - checkNoMatch("*test", "tsttst"); - checkNoMatch("*.*", "tsttst"); - checkNoMatch("test*aaa", "test"); - checkNoMatch("test*aaa", "testblaaab"); - - // test matching with ?'s and /'s - checkMatches("/?", "/a"); - checkMatches("/?/a", "/a/a"); - checkMatches("/a/?", "/a/b"); - checkMatches("/??/a", "/aa/a"); - checkMatches("/a/??", "/a/bb"); - checkMatches("/?", "/a"); - - checkMatches("/**", ""); - checkMatches("/books/**", "/books"); - checkMatches("/**", "/testing/testing"); - checkMatches("/*/**", "/testing/testing"); - checkMatches("/bla*bla/test", "/blaXXXbla/test"); - checkMatches("/*bla/test", "/XXXbla/test"); - checkNoMatch("/bla*bla/test", "/blaXXXbl/test"); - checkNoMatch("/*bla/test", "XXXblab/test"); - checkNoMatch("/*bla/test", "XXXbl/test"); - checkNoMatch("/????", "/bala/bla"); - checkMatches("/foo/bar/**", "/foo/bar/"); - checkMatches("/{bla}.html", "/testing.html"); - checkCapture("/{bla}.*", "/testing.html", "bla", "testing"); - } - - @Test - void pathRemainingEnhancements_spr15419() { + @Test // SPR-15149 + void pathRemainingEnhancements() { PathPattern pp; PathPattern.PathRemainingMatchInfo pri; // It would be nice to partially match a path and get any bound variables in one step @@ -495,8 +540,8 @@ void caseSensitivity() { assertMatches(p,"bAb"); } - @Test - void extractPathWithinPattern_spr15259() { + @Test // SPR-15259 + void extractPathWithinPatternWildards() { checkExtractPathWithinPattern("/**","//",""); checkExtractPathWithinPattern("/**","/",""); checkExtractPathWithinPattern("/**","",""); @@ -553,9 +598,8 @@ void extractPathWithinPatternCustomSeparator() { assertThat(result.elements()).hasSize(3); } - @Test - @SuppressWarnings("deprecation") - public void extractUriTemplateVariables_spr15264() { + @Test // SPR-15264 + public void extractUriTemplateVariables() { PathPattern pp; pp = new PathPatternParser().parse("/{foo}"); assertMatches(pp,"/abc"); @@ -611,10 +655,7 @@ public void extractUriTemplateVariables_spr15264() { Map vars = new AntPathMatcher().extractUriTemplateVariables("/{foo}{bar}", "/a"); assertThat(vars).containsEntry("foo", "a"); assertThat(vars.get("bar")).isEmpty(); - } - @Test - void extractUriTemplateVariables() { assertMatches(parse("{hotel}"),"1"); assertMatches(parse("/hotels/{hotel}"),"/hotels/1"); checkCapture("/hotels/{hotel}", "/hotels/1", "hotel", "1"); @@ -1003,6 +1044,43 @@ void parameters() { assertThat(result).isNotNull(); } + @Test + void regexPathElementPatterns() { + PathPatternParser pp = new PathPatternParser(); + + PathPattern pattern = pp.parse("/{var:\\\\}"); + assertMatches(pattern, "/\\"); + + pattern = pp.parse("/{var:\\/}"); + assertNoMatch(pattern, "/aaa"); + + pattern = pp.parse("/{var:[^\\/]*}"); + PathPattern.PathMatchInfo result = matchAndExtract(pattern, "/foo"); + assertThat(result.getUriVariables().get("var")).isEqualTo("foo"); + + pattern = pp.parse("/{var:\\[*}"); + result = matchAndExtract(pattern, "/[[["); + assertThat(result.getUriVariables().get("var")).isEqualTo("[[["); + + pattern = pp.parse("/{var:[\\{]*}"); + result = matchAndExtract(pattern, "/{{{"); + assertThat(result.getUriVariables().get("var")).isEqualTo("{{{"); + + pattern = pp.parse("/{var:[\\}]*}"); + result = matchAndExtract(pattern, "/}}}"); + assertThat(result.getUriVariables().get("var")).isEqualTo("}}}"); + } + + private void assertMatches(PathPattern pp, String path) { + assertThat(pp.matches(toPathContainer(path))).isTrue(); + } + + private void assertNoMatch(PathPattern pp, String path) { + assertThat(pp.matches(toPathContainer(path))).isFalse(); + } + + + private PathPattern.PathMatchInfo matchAndExtract(String pattern, String path) { return parse(pattern).matchAndExtract(PathPatternTests.toPathContainer(path)); } From 444573d4b577842d2e7a5dc24d92dff5912d03f9 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Fri, 25 Jul 2025 15:04:59 +0200 Subject: [PATCH 006/591] Display original request URI in NoResourceFoundException message This commit ensures that the original request URI is displayed in `NoResourceFoundException` error messages when logged. Without this change, it can be confusing to see only the attempted resource path. There are cases where the original request was not meant for resource handling and we want to understand why this wasn't processed by another handler. The Problem Detail attribute has not been changed as the "instance" attribute already displays the request path. Closes gh-34553 --- .../resource/NoResourceFoundException.java | 8 ++-- .../reactive/resource/ResourceWebHandler.java | 2 +- .../NoResourceFoundExceptionTests.java | 43 +++++++++++++++++++ .../resource/NoResourceFoundException.java | 6 +-- .../resource/ResourceHttpRequestHandler.java | 2 +- .../ResponseEntityExceptionHandlerTests.java | 2 +- .../DefaultHandlerExceptionResolverTests.java | 2 +- .../NoResourceFoundExceptionTests.java | 43 +++++++++++++++++++ 8 files changed, 98 insertions(+), 10 deletions(-) create mode 100644 spring-webflux/src/test/java/org/springframework/web/reactive/resource/NoResourceFoundExceptionTests.java create mode 100644 spring-webmvc/src/test/java/org/springframework/web/servlet/resource/NoResourceFoundExceptionTests.java diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/NoResourceFoundException.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/NoResourceFoundException.java index aa59dae0a0f9..4f4ab4cc82ec 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/NoResourceFoundException.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/NoResourceFoundException.java @@ -16,6 +16,8 @@ package org.springframework.web.reactive.resource; +import java.net.URI; + import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; @@ -30,9 +32,9 @@ public class NoResourceFoundException extends ResponseStatusException { - public NoResourceFoundException(String resourcePath) { - super(HttpStatus.NOT_FOUND, "No static resource " + resourcePath + "."); - setDetail(getReason()); + public NoResourceFoundException(URI uri, String resourcePath) { + super(HttpStatus.NOT_FOUND, "No static resource " + resourcePath + " for request '" + uri + "'."); + setDetail("No static resource " + resourcePath + "."); } } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceWebHandler.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceWebHandler.java index d62bec9271d8..3704dcb8d092 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceWebHandler.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceWebHandler.java @@ -420,7 +420,7 @@ public Mono handle(ServerWebExchange exchange) { if (logger.isDebugEnabled()) { logger.debug(exchange.getLogPrefix() + "Resource not found"); } - return Mono.error(new NoResourceFoundException(getResourcePath(exchange))); + return Mono.error(new NoResourceFoundException(exchange.getRequest().getURI(), getResourcePath(exchange))); })) .flatMap(resource -> { try { diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/resource/NoResourceFoundExceptionTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/resource/NoResourceFoundExceptionTests.java new file mode 100644 index 000000000000..38ef9800689c --- /dev/null +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/resource/NoResourceFoundExceptionTests.java @@ -0,0 +1,43 @@ +/* + * Copyright 2002-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.reactive.resource; + +import java.net.URI; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link NoResourceFoundException}. + * @author Brian Clozel + */ +class NoResourceFoundExceptionTests { + + @Test + void messageShouldContainRequestUriAndResourcePath() { + var noResourceFoundException = new NoResourceFoundException(URI.create("/context/resource"), "/resource"); + assertThat(noResourceFoundException.getMessage()).isEqualTo("404 NOT_FOUND \"No static resource /resource for request '/context/resource'.\""); + } + + @Test + void detailShouldContainResourcePath() { + var noResourceFoundException = new NoResourceFoundException(URI.create("/context/resource"), "/resource"); + assertThat(noResourceFoundException.getBody().getDetail()).isEqualTo("No static resource /resource."); + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/NoResourceFoundException.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/NoResourceFoundException.java index 9e791269da2e..593e33ef73ff 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/NoResourceFoundException.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/NoResourceFoundException.java @@ -45,11 +45,11 @@ public class NoResourceFoundException extends ServletException implements ErrorR /** * Create an instance. */ - public NoResourceFoundException(HttpMethod httpMethod, String resourcePath) { - super("No static resource " + resourcePath + "."); + public NoResourceFoundException(HttpMethod httpMethod, String requestUri, String resourcePath) { + super("No static resource " + resourcePath + " for request '" + requestUri + "'."); this.httpMethod = httpMethod; this.resourcePath = resourcePath; - this.body = ProblemDetail.forStatusAndDetail(getStatusCode(), getMessage()); + this.body = ProblemDetail.forStatusAndDetail(getStatusCode(), "No static resource " + resourcePath + "."); } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandler.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandler.java index a89edd5c3383..8949b91d4134 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandler.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandler.java @@ -524,7 +524,7 @@ public void handleRequest(HttpServletRequest request, HttpServletResponse respon Resource resource = getResource(request); if (resource == null) { logger.debug("Resource not found"); - throw new NoResourceFoundException(HttpMethod.valueOf(request.getMethod()), getPath(request)); + throw new NoResourceFoundException(HttpMethod.valueOf(request.getMethod()), request.getRequestURI(), getPath(request)); } if (HttpMethod.OPTIONS.matches(request.getMethod())) { 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 81378a992112..46bc91fd61c2 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 @@ -306,7 +306,7 @@ void noHandlerFoundException() { @Test void noResourceFoundException() { - testException(new NoResourceFoundException(HttpMethod.GET, "/resource")); + testException(new NoResourceFoundException(HttpMethod.GET, "/context/resource", "/resource")); } @Test diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/support/DefaultHandlerExceptionResolverTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/support/DefaultHandlerExceptionResolverTests.java index 3221ccfeeaf8..bfd81a23867d 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/support/DefaultHandlerExceptionResolverTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/support/DefaultHandlerExceptionResolverTests.java @@ -209,7 +209,7 @@ void handleNoHandlerFoundException() { @Test void handleNoResourceFoundException() { - NoResourceFoundException ex = new NoResourceFoundException(HttpMethod.GET, "/resource"); + NoResourceFoundException ex = new NoResourceFoundException(HttpMethod.GET, "/context/resource", "/resource"); ModelAndView mav = exceptionResolver.resolveException(request, response, null, ex); assertThat(mav).as("No ModelAndView returned").isNotNull(); assertThat(mav.isEmpty()).as("No Empty ModelAndView returned").isTrue(); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/NoResourceFoundExceptionTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/NoResourceFoundExceptionTests.java new file mode 100644 index 000000000000..56cf687c1fa7 --- /dev/null +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/NoResourceFoundExceptionTests.java @@ -0,0 +1,43 @@ +/* + * Copyright 2002-present 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.servlet.resource; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpMethod; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link NoResourceFoundException}. + * @author Brian Clozel + */ +class NoResourceFoundExceptionTests { + + @Test + void messageShouldContainRequestUriAndResourcePath() { + var noResourceFoundException = new NoResourceFoundException(HttpMethod.GET, "/context/resource", "/resource"); + assertThat(noResourceFoundException.getMessage()).isEqualTo("No static resource /resource for request '/context/resource'."); + } + + @Test + void detailShouldContainResourcePath() { + var noResourceFoundException = new NoResourceFoundException(HttpMethod.GET, "/context/resource", "/resource"); + assertThat(noResourceFoundException.getBody().getDetail()).isEqualTo("No static resource /resource."); + } + +} From 8c44a610333b04d85f8a099bd78eb0da970d270b Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Fri, 25 Jul 2025 22:38:57 +0200 Subject: [PATCH 007/591] Invalidate cache entries for matching types after singleton creation Closes gh-35239 --- .../support/DefaultListableBeanFactory.java | 9 +++++- .../DefaultListableBeanFactoryTests.java | 29 +++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java index 4173274a3946..cbe60f9a525c 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java @@ -1455,11 +1455,18 @@ protected void checkForAliasCircle(String name, String alias) { } } + @Override + protected void addSingleton(String beanName, Object singletonObject) { + super.addSingleton(beanName, singletonObject); + Predicate> filter = (beanType -> beanType != Object.class && beanType.isInstance(singletonObject)); + this.allBeanNamesByType.keySet().removeIf(filter); + this.singletonBeanNamesByType.keySet().removeIf(filter); + } + @Override public void registerSingleton(String beanName, Object singletonObject) throws IllegalStateException { super.registerSingleton(beanName, singletonObject); updateManualSingletonNames(set -> set.add(beanName), set -> !this.beanDefinitionMap.containsKey(beanName)); - clearByTypeCache(); } @Override diff --git a/spring-beans/src/test/java/org/springframework/beans/factory/DefaultListableBeanFactoryTests.java b/spring-beans/src/test/java/org/springframework/beans/factory/DefaultListableBeanFactoryTests.java index b05c53d87a0d..dc964ad5ff4b 100644 --- a/spring-beans/src/test/java/org/springframework/beans/factory/DefaultListableBeanFactoryTests.java +++ b/spring-beans/src/test/java/org/springframework/beans/factory/DefaultListableBeanFactoryTests.java @@ -3202,6 +3202,29 @@ void nonPublicEnum() { assertThat(holder.getNonPublicEnum()).isEqualTo(NonPublicEnum.VALUE_1); } + @Test + void mostSpecificCacheEntryForTypeMatching() { + RootBeanDefinition bd1 = new RootBeanDefinition(); + bd1.setFactoryBeanName("config"); + bd1.setFactoryMethodName("create"); + lbf.registerBeanDefinition("config", new RootBeanDefinition(BeanWithFactoryMethod.class)); + lbf.registerBeanDefinition("bd1", bd1); + lbf.registerBeanDefinition("bd2", new RootBeanDefinition(NestedTestBean.class)); + lbf.freezeConfiguration(); + + String[] allBeanNames = lbf.getBeanNamesForType(Object.class); + String[] nestedBeanNames = lbf.getBeanNamesForType(NestedTestBean.class); + assertThat(lbf.getType("bd1")).isEqualTo(TestBean.class); + assertThat(lbf.getBeanNamesForType(TestBean.class)).containsExactly("bd1"); + assertThat(lbf.getBeanNamesForType(DerivedTestBean.class)).isEmpty(); + lbf.getBean("bd1"); + assertThat(lbf.getType("bd1")).isEqualTo(DerivedTestBean.class); + assertThat(lbf.getBeanNamesForType(TestBean.class)).containsExactly("bd1"); + assertThat(lbf.getBeanNamesForType(DerivedTestBean.class)).containsExactly("bd1"); + assertThat(lbf.getBeanNamesForType(NestedTestBean.class)).isSameAs(nestedBeanNames); + assertThat(lbf.getBeanNamesForType(Object.class)).isSameAs(allBeanNames); + } + private int registerBeanDefinitions(Properties p) { return registerBeanDefinitions(p, null); @@ -3418,7 +3441,7 @@ public void setName(String name) { } public TestBean create() { - TestBean tb = new TestBean(); + DerivedTestBean tb = new DerivedTestBean(); tb.setName(this.name); return tb; } @@ -3646,11 +3669,11 @@ private static class FactoryBeanDependentBean { private FactoryBean factoryBean; - public final FactoryBean getFactoryBean() { + public FactoryBean getFactoryBean() { return this.factoryBean; } - public final void setFactoryBean(final FactoryBean factoryBean) { + public void setFactoryBean(FactoryBean factoryBean) { this.factoryBean = factoryBean; } } From 3c112703d9dc8e75152e658282f0c75b2a6cbeaa Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Fri, 25 Jul 2025 22:40:09 +0200 Subject: [PATCH 008/591] Introduce useCaches flag on UrlResource (for URLConnection access) Propagated from PathMatchingResourcePatternResolver's setUseCaches. Closes gh-35218 --- .../io/AbstractFileResolvingResource.java | 12 +++- .../core/io/FileUrlResource.java | 4 +- .../springframework/core/io/UrlResource.java | 36 +++++++++- .../PathMatchingResourcePatternResolver.java | 67 +++++++++++++------ 4 files changed, 93 insertions(+), 26 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/core/io/AbstractFileResolvingResource.java b/spring-core/src/main/java/org/springframework/core/io/AbstractFileResolvingResource.java index 39b7287a793f..2aed7cc6967e 100644 --- a/spring-core/src/main/java/org/springframework/core/io/AbstractFileResolvingResource.java +++ b/spring-core/src/main/java/org/springframework/core/io/AbstractFileResolvingResource.java @@ -361,12 +361,22 @@ public long lastModified() throws IOException { * @throws IOException if thrown from URLConnection methods */ protected void customizeConnection(URLConnection con) throws IOException { - ResourceUtils.useCachesIfNecessary(con); + useCachesIfNecessary(con); if (con instanceof HttpURLConnection httpCon) { customizeConnection(httpCon); } } + /** + * Apply {@link URLConnection#setUseCaches useCaches} if necessary. + * @param con the URLConnection to customize + * @since 6.2.10 + * @see ResourceUtils#useCachesIfNecessary(URLConnection) + */ + void useCachesIfNecessary(URLConnection con) { + ResourceUtils.useCachesIfNecessary(con); + } + /** * Customize the given {@link HttpURLConnection} before fetching the resource. *

Can be overridden in subclasses for configuring request headers and timeouts. diff --git a/spring-core/src/main/java/org/springframework/core/io/FileUrlResource.java b/spring-core/src/main/java/org/springframework/core/io/FileUrlResource.java index 300b5be4a3f2..1878432af633 100644 --- a/spring-core/src/main/java/org/springframework/core/io/FileUrlResource.java +++ b/spring-core/src/main/java/org/springframework/core/io/FileUrlResource.java @@ -109,7 +109,9 @@ public WritableByteChannel writableChannel() throws IOException { @Override public Resource createRelative(String relativePath) throws MalformedURLException { - return new FileUrlResource(createRelativeURL(relativePath)); + FileUrlResource resource = new FileUrlResource(createRelativeURL(relativePath)); + resource.useCaches = this.useCaches; + return resource; } } diff --git a/spring-core/src/main/java/org/springframework/core/io/UrlResource.java b/spring-core/src/main/java/org/springframework/core/io/UrlResource.java index 80ce1680590d..8ca9b80c0dcf 100644 --- a/spring-core/src/main/java/org/springframework/core/io/UrlResource.java +++ b/spring-core/src/main/java/org/springframework/core/io/UrlResource.java @@ -67,6 +67,12 @@ public class UrlResource extends AbstractFileResolvingResource { @Nullable private volatile String cleanedUrl; + /** + * Whether to use URLConnection caches ({@code null} means default). + */ + @Nullable + volatile Boolean useCaches; + /** * Create a new {@code UrlResource} based on the given URL object. @@ -216,11 +222,22 @@ private String getCleanedUrl() { return cleanedUrl; } + /** + * Set an explicit flag for {@link URLConnection#setUseCaches}, + * to be applied for any {@link URLConnection} operation in this resource. + *

By default, caching will be applied only to jar resources. + * An explicit {@code true} flag applies caching to all resources, whereas an + * explicit {@code false} flag turns off caching for jar resources as well. + * @since 6.2.10 + * @see ResourceUtils#useCachesIfNecessary + */ + public void setUseCaches(boolean useCaches) { + this.useCaches = useCaches; + } + /** * This implementation opens an InputStream for the given URL. - *

It sets the {@code useCaches} flag to {@code false}, - * mainly to avoid jar file locking on Windows. * @see java.net.URL#openConnection() * @see java.net.URLConnection#setUseCaches(boolean) * @see java.net.URLConnection#getInputStream() @@ -251,6 +268,17 @@ protected void customizeConnection(URLConnection con) throws IOException { } } + @Override + void useCachesIfNecessary(URLConnection con) { + Boolean useCaches = this.useCaches; + if (useCaches != null) { + con.setUseCaches(useCaches); + } + else { + super.useCachesIfNecessary(con); + } + } + /** * This implementation returns the underlying URL reference. */ @@ -305,7 +333,9 @@ public File getFile() throws IOException { */ @Override public Resource createRelative(String relativePath) throws MalformedURLException { - return new UrlResource(createRelativeURL(relativePath)); + UrlResource resource = new UrlResource(createRelativeURL(relativePath)); + resource.useCaches = this.useCaches; + return resource; } /** diff --git a/spring-core/src/main/java/org/springframework/core/io/support/PathMatchingResourcePatternResolver.java b/spring-core/src/main/java/org/springframework/core/io/support/PathMatchingResourcePatternResolver.java index 1634796d1573..0ecabb7e19d9 100644 --- a/spring-core/src/main/java/org/springframework/core/io/support/PathMatchingResourcePatternResolver.java +++ b/spring-core/src/main/java/org/springframework/core/io/support/PathMatchingResourcePatternResolver.java @@ -260,7 +260,8 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol private PathMatcher pathMatcher = new AntPathMatcher(); - private boolean useCaches = true; + @Nullable + private Boolean useCaches; private final Map rootDirCache = new ConcurrentHashMap<>(); @@ -342,10 +343,12 @@ public PathMatcher getPathMatcher() { * the {@link JarURLConnection} level as well as within this resolver instance. *

Note that {@link JarURLConnection#setDefaultUseCaches} can be turned off * independently. This resolver-level setting is designed to only enforce - * {@code JarURLConnection#setUseCaches(false)} if necessary but otherwise - * leaves the JVM-level default in place. + * {@code JarURLConnection#setUseCaches(true/false)} if necessary but otherwise + * leaves the JVM-level default in place (if this setter has not been called). + *

As of 6.2.10, this setting propagates to {@link UrlResource#setUseCaches}. * @since 6.1.19 * @see JarURLConnection#setUseCaches + * @see UrlResource#setUseCaches * @see #clearCache() */ public void setUseCaches(boolean useCaches) { @@ -355,7 +358,11 @@ public void setUseCaches(boolean useCaches) { @Override public Resource getResource(String location) { - return getResourceLoader().getResource(location); + Resource resource = getResourceLoader().getResource(location); + if (this.useCaches != null && resource instanceof UrlResource urlResource) { + urlResource.setUseCaches(this.useCaches); + } + return resource; } @Override @@ -473,20 +480,27 @@ protected Resource convertClassLoaderURL(URL url) { } } else { + UrlResource resource = null; String urlString = url.toString(); String cleanedPath = StringUtils.cleanPath(urlString); if (!cleanedPath.equals(urlString)) { // Prefer cleaned URL, aligned with UrlResource#createRelative(String) try { // Retain original URL instance, potentially including custom URLStreamHandler. - return new UrlResource(new URL(url, cleanedPath)); + resource = new UrlResource(new URL(url, cleanedPath)); } catch (MalformedURLException ex) { // Fallback to regular URL construction below... } } // Retain original URL instance, potentially including custom URLStreamHandler. - return new UrlResource(url); + if (resource == null) { + resource = new UrlResource(url); + } + if (this.useCaches != null) { + resource.setUseCaches(this.useCaches); + } + return resource; } } @@ -505,6 +519,9 @@ protected void addAllClassLoaderJarRoots(@Nullable ClassLoader classLoader, Set< UrlResource jarResource = (ResourceUtils.URL_PROTOCOL_JAR.equals(url.getProtocol()) ? new UrlResource(url) : new UrlResource(ResourceUtils.JAR_URL_PREFIX + url + ResourceUtils.JAR_URL_SEPARATOR)); + if (this.useCaches != null) { + jarResource.setUseCaches(this.useCaches); + } if (jarResource.exists()) { result.add(jarResource); } @@ -556,7 +573,7 @@ protected void addClassPathManifestEntries(Set result) { Set entries = this.manifestEntriesCache; if (entries == null) { entries = getClassPathManifestEntries(); - if (this.useCaches) { + if (this.useCaches == null || this.useCaches) { this.manifestEntriesCache = entries; } } @@ -577,7 +594,7 @@ private Set getClassPathManifestEntries() { try { File jar = new File(path).getAbsoluteFile(); if (jar.isFile() && seen.add(jar)) { - manifestEntries.add(ClassPathManifestEntry.of(jar)); + manifestEntries.add(ClassPathManifestEntry.of(jar, this.useCaches)); manifestEntries.addAll(getClassPathManifestEntriesFromJar(jar)); } } @@ -616,7 +633,7 @@ private Set getClassPathManifestEntriesFromJar(File jar) } File candidate = new File(parent, path); if (candidate.isFile() && candidate.getCanonicalPath().contains(parent.getCanonicalPath())) { - manifestEntries.add(ClassPathManifestEntry.of(candidate)); + manifestEntries.add(ClassPathManifestEntry.of(candidate, this.useCaches)); } } } @@ -710,7 +727,7 @@ else if (commonPrefix.equals(rootDirPath)) { if (rootDirResources == null) { // Lookup for specific directory, creating a cache entry for it. rootDirResources = getResources(rootDirPath); - if (this.useCaches) { + if (this.useCaches == null || this.useCaches) { this.rootDirCache.put(rootDirPath, rootDirResources); } } @@ -729,7 +746,11 @@ else if (commonPrefix.equals(rootDirPath)) { if (resolvedUrl != null) { rootDirUrl = resolvedUrl; } - rootDirResource = new UrlResource(rootDirUrl); + UrlResource urlResource = new UrlResource(rootDirUrl); + if (this.useCaches != null) { + urlResource.setUseCaches(this.useCaches); + } + rootDirResource = urlResource; } if (rootDirUrl.getProtocol().startsWith(ResourceUtils.URL_PROTOCOL_VFS)) { result.addAll(VfsResourceMatchingDelegate.findMatchingResources(rootDirUrl, subPattern, getPathMatcher())); @@ -865,8 +886,8 @@ protected Set doFindPathMatchingJarResources(Resource rootDirResource, if (con instanceof JarURLConnection jarCon) { // Should usually be the case for traditional JAR files. - if (!this.useCaches) { - jarCon.setUseCaches(false); + if (this.useCaches != null) { + jarCon.setUseCaches(this.useCaches); } try { jarFile = jarCon.getJarFile(); @@ -931,7 +952,7 @@ protected Set doFindPathMatchingJarResources(Resource rootDirResource, } } } - if (this.useCaches) { + if (this.useCaches == null || this.useCaches) { // Cache jar entries in TreeSet for efficient searching on re-encounter. this.jarEntriesCache.put(jarFileUrl, entriesCache); } @@ -1257,10 +1278,10 @@ private record ClassPathManifestEntry(Resource resource, @Nullable Resource alte private static final String JARFILE_URL_PREFIX = ResourceUtils.JAR_URL_PREFIX + ResourceUtils.FILE_URL_PREFIX; - static ClassPathManifestEntry of(File file) throws MalformedURLException { + static ClassPathManifestEntry of(File file, @Nullable Boolean useCaches) throws MalformedURLException { String path = fixPath(file.getAbsolutePath()); - Resource resource = asJarFileResource(path); - Resource alternative = createAlternative(path); + Resource resource = asJarFileResource(path, useCaches); + Resource alternative = createAlternative(path, useCaches); return new ClassPathManifestEntry(resource, alternative); } @@ -1281,18 +1302,22 @@ private static String fixPath(String path) { * @return the alternative form or {@code null} */ @Nullable - private static Resource createAlternative(String path) { + private static Resource createAlternative(String path, @Nullable Boolean useCaches) { try { String alternativePath = path.startsWith("/") ? path.substring(1) : "/" + path; - return asJarFileResource(alternativePath); + return asJarFileResource(alternativePath, useCaches); } catch (MalformedURLException ex) { return null; } } - private static Resource asJarFileResource(String path) throws MalformedURLException { - return new UrlResource(JARFILE_URL_PREFIX + path + ResourceUtils.JAR_URL_SEPARATOR); + private static Resource asJarFileResource(String path, @Nullable Boolean useCaches) throws MalformedURLException { + UrlResource resource = new UrlResource(JARFILE_URL_PREFIX + path + ResourceUtils.JAR_URL_SEPARATOR); + if (useCaches != null) { + resource.setUseCaches(useCaches); + } + return resource; } } From 4f6304707dd94d2fe424d557ff61889bbfa3ad75 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Fri, 25 Jul 2025 22:40:15 +0200 Subject: [PATCH 009/591] Polishing --- .../servlet/resource/PathResourceResolverTests.java | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/PathResourceResolverTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/PathResourceResolverTests.java index e0dd66c78671..48cf379740fe 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/PathResourceResolverTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/PathResourceResolverTests.java @@ -183,21 +183,19 @@ private static class TestUrlResource extends UrlResource { private String relativePath; - public TestUrlResource(String path) throws MalformedURLException { super(path); } - - public String getSavedRelativePath() { - return this.relativePath; - } - @Override public Resource createRelative(String relativePath) { this.relativePath = relativePath; return this; } + + public String getSavedRelativePath() { + return this.relativePath; + } } } From 7316aab04803af173e89f2cfbce0963c2e058423 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Fri, 25 Jul 2025 22:43:57 +0200 Subject: [PATCH 010/591] Align @Nullable annotation --- .../src/main/java/org/springframework/core/io/UrlResource.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/core/io/UrlResource.java b/spring-core/src/main/java/org/springframework/core/io/UrlResource.java index bfaa26273560..af41150cdf50 100644 --- a/spring-core/src/main/java/org/springframework/core/io/UrlResource.java +++ b/spring-core/src/main/java/org/springframework/core/io/UrlResource.java @@ -69,8 +69,7 @@ public class UrlResource extends AbstractFileResolvingResource { /** * Whether to use URLConnection caches ({@code null} means default). */ - @Nullable - volatile Boolean useCaches; + volatile @Nullable Boolean useCaches; /** From c7fbf7809fbbbd408be8cd668f2eab0b9111b733 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Sat, 26 Jul 2025 11:38:02 +0200 Subject: [PATCH 011/591] Provide @WebSocketScope annotation and public SCOPE_WEBSOCKET constant Closes gh-35235 --- .../ROOT/pages/web/websocket/stomp/scope.adoc | 10 ++-- ...cketMessageBrokerConfigurationSupport.java | 10 +++- .../config/annotation/WebSocketScope.java | 60 +++++++++++++++++++ 3 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 spring-websocket/src/main/java/org/springframework/web/socket/config/annotation/WebSocketScope.java diff --git a/framework-docs/modules/ROOT/pages/web/websocket/stomp/scope.adoc b/framework-docs/modules/ROOT/pages/web/websocket/stomp/scope.adoc index b066e00e56a5..0be9eecb94a4 100644 --- a/framework-docs/modules/ROOT/pages/web/websocket/stomp/scope.adoc +++ b/framework-docs/modules/ROOT/pages/web/websocket/stomp/scope.adoc @@ -1,8 +1,8 @@ [[websocket-stomp-websocket-scope]] = WebSocket Scope -Each WebSocket session has a map of attributes. The map is attached as a header to -inbound client messages and may be accessed from a controller method, as the following example shows: +Each WebSocket session has a map of attributes. The map is attached as a header to inbound +client messages and may be accessed from a controller method, as the following example shows: [source,java,indent=0,subs="verbatim,quotes"] ---- @@ -20,13 +20,13 @@ public class MyController { You can declare a Spring-managed bean in the `websocket` scope. You can inject WebSocket-scoped beans into controllers and any channel interceptors registered on the `clientInboundChannel`. Those are typically singletons and live -longer than any individual WebSocket session. Therefore, you need to use a -scope proxy mode for WebSocket-scoped beans, as the following example shows: +longer than any individual WebSocket session. Therefore, you need to use +WebSocket-scoped beans in proxy mode, conveniently defined with `@WebSocketScope`: [source,java,indent=0,subs="verbatim,quotes"] ---- @Component - @Scope(scopeName = "websocket", proxyMode = ScopedProxyMode.TARGET_CLASS) + @WebSocketScope public class MyBean { @PostConstruct diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/config/annotation/WebSocketMessageBrokerConfigurationSupport.java b/spring-websocket/src/main/java/org/springframework/web/socket/config/annotation/WebSocketMessageBrokerConfigurationSupport.java index 31a65520dcc1..004ccc5768aa 100644 --- a/spring-websocket/src/main/java/org/springframework/web/socket/config/annotation/WebSocketMessageBrokerConfigurationSupport.java +++ b/spring-websocket/src/main/java/org/springframework/web/socket/config/annotation/WebSocketMessageBrokerConfigurationSupport.java @@ -54,10 +54,18 @@ * @author Rossen Stoyanchev * @author Artem Bilan * @author Sebastien Deleuze + * @author Juergen Hoeller * @since 4.0 */ public abstract class WebSocketMessageBrokerConfigurationSupport extends AbstractMessageBrokerConfiguration { + /** + * Scope identifier for WebSocket scope: "websocket". + * @since 7.0 + */ + public static final String SCOPE_WEBSOCKET = "websocket"; + + private @Nullable WebSocketTransportRegistration transportRegistration; @@ -137,7 +145,7 @@ protected void configureWebSocketTransport(WebSocketTransportRegistration regist @Bean public static CustomScopeConfigurer webSocketScopeConfigurer() { CustomScopeConfigurer configurer = new CustomScopeConfigurer(); - configurer.addScope("websocket", new SimpSessionScope()); + configurer.addScope(SCOPE_WEBSOCKET, new SimpSessionScope()); return configurer; } diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/config/annotation/WebSocketScope.java b/spring-websocket/src/main/java/org/springframework/web/socket/config/annotation/WebSocketScope.java new file mode 100644 index 000000000000..1b9fce7bffea --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/config/annotation/WebSocketScope.java @@ -0,0 +1,60 @@ +/* + * Copyright 2002-present 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.socket.config.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.context.annotation.Scope; +import org.springframework.context.annotation.ScopedProxyMode; +import org.springframework.core.annotation.AliasFor; + +/** + * {@code @WebSocketScope} is a specialization of {@link Scope @Scope} for a + * component whose lifecycle is bound to the current WebSocket lifecycle. + * + *

Specifically, {@code @WebSocketScope} is a composed annotation that + * acts as a shortcut for {@code @Scope("websocket")} with the default + * {@link #proxyMode} set to {@link ScopedProxyMode#TARGET_CLASS TARGET_CLASS}. + * + *

{@code @WebSocketScope} may be used as a meta-annotation to create custom + * composed annotations. + * + * @author Juergen Hoeller + * @since 7.0 + * @see org.springframework.context.annotation.Scope + * @see WebSocketMessageBrokerConfigurationSupport#SCOPE_WEBSOCKET + * @see org.springframework.stereotype.Component + * @see org.springframework.context.annotation.Bean + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Scope(WebSocketMessageBrokerConfigurationSupport.SCOPE_WEBSOCKET) +public @interface WebSocketScope { + + /** + * Alias for {@link Scope#proxyMode}. + *

Defaults to {@link ScopedProxyMode#TARGET_CLASS}. + */ + @AliasFor(annotation = Scope.class) + ScopedProxyMode proxyMode() default ScopedProxyMode.TARGET_CLASS; + +} From 48506db9962ec6937731ee8c810c731ed624af4c Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Fri, 25 Jul 2025 06:04:53 +0100 Subject: [PATCH 012/591] Avoid IllegalStateException for unversioned request Closes gh-35236 --- .../result/condition/VersionRequestCondition.java | 3 +-- .../condition/VersionRequestConditionTests.java | 11 +++++++++++ .../mvc/condition/VersionRequestCondition.java | 3 +-- .../mvc/condition/VersionRequestConditionTests.java | 11 +++++++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/VersionRequestCondition.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/VersionRequestCondition.java index 5d701f28f304..45302f7af2b1 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/VersionRequestCondition.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/VersionRequestCondition.java @@ -149,8 +149,7 @@ else if (this.version != null && otherVersion != null) { public void handleMatch(ServerWebExchange exchange) { if (this.version != null && !this.baselineVersion) { Comparable version = exchange.getAttribute(HandlerMapping.API_VERSION_ATTRIBUTE); - Assert.state(version != null, "No API version attribute"); - if (!this.version.equals(version)) { + if (version != null && !this.version.equals(version)) { throw new NotAcceptableApiVersionException(version.toString()); } } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java index 2103ccc35420..0f57ce25d262 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java @@ -148,6 +148,17 @@ private void testCompare(String expected, String... versions) { assertThat(list.get(0)).isEqualTo(condition(expected)); } + @Test // gh-35236 + void noRequestVersion() { + MockServerWebExchange exchange = exchange(); + VersionRequestCondition condition = condition("1.1"); + + VersionRequestCondition match = condition.getMatchingCondition(exchange); + assertThat(match).isSameAs(condition); + + condition.handleMatch(exchange); + } + private VersionRequestCondition condition(String v) { this.strategy.addSupportedVersion(v.endsWith("+") ? v.substring(0, v.length() - 1) : v); return new VersionRequestCondition(v, this.strategy); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/VersionRequestCondition.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/VersionRequestCondition.java index fd824ee83053..dce48a3b651e 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/VersionRequestCondition.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/VersionRequestCondition.java @@ -148,8 +148,7 @@ else if (this.version != null && otherVersion != null) { public void handleMatch(HttpServletRequest request) { if (this.version != null && !this.baselineVersion) { Comparable version = (Comparable) request.getAttribute(HandlerMapping.API_VERSION_ATTRIBUTE); - Assert.state(version != null, "No API version attribute"); - if (!this.version.equals(version)) { + if (version != null && !this.version.equals(version)) { throw new NotAcceptableApiVersionException(version.toString()); } } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java index 27c709600830..543de9e18f7b 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java @@ -146,6 +146,17 @@ private void testCompare(String expected, String... versions) { assertThat(list.get(0)).isEqualTo(condition(expected)); } + @Test // gh-35236 + void noRequestVersion() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/path"); + VersionRequestCondition condition = condition("1.1"); + + VersionRequestCondition match = condition.getMatchingCondition(request); + assertThat(match).isSameAs(condition); + + condition.handleMatch(request); + } + private VersionRequestCondition condition(String v) { this.strategy.addSupportedVersion(v.endsWith("+") ? v.substring(0, v.length() - 1) : v); return new VersionRequestCondition(v, this.strategy); From 223812135082bc554ce6adf08a8dd3987c966eed Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Mon, 28 Jul 2025 06:26:29 +0100 Subject: [PATCH 013/591] Prefer mapping without version for unversioned request Closes gh-35237 --- .../result/condition/VersionRequestCondition.java | 4 +++- .../result/condition/VersionRequestConditionTests.java | 10 ++++++++++ .../servlet/mvc/condition/VersionRequestCondition.java | 4 +++- .../mvc/condition/VersionRequestConditionTests.java | 10 ++++++++++ 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/VersionRequestCondition.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/VersionRequestCondition.java index 45302f7af2b1..b762aec91d2f 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/VersionRequestCondition.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/VersionRequestCondition.java @@ -128,7 +128,9 @@ else if (this.version != null && otherVersion != null) { return (-1 * compareVersions(this.version, otherVersion)); } else { - return (this.version != null ? -1 : 1); + // Prefer mapping without version for unversioned request + Comparable version = exchange.getAttribute(HandlerMapping.API_VERSION_ATTRIBUTE); + return (version != null ? (this.version != null ? -1 : 1) : (this.version != null ? 1 : -1)); } } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java index 0f57ce25d262..06940692aa1d 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java @@ -18,6 +18,7 @@ import java.util.Arrays; import java.util.List; +import java.util.stream.Stream; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.BeforeEach; @@ -159,6 +160,15 @@ void noRequestVersion() { condition.handleMatch(exchange); } + @Test + void compareWithoutRequestVersion() { + VersionRequestCondition condition = Stream.of(condition("1.1"), condition("1.2"), emptyCondition()) + .min((c1, c2) -> c1.compareTo(c2, exchange())) + .get(); + + assertThat(condition).isEqualTo(emptyCondition()); + } + private VersionRequestCondition condition(String v) { this.strategy.addSupportedVersion(v.endsWith("+") ? v.substring(0, v.length() - 1) : v); return new VersionRequestCondition(v, this.strategy); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/VersionRequestCondition.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/VersionRequestCondition.java index dce48a3b651e..2bc8f0a33e8a 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/VersionRequestCondition.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/VersionRequestCondition.java @@ -127,7 +127,9 @@ else if (this.version != null && otherVersion != null) { return (-1 * compareVersions(this.version, otherVersion)); } else { - return (this.version != null ? -1 : 1); + // Prefer mapping without version for unversioned request + Comparable version = (Comparable) request.getAttribute(HandlerMapping.API_VERSION_ATTRIBUTE); + return (version != null ? (this.version != null ? -1 : 1) : (this.version != null ? 1 : -1)); } } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java index 543de9e18f7b..5ee4ddff5c35 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java @@ -18,6 +18,7 @@ import java.util.Arrays; import java.util.List; +import java.util.stream.Stream; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.BeforeEach; @@ -146,6 +147,15 @@ private void testCompare(String expected, String... versions) { assertThat(list.get(0)).isEqualTo(condition(expected)); } + @Test + void compareWithoutRequestVersion() { + VersionRequestCondition condition = Stream.of(condition("1.1"), condition("1.2"), emptyCondition()) + .min((c1, c2) -> c1.compareTo(c2, new MockHttpServletRequest())) + .get(); + + assertThat(condition).isEqualTo(emptyCondition()); + } + @Test // gh-35236 void noRequestVersion() { MockHttpServletRequest request = new MockHttpServletRequest("GET", "/path"); From 2c32c770d5fab6764c9d83c323fbaac471d94c2f Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Mon, 28 Jul 2025 09:06:19 +0100 Subject: [PATCH 014/591] Polishing in VersionRequestCondition See gh-35237 --- .../reactive/result/condition/VersionRequestCondition.java | 5 +++-- .../web/servlet/mvc/condition/VersionRequestCondition.java | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/VersionRequestCondition.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/VersionRequestCondition.java index b762aec91d2f..1d0843f289a6 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/VersionRequestCondition.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/VersionRequestCondition.java @@ -128,9 +128,10 @@ else if (this.version != null && otherVersion != null) { return (-1 * compareVersions(this.version, otherVersion)); } else { - // Prefer mapping without version for unversioned request + // Prefer mappings with a version unless the request is without a version + int result = this.version != null ? -1 : 1; Comparable version = exchange.getAttribute(HandlerMapping.API_VERSION_ATTRIBUTE); - return (version != null ? (this.version != null ? -1 : 1) : (this.version != null ? 1 : -1)); + return (version == null ? -1 * result : result); } } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/VersionRequestCondition.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/VersionRequestCondition.java index 2bc8f0a33e8a..cc71d473bf7a 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/VersionRequestCondition.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/VersionRequestCondition.java @@ -127,9 +127,10 @@ else if (this.version != null && otherVersion != null) { return (-1 * compareVersions(this.version, otherVersion)); } else { - // Prefer mapping without version for unversioned request + // Prefer mappings with a version unless the request is without a version + int result = this.version != null ? -1 : 1; Comparable version = (Comparable) request.getAttribute(HandlerMapping.API_VERSION_ATTRIBUTE); - return (version != null ? (this.version != null ? -1 : 1) : (this.version != null ? 1 : -1)); + return (version == null ? -1 * result : result); } } From 642e554c5208fe7458f3b34b049694fb55b03d22 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Mon, 28 Jul 2025 20:28:45 +0200 Subject: [PATCH 015/591] Process PostgreSQL-returned catalog/schema names in given case Closes gh-35064 --- .../metadata/GenericTableMetaDataProvider.java | 14 +++++++++----- .../PostgresTableMetaDataProvider.java | 18 +++++++++++++++++- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/GenericTableMetaDataProvider.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/GenericTableMetaDataProvider.java index c01516b12763..a694b7500feb 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/GenericTableMetaDataProvider.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/GenericTableMetaDataProvider.java @@ -225,19 +225,23 @@ else if (isStoresLowerCaseIdentifiers()) { } } + /** + * This implementation delegates to {@link #catalogNameToUse}. + */ @Override @Nullable public String metaDataCatalogNameToUse(@Nullable String catalogName) { return catalogNameToUse(catalogName); } + /** + * This implementation delegates to {@link #schemaNameToUse}. + * @see #getDefaultSchema() + */ @Override @Nullable public String metaDataSchemaNameToUse(@Nullable String schemaName) { - if (schemaName == null) { - return schemaNameToUse(getDefaultSchema()); - } - return schemaNameToUse(schemaName); + return schemaNameToUse(schemaName != null ? schemaName : getDefaultSchema()); } /** @@ -401,7 +405,7 @@ private void processTableColumns(DatabaseMetaData databaseMetaData, TableMetaDat try { tableColumns = databaseMetaData.getColumns( metaDataCatalogName, metaDataSchemaName, metaDataTableName, null); - while (tableColumns.next()) { + while (tableColumns != null && tableColumns.next()) { String columnName = tableColumns.getString("COLUMN_NAME"); int dataType = tableColumns.getInt("DATA_TYPE"); if (dataType == Types.DECIMAL) { diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/PostgresTableMetaDataProvider.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/PostgresTableMetaDataProvider.java index fa9338a7d2ee..cfea217acf16 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/PostgresTableMetaDataProvider.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/PostgresTableMetaDataProvider.java @@ -19,12 +19,16 @@ import java.sql.DatabaseMetaData; import java.sql.SQLException; +import org.springframework.lang.Nullable; + /** * The PostgreSQL specific implementation of {@link TableMetaDataProvider}. * Supports a feature for retrieving generated keys without the JDBC 3.0 - * {@code getGeneratedKeys} support. + * {@code getGeneratedKeys} support. Also, it processes PostgreSQL-returned + * catalog and schema names from {@code DatabaseMetaData} in the given case. * * @author Thomas Risberg + * @author Juergen Hoeller * @since 2.5 */ public class PostgresTableMetaDataProvider extends GenericTableMetaDataProvider { @@ -34,6 +38,18 @@ public PostgresTableMetaDataProvider(DatabaseMetaData databaseMetaData) throws S } + @Override + @Nullable + public String metaDataCatalogNameToUse(@Nullable String catalogName) { + return catalogName; + } + + @Override + @Nullable + public String metaDataSchemaNameToUse(@Nullable String schemaName) { + return (schemaName != null ? schemaName : getDefaultSchema()); + } + @Override public boolean isGetGeneratedKeysSimulated() { return true; From dacaf7fd563cef775a07efa216e7c8447959237b Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Mon, 28 Jul 2025 20:33:33 +0200 Subject: [PATCH 016/591] Align @Nullable annotation --- .../jdbc/core/metadata/PostgresTableMetaDataProvider.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/PostgresTableMetaDataProvider.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/PostgresTableMetaDataProvider.java index cfea217acf16..614c532b88ad 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/PostgresTableMetaDataProvider.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/PostgresTableMetaDataProvider.java @@ -19,7 +19,7 @@ import java.sql.DatabaseMetaData; import java.sql.SQLException; -import org.springframework.lang.Nullable; +import org.jspecify.annotations.Nullable; /** * The PostgreSQL specific implementation of {@link TableMetaDataProvider}. @@ -39,14 +39,12 @@ public PostgresTableMetaDataProvider(DatabaseMetaData databaseMetaData) throws S @Override - @Nullable - public String metaDataCatalogNameToUse(@Nullable String catalogName) { + public @Nullable String metaDataCatalogNameToUse(@Nullable String catalogName) { return catalogName; } @Override - @Nullable - public String metaDataSchemaNameToUse(@Nullable String schemaName) { + public @Nullable String metaDataSchemaNameToUse(@Nullable String schemaName) { return (schemaName != null ? schemaName : getDefaultSchema()); } From 16e99f289c53185b31b0f3e75ace206181794bc7 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Mon, 28 Jul 2025 22:04:18 +0200 Subject: [PATCH 017/591] Accept support for generated keys column name array on HSQLDB/Derby Closes gh-34790 --- .../GenericTableMetaDataProvider.java | 41 +------------------ 1 file changed, 2 insertions(+), 39 deletions(-) diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/GenericTableMetaDataProvider.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/GenericTableMetaDataProvider.java index a694b7500feb..56701af1e242 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/GenericTableMetaDataProvider.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/metadata/GenericTableMetaDataProvider.java @@ -21,7 +21,6 @@ import java.sql.SQLException; import java.sql.Types; import java.util.ArrayList; -import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -48,11 +47,6 @@ public class GenericTableMetaDataProvider implements TableMetaDataProvider { /** Logger available to subclasses. */ protected static final Log logger = LogFactory.getLog(TableMetaDataProvider.class); - /** Database products we know not supporting the use of a String[] for generated keys. */ - private static final List productsNotSupportingGeneratedKeysColumnNameArray = - Arrays.asList("Apache Derby", "HSQL Database Engine"); - - /** The name of the user currently connected. */ @Nullable private final String userName; @@ -95,45 +89,14 @@ protected GenericTableMetaDataProvider(DatabaseMetaData databaseMetaData) throws @Override public void initializeWithMetaData(DatabaseMetaData databaseMetaData) throws SQLException { try { - if (databaseMetaData.supportsGetGeneratedKeys()) { - logger.debug("GetGeneratedKeys is supported"); - setGetGeneratedKeysSupported(true); - } - else { - logger.debug("GetGeneratedKeys is not supported"); - setGetGeneratedKeysSupported(false); - } + setGetGeneratedKeysSupported(databaseMetaData.supportsGetGeneratedKeys()); + setGeneratedKeysColumnNameArraySupported(isGetGeneratedKeysSupported()); } catch (SQLException ex) { if (logger.isWarnEnabled()) { logger.warn("Error retrieving 'DatabaseMetaData.supportsGetGeneratedKeys': " + ex.getMessage()); } } - try { - String databaseProductName = databaseMetaData.getDatabaseProductName(); - if (productsNotSupportingGeneratedKeysColumnNameArray.contains(databaseProductName)) { - if (logger.isDebugEnabled()) { - logger.debug("GeneratedKeysColumnNameArray is not supported for " + databaseProductName); - } - setGeneratedKeysColumnNameArraySupported(false); - } - else { - if (isGetGeneratedKeysSupported()) { - if (logger.isDebugEnabled()) { - logger.debug("GeneratedKeysColumnNameArray is supported for " + databaseProductName); - } - setGeneratedKeysColumnNameArraySupported(true); - } - else { - setGeneratedKeysColumnNameArraySupported(false); - } - } - } - catch (SQLException ex) { - if (logger.isWarnEnabled()) { - logger.warn("Error retrieving 'DatabaseMetaData.getDatabaseProductName': " + ex.getMessage()); - } - } try { this.databaseVersion = databaseMetaData.getDatabaseProductVersion(); From f3832c7262514e8ef0ccf07ce198b0fd88b1a6b0 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Mon, 28 Jul 2025 22:06:38 +0200 Subject: [PATCH 018/591] Add note on SQL types with SqlBinaryValue/SqlCharacterValue Closes gh-34786 --- .../jdbc/core/support/SqlBinaryValue.java | 12 +++++++++--- .../jdbc/core/support/SqlCharacterValue.java | 11 ++++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlBinaryValue.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlBinaryValue.java index 46672b4b0a5e..fd796de79cab 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlBinaryValue.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlBinaryValue.java @@ -34,12 +34,18 @@ * *

Designed for use with {@link org.springframework.jdbc.core.JdbcTemplate} * as well as {@link org.springframework.jdbc.core.simple.JdbcClient}, to be - * passed in as a parameter value wrapping the target content value. Can be - * combined with {@link org.springframework.jdbc.core.SqlParameterValue} for - * specifying a SQL type, for example, + * passed in as a parameter value wrapping the target content value. + * + *

Can be combined with {@link org.springframework.jdbc.core.SqlParameterValue} + * for specifying a SQL type, for example, * {@code new SqlParameterValue(Types.BLOB, new SqlBinaryValue(myContent))}. * With most database drivers, the type hint is not actually necessary. * + *

Note: Only specify {@code Types.BLOB} in case of an actual BLOB, preferring + * {@code Types.LONGVARBINARY} otherwise. With PostgreSQL, {@code Types.ARRAY} + * has to be specified for BYTEA columns, rather than {@code Types.BLOB}. This + * is in contrast to {@link SqlLobValue} where byte array handling was lenient. + * * @author Juergen Hoeller * @since 6.1.4 * @see SqlCharacterValue diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlCharacterValue.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlCharacterValue.java index e5f3f42deebd..97b1587c558d 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlCharacterValue.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/support/SqlCharacterValue.java @@ -33,12 +33,17 @@ * *

Designed for use with {@link org.springframework.jdbc.core.JdbcTemplate} * as well as {@link org.springframework.jdbc.core.simple.JdbcClient}, to be - * passed in as a parameter value wrapping the target content value. Can be - * combined with {@link org.springframework.jdbc.core.SqlParameterValue} for - * specifying a SQL type, for example, + * passed in as a parameter value wrapping the target content value. + * + *

Can be combined with {@link org.springframework.jdbc.core.SqlParameterValue} + * for specifying a SQL type, for example, * {@code new SqlParameterValue(Types.CLOB, new SqlCharacterValue(myContent))}. * With most database drivers, the type hint is not actually necessary. * + *

Note: Only specify {@code Types.CLOB} in case of an actual CLOB, preferring + * {@code Types.LONGVARCHAR} otherwise. This is in contrast to {@link SqlLobValue} + * where char sequence handling was lenient. + * * @author Juergen Hoeller * @since 6.1.4 * @see SqlBinaryValue From 24e66b63d1d12c45e4af333d6271451083f81e90 Mon Sep 17 00:00:00 2001 From: Patrick Strawderman Date: Fri, 28 Mar 2025 11:54:16 -0700 Subject: [PATCH 019/591] Refine StringUtils#uriDecode and update documentation Refine the StringUtils#uriDecode method in the following ways: - Use a StringBuilder instead of ByteArrayOutputStream, and only decode %-encoded sequences. - Use HexFormat.fromHexDigits to decode hex sequences. - Decode to a byte array that is only allocated if encoded sequences are encountered. This commit adds another optimization mainly for the use case where there is no encoded sequence, and updates the Javadoc of both StringUtils#uriDecode and UriUtils#decode to match the implementation. Signed-off-by: Patrick Strawderman Co-Authored-by: Sebastien Deleuze Closes gh-35253 --- .../org/springframework/util/StringUtils.java | 68 ++++++++++--------- .../springframework/web/util/UriUtils.java | 11 +-- .../web/util/UriUtilsTests.java | 9 +++ 3 files changed, 52 insertions(+), 36 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/util/StringUtils.java b/spring-core/src/main/java/org/springframework/util/StringUtils.java index 8b2553ce2766..760fc3da87a4 100644 --- a/spring-core/src/main/java/org/springframework/util/StringUtils.java +++ b/spring-core/src/main/java/org/springframework/util/StringUtils.java @@ -16,7 +16,6 @@ package org.springframework.util; -import java.io.ByteArrayOutputStream; import java.nio.charset.Charset; import java.util.ArrayDeque; import java.util.ArrayList; @@ -25,6 +24,7 @@ import java.util.Collections; import java.util.Deque; import java.util.Enumeration; +import java.util.HexFormat; import java.util.Iterator; import java.util.LinkedHashSet; import java.util.List; @@ -803,54 +803,60 @@ public static boolean pathEquals(String path1, String path2) { } /** - * Decode the given encoded URI component value. Based on the following rules: - *

    - *
  • Alphanumeric characters {@code "a"} through {@code "z"}, {@code "A"} through {@code "Z"}, - * and {@code "0"} through {@code "9"} stay the same.
  • - *
  • Special characters {@code "-"}, {@code "_"}, {@code "."}, and {@code "*"} stay the same.
  • - *
  • A sequence "{@code %xy}" is interpreted as a hexadecimal representation of the character.
  • - *
  • For all other characters (including those already decoded), the output is undefined.
  • - *
- * @param source the encoded String - * @param charset the character set + * Decode the given encoded URI component value by replacing "{@code %xy}" sequences + * by an hexadecimal representation of the character in the specified charset, letting other + * characters unchanged. + * @param source the encoded {@code String} + * @param charset the character encoding to use to decode the "{@code %xy}" sequences * @return the decoded value * @throws IllegalArgumentException when the given source contains invalid encoded sequences * @since 5.0 - * @see java.net.URLDecoder#decode(String, String) + * @see java.net.URLDecoder#decode(String, String) java.net.URLDecoder#decode for HTML form decoding */ public static String uriDecode(String source, Charset charset) { int length = source.length(); - if (length == 0) { + int firstPercentIndex = source.indexOf('%'); + if (length == 0 || firstPercentIndex < 0) { return source; } - Assert.notNull(charset, "Charset must not be null"); - ByteArrayOutputStream baos = new ByteArrayOutputStream(length); - boolean changed = false; - for (int i = 0; i < length; i++) { - int ch = source.charAt(i); + StringBuilder output = new StringBuilder(length); + output.append(source, 0, firstPercentIndex); + byte[] bytes = null; + int i = firstPercentIndex; + while (i < length) { + char ch = source.charAt(i); if (ch == '%') { - if (i + 2 < length) { - char hex1 = source.charAt(i + 1); - char hex2 = source.charAt(i + 2); - int u = Character.digit(hex1, 16); - int l = Character.digit(hex2, 16); - if (u == -1 || l == -1) { - throw new IllegalArgumentException("Invalid encoded sequence \"" + source.substring(i) + "\""); + try { + if (bytes == null) { + bytes = new byte[(length - i) / 3]; } - baos.write((char) ((u << 4) + l)); - i += 2; - changed = true; + + int pos = 0; + while (i + 2 < length && ch == '%') { + bytes[pos++] = (byte) HexFormat.fromHexDigits(source, i + 1, i + 3); + i += 3; + if (i < length) { + ch = source.charAt(i); + } + } + + if (i < length && ch == '%') { + throw new IllegalArgumentException("Incomplete trailing escape (%) pattern"); + } + + output.append(new String(bytes, 0, pos, charset)); } - else { + catch (NumberFormatException ex) { throw new IllegalArgumentException("Invalid encoded sequence \"" + source.substring(i) + "\""); } } else { - baos.write(ch); + output.append(ch); + i++; } } - return (changed ? StreamUtils.copyToString(baos, charset) : source); + return output.toString(); } /** diff --git a/spring-web/src/main/java/org/springframework/web/util/UriUtils.java b/spring-web/src/main/java/org/springframework/web/util/UriUtils.java index 0b89fb3bd06a..7711470fa99c 100644 --- a/spring-web/src/main/java/org/springframework/web/util/UriUtils.java +++ b/spring-web/src/main/java/org/springframework/web/util/UriUtils.java @@ -373,15 +373,16 @@ public static String decode(String source, String encoding) { } /** - * Decode the given encoded URI component. - *

See {@link StringUtils#uriDecode(String, Charset)} for the decoding rules. - * @param source the encoded String - * @param charset the character encoding to use + * Decode the given encoded URI component value by replacing "{@code %xy}" sequences + * by an hexadecimal representation of the character in the specified charset, letting other + * characters unchanged. + * @param source the encoded {@code String} + * @param charset the character encoding to use to decode the "{@code %xy}" sequences * @return the decoded value * @throws IllegalArgumentException when the given source contains invalid encoded sequences * @since 5.0 * @see StringUtils#uriDecode(String, Charset) - * @see java.net.URLDecoder#decode(String, String) + * @see java.net.URLDecoder#decode(String, String) java.net.URLDecoder#decode for HTML form decoding */ public static String decode(String source, Charset charset) { return StringUtils.uriDecode(source, charset); diff --git a/spring-web/src/test/java/org/springframework/web/util/UriUtilsTests.java b/spring-web/src/test/java/org/springframework/web/util/UriUtilsTests.java index 53587c51bacf..ff3159a5e20e 100644 --- a/spring-web/src/test/java/org/springframework/web/util/UriUtilsTests.java +++ b/spring-web/src/test/java/org/springframework/web/util/UriUtilsTests.java @@ -107,12 +107,21 @@ void decode() { assertThat(UriUtils.decode("T%C5%8Dky%C5%8D", CHARSET)).as("Invalid encoded result").isEqualTo("T\u014dky\u014d"); assertThat(UriUtils.decode("/Z%C3%BCrich", CHARSET)).as("Invalid encoded result").isEqualTo("/Z\u00fcrich"); assertThat(UriUtils.decode("T\u014dky\u014d", CHARSET)).as("Invalid encoded result").isEqualTo("T\u014dky\u014d"); + assertThat(UriUtils.decode("%20\u2019", CHARSET)).as("Invalid encoded result").isEqualTo(" \u2019"); + assertThat(UriUtils.decode("\u015bp\u0159\u00ec\u0144\u0121", CHARSET)).as("Invalid encoded result").isEqualTo("śpřìńġ"); + assertThat(UriUtils.decode("%20\u015bp\u0159\u00ec\u0144\u0121", CHARSET)).as("Invalid encoded result").isEqualTo(" śpřìńġ"); } @Test void decodeInvalidSequence() { assertThatIllegalArgumentException().isThrownBy(() -> UriUtils.decode("foo%2", CHARSET)); + assertThatIllegalArgumentException().isThrownBy(() -> + UriUtils.decode("foo%", CHARSET)); + assertThatIllegalArgumentException().isThrownBy(() -> + UriUtils.decode("%", CHARSET)); + assertThatIllegalArgumentException().isThrownBy(() -> + UriUtils.decode("%zz", CHARSET)); } @Test From 336a5d0ac848853220a14c39101c4e4ef44e67b8 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Fri, 18 Jul 2025 06:46:31 +0100 Subject: [PATCH 020/591] Add container for MockMvcServerServerSpec hierarchy See gh-34428 --- .../client/AbstractMockMvcServerSpec.java | 107 ----- .../client/ApplicationContextMockMvcSpec.java | 44 -- .../servlet/client/MockMvcWebTestClient.java | 6 +- .../client/MockMvcWebTestClientSpecs.java | 378 ++++++++++++++++++ .../client/RouterFunctionMockMvcSpec.java | 101 ----- .../servlet/client/StandaloneMockMvcSpec.java | 179 --------- 6 files changed, 381 insertions(+), 434 deletions(-) delete mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/client/AbstractMockMvcServerSpec.java delete mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/client/ApplicationContextMockMvcSpec.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcWebTestClientSpecs.java delete mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/client/RouterFunctionMockMvcSpec.java delete mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/client/StandaloneMockMvcSpec.java diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/AbstractMockMvcServerSpec.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/AbstractMockMvcServerSpec.java deleted file mode 100644 index 6db5a2f38948..000000000000 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/AbstractMockMvcServerSpec.java +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright 2002-present 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.test.web.servlet.client; - -import jakarta.servlet.Filter; - -import org.springframework.http.client.reactive.ClientHttpConnector; -import org.springframework.test.web.reactive.server.WebTestClient; -import org.springframework.test.web.servlet.DispatcherServletCustomizer; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.RequestBuilder; -import org.springframework.test.web.servlet.ResultMatcher; -import org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder; -import org.springframework.test.web.servlet.setup.MockMvcConfigurer; - -/** - * Base class for implementations of {@link MockMvcWebTestClient.MockMvcServerSpec} - * that simply delegates to a {@link ConfigurableMockMvcBuilder} supplied by - * the concrete subclasses. - * - * @author Rossen Stoyanchev - * @since 5.3 - * @param the type of the concrete subclass spec - */ -abstract class AbstractMockMvcServerSpec> - implements MockMvcWebTestClient.MockMvcServerSpec { - - @Override - public T filters(Filter... filters) { - getMockMvcBuilder().addFilters(filters); - return self(); - } - - @Override - public final T filter(Filter filter, String... urlPatterns) { - getMockMvcBuilder().addFilter(filter, urlPatterns); - return self(); - } - - @Override - public T defaultRequest(RequestBuilder requestBuilder) { - getMockMvcBuilder().defaultRequest(requestBuilder); - return self(); - } - - @Override - public T alwaysExpect(ResultMatcher resultMatcher) { - getMockMvcBuilder().alwaysExpect(resultMatcher); - return self(); - } - - @Override - public T dispatchOptions(boolean dispatchOptions) { - getMockMvcBuilder().dispatchOptions(dispatchOptions); - return self(); - } - - @Override - public T dispatcherServletCustomizer(DispatcherServletCustomizer customizer) { - getMockMvcBuilder().addDispatcherServletCustomizer(customizer); - return self(); - } - - @Override - public T apply(MockMvcConfigurer configurer) { - getMockMvcBuilder().apply(configurer); - return self(); - } - - @SuppressWarnings("unchecked") - private T self() { - return (T) this; - } - - /** - * Return the concrete {@link ConfigurableMockMvcBuilder} to delegate - * configuration methods and to use to create the {@link MockMvc}. - */ - protected abstract ConfigurableMockMvcBuilder getMockMvcBuilder(); - - @Override - public WebTestClient.Builder configureClient() { - MockMvc mockMvc = getMockMvcBuilder().build(); - ClientHttpConnector connector = new MockMvcHttpConnector(mockMvc); - return WebTestClient.bindToServer(connector); - } - - @Override - public WebTestClient build() { - return configureClient().build(); - } - -} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/ApplicationContextMockMvcSpec.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/ApplicationContextMockMvcSpec.java deleted file mode 100644 index 0aaca79d9379..000000000000 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/ApplicationContextMockMvcSpec.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2002-present 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.test.web.servlet.client; - -import org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder; -import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import org.springframework.web.context.WebApplicationContext; - -/** - * Simple wrapper around a {@link DefaultMockMvcBuilder}. - * - * @author Rossen Stoyanchev - * @since 5.3 - */ -class ApplicationContextMockMvcSpec extends AbstractMockMvcServerSpec { - - private final DefaultMockMvcBuilder mockMvcBuilder; - - - public ApplicationContextMockMvcSpec(WebApplicationContext context) { - this.mockMvcBuilder = MockMvcBuilders.webAppContextSetup(context); - } - - @Override - protected ConfigurableMockMvcBuilder getMockMvcBuilder() { - return this.mockMvcBuilder; - } - -} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcWebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcWebTestClient.java index 47a6692dc960..8f69dac87362 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcWebTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcWebTestClient.java @@ -88,7 +88,7 @@ public interface MockMvcWebTestClient { * to initialize {@link MockMvc}. */ static ControllerSpec bindToController(Object... controllers) { - return new StandaloneMockMvcSpec(controllers); + return new MockMvcWebTestClientSpecs.StandaloneMockMvcSpec(controllers); } /** @@ -100,7 +100,7 @@ static ControllerSpec bindToController(Object... controllers) { * @since 6.2 */ static RouterFunctionSpec bindToRouterFunction(RouterFunction... routerFunctions) { - return new RouterFunctionMockMvcSpec(routerFunctions); + return new MockMvcWebTestClientSpecs.RouterFunctionMockMvcSpec(routerFunctions); } /** @@ -112,7 +112,7 @@ static RouterFunctionSpec bindToRouterFunction(RouterFunction... routerFuncti * to initialize {@code MockMvc}. */ static MockMvcServerSpec bindToApplicationContext(WebApplicationContext context) { - return new ApplicationContextMockMvcSpec(context); + return new MockMvcWebTestClientSpecs.ApplicationContextMockMvcSpec(context); } /** diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcWebTestClientSpecs.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcWebTestClientSpecs.java new file mode 100644 index 000000000000..6b59f956dff2 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcWebTestClientSpecs.java @@ -0,0 +1,378 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client; + + +import java.util.function.Supplier; + +import jakarta.servlet.Filter; +import org.jspecify.annotations.Nullable; + +import org.springframework.format.support.FormattingConversionService; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.test.web.servlet.DispatcherServletCustomizer; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.RequestBuilder; +import org.springframework.test.web.servlet.ResultMatcher; +import org.springframework.test.web.servlet.client.MockMvcWebTestClient.MockMvcServerSpec; +import org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder; +import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.test.web.servlet.setup.MockMvcConfigurer; +import org.springframework.test.web.servlet.setup.RouterFunctionMockMvcBuilder; +import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder; +import org.springframework.validation.Validator; +import org.springframework.web.accept.ApiVersionStrategy; +import org.springframework.web.accept.ContentNegotiationManager; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.HandlerMethodReturnValueHandler; +import org.springframework.web.servlet.FlashMapManager; +import org.springframework.web.servlet.HandlerExceptionResolver; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.LocaleResolver; +import org.springframework.web.servlet.View; +import org.springframework.web.servlet.ViewResolver; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.util.pattern.PathPatternParser; + +/** + * Container class to encapsulate the {@link MockMvcServerSpec} implementation + * hierarchy. This class was added in 7.0 to reduce mixing WebTestClient and + * RestTestClient classes in the same package. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +abstract class MockMvcWebTestClientSpecs { + + /** + * Base class for implementations of {@link MockMvcServerSpec} + * that simply delegates to a {@link ConfigurableMockMvcBuilder} supplied by + * the concrete subclasses. + * + * @author Rossen Stoyanchev + * @since 5.3 + * @param the type of the concrete subclass spec + */ + abstract static class AbstractMockMvcServerSpec> + implements MockMvcServerSpec { + + @Override + public T filters(Filter... filters) { + getMockMvcBuilder().addFilters(filters); + return self(); + } + + @Override + public final T filter(Filter filter, String... urlPatterns) { + getMockMvcBuilder().addFilter(filter, urlPatterns); + return self(); + } + + @Override + public T defaultRequest(RequestBuilder requestBuilder) { + getMockMvcBuilder().defaultRequest(requestBuilder); + return self(); + } + + @Override + public T alwaysExpect(ResultMatcher resultMatcher) { + getMockMvcBuilder().alwaysExpect(resultMatcher); + return self(); + } + + @Override + public T dispatchOptions(boolean dispatchOptions) { + getMockMvcBuilder().dispatchOptions(dispatchOptions); + return self(); + } + + @Override + public T dispatcherServletCustomizer(DispatcherServletCustomizer customizer) { + getMockMvcBuilder().addDispatcherServletCustomizer(customizer); + return self(); + } + + @Override + public T apply(MockMvcConfigurer configurer) { + getMockMvcBuilder().apply(configurer); + return self(); + } + + @SuppressWarnings("unchecked") + private T self() { + return (T) this; + } + + /** + * Return the concrete {@link ConfigurableMockMvcBuilder} to delegate + * configuration methods and to use to create the {@link MockMvc}. + */ + protected abstract ConfigurableMockMvcBuilder getMockMvcBuilder(); + + @Override + public WebTestClient.Builder configureClient() { + MockMvc mockMvc = getMockMvcBuilder().build(); + ClientHttpConnector connector = new MockMvcHttpConnector(mockMvc); + return WebTestClient.bindToServer(connector); + } + + @Override + public WebTestClient build() { + return configureClient().build(); + } + + } + + + /** + * Simple wrapper around a {@link DefaultMockMvcBuilder}. + * + * @author Rossen Stoyanchev + * @since 5.3 + */ + static class ApplicationContextMockMvcSpec extends AbstractMockMvcServerSpec { + + private final DefaultMockMvcBuilder mockMvcBuilder; + + + public ApplicationContextMockMvcSpec(WebApplicationContext context) { + this.mockMvcBuilder = MockMvcBuilders.webAppContextSetup(context); + } + + @Override + protected ConfigurableMockMvcBuilder getMockMvcBuilder() { + return this.mockMvcBuilder; + } + + } + + + /** + * Simple wrapper around a {@link RouterFunctionMockMvcBuilder} that implements + * {@link MockMvcWebTestClient.RouterFunctionSpec}. + * + * @author Arjen Poutsma + * @since 6.2 + */ + static class RouterFunctionMockMvcSpec extends AbstractMockMvcServerSpec + implements MockMvcWebTestClient.RouterFunctionSpec { + + private final RouterFunctionMockMvcBuilder mockMvcBuilder; + + + RouterFunctionMockMvcSpec(RouterFunction... routerFunctions) { + this.mockMvcBuilder = MockMvcBuilders.routerFunctions(routerFunctions); + } + + @Override + public MockMvcWebTestClient.RouterFunctionSpec messageConverters(HttpMessageConverter... messageConverters) { + this.mockMvcBuilder.setMessageConverters(messageConverters); + return this; + } + + @Override + public MockMvcWebTestClient.RouterFunctionSpec interceptors(HandlerInterceptor... interceptors) { + mappedInterceptors(null, interceptors); + return this; + } + + @Override + public MockMvcWebTestClient.RouterFunctionSpec mappedInterceptors(String @Nullable [] pathPatterns, HandlerInterceptor... interceptors) { + this.mockMvcBuilder.addMappedInterceptors(pathPatterns, interceptors); + return this; + } + + @Override + public MockMvcWebTestClient.RouterFunctionSpec asyncRequestTimeout(long timeout) { + this.mockMvcBuilder.setAsyncRequestTimeout(timeout); + return this; + } + + @Override + public MockMvcWebTestClient.RouterFunctionSpec handlerExceptionResolvers(HandlerExceptionResolver... exceptionResolvers) { + this.mockMvcBuilder.setHandlerExceptionResolvers(exceptionResolvers); + return this; + } + + @Override + public MockMvcWebTestClient.RouterFunctionSpec viewResolvers(ViewResolver... resolvers) { + this.mockMvcBuilder.setViewResolvers(resolvers); + return this; + } + + @Override + public MockMvcWebTestClient.RouterFunctionSpec singleView(View view) { + this.mockMvcBuilder.setSingleView(view); + return this; + } + + @Override + public MockMvcWebTestClient.RouterFunctionSpec patternParser(PathPatternParser parser) { + this.mockMvcBuilder.setPatternParser(parser); + return this; + } + + @Override + protected ConfigurableMockMvcBuilder getMockMvcBuilder() { + return this.mockMvcBuilder; + } + } + + /** + * Simple wrapper around a {@link StandaloneMockMvcBuilder} that implements + * {@link MockMvcWebTestClient.ControllerSpec}. + * + * @author Rossen Stoyanchev + * @since 5.3 + */ + static class StandaloneMockMvcSpec extends AbstractMockMvcServerSpec + implements MockMvcWebTestClient.ControllerSpec { + + private final StandaloneMockMvcBuilder mockMvcBuilder; + + + StandaloneMockMvcSpec(Object... controllers) { + this.mockMvcBuilder = MockMvcBuilders.standaloneSetup(controllers); + } + + @Override + public StandaloneMockMvcSpec controllerAdvice(Object... controllerAdvice) { + this.mockMvcBuilder.setControllerAdvice(controllerAdvice); + return this; + } + + @Override + public StandaloneMockMvcSpec messageConverters(HttpMessageConverter... messageConverters) { + this.mockMvcBuilder.setMessageConverters(messageConverters); + return this; + } + + @Override + public StandaloneMockMvcSpec validator(Validator validator) { + this.mockMvcBuilder.setValidator(validator); + return this; + } + + @Override + public StandaloneMockMvcSpec conversionService(FormattingConversionService conversionService) { + this.mockMvcBuilder.setConversionService(conversionService); + return this; + } + + @Override + public MockMvcWebTestClient.ControllerSpec apiVersionStrategy(ApiVersionStrategy versionStrategy) { + this.mockMvcBuilder.setApiVersionStrategy(versionStrategy); + return this; + } + + @Override + public StandaloneMockMvcSpec interceptors(HandlerInterceptor... interceptors) { + mappedInterceptors(null, interceptors); + return this; + } + + @Override + public StandaloneMockMvcSpec mappedInterceptors( + String @Nullable [] pathPatterns, HandlerInterceptor... interceptors) { + + this.mockMvcBuilder.addMappedInterceptors(pathPatterns, interceptors); + return this; + } + + @Override + public StandaloneMockMvcSpec contentNegotiationManager(ContentNegotiationManager manager) { + this.mockMvcBuilder.setContentNegotiationManager(manager); + return this; + } + + @Override + public StandaloneMockMvcSpec asyncRequestTimeout(long timeout) { + this.mockMvcBuilder.setAsyncRequestTimeout(timeout); + return this; + } + + @Override + public StandaloneMockMvcSpec customArgumentResolvers(HandlerMethodArgumentResolver... argumentResolvers) { + this.mockMvcBuilder.setCustomArgumentResolvers(argumentResolvers); + return this; + } + + @Override + public StandaloneMockMvcSpec customReturnValueHandlers(HandlerMethodReturnValueHandler... handlers) { + this.mockMvcBuilder.setCustomReturnValueHandlers(handlers); + return this; + } + + @Override + public StandaloneMockMvcSpec handlerExceptionResolvers(HandlerExceptionResolver... exceptionResolvers) { + this.mockMvcBuilder.setHandlerExceptionResolvers(exceptionResolvers); + return this; + } + + @Override + public StandaloneMockMvcSpec viewResolvers(ViewResolver... resolvers) { + this.mockMvcBuilder.setViewResolvers(resolvers); + return this; + } + + @Override + public StandaloneMockMvcSpec singleView(View view) { + this.mockMvcBuilder.setSingleView(view); + return this; + } + + @Override + public StandaloneMockMvcSpec localeResolver(LocaleResolver localeResolver) { + this.mockMvcBuilder.setLocaleResolver(localeResolver); + return this; + } + + @Override + public StandaloneMockMvcSpec flashMapManager(FlashMapManager flashMapManager) { + this.mockMvcBuilder.setFlashMapManager(flashMapManager); + return this; + } + + @Override + public StandaloneMockMvcSpec patternParser(PathPatternParser parser) { + this.mockMvcBuilder.setPatternParser(parser); + return this; + } + + @Override + public StandaloneMockMvcSpec placeholderValue(String name, String value) { + this.mockMvcBuilder.addPlaceholderValue(name, value); + return this; + } + + @Override + public StandaloneMockMvcSpec customHandlerMapping(Supplier factory) { + this.mockMvcBuilder.setCustomHandlerMapping(factory); + return this; + } + + @Override + public ConfigurableMockMvcBuilder getMockMvcBuilder() { + return this.mockMvcBuilder; + } + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/RouterFunctionMockMvcSpec.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/RouterFunctionMockMvcSpec.java deleted file mode 100644 index 8cccb127fff5..000000000000 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/RouterFunctionMockMvcSpec.java +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright 2002-present 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.test.web.servlet.client; - -import org.jspecify.annotations.Nullable; - -import org.springframework.http.converter.HttpMessageConverter; -import org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import org.springframework.test.web.servlet.setup.RouterFunctionMockMvcBuilder; -import org.springframework.web.servlet.HandlerExceptionResolver; -import org.springframework.web.servlet.HandlerInterceptor; -import org.springframework.web.servlet.View; -import org.springframework.web.servlet.ViewResolver; -import org.springframework.web.servlet.function.RouterFunction; -import org.springframework.web.util.pattern.PathPatternParser; - -/** - * Simple wrapper around a {@link RouterFunctionMockMvcBuilder} that implements - * {@link MockMvcWebTestClient.RouterFunctionSpec}. - * - * @author Arjen Poutsma - * @since 6.2 - */ -class RouterFunctionMockMvcSpec extends AbstractMockMvcServerSpec - implements MockMvcWebTestClient.RouterFunctionSpec { - - private final RouterFunctionMockMvcBuilder mockMvcBuilder; - - - RouterFunctionMockMvcSpec(RouterFunction... routerFunctions) { - this.mockMvcBuilder = MockMvcBuilders.routerFunctions(routerFunctions); - } - - @Override - public MockMvcWebTestClient.RouterFunctionSpec messageConverters(HttpMessageConverter... messageConverters) { - this.mockMvcBuilder.setMessageConverters(messageConverters); - return this; - } - - @Override - public MockMvcWebTestClient.RouterFunctionSpec interceptors(HandlerInterceptor... interceptors) { - mappedInterceptors(null, interceptors); - return this; - } - - @Override - public MockMvcWebTestClient.RouterFunctionSpec mappedInterceptors(String @Nullable [] pathPatterns, HandlerInterceptor... interceptors) { - this.mockMvcBuilder.addMappedInterceptors(pathPatterns, interceptors); - return this; - } - - @Override - public MockMvcWebTestClient.RouterFunctionSpec asyncRequestTimeout(long timeout) { - this.mockMvcBuilder.setAsyncRequestTimeout(timeout); - return this; - } - - @Override - public MockMvcWebTestClient.RouterFunctionSpec handlerExceptionResolvers(HandlerExceptionResolver... exceptionResolvers) { - this.mockMvcBuilder.setHandlerExceptionResolvers(exceptionResolvers); - return this; - } - - @Override - public MockMvcWebTestClient.RouterFunctionSpec viewResolvers(ViewResolver... resolvers) { - this.mockMvcBuilder.setViewResolvers(resolvers); - return this; - } - - @Override - public MockMvcWebTestClient.RouterFunctionSpec singleView(View view) { - this.mockMvcBuilder.setSingleView(view); - return this; - } - - @Override - public MockMvcWebTestClient.RouterFunctionSpec patternParser(PathPatternParser parser) { - this.mockMvcBuilder.setPatternParser(parser); - return this; - } - - @Override - protected ConfigurableMockMvcBuilder getMockMvcBuilder() { - return this.mockMvcBuilder; - } -} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/StandaloneMockMvcSpec.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/StandaloneMockMvcSpec.java deleted file mode 100644 index 9b96287e7645..000000000000 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/StandaloneMockMvcSpec.java +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Copyright 2002-present 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.test.web.servlet.client; - -import java.util.function.Supplier; - -import org.jspecify.annotations.Nullable; - -import org.springframework.format.support.FormattingConversionService; -import org.springframework.http.converter.HttpMessageConverter; -import org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder; -import org.springframework.validation.Validator; -import org.springframework.web.accept.ApiVersionStrategy; -import org.springframework.web.accept.ContentNegotiationManager; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.method.support.HandlerMethodReturnValueHandler; -import org.springframework.web.servlet.FlashMapManager; -import org.springframework.web.servlet.HandlerExceptionResolver; -import org.springframework.web.servlet.HandlerInterceptor; -import org.springframework.web.servlet.LocaleResolver; -import org.springframework.web.servlet.View; -import org.springframework.web.servlet.ViewResolver; -import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; -import org.springframework.web.util.pattern.PathPatternParser; - -/** - * Simple wrapper around a {@link StandaloneMockMvcBuilder} that implements - * {@link MockMvcWebTestClient.ControllerSpec}. - * - * @author Rossen Stoyanchev - * @since 5.3 - */ -class StandaloneMockMvcSpec extends AbstractMockMvcServerSpec - implements MockMvcWebTestClient.ControllerSpec { - - private final StandaloneMockMvcBuilder mockMvcBuilder; - - - StandaloneMockMvcSpec(Object... controllers) { - this.mockMvcBuilder = MockMvcBuilders.standaloneSetup(controllers); - } - - @Override - public StandaloneMockMvcSpec controllerAdvice(Object... controllerAdvice) { - this.mockMvcBuilder.setControllerAdvice(controllerAdvice); - return this; - } - - @Override - public StandaloneMockMvcSpec messageConverters(HttpMessageConverter... messageConverters) { - this.mockMvcBuilder.setMessageConverters(messageConverters); - return this; - } - - @Override - public StandaloneMockMvcSpec validator(Validator validator) { - this.mockMvcBuilder.setValidator(validator); - return this; - } - - @Override - public StandaloneMockMvcSpec conversionService(FormattingConversionService conversionService) { - this.mockMvcBuilder.setConversionService(conversionService); - return this; - } - - @Override - public MockMvcWebTestClient.ControllerSpec apiVersionStrategy(ApiVersionStrategy versionStrategy) { - this.mockMvcBuilder.setApiVersionStrategy(versionStrategy); - return this; - } - - @Override - public StandaloneMockMvcSpec interceptors(HandlerInterceptor... interceptors) { - mappedInterceptors(null, interceptors); - return this; - } - - @Override - public StandaloneMockMvcSpec mappedInterceptors( - String @Nullable [] pathPatterns, HandlerInterceptor... interceptors) { - - this.mockMvcBuilder.addMappedInterceptors(pathPatterns, interceptors); - return this; - } - - @Override - public StandaloneMockMvcSpec contentNegotiationManager(ContentNegotiationManager manager) { - this.mockMvcBuilder.setContentNegotiationManager(manager); - return this; - } - - @Override - public StandaloneMockMvcSpec asyncRequestTimeout(long timeout) { - this.mockMvcBuilder.setAsyncRequestTimeout(timeout); - return this; - } - - @Override - public StandaloneMockMvcSpec customArgumentResolvers(HandlerMethodArgumentResolver... argumentResolvers) { - this.mockMvcBuilder.setCustomArgumentResolvers(argumentResolvers); - return this; - } - - @Override - public StandaloneMockMvcSpec customReturnValueHandlers(HandlerMethodReturnValueHandler... handlers) { - this.mockMvcBuilder.setCustomReturnValueHandlers(handlers); - return this; - } - - @Override - public StandaloneMockMvcSpec handlerExceptionResolvers(HandlerExceptionResolver... exceptionResolvers) { - this.mockMvcBuilder.setHandlerExceptionResolvers(exceptionResolvers); - return this; - } - - @Override - public StandaloneMockMvcSpec viewResolvers(ViewResolver... resolvers) { - this.mockMvcBuilder.setViewResolvers(resolvers); - return this; - } - - @Override - public StandaloneMockMvcSpec singleView(View view) { - this.mockMvcBuilder.setSingleView(view); - return this; - } - - @Override - public StandaloneMockMvcSpec localeResolver(LocaleResolver localeResolver) { - this.mockMvcBuilder.setLocaleResolver(localeResolver); - return this; - } - - @Override - public StandaloneMockMvcSpec flashMapManager(FlashMapManager flashMapManager) { - this.mockMvcBuilder.setFlashMapManager(flashMapManager); - return this; - } - - @Override - public StandaloneMockMvcSpec patternParser(PathPatternParser parser) { - this.mockMvcBuilder.setPatternParser(parser); - return this; - } - - @Override - public StandaloneMockMvcSpec placeholderValue(String name, String value) { - this.mockMvcBuilder.addPlaceholderValue(name, value); - return this; - } - - @Override - public StandaloneMockMvcSpec customHandlerMapping(Supplier factory) { - this.mockMvcBuilder.setCustomHandlerMapping(factory); - return this; - } - - @Override - public ConfigurableMockMvcBuilder getMockMvcBuilder() { - return this.mockMvcBuilder; - } -} From 37dcca54d2b5d60599f15cdd7f1a311d624ef609 Mon Sep 17 00:00:00 2001 From: Rob Worsnop Date: Mon, 16 Dec 2024 22:41:01 -0500 Subject: [PATCH 021/591] Add RestTestClient See gh-34428 Signed-off-by: Rob Worsnop --- framework-docs/modules/ROOT/nav.adoc | 1 + .../ROOT/pages/testing/resttestclient.adoc | 440 ++++++++++++ .../MockMvcClientHttpRequestFactory.java | 31 +- .../web/servlet/client/CookieAssertions.java | 236 +++++++ .../client/DefaultMockServerBuilder.java | 49 ++ .../servlet/client/DefaultRestTestClient.java | 429 ++++++++++++ .../client/DefaultRestTestClientBuilder.java | 89 +++ .../servlet/client/EntityExchangeResult.java | 46 ++ .../web/servlet/client/ExchangeResult.java | 135 ++++ .../web/servlet/client/HeaderAssertions.java | 311 +++++++++ .../servlet/client/JsonPathAssertions.java | 205 ++++++ .../MockMvcClientHttpRequestFactory.java | 133 ++++ .../web/servlet/client/RestTestClient.java | 656 ++++++++++++++++++ .../web/servlet/client/StatusAssertions.java | 250 +++++++ .../web/servlet/client/XpathAssertions.java | 205 ++++++ .../MockMvcClientHttpRequestFactoryTests.java | 1 + .../servlet/client/CookieAssertionsTests.java | 147 ++++ .../servlet/client/HeaderAssertionTests.java | 320 +++++++++ .../client/JsonPathAssertionTests.java | 218 ++++++ .../MockMvcClientHttpRequestFactoryTests.java | 103 +++ .../servlet/client/StatusAssertionTests.java | 266 +++++++ .../servlet/client/samples/ErrorTests.java | 59 ++ .../client/samples/HeaderAndCookieTests.java | 86 +++ .../client/samples/JsonContentTests.java | 171 +++++ .../web/servlet/client/samples/Person.java | 69 ++ .../client/samples/ResponseEntityTests.java | 156 +++++ .../client/samples/RestTestClientTests.java | 330 +++++++++ .../client/samples/SoftAssertionTests.java | 72 ++ .../client/samples/XmlContentTests.java | 196 ++++++ .../samples/bind/ApplicationContextTests.java | 79 +++ .../client/samples/bind/ControllerTests.java | 61 ++ .../client/samples/bind/FilterTests.java | 63 ++ .../client/samples/bind/HttpServerTests.java | 73 ++ .../samples/bind/RouterFunctionTests.java | 56 ++ src/checkstyle/checkstyle-suppressions.xml | 1 + 35 files changed, 5742 insertions(+), 1 deletion(-) create mode 100644 framework-docs/modules/ROOT/pages/testing/resttestclient.adoc create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/client/CookieAssertions.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultMockServerBuilder.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/client/EntityExchangeResult.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/client/ExchangeResult.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/client/HeaderAssertions.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/client/JsonPathAssertions.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcClientHttpRequestFactory.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/client/StatusAssertions.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/client/XpathAssertions.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/client/CookieAssertionsTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/client/HeaderAssertionTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/client/JsonPathAssertionTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/client/MockMvcClientHttpRequestFactoryTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/client/StatusAssertionTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ErrorTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/HeaderAndCookieTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/JsonContentTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/Person.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ResponseEntityTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/RestTestClientTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/SoftAssertionTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/XmlContentTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/ApplicationContextTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/ControllerTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/FilterTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/HttpServerTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/RouterFunctionTests.java diff --git a/framework-docs/modules/ROOT/nav.adoc b/framework-docs/modules/ROOT/nav.adoc index 7d8e6903e15f..06d105e209be 100644 --- a/framework-docs/modules/ROOT/nav.adoc +++ b/framework-docs/modules/ROOT/nav.adoc @@ -355,6 +355,7 @@ *** xref:testing/testcontext-framework/support-classes.adoc[] *** xref:testing/testcontext-framework/aot.adoc[] ** xref:testing/webtestclient.adoc[] +** xref:testing/resttestclient.adoc[] ** xref:testing/mockmvc.adoc[] *** xref:testing/mockmvc/overview.adoc[] *** xref:testing/mockmvc/setup-options.adoc[] diff --git a/framework-docs/modules/ROOT/pages/testing/resttestclient.adoc b/framework-docs/modules/ROOT/pages/testing/resttestclient.adoc new file mode 100644 index 000000000000..ddae5fb295c6 --- /dev/null +++ b/framework-docs/modules/ROOT/pages/testing/resttestclient.adoc @@ -0,0 +1,440 @@ +[[resttestclient]] += RestTestClient + +`RestTestClient` is an HTTP client designed for testing server applications. It wraps +Spring's xref:integration/rest-clients.adoc#rest-restclient[`RestClient`] and uses it to perform requests +but exposes a testing facade for verifying responses. `RestTestClient` can be used to +perform end-to-end HTTP tests. It can also be used to test Spring MVC +applications without a running server via mock server request and response objects. + + + + +[[resttestclient-setup]] +== Setup + +To set up a `RestTestClient` you need to choose a server setup to bind to. This can be one +of several mock server setup choices or a connection to a live server. + + + +[[resttestclient-controller-config]] +=== Bind to Controller + +This setup allows you to test specific controller(s) via mock request and response objects, +without a running server. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + RestTestClient client = + RestTestClient.bindToController(new TestController()).build(); +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + val client = RestTestClient.bindToController(TestController()).build() +---- +====== + +[[resttestclient-context-config]] +=== Bind to `ApplicationContext` + +This setup allows you to load Spring configuration with Spring MVC +infrastructure and controller declarations and use it to handle requests via mock request +and response objects, without a running server. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @SpringJUnitConfig(WebConfig.class) // <1> + class MyTests { + + RestTestClient client; + + @BeforeEach + void setUp(ApplicationContext context) { // <2> + client = RestTestClient.bindToApplicationContext(context).build(); // <3> + } + } +---- +<1> Specify the configuration to load +<2> Inject the configuration +<3> Create the `RestTestClient` + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + @SpringJUnitConfig(WebConfig::class) // <1> + class MyTests { + + lateinit var client: RestTestClient + + @BeforeEach + fun setUp(context: ApplicationContext) { // <2> + client = RestTestClient.bindToApplicationContext(context).build() // <3> + } + } +---- +<1> Specify the configuration to load +<2> Inject the configuration +<3> Create the `RestTestClient` +====== + +[[resttestclient-fn-config]] +=== Bind to Router Function + +This setup allows you to test xref:web/webmvc-functional.adoc[functional endpoints] via +mock request and response objects, without a running server. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + RouterFunction route = ... + client = RestTestClient.bindToRouterFunction(route).build(); +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + val route: RouterFunction<*> = ... + val client = RestTestClient.bindToRouterFunction(route).build() +---- +====== + +[[resttestclient-server-config]] +=== Bind to Server + +This setup connects to a running server to perform full, end-to-end HTTP tests: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + client = RestTestClient.bindToServer().baseUrl("http://localhost:8080").build(); +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + client = RestTestClient.bindToServer().baseUrl("http://localhost:8080").build() +---- +====== + + + +[[resttestclient-client-config]] +=== Client Config + +In addition to the server setup options described earlier, you can also configure client +options, including base URL, default headers, client filters, and others. These options +are readily available following `bindToServer()`. For all other configuration options, +you need to use `configureClient()` to transition from server to client configuration, as +follows: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + client = RestTestClient.bindToController(new TestController()) + .configureClient() + .baseUrl("/test") + .build(); +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + client = RestTestClient.bindToController(TestController()) + .configureClient() + .baseUrl("/test") + .build() +---- +====== + + + + +[[resttestclient-tests]] +== Writing Tests + +`RestTestClient` provides an API identical to xref:integration/rest-clients.adoc#rest-restclient[`RestClient`] +up to the point of performing a request by using `exchange()`. + +After the call to `exchange()`, `RestTestClient` diverges from the `RestClient` and +instead continues with a workflow to verify responses. + +To assert the response status and headers, use the following: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + client.get().uri("/persons/1") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON); +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + client.get().uri("/persons/1") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) +---- +====== + +If you would like for all expectations to be asserted even if one of them fails, you can +use `expectAll(..)` instead of multiple chained `expect*(..)` calls. This feature is +similar to the _soft assertions_ support in AssertJ and the `assertAll()` support in +JUnit Jupiter. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + client.get().uri("/persons/1") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectAll( + spec -> spec.expectStatus().isOk(), + spec -> spec.expectHeader().contentType(MediaType.APPLICATION_JSON) + ); +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + client.get().uri("/persons/1") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectAll( + { spec -> spec.expectStatus().isOk() }, + { spec -> spec.expectHeader().contentType(MediaType.APPLICATION_JSON) } + ) +---- +====== + +You can then choose to decode the response body through one of the following: + +* `expectBody(Class)`: Decode to single object. +* `expectBody()`: Decode to `byte[]` for xref:testing/resttestclient.adoc#resttestclient-json[JSON Content] or an empty body. + + +If the built-in assertions are insufficient, you can consume the object instead and +perform any other assertions: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + client.get().uri("/persons/1") + .exchange() + .expectStatus().isOk() + .expectBody(Person.class) + .consumeWith(result -> { + // custom assertions (for example, AssertJ)... + }); +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + client.get().uri("/persons/1") + .exchange() + .expectStatus().isOk() + .expectBody() + .consumeWith { + // custom assertions (for example, AssertJ)... + } +---- +====== + +Or you can exit the workflow and obtain a `EntityExchangeResult`: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + EntityExchangeResult result = client.get().uri("/persons/1") + .exchange() + .expectStatus().isOk() + .expectBody(Person.class) + .returnResult(); +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + val result = client.get().uri("/persons/1") + .exchange() + .expectStatus().isOk + .expectBody() + .returnResult() +---- +====== + +TIP: When you need to decode to a target type with generics, look for the overloaded methods +that accept +{spring-framework-api}/core/ParameterizedTypeReference.html[`ParameterizedTypeReference`] +instead of `Class`. + + + +[[resttestclient-no-content]] +=== No Content + +If the response is not expected to have content, you can assert that as follows: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + client.post().uri("/persons") + .body(person) + .exchange() + .expectStatus().isCreated() + .expectBody().isEmpty(); +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + client.post().uri("/persons") + .body(person) + .exchange() + .expectStatus().isCreated() + .expectBody().isEmpty() +---- +====== + +If you want to ignore the response content, the following releases the content without +any assertions: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + client.get().uri("/persons/123") + .exchange() + .expectStatus().isNotFound() + .expectBody(Void.class); +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + client.get().uri("/persons/123") + .exchange() + .expectStatus().isNotFound + .expectBody() +---- +====== + + + +[[resttestclient-json]] +=== JSON Content + +You can use `expectBody()` without a target type to perform assertions on the raw +content rather than through higher level Object(s). + +To verify the full JSON content with https://jsonassert.skyscreamer.org[JSONAssert]: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + client.get().uri("/persons/1") + .exchange() + .expectStatus().isOk() + .expectBody() + .json("{\"name\":\"Jane\"}") +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + client.get().uri("/persons/1") + .exchange() + .expectStatus().isOk() + .expectBody() + .json("{\"name\":\"Jane\"}") +---- +====== + +To verify JSON content with https://github.com/jayway/JsonPath[JSONPath]: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + client.get().uri("/persons") + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$[0].name").isEqualTo("Jane") + .jsonPath("$[1].name").isEqualTo("Jason"); +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + client.get().uri("/persons") + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$[0].name").isEqualTo("Jane") + .jsonPath("$[1].name").isEqualTo("Jason") +---- +====== + + + diff --git a/spring-test/src/main/java/org/springframework/test/web/client/MockMvcClientHttpRequestFactory.java b/spring-test/src/main/java/org/springframework/test/web/client/MockMvcClientHttpRequestFactory.java index 5075de0a2a34..d8906052ff57 100644 --- a/spring-test/src/main/java/org/springframework/test/web/client/MockMvcClientHttpRequestFactory.java +++ b/spring-test/src/main/java/org/springframework/test/web/client/MockMvcClientHttpRequestFactory.java @@ -20,6 +20,9 @@ import java.nio.charset.StandardCharsets; import java.util.List; +import jakarta.servlet.http.Cookie; +import org.jspecify.annotations.Nullable; + import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; @@ -31,6 +34,8 @@ import org.springframework.mock.http.client.MockClientHttpResponse; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.client.RestTestClient; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -41,7 +46,9 @@ * * @author Rossen Stoyanchev * @since 3.2 + * @deprecated in favor of {@link RestTestClient#bindTo(MockMvc)} */ +@Deprecated(since = "7.0") public class MockMvcClientHttpRequestFactory implements ClientHttpRequestFactory { private final MockMvc mockMvc; @@ -67,8 +74,14 @@ private ClientHttpResponse getClientHttpResponse( HttpMethod httpMethod, URI uri, HttpHeaders requestHeaders, byte[] requestBody) { try { + Cookie[] cookies = parseCookies(requestHeaders.get(HttpHeaders.COOKIE)); + MockHttpServletRequestBuilder requestBuilder = request(httpMethod, uri) + .content(requestBody).headers(requestHeaders); + if (cookies.length > 0) { + requestBuilder.cookie(cookies); + } MockHttpServletResponse servletResponse = this.mockMvc - .perform(request(httpMethod, uri).content(requestBody).headers(requestHeaders)) + .perform(requestBuilder) .andReturn() .getResponse(); @@ -92,6 +105,22 @@ private ClientHttpResponse getClientHttpResponse( } } + private static Cookie[] parseCookies(@Nullable List headerValues) { + if (headerValues == null) { + return new Cookie[0]; + } + return headerValues.stream() + .flatMap(header -> StringUtils.commaDelimitedListToSet(header).stream()) + .map(MockMvcClientHttpRequestFactory::parseCookie) + .toArray(Cookie[]::new); + } + + private static Cookie parseCookie(String cookie) { + String[] parts = StringUtils.split(cookie, "="); + Assert.isTrue(parts != null && parts.length == 2, "Invalid cookie: '" + cookie + "'"); + return new Cookie(parts[0], parts[1]); + } + private HttpHeaders getResponseHeaders(MockHttpServletResponse response) { HttpHeaders headers = new HttpHeaders(); for (String name : response.getHeaderNames()) { diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/CookieAssertions.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/CookieAssertions.java new file mode 100644 index 000000000000..3bfb787cd4d1 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/CookieAssertions.java @@ -0,0 +1,236 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client; + +import java.time.Duration; +import java.util.function.Consumer; + +import org.hamcrest.Matcher; +import org.hamcrest.MatcherAssert; + +import org.springframework.http.ResponseCookie; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.fail; + +/** + * Assertions on cookies of the response. + * + * @author Rob Worsnop + */ +public class CookieAssertions { + + private final ExchangeResult exchangeResult; + + private final RestTestClient.ResponseSpec responseSpec; + + public CookieAssertions(ExchangeResult exchangeResult, RestTestClient.ResponseSpec responseSpec) { + this.exchangeResult = exchangeResult; + this.responseSpec = responseSpec; + } + + + /** + * Expect a response cookie with the given name to match the specified value. + */ + public RestTestClient.ResponseSpec valueEquals(String name, String value) { + String cookieValue = getCookie(name).getValue(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name); + assertEquals(message, value, cookieValue); + }); + return this.responseSpec; + } + + /** + * Assert the value of the response cookie with the given name with a Hamcrest + * {@link Matcher}. + */ + public RestTestClient.ResponseSpec value(String name, Matcher matcher) { + String value = getCookie(name).getValue(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name); + MatcherAssert.assertThat(message, value, matcher); + }); + return this.responseSpec; + } + + /** + * Consume the value of the response cookie with the given name. + */ + public RestTestClient.ResponseSpec value(String name, Consumer consumer) { + String value = getCookie(name).getValue(); + this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(value)); + return this.responseSpec; + } + + /** + * Expect that the cookie with the given name is present. + */ + public RestTestClient.ResponseSpec exists(String name) { + getCookie(name); + return this.responseSpec; + } + + /** + * Expect that the cookie with the given name is not present. + */ + public RestTestClient.ResponseSpec doesNotExist(String name) { + ResponseCookie cookie = this.exchangeResult.getResponseCookies().getFirst(name); + if (cookie != null) { + String message = getMessage(name) + " exists with value=[" + cookie.getValue() + "]"; + this.exchangeResult.assertWithDiagnostics(() -> fail(message)); + } + return this.responseSpec; + } + + /** + * Assert a cookie's "Max-Age" attribute. + */ + public RestTestClient.ResponseSpec maxAge(String name, Duration expected) { + Duration maxAge = getCookie(name).getMaxAge(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " maxAge"; + assertEquals(message, expected, maxAge); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "Max-Age" attribute with a Hamcrest {@link Matcher}. + */ + public RestTestClient.ResponseSpec maxAge(String name, Matcher matcher) { + long maxAge = getCookie(name).getMaxAge().getSeconds(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " maxAge"; + assertThat(message, maxAge, matcher); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "Path" attribute. + */ + public RestTestClient.ResponseSpec path(String name, String expected) { + String path = getCookie(name).getPath(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " path"; + assertEquals(message, expected, path); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "Path" attribute with a Hamcrest {@link Matcher}. + */ + public RestTestClient.ResponseSpec path(String name, Matcher matcher) { + String path = getCookie(name).getPath(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " path"; + assertThat(message, path, matcher); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "Domain" attribute. + */ + public RestTestClient.ResponseSpec domain(String name, String expected) { + String path = getCookie(name).getDomain(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " domain"; + assertEquals(message, expected, path); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "Domain" attribute with a Hamcrest {@link Matcher}. + */ + public RestTestClient.ResponseSpec domain(String name, Matcher matcher) { + String domain = getCookie(name).getDomain(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " domain"; + assertThat(message, domain, matcher); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "Secure" attribute. + */ + public RestTestClient.ResponseSpec secure(String name, boolean expected) { + boolean isSecure = getCookie(name).isSecure(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " secure"; + assertEquals(message, expected, isSecure); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "HttpOnly" attribute. + */ + public RestTestClient.ResponseSpec httpOnly(String name, boolean expected) { + boolean isHttpOnly = getCookie(name).isHttpOnly(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " httpOnly"; + assertEquals(message, expected, isHttpOnly); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "Partitioned" attribute. + */ + public RestTestClient.ResponseSpec partitioned(String name, boolean expected) { + boolean isPartitioned = getCookie(name).isPartitioned(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " isPartitioned"; + assertEquals(message, expected, isPartitioned); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "SameSite" attribute. + */ + public RestTestClient.ResponseSpec sameSite(String name, String expected) { + String sameSite = getCookie(name).getSameSite(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " sameSite"; + assertEquals(message, expected, sameSite); + }); + return this.responseSpec; + } + + private ResponseCookie getCookie(String name) { + ResponseCookie cookie = this.exchangeResult.getResponseCookies().getFirst(name); + if (cookie != null) { + return cookie; + } + else { + this.exchangeResult.assertWithDiagnostics(() -> fail("No cookie with name '" + name + "'")); + } + throw new IllegalStateException("This code path should not be reachable"); + } + + private static String getMessage(String cookie) { + return "Response cookie '" + cookie + "'"; + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultMockServerBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultMockServerBuilder.java new file mode 100644 index 000000000000..9cfdf87f9faf --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultMockServerBuilder.java @@ -0,0 +1,49 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client; + +import java.util.function.Consumer; + +import org.springframework.test.web.servlet.MockMvcBuilder; + +/** + * Default implementation of {@link RestTestClient.MockServerBuilder}. + * @author Rob Worsnop + * @param the type of the {@link MockMvcBuilder} to use for building the mock server + */ +class DefaultMockServerBuilder + extends DefaultRestTestClientBuilder> + implements RestTestClient.MockServerBuilder { + + private final M builder; + + public DefaultMockServerBuilder(M builder) { + this.builder = builder; + } + + @Override + public RestTestClient.MockServerBuilder configureServer(Consumer consumer) { + consumer.accept(this.builder); + return this; + } + + @Override + public RestTestClient build() { + this.restClientBuilder.requestFactory(new MockMvcClientHttpRequestFactory(this.builder.build())); + return super.build(); + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java new file mode 100644 index 000000000000..10bff023a62d --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java @@ -0,0 +1,429 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client; + +import java.net.URI; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.time.ZonedDateTime; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.hamcrest.Matcher; +import org.hamcrest.MatcherAssert; +import org.jspecify.annotations.Nullable; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.json.JsonAssert; +import org.springframework.test.json.JsonComparator; +import org.springframework.test.json.JsonCompareMode; +import org.springframework.test.util.AssertionErrors; +import org.springframework.test.util.ExceptionCollector; +import org.springframework.test.util.XmlExpectationsHelper; +import org.springframework.util.MimeType; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriBuilder; + +/** + * Default implementation of {@link RestTestClient}. + * + * @author Rob Worsnop + */ +class DefaultRestTestClient implements RestTestClient { + + private final RestClient restClient; + + private final AtomicLong requestIndex = new AtomicLong(); + + private final RestClient.Builder restClientBuilder; + + DefaultRestTestClient(RestClient.Builder restClientBuilder) { + this.restClient = restClientBuilder.build(); + this.restClientBuilder = restClientBuilder; + } + + @Override + public RequestHeadersUriSpec get() { + return methodInternal(HttpMethod.GET); + } + + @Override + public RequestHeadersUriSpec head() { + return methodInternal(HttpMethod.HEAD); + } + + @Override + public RequestBodyUriSpec post() { + return methodInternal(HttpMethod.POST); + } + + @Override + public RequestBodyUriSpec put() { + return methodInternal(HttpMethod.PUT); + } + + @Override + public RequestBodyUriSpec patch() { + return methodInternal(HttpMethod.PATCH); + } + + @Override + public RequestHeadersUriSpec delete() { + return methodInternal(HttpMethod.DELETE); + } + + @Override + public RequestHeadersUriSpec options() { + return methodInternal(HttpMethod.OPTIONS); + } + + @Override + public RequestBodyUriSpec method(HttpMethod method) { + return methodInternal(method); + } + + @Override + public > Builder mutate() { + return new DefaultRestTestClientBuilder<>(this.restClientBuilder); + } + + private RequestBodyUriSpec methodInternal(HttpMethod httpMethod) { + return new DefaultRequestBodyUriSpec(this.restClient.method(httpMethod)); + } + + + private class DefaultRequestBodyUriSpec implements RequestBodyUriSpec { + + private final RestClient.RequestBodyUriSpec requestHeadersUriSpec; + private RestClient.RequestBodySpec requestBodySpec; + private final String requestId; + + + public DefaultRequestBodyUriSpec(RestClient.RequestBodyUriSpec spec) { + this.requestHeadersUriSpec = spec; + this.requestBodySpec = spec; + this.requestId = String.valueOf(requestIndex.incrementAndGet()); + } + + @Override + public RequestBodySpec accept(MediaType... acceptableMediaTypes) { + this.requestBodySpec = this.requestHeadersUriSpec.accept(acceptableMediaTypes); + return this; + } + + @Override + public RequestBodySpec uri(URI uri) { + this.requestBodySpec = this.requestHeadersUriSpec.uri(uri); + return this; + } + + @Override + public RequestBodySpec uri(String uriTemplate, Object... uriVariables) { + this.requestBodySpec = this.requestHeadersUriSpec.uri(uriTemplate, uriVariables); + return this; + } + + @Override + public RequestBodySpec uri(String uri, Map uriVariables) { + this.requestBodySpec = this.requestHeadersUriSpec.uri(uri, uriVariables); + return this; + } + + @Override + public RequestBodySpec uri(Function uriFunction) { + this.requestBodySpec = this.requestHeadersUriSpec.uri(uriFunction); + return this; + } + + @Override + public RequestBodySpec cookie(String name, String value) { + this.requestBodySpec = this.requestHeadersUriSpec.cookie(name, value); + return this; + } + + @Override + public RequestBodySpec cookies(Consumer> cookiesConsumer) { + this.requestBodySpec = this.requestHeadersUriSpec.cookies(cookiesConsumer); + return this; + } + + @Override + public RequestBodySpec header(String headerName, String... headerValues) { + this.requestBodySpec = this.requestHeadersUriSpec.header(headerName, headerValues); + return this; + } + + @Override + public RequestBodySpec contentType(MediaType contentType) { + this.requestBodySpec = this.requestHeadersUriSpec.contentType(contentType); + return this; + } + + @Override + public RequestHeadersSpec body(Object body) { + this.requestHeadersUriSpec.body(body); + return this; + } + + @Override + public RequestBodySpec acceptCharset(Charset... acceptableCharsets) { + this.requestBodySpec = this.requestHeadersUriSpec.acceptCharset(acceptableCharsets); + return this; + } + + @Override + public RequestBodySpec ifModifiedSince(ZonedDateTime ifModifiedSince) { + this.requestBodySpec = this.requestHeadersUriSpec.ifModifiedSince(ifModifiedSince); + return this; + } + + @Override + public RequestBodySpec ifNoneMatch(String... ifNoneMatches) { + this.requestBodySpec = this.requestHeadersUriSpec.ifNoneMatch(ifNoneMatches); + return this; + } + + @Override + public RequestBodySpec headers(Consumer headersConsumer) { + this.requestBodySpec = this.requestHeadersUriSpec.headers(headersConsumer); + return this; + } + + @Override + public RequestBodySpec attribute(String name, Object value) { + this.requestBodySpec = this.requestHeadersUriSpec.attribute(name, value); + return this; + } + + @Override + public RequestBodySpec attributes(Consumer> attributesConsumer) { + this.requestBodySpec = this.requestHeadersUriSpec.attributes(attributesConsumer); + return this; + } + + @Override + public ResponseSpec exchange() { + this.requestBodySpec = this.requestBodySpec.header(RESTTESTCLIENT_REQUEST_ID, this.requestId); + ExchangeResult exchangeResult = this.requestBodySpec.exchange( + (clientRequest, clientResponse) -> new ExchangeResult(clientResponse), + false); + return new DefaultResponseSpec(Objects.requireNonNull(exchangeResult)); + } + } + + private static class DefaultResponseSpec implements ResponseSpec { + + private final ExchangeResult exchangeResult; + + public DefaultResponseSpec(ExchangeResult exchangeResult) { + this.exchangeResult = exchangeResult; + } + + @Override + public StatusAssertions expectStatus() { + return new StatusAssertions(this.exchangeResult, this); + } + + @Override + public BodyContentSpec expectBody() { + byte[] body = this.exchangeResult.getBody(byte[].class); + return new DefaultBodyContentSpec( new EntityExchangeResult<>(this.exchangeResult, body)); + } + + @Override + public BodySpec expectBody(Class bodyType) { + B body = this.exchangeResult.getBody(bodyType); + return new DefaultBodySpec<>(new EntityExchangeResult<>(this.exchangeResult, body)); + } + + @Override + public BodySpec expectBody(ParameterizedTypeReference bodyType) { + B body = this.exchangeResult.getBody(bodyType); + return new DefaultBodySpec<>(new EntityExchangeResult<>(this.exchangeResult, body)); + } + + @Override + public CookieAssertions expectCookie() { + return new CookieAssertions(this.exchangeResult, this); + } + + @Override + public HeaderAssertions expectHeader() { + return new HeaderAssertions(this.exchangeResult, this); + } + + @Override + public ResponseSpec expectAll(ResponseSpecConsumer... consumers) { + ExceptionCollector exceptionCollector = new ExceptionCollector(); + for (ResponseSpecConsumer consumer : consumers) { + exceptionCollector.execute(() -> consumer.accept(this)); + } + try { + exceptionCollector.assertEmpty(); + } + catch (RuntimeException ex) { + throw ex; + } + catch (Exception ex) { + // In theory, a ResponseSpecConsumer should never throw an Exception + // that is not a RuntimeException, but since ExceptionCollector may + // throw a checked Exception, we handle this to appease the compiler + // and in case someone uses a "sneaky throws" technique. + throw new AssertionError(ex.getMessage(), ex); + } + return this; + } + + @Override + public EntityExchangeResult returnResult(Class elementClass) { + return new EntityExchangeResult<>(this.exchangeResult, this.exchangeResult.getBody(elementClass)); + } + + @Override + public EntityExchangeResult returnResult(ParameterizedTypeReference elementTypeRef) { + return new EntityExchangeResult<>(this.exchangeResult, this.exchangeResult.getBody(elementTypeRef)); + } + } + + private static class DefaultBodyContentSpec implements BodyContentSpec { + private final EntityExchangeResult result; + + public DefaultBodyContentSpec(EntityExchangeResult result) { + this.result = result; + } + + @Override + public EntityExchangeResult isEmpty() { + this.result.assertWithDiagnostics(() -> + AssertionErrors.assertTrue("Expected empty body", + this.result.getBody(byte[].class) == null)); + return new EntityExchangeResult<>(this.result, null); + } + + @Override + public BodyContentSpec json(String expectedJson, JsonCompareMode compareMode) { + return json(expectedJson, JsonAssert.comparator(compareMode)); + } + + @Override + public BodyContentSpec json(String expectedJson, JsonComparator comparator) { + this.result.assertWithDiagnostics(() -> { + try { + comparator.assertIsMatch(expectedJson, getBodyAsString()); + } + catch (Exception ex) { + throw new AssertionError("JSON parsing error", ex); + } + }); + return this; + } + + @Override + public BodyContentSpec xml(String expectedXml) { + this.result.assertWithDiagnostics(() -> { + try { + new XmlExpectationsHelper().assertXmlEqual(expectedXml, getBodyAsString()); + } + catch (Exception ex) { + throw new AssertionError("XML parsing error", ex); + } + }); + return this; + } + + @Override + public JsonPathAssertions jsonPath(String expression) { + return new JsonPathAssertions(this, getBodyAsString(), expression, null); + } + + @Override + public XpathAssertions xpath(String expression, @Nullable Map namespaces, Object... args) { + return new XpathAssertions(this, expression, namespaces, args); + } + + private String getBodyAsString() { + byte[] body = this.result.getResponseBody(); + if (body == null || body.length == 0) { + return ""; + } + Charset charset = Optional.ofNullable(this.result.getResponseHeaders().getContentType()) + .map(MimeType::getCharset).orElse(StandardCharsets.UTF_8); + return new String(body, charset); + } + + @Override + public EntityExchangeResult returnResult() { + return this.result; + } + } + + private static class DefaultBodySpec> implements BodySpec { + + private final EntityExchangeResult result; + + public DefaultBodySpec(@Nullable EntityExchangeResult result) { + this.result = Objects.requireNonNull(result, "exchangeResult must be non-null"); + } + + @Override + public EntityExchangeResult returnResult() { + return this.result; + } + + @Override + public T isEqualTo(B expected) { + this.result.assertWithDiagnostics(() -> + AssertionErrors.assertEquals("Response body", expected, this.result.getResponseBody())); + return self(); + } + + @Override + @SuppressWarnings("NullAway") // https://github.com/uber/NullAway/issues/1129 + public T value(Function bodyMapper, Matcher matcher) { + this.result.assertWithDiagnostics(() -> { + B body = this.result.getResponseBody(); + MatcherAssert.assertThat(bodyMapper.apply(body), matcher); + }); + return self(); + } + + @Override + public T value(Consumer consumer) { + this.result.assertWithDiagnostics(() -> consumer.accept(this.result.getResponseBody())); + return self(); + } + + @Override + public T consumeWith(Consumer> consumer) { + this.result.assertWithDiagnostics(() -> consumer.accept(this.result)); + return self(); + } + + @SuppressWarnings("unchecked") + private T self() { + return (T) this; + } + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java new file mode 100644 index 000000000000..dcd05e779b49 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java @@ -0,0 +1,89 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client; + +import java.util.function.Consumer; + +import org.springframework.http.HttpHeaders; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriBuilderFactory; + +/** + * Default implementation of {@link RestTestClient.Builder}. + * @author Rob Worsnop + * @param the type of the builder + */ +class DefaultRestTestClientBuilder> implements RestTestClient.Builder { + + protected final RestClient.Builder restClientBuilder; + + DefaultRestTestClientBuilder() { + this.restClientBuilder = RestClient.builder(); + } + + DefaultRestTestClientBuilder(RestClient.Builder restClientBuilder) { + this.restClientBuilder = restClientBuilder; + } + + @Override + public RestTestClient.Builder apply(Consumer> builderConsumer) { + builderConsumer.accept(this); + return this; + } + + @Override + public RestTestClient.Builder baseUrl(String baseUrl) { + this.restClientBuilder.baseUrl(baseUrl); + return this; + } + + @Override + public RestTestClient.Builder defaultCookie(String cookieName, String... cookieValues) { + this.restClientBuilder.defaultCookie(cookieName, cookieValues); + return this; + } + + @Override + public RestTestClient.Builder defaultCookies(Consumer> cookiesConsumer) { + this.restClientBuilder.defaultCookies(cookiesConsumer); + return this; + } + + @Override + public RestTestClient.Builder defaultHeader(String headerName, String... headerValues) { + this.restClientBuilder.defaultHeader(headerName, headerValues); + return this; + } + + @Override + public RestTestClient.Builder defaultHeaders(Consumer headersConsumer) { + this.restClientBuilder.defaultHeaders(headersConsumer); + return this; + } + + @Override + public RestTestClient.Builder uriBuilderFactory(UriBuilderFactory uriFactory) { + this.restClientBuilder.uriBuilderFactory(uriFactory); + return this; + } + + @Override + public RestTestClient build() { + return new DefaultRestTestClient(this.restClientBuilder); + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/EntityExchangeResult.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/EntityExchangeResult.java new file mode 100644 index 000000000000..f9c9702f8bbc --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/EntityExchangeResult.java @@ -0,0 +1,46 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client; + +import org.jspecify.annotations.Nullable; + +/** + * {@code ExchangeResult} sub-class that exposes the response body fully + * extracted to a representation of type {@code }. + * + * @author Rob Worsnop + * @param the response body type + */ +public class EntityExchangeResult extends ExchangeResult { + + private final @Nullable T body; + + + EntityExchangeResult(ExchangeResult result, @Nullable T body) { + super(result); + this.body = body; + } + + + /** + * Return the entity extracted from the response body. + */ + public @Nullable T getResponseBody() { + return this.body; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/ExchangeResult.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/ExchangeResult.java new file mode 100644 index 000000000000..bb899d2a8b02 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/ExchangeResult.java @@ -0,0 +1,135 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client; + +import java.io.IOException; +import java.net.HttpCookie; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseCookie; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient.RequestHeadersSpec.ConvertibleClientHttpResponse; + +/** + * Container for request and response details for exchanges performed through + * {@link RestTestClient}. + * + * @author Rob Worsnop + */ +public class ExchangeResult { + private static final Pattern SAME_SITE_PATTERN = Pattern.compile("(?i).*SameSite=(Strict|Lax|None).*"); + private static final Pattern PARTITIONED_PATTERN = Pattern.compile("(?i).*;\\s*Partitioned(\\s*;.*|\\s*)$"); + + + private static final Log logger = LogFactory.getLog(ExchangeResult.class); + + /** Ensure single logging; for example, for expectAll. */ + private boolean diagnosticsLogged; + + private final ConvertibleClientHttpResponse clientResponse; + + ExchangeResult(@Nullable ConvertibleClientHttpResponse clientResponse) { + this.clientResponse = Objects.requireNonNull(clientResponse, "clientResponse must be non-null"); + } + + ExchangeResult(ExchangeResult result) { + this(result.clientResponse); + this.diagnosticsLogged = result.diagnosticsLogged; + } + + public HttpStatusCode getStatus() { + try { + return this.clientResponse.getStatusCode(); + } + catch (IOException ex) { + throw new AssertionError(ex); + } + } + + public HttpHeaders getResponseHeaders() { + return this.clientResponse.getHeaders(); + } + + @Nullable + public T getBody(Class bodyType) { + return this.clientResponse.bodyTo(bodyType); + } + + @Nullable + public T getBody(ParameterizedTypeReference bodyType) { + return this.clientResponse.bodyTo(bodyType); + } + + + /** + * Execute the given Runnable, catch any {@link AssertionError}, log details + * about the request and response at ERROR level under the class log + * category, and after that re-throw the error. + */ + public void assertWithDiagnostics(Runnable assertion) { + try { + assertion.run(); + } + catch (AssertionError ex) { + if (!this.diagnosticsLogged && logger.isErrorEnabled()) { + this.diagnosticsLogged = true; + logger.error("Request details for assertion failure:\n" + this); + } + throw ex; + } + } + + /** + * Return response cookies received from the server. + */ + public MultiValueMap getResponseCookies() { + return Optional.ofNullable(this.clientResponse.getHeaders().get(HttpHeaders.SET_COOKIE)).orElse(List.of()).stream() + .flatMap(header -> { + Matcher matcher = SAME_SITE_PATTERN.matcher(header); + String sameSite = (matcher.matches() ? matcher.group(1) : null); + boolean partitioned = PARTITIONED_PATTERN.matcher(header).matches(); + return HttpCookie.parse(header).stream().map(cookie -> toResponseCookie(cookie, sameSite, partitioned)); + }) + .collect(LinkedMultiValueMap::new, + (cookies, cookie) -> cookies.add(cookie.getName(), cookie), + LinkedMultiValueMap::addAll); + } + + private static ResponseCookie toResponseCookie(HttpCookie cookie, @Nullable String sameSite, boolean partitioned) { + return ResponseCookie.from(cookie.getName(), cookie.getValue()) + .domain(cookie.getDomain()) + .httpOnly(cookie.isHttpOnly()) + .maxAge(cookie.getMaxAge()) + .path(cookie.getPath()) + .secure(cookie.getSecure()) + .sameSite(sameSite) + .partitioned(partitioned) + .build(); + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/HeaderAssertions.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/HeaderAssertions.java new file mode 100644 index 000000000000..577e6abef90a --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/HeaderAssertions.java @@ -0,0 +1,311 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client; + +import java.net.URI; +import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; + +import org.hamcrest.Matcher; +import org.jspecify.annotations.Nullable; + +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.util.CollectionUtils; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.assertNotNull; +import static org.springframework.test.util.AssertionErrors.assertTrue; +import static org.springframework.test.util.AssertionErrors.fail; + +/** + * Assertions on headers of the response. + * + * @author Rob Worsnop + * @see RestTestClient.ResponseSpec#expectHeader() + */ +public class HeaderAssertions { + + private final ExchangeResult exchangeResult; + + private final RestTestClient.ResponseSpec responseSpec; + + public HeaderAssertions(ExchangeResult exchangeResult, RestTestClient.ResponseSpec responseSpec) { + this.exchangeResult = exchangeResult; + this.responseSpec = responseSpec; + } + + /** + * Expect a header with the given name to match the specified values. + */ + public RestTestClient.ResponseSpec valueEquals(String headerName, String... values) { + return assertHeader(headerName, Arrays.asList(values), getHeaders().getOrEmpty(headerName)); + } + + /** + * Expect a header with the given name to match the given long value. + */ + public RestTestClient.ResponseSpec valueEquals(String headerName, long value) { + String actual = getHeaders().getFirst(headerName); + this.exchangeResult.assertWithDiagnostics(() -> + assertNotNull("Response does not contain header '" + headerName + "'", actual)); + return assertHeader(headerName, value, Long.parseLong(actual)); + } + + /** + * Expect a header with the given name to match the specified long value + * parsed into a date using the preferred date format described in RFC 7231. + *

An {@link AssertionError} is thrown if the response does not contain + * the specified header, or if the supplied {@code value} does not match the + * primary header value. + */ + public RestTestClient.ResponseSpec valueEqualsDate(String headerName, long value) { + this.exchangeResult.assertWithDiagnostics(() -> { + String headerValue = getHeaders().getFirst(headerName); + assertNotNull("Response does not contain header '" + headerName + "'", headerValue); + + HttpHeaders headers = new HttpHeaders(); + headers.setDate("expected", value); + headers.set("actual", headerValue); + + assertEquals(getMessage(headerName) + "='" + headerValue + "' " + + "does not match expected value '" + headers.getFirst("expected") + "'", + headers.getFirstDate("expected"), headers.getFirstDate("actual")); + }); + return this.responseSpec; + } + + /** + * Match the first value of the response header with a regex. + * @param name the header name + * @param pattern the regex pattern + */ + public RestTestClient.ResponseSpec valueMatches(String name, String pattern) { + String value = getRequiredValue(name); + String message = getMessage(name) + "=[" + value + "] does not match [" + pattern + "]"; + this.exchangeResult.assertWithDiagnostics(() -> assertTrue(message, value.matches(pattern))); + return this.responseSpec; + } + + /** + * Match all values of the response header with the given regex + * patterns which are applied to the values of the header in the + * same order. Note that the number of patterns must match the + * number of actual values. + * @param name the header name + * @param patterns one or more regex patterns, one per expected value + */ + public RestTestClient.ResponseSpec valuesMatch(String name, String... patterns) { + List values = getRequiredValues(name); + this.exchangeResult.assertWithDiagnostics(() -> { + assertTrue( + getMessage(name) + " has fewer or more values " + values + + " than number of patterns to match with " + Arrays.toString(patterns), + values.size() == patterns.length); + for (int i = 0; i < values.size(); i++) { + String value = values.get(i); + String pattern = patterns[i]; + assertTrue( + getMessage(name) + "[" + i + "]='" + value + "' does not match '" + pattern + "'", + value.matches(pattern)); + } + }); + return this.responseSpec; + } + + /** + * Assert the first value of the response header with a Hamcrest {@link Matcher}. + * @param name the header name + * @param matcher the matcher to use + */ + public RestTestClient.ResponseSpec value(String name, Matcher matcher) { + String value = getHeaders().getFirst(name); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name); + assertThat(message, value, matcher); + }); + return this.responseSpec; + } + + /** + * Assert all values of the response header with a Hamcrest {@link Matcher}. + * @param name the header name + * @param matcher the matcher to use + */ + public RestTestClient.ResponseSpec values(String name, Matcher> matcher) { + List values = getHeaders().get(name); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name); + assertThat(message, values, matcher); + }); + return this.responseSpec; + } + + /** + * Consume the first value of the named response header. + * @param name the header name + * @param consumer the consumer to use + */ + public RestTestClient.ResponseSpec value(String name, Consumer consumer) { + String value = getRequiredValue(name); + this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(value)); + return this.responseSpec; + } + + /** + * Consume all values of the named response header. + * @param name the header name + * @param consumer the consumer to use + */ + public RestTestClient.ResponseSpec values(String name, Consumer> consumer) { + List values = getRequiredValues(name); + this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(values)); + return this.responseSpec; + } + + /** + * Expect that the header with the given name is present. + */ + public RestTestClient.ResponseSpec exists(String name) { + if (!this.exchangeResult.getResponseHeaders().containsHeader(name)) { + String message = getMessage(name) + " does not exist"; + this.exchangeResult.assertWithDiagnostics(() -> fail(message)); + } + return this.responseSpec; + } + + /** + * Expect that the header with the given name is not present. + */ + public RestTestClient.ResponseSpec doesNotExist(String name) { + if (getHeaders().containsHeader(name)) { + String message = getMessage(name) + " exists with value=[" + getHeaders().getFirst(name) + "]"; + this.exchangeResult.assertWithDiagnostics(() -> fail(message)); + } + return this.responseSpec; + } + + /** + * Expect a "Cache-Control" header with the given value. + */ + public RestTestClient.ResponseSpec cacheControl(CacheControl cacheControl) { + return assertHeader("Cache-Control", cacheControl.getHeaderValue(), getHeaders().getCacheControl()); + } + + /** + * Expect a "Content-Disposition" header with the given value. + */ + public RestTestClient.ResponseSpec contentDisposition(ContentDisposition contentDisposition) { + return assertHeader("Content-Disposition", contentDisposition, getHeaders().getContentDisposition()); + } + + /** + * Expect a "Content-Length" header with the given value. + */ + public RestTestClient.ResponseSpec contentLength(long contentLength) { + return assertHeader("Content-Length", contentLength, getHeaders().getContentLength()); + } + + /** + * Expect a "Content-Type" header with the given value. + */ + public RestTestClient.ResponseSpec contentType(MediaType mediaType) { + return assertHeader("Content-Type", mediaType, getHeaders().getContentType()); + } + + /** + * Expect a "Content-Type" header with the given value. + */ + public RestTestClient.ResponseSpec contentType(String mediaType) { + return contentType(MediaType.parseMediaType(mediaType)); + } + + /** + * Expect a "Content-Type" header compatible with the given value. + */ + public RestTestClient.ResponseSpec contentTypeCompatibleWith(MediaType mediaType) { + MediaType actual = getHeaders().getContentType(); + String message = getMessage("Content-Type") + "=[" + actual + "] is not compatible with [" + mediaType + "]"; + this.exchangeResult.assertWithDiagnostics(() -> + assertTrue(message, (actual != null && actual.isCompatibleWith(mediaType)))); + return this.responseSpec; + } + + /** + * Expect a "Content-Type" header compatible with the given value. + */ + public RestTestClient.ResponseSpec contentTypeCompatibleWith(String mediaType) { + return contentTypeCompatibleWith(MediaType.parseMediaType(mediaType)); + } + + /** + * Expect an "Expires" header with the given value. + */ + public RestTestClient.ResponseSpec expires(long expires) { + return assertHeader("Expires", expires, getHeaders().getExpires()); + } + + /** + * Expect a "Last-Modified" header with the given value. + */ + public RestTestClient.ResponseSpec lastModified(long lastModified) { + return assertHeader("Last-Modified", lastModified, getHeaders().getLastModified()); + } + + /** + * Expect a "Location" header with the given value. + */ + public RestTestClient.ResponseSpec location(String location) { + return assertHeader("Location", URI.create(location), getHeaders().getLocation()); + } + + + private HttpHeaders getHeaders() { + return this.exchangeResult.getResponseHeaders(); + } + + private String getRequiredValue(String name) { + return getRequiredValues(name).get(0); + } + + private List getRequiredValues(String name) { + List values = getHeaders().get(name); + if (!CollectionUtils.isEmpty(values)) { + return values; + } + else { + this.exchangeResult.assertWithDiagnostics(() -> fail(getMessage(name) + " not found")); + } + throw new IllegalStateException("This code path should not be reachable"); + } + + private RestTestClient.ResponseSpec assertHeader(String name, @Nullable Object expected, @Nullable Object actual) { + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name); + assertEquals(message, expected, actual); + }); + return this.responseSpec; + } + + private static String getMessage(String headerName) { + return "Response header '" + headerName + "'"; + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/JsonPathAssertions.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/JsonPathAssertions.java new file mode 100644 index 000000000000..cf6174caa330 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/JsonPathAssertions.java @@ -0,0 +1,205 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client; + +import java.util.function.Consumer; + +import com.jayway.jsonpath.Configuration; +import org.hamcrest.Matcher; +import org.jspecify.annotations.Nullable; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.test.util.JsonPathExpectationsHelper; +import org.springframework.util.Assert; + +/** + * JsonPath assertions. + * + * @author Rob Worsnop + * + * @see https://github.com/jayway/JsonPath + * @see JsonPathExpectationsHelper + */ +public class JsonPathAssertions { + + private final RestTestClient.BodyContentSpec bodySpec; + + private final String content; + + private final JsonPathExpectationsHelper pathHelper; + + + JsonPathAssertions(RestTestClient.BodyContentSpec spec, String content, String expression, @Nullable Configuration configuration) { + Assert.hasText(expression, "expression must not be null or empty"); + this.bodySpec = spec; + this.content = content; + this.pathHelper = new JsonPathExpectationsHelper(expression, configuration); + } + + + /** + * Applies {@link JsonPathExpectationsHelper#assertValue(String, Object)}. + */ + public RestTestClient.BodyContentSpec isEqualTo(Object expectedValue) { + this.pathHelper.assertValue(this.content, expectedValue); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#exists(String)}. + */ + public RestTestClient.BodyContentSpec exists() { + this.pathHelper.exists(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#doesNotExist(String)}. + */ + public RestTestClient.BodyContentSpec doesNotExist() { + this.pathHelper.doesNotExist(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#assertValueIsEmpty(String)}. + */ + public RestTestClient.BodyContentSpec isEmpty() { + this.pathHelper.assertValueIsEmpty(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#assertValueIsNotEmpty(String)}. + */ + public RestTestClient.BodyContentSpec isNotEmpty() { + this.pathHelper.assertValueIsNotEmpty(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#hasJsonPath}. + */ + public RestTestClient.BodyContentSpec hasJsonPath() { + this.pathHelper.hasJsonPath(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#doesNotHaveJsonPath}. + */ + public RestTestClient.BodyContentSpec doesNotHaveJsonPath() { + this.pathHelper.doesNotHaveJsonPath(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#assertValueIsBoolean(String)}. + */ + public RestTestClient.BodyContentSpec isBoolean() { + this.pathHelper.assertValueIsBoolean(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#assertValueIsNumber(String)}. + */ + public RestTestClient.BodyContentSpec isNumber() { + this.pathHelper.assertValueIsNumber(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#assertValueIsArray(String)}. + */ + public RestTestClient.BodyContentSpec isArray() { + this.pathHelper.assertValueIsArray(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#assertValueIsMap(String)}. + */ + public RestTestClient.BodyContentSpec isMap() { + this.pathHelper.assertValueIsMap(this.content); + return this.bodySpec; + } + + /** + * Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher)}. + */ + public RestTestClient.BodyContentSpec value(Matcher matcher) { + this.pathHelper.assertValue(this.content, matcher); + return this.bodySpec; + } + + /** + * Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher, Class)}. + */ + public RestTestClient.BodyContentSpec value(Class targetType, Matcher matcher) { + this.pathHelper.assertValue(this.content, matcher, targetType); + return this.bodySpec; + } + + /** + * Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher, ParameterizedTypeReference)}. + */ + public RestTestClient.BodyContentSpec value(ParameterizedTypeReference targetType, Matcher matcher) { + this.pathHelper.assertValue(this.content, matcher, targetType); + return this.bodySpec; + } + + /** + * Consume the result of the JSONPath evaluation. + */ + @SuppressWarnings("unchecked") + public RestTestClient.BodyContentSpec value(Consumer consumer) { + Object value = this.pathHelper.evaluateJsonPath(this.content); + consumer.accept((T) value); + return this.bodySpec; + } + + /** + * Consume the result of the JSONPath evaluation and provide a target class. + */ + public RestTestClient.BodyContentSpec value(Class targetType, Consumer consumer) { + T value = this.pathHelper.evaluateJsonPath(this.content, targetType); + consumer.accept(value); + return this.bodySpec; + } + + /** + * Consume the result of the JSONPath evaluation and provide a parameterized type. + */ + public RestTestClient.BodyContentSpec value(ParameterizedTypeReference targetType, Consumer consumer) { + T value = this.pathHelper.evaluateJsonPath(this.content, targetType); + consumer.accept(value); + return this.bodySpec; + } + + @Override + public boolean equals(@Nullable Object obj) { + throw new AssertionError("Object#equals is disabled " + + "to avoid being used in error instead of JsonPathAssertions#isEqualTo(String)."); + } + + @Override + public int hashCode() { + return super.hashCode(); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcClientHttpRequestFactory.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcClientHttpRequestFactory.java new file mode 100644 index 000000000000..509443698ebe --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcClientHttpRequestFactory.java @@ -0,0 +1,133 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import jakarta.servlet.http.Cookie; +import org.jspecify.annotations.Nullable; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.client.ClientHttpRequest; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.mock.http.client.MockClientHttpRequest; +import org.springframework.mock.http.client.MockClientHttpResponse; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request; + +/** + * A {@link ClientHttpRequestFactory} for requests executed via {@link MockMvc}. + * + * @author Rossen Stoyanchev + * @author Rob Worsnop + * @since 7.0 + */ +class MockMvcClientHttpRequestFactory implements ClientHttpRequestFactory { + + private final MockMvc mockMvc; + + + MockMvcClientHttpRequestFactory(MockMvc mockMvc) { + Assert.notNull(mockMvc, "MockMvc must not be null"); + this.mockMvc = mockMvc; + } + + + @Override + public ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod) { + return new MockClientHttpRequest(httpMethod, uri) { + @Override + public ClientHttpResponse executeInternal() { + return getClientHttpResponse(httpMethod, uri, getHeaders(), getBodyAsBytes()); + } + }; + } + + private ClientHttpResponse getClientHttpResponse( + HttpMethod httpMethod, URI uri, HttpHeaders requestHeaders, byte[] requestBody) { + + try { + Cookie[] cookies = parseCookies(requestHeaders.get(HttpHeaders.COOKIE)); + MockHttpServletRequestBuilder requestBuilder = request(httpMethod, uri) + .content(requestBody).headers(requestHeaders); + if (cookies.length > 0) { + requestBuilder.cookie(cookies); + } + MockHttpServletResponse servletResponse = this.mockMvc + .perform(requestBuilder) + .andReturn() + .getResponse(); + + HttpStatusCode status = HttpStatusCode.valueOf(servletResponse.getStatus()); + byte[] body = servletResponse.getContentAsByteArray(); + if (body.length == 0) { + String error = servletResponse.getErrorMessage(); + if (StringUtils.hasLength(error)) { + // sendError message as default body + body = error.getBytes(StandardCharsets.UTF_8); + } + } + + MockClientHttpResponse clientResponse = new MockClientHttpResponse(body, status); + clientResponse.getHeaders().putAll(getResponseHeaders(servletResponse)); + return clientResponse; + } + catch (Exception ex) { + byte[] body = ex.toString().getBytes(StandardCharsets.UTF_8); + return new MockClientHttpResponse(body, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + private static Cookie[] parseCookies(@Nullable List headerValues) { + if (headerValues == null) { + return new Cookie[0]; + } + return headerValues.stream() + .flatMap(header -> StringUtils.commaDelimitedListToSet(header).stream()) + .map(MockMvcClientHttpRequestFactory::parseCookie) + .toArray(Cookie[]::new); + } + + private static Cookie parseCookie(String cookie) { + String[] parts = StringUtils.split(cookie, "="); + Assert.isTrue(parts != null && parts.length == 2, "Invalid cookie: '" + cookie + "'"); + return new Cookie(parts[0], parts[1]); + } + + private HttpHeaders getResponseHeaders(MockHttpServletResponse response) { + HttpHeaders headers = new HttpHeaders(); + for (String name : response.getHeaderNames()) { + List values = response.getHeaders(name); + for (String value : values) { + headers.add(name, value); + } + } + return headers; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java new file mode 100644 index 000000000000..23314716fe6f --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java @@ -0,0 +1,656 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client; + +import java.net.URI; +import java.nio.charset.Charset; +import java.time.ZonedDateTime; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.hamcrest.Matcher; +import org.jspecify.annotations.Nullable; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.test.json.JsonComparator; +import org.springframework.test.json.JsonCompareMode; +import org.springframework.test.json.JsonComparison; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MockMvcBuilder; +import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.test.web.servlet.setup.RouterFunctionMockMvcBuilder; +import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.util.UriBuilder; +import org.springframework.web.util.UriBuilderFactory; + +/** + * Client for testing web servers. + * + * @author Rob Worsnop + */ +public interface RestTestClient { + + /** + * The name of a request header used to assign a unique id to every request + * performed through the {@code RestTestClient}. This can be useful for + * storing contextual information at all phases of request processing (for example, + * from a server-side component) under that id and later to look up + * that information once an {@link ExchangeResult} is available. + */ + String RESTTESTCLIENT_REQUEST_ID = "RestTestClient-Request-Id"; + + /** + * Prepare an HTTP GET request. + * @return a spec for specifying the target URL + */ + RequestHeadersUriSpec get(); + + /** + * Prepare an HTTP HEAD request. + * @return a spec for specifying the target URL + */ + RequestHeadersUriSpec head(); + + /** + * Prepare an HTTP POST request. + * @return a spec for specifying the target URL + */ + RequestBodyUriSpec post(); + + /** + * Prepare an HTTP PUT request. + * @return a spec for specifying the target URL + */ + RequestBodyUriSpec put(); + + /** + * Prepare an HTTP PATCH request. + * @return a spec for specifying the target URL + */ + RequestBodyUriSpec patch(); + + /** + * Prepare an HTTP DELETE request. + * @return a spec for specifying the target URL + */ + RequestHeadersUriSpec delete(); + + /** + * Prepare an HTTP OPTIONS request. + * @return a spec for specifying the target URL + */ + RequestHeadersUriSpec options(); + + /** + * Prepare a request for the specified {@code HttpMethod}. + * @return a spec for specifying the target URL + */ + RequestBodyUriSpec method(HttpMethod method); + + /** + * Return a builder to mutate properties of this test client. + */ + > Builder mutate(); + + /** + * Begin creating a {@link RestTestClient} by providing the {@code @Controller} + * instance(s) to handle requests with. + *

Internally this is delegated to and equivalent to using + * {@link org.springframework.test.web.servlet.setup.MockMvcBuilders#standaloneSetup(Object...)} + * to initialize {@link MockMvc}. + */ + static MockServerBuilder standaloneSetup(Object... controllers) { + StandaloneMockMvcBuilder builder = MockMvcBuilders.standaloneSetup(controllers); + return new DefaultMockServerBuilder<>(builder); + } + + /** + * Begin creating a {@link RestTestClient} by providing the {@link RouterFunction} + * instance(s) to handle requests with. + *

Internally this is delegated to and equivalent to using + * {@link org.springframework.test.web.servlet.setup.MockMvcBuilders#routerFunctions(RouterFunction[])} + * to initialize {@link MockMvc}. + */ + static MockServerBuilder bindToRouterFunction(RouterFunction... routerFunctions) { + RouterFunctionMockMvcBuilder builder = MockMvcBuilders.routerFunctions(routerFunctions); + return new DefaultMockServerBuilder<>(builder); + } + + /** + * Begin creating a {@link RestTestClient} by providing a + * {@link WebApplicationContext} with Spring MVC infrastructure and + * controllers. + *

Internally this is delegated to and equivalent to using + * {@link org.springframework.test.web.servlet.setup.MockMvcBuilders#webAppContextSetup(WebApplicationContext)} + * to initialize {@code MockMvc}. + */ + static MockServerBuilder bindToApplicationContext(WebApplicationContext context) { + DefaultMockMvcBuilder builder = MockMvcBuilders.webAppContextSetup(context); + return new DefaultMockServerBuilder<>(builder); + } + + /** + * Begin creating a {@link RestTestClient} by providing an already + * initialized {@link MockMvc} instance to use as the server. + */ + static > Builder bindTo(MockMvc mockMvc) { + ClientHttpRequestFactory requestFactory = new MockMvcClientHttpRequestFactory(mockMvc); + return RestTestClient.bindToServer(requestFactory); + } + + /** + * This server setup option allows you to connect to a live server through + * a client connector. + *

+	 * RestTestClient client = RestTestClient.bindToServer()
+	 *         .baseUrl("http://localhost:8080")
+	 *         .build();
+	 * 
+ * @return chained API to customize client config + */ + static > Builder bindToServer() { + return new DefaultRestTestClientBuilder<>(); + } + + /** + * A variant of {@link #bindToServer()} with a pre-configured request factory. + * @return chained API to customize client config + */ + static > Builder bindToServer(ClientHttpRequestFactory requestFactory) { + return new DefaultRestTestClientBuilder<>(RestClient.builder().requestFactory(requestFactory)); + } + + /** + * Specification for providing request headers and the URI of a request. + * + * @param a self reference to the spec type + */ + interface RequestHeadersUriSpec> extends UriSpec, RequestHeadersSpec { + } + + /** + * Specification for providing the body and the URI of a request. + */ + interface RequestBodyUriSpec extends RequestBodySpec, RequestHeadersUriSpec { + } + + /** + * Chained API for applying assertions to a response. + */ + interface ResponseSpec { + /** + * Assertions on the response status. + */ + StatusAssertions expectStatus(); + + /** + * Consume and decode the response body to {@code byte[]} and then apply + * assertions on the raw content (for example, isEmpty, JSONPath, etc.). + */ + BodyContentSpec expectBody(); + + /** + * Consume and decode the response body to a single object of type + * {@code } and then apply assertions. + * @param bodyType the expected body type + */ + BodySpec expectBody(Class bodyType); + + /** + * Alternative to {@link #expectBody(Class)} that accepts information + * about a target type with generics. + */ + BodySpec expectBody(ParameterizedTypeReference bodyType); + + /** + * Assertions on the cookies of the response. + */ + CookieAssertions expectCookie(); + + /** + * Assertions on the headers of the response. + */ + HeaderAssertions expectHeader(); + + /** + * Apply multiple assertions to a response with the given + * {@linkplain RestTestClient.ResponseSpec.ResponseSpecConsumer consumers}, with the guarantee that + * all assertions will be applied even if one or more assertions fails + * with an exception. + *

If a single {@link Error} or {@link RuntimeException} is thrown, + * it will be rethrown. + *

If multiple exceptions are thrown, this method will throw an + * {@link AssertionError} whose error message is a summary of all the + * exceptions. In addition, each exception will be added as a + * {@linkplain Throwable#addSuppressed(Throwable) suppressed exception} to + * the {@code AssertionError}. + *

This feature is similar to the {@code SoftAssertions} support in + * AssertJ and the {@code assertAll()} support in JUnit Jupiter. + * + *

Example

+ *
+		 * restTestClient.get().uri("/hello").exchange()
+		 *     .expectAll(
+		 *         responseSpec -> responseSpec.expectStatus().isOk(),
+		 *         responseSpec -> responseSpec.expectBody(String.class).isEqualTo("Hello, World!")
+		 *     );
+		 * 
+ * @param consumers the list of {@code ResponseSpec} consumers + */ + ResponseSpec expectAll(ResponseSpecConsumer... consumers); + + /** + * Exit the chained flow in order to consume the response body + * externally. + */ + EntityExchangeResult returnResult(Class elementClass); + + /** + * Alternative to {@link #returnResult(Class)} that accepts information + * about a target type with generics. + */ + EntityExchangeResult returnResult(ParameterizedTypeReference elementTypeRef); + + /** + * {@link Consumer} of a {@link RestTestClient.ResponseSpec}. + * @see RestTestClient.ResponseSpec#expectAll(RestTestClient.ResponseSpec.ResponseSpecConsumer...) + */ + @FunctionalInterface + interface ResponseSpecConsumer extends Consumer { + } + } + + /** + * Spec for expectations on the response body content. + */ + interface BodyContentSpec { + /** + * Assert the response body is empty and return the exchange result. + */ + EntityExchangeResult isEmpty(); + + /** + * Parse the expected and actual response content as JSON and perform a + * comparison verifying that they contain the same attribute-value pairs + * regardless of formatting with lenient checking (extensible + * and non-strict array ordering). + *

Use of this method requires the + * JSONassert library + * to be on the classpath. + * @param expectedJson the expected JSON content + * @see #json(String, JsonCompareMode) + */ + default BodyContentSpec json(String expectedJson) { + return json(expectedJson, JsonCompareMode.LENIENT); + } + + /** + * Parse the expected and actual response content as JSON and perform a + * comparison using the given {@linkplain JsonCompareMode mode}. If the + * comparison failed, throws an {@link AssertionError} with the message + * of the {@link JsonComparison}. + *

Use of this method requires the + * JSONassert library + * to be on the classpath. + * @param expectedJson the expected JSON content + * @param compareMode the compare mode + * @see #json(String) + */ + BodyContentSpec json(String expectedJson, JsonCompareMode compareMode); + + /** + * Parse the expected and actual response content as JSON and perform a + * comparison using the given {@link JsonComparator}. If the comparison + * failed, throws an {@link AssertionError} with the message of the + * {@link JsonComparison}. + * @param expectedJson the expected JSON content + * @param comparator the comparator to use + */ + BodyContentSpec json(String expectedJson, JsonComparator comparator); + + /** + * Parse expected and actual response content as XML and assert that + * the two are "similar", i.e. they contain the same elements and + * attributes regardless of order. + *

Use of this method requires the + * XMLUnit library on + * the classpath. + * @param expectedXml the expected XML content. + * @see org.springframework.test.util.XmlExpectationsHelper#assertXmlEqual(String, String) + */ + BodyContentSpec xml(String expectedXml); + + /** + * Access to response body assertions using an XPath expression to + * inspect a specific subset of the body. + *

The XPath expression can be a parameterized string using + * formatting specifiers as defined in {@link String#format}. + * @param expression the XPath expression + * @param args arguments to parameterize the expression + * @see #xpath(String, Map, Object...) + */ + default XpathAssertions xpath(String expression, Object... args) { + return xpath(expression, null, args); + } + + /** + * Access to response body assertions with specific namespaces using an + * XPath expression to inspect a specific subset of the body. + *

The XPath expression can be a parameterized string using + * formatting specifiers as defined in {@link String#format}. + * @param expression the XPath expression + * @param namespaces the namespaces to use + * @param args arguments to parameterize the expression + */ + XpathAssertions xpath(String expression, @Nullable Map namespaces, Object... args); + + /** + * Access to response body assertions using a + * JsonPath expression + * to inspect a specific subset of the body. + * @param expression the JsonPath expression + */ + JsonPathAssertions jsonPath(String expression); + + /** + * Exit the chained API and return an {@code ExchangeResult} with the + * raw response content. + */ + EntityExchangeResult returnResult(); + } + + /** + * Spec for expectations on the response body decoded to a single Object. + * + * @param a self reference to the spec type + * @param the body type + */ + interface BodySpec> { + /** + * Transform the extracted the body with a function, for example, extracting a + * property, and assert the mapped value with a {@link Matcher}. + */ + T value(Function bodyMapper, Matcher matcher); + + /** + * Assert the extracted body with a {@link Consumer}. + */ + T value(Consumer consumer); + + /** + * Assert the exchange result with the given {@link Consumer}. + */ + T consumeWith(Consumer> consumer); + + /** + * Exit the chained API and return an {@code EntityExchangeResult} with the + * decoded response content. + */ + EntityExchangeResult returnResult(); + + /** + * Assert the extracted body is equal to the given value. + */ + T isEqualTo(B expected); + } + + /** + * Specification for providing the URI of a request. + * + * @param a self reference to the spec type + */ + interface UriSpec> { + /** + * Specify the URI using an absolute, fully constructed {@link java.net.URI}. + *

If a {@link UriBuilderFactory} was configured for the client with + * a base URI, that base URI will not be applied to the + * supplied {@code java.net.URI}. If you wish to have a base URI applied to a + * {@code java.net.URI} you must invoke either {@link #uri(String, Object...)} + * or {@link #uri(String, Map)} — for example, {@code uri(myUri.toString())}. + * @return spec to add headers or perform the exchange + */ + S uri(URI uri); + + /** + * Specify the URI for the request using a URI template and URI variables. + *

If a {@link UriBuilderFactory} was configured for the client (for example, + * with a base URI) it will be used to expand the URI template. + * @return spec to add headers or perform the exchange + */ + S uri(String uri, Object... uriVariables); + + /** + * Specify the URI for the request using a URI template and URI variables. + *

If a {@link UriBuilderFactory} was configured for the client (for example, + * with a base URI) it will be used to expand the URI template. + * @return spec to add headers or perform the exchange + */ + S uri(String uri, Map uriVariables); + + /** + * Build the URI for the request with a {@link UriBuilder} obtained + * through the {@link UriBuilderFactory} configured for this client. + * @return spec to add headers or perform the exchange + */ + S uri(Function uriFunction); + + } + + + + + /** + * Specification for adding request headers and performing an exchange. + * + * @param a self reference to the spec type + */ + interface RequestHeadersSpec> { + + /** + * Set the list of acceptable {@linkplain MediaType media types}, as + * specified by the {@code Accept} header. + * @param acceptableMediaTypes the acceptable media types + * @return the same instance + */ + S accept(MediaType... acceptableMediaTypes); + + /** + * Set the list of acceptable {@linkplain Charset charsets}, as specified + * by the {@code Accept-Charset} header. + * @param acceptableCharsets the acceptable charsets + * @return the same instance + */ + S acceptCharset(Charset... acceptableCharsets); + + /** + * Add a cookie with the given name and value. + * @param name the cookie name + * @param value the cookie value + * @return the same instance + */ + S cookie(String name, String value); + + /** + * Manipulate this request's cookies with the given consumer. The + * map provided to the consumer is "live", so that the consumer can be used to + * {@linkplain MultiValueMap#set(Object, Object) overwrite} existing header values, + * {@linkplain MultiValueMap#remove(Object) remove} values, or use any of the other + * {@link MultiValueMap} methods. + * @param cookiesConsumer a function that consumes the cookies map + * @return this builder + */ + S cookies(Consumer> cookiesConsumer); + + /** + * Set the value of the {@code If-Modified-Since} header. + *

The date should be specified as the number of milliseconds since + * January 1, 1970 GMT. + * @param ifModifiedSince the new value of the header + * @return the same instance + */ + S ifModifiedSince(ZonedDateTime ifModifiedSince); + + /** + * Set the values of the {@code If-None-Match} header. + * @param ifNoneMatches the new value of the header + * @return the same instance + */ + S ifNoneMatch(String... ifNoneMatches); + + /** + * Add the given, single header value under the given name. + * @param headerName the header name + * @param headerValues the header value(s) + * @return the same instance + */ + S header(String headerName, String... headerValues); + + /** + * Manipulate the request's headers with the given consumer. The + * headers provided to the consumer are "live", so that the consumer can be used to + * {@linkplain HttpHeaders#set(String, String) overwrite} existing header values, + * {@linkplain HttpHeaders#remove(String) remove} values, or use any of the other + * {@link HttpHeaders} methods. + * @param headersConsumer a function that consumes the {@code HttpHeaders} + * @return this builder + */ + S headers(Consumer headersConsumer); + + /** + * Set the attribute with the given name to the given value. + * @param name the name of the attribute to add + * @param value the value of the attribute to add + * @return this builder + */ + S attribute(String name, Object value); + + /** + * Manipulate the request attributes with the given consumer. The attributes provided to + * the consumer are "live", so that the consumer can be used to inspect attributes, + * remove attributes, or use any of the other map-provided methods. + * @param attributesConsumer a function that consumes the attributes + * @return this builder + */ + S attributes(Consumer> attributesConsumer); + + /** + * Perform the exchange without a request body. + * @return spec for decoding the response + */ + ResponseSpec exchange(); + } + + /** + * Specification for providing body of a request. + */ + interface RequestBodySpec extends RequestHeadersSpec { + /** + * Set the {@linkplain MediaType media type} of the body, as specified + * by the {@code Content-Type} header. + * @param contentType the content type + * @return the same instance + * @see HttpHeaders#setContentType(MediaType) + */ + RequestBodySpec contentType(MediaType contentType); + + /** + * Set the body to the given {@code Object} value. This method invokes the + * {@link org.springframework.web.client.RestClient.RequestBodySpec#body(Object)} (Object) + * bodyValue} method on the underlying {@code RestClient}. + * @param body the value to write to the request body + * @return spec for further declaration of the request + */ + RequestHeadersSpec body(Object body); + } + + interface Builder> { + /** + * Apply the given {@code Consumer} to this builder instance. + *

This can be useful for applying pre-packaged customizations. + * @param builderConsumer the consumer to apply + */ + Builder apply(Consumer> builderConsumer); + + /** + * Add the given cookie to all requests. + * @param cookieName the cookie name + * @param cookieValues the cookie values + */ + Builder defaultCookie(String cookieName, String... cookieValues); + + /** + * Manipulate the default cookies with the given consumer. The + * map provided to the consumer is "live", so that the consumer can be used to + * {@linkplain MultiValueMap#set(Object, Object) overwrite} existing header values, + * {@linkplain MultiValueMap#remove(Object) remove} values, or use any of the other + * {@link MultiValueMap} methods. + * @param cookiesConsumer a function that consumes the cookies map + * @return this builder + */ + Builder defaultCookies(Consumer> cookiesConsumer); + + /** + * Add the given header to all requests that haven't added it. + * @param headerName the header name + * @param headerValues the header values + */ + Builder defaultHeader(String headerName, String... headerValues); + + /** + * Manipulate the default headers with the given consumer. The + * headers provided to the consumer are "live", so that the consumer can be used to + * {@linkplain HttpHeaders#set(String, String) overwrite} existing header values, + * {@linkplain HttpHeaders#remove(String) remove} values, or use any of the other + * {@link HttpHeaders} methods. + * @param headersConsumer a function that consumes the {@code HttpHeaders} + * @return this builder + */ + Builder defaultHeaders(Consumer headersConsumer); + + /** + * Provide a pre-configured {@link UriBuilderFactory} instance as an + * alternative to and effectively overriding {@link #baseUrl(String)}. + */ + Builder uriBuilderFactory(UriBuilderFactory uriFactory); + + /** + * Build the {@link RestTestClient} instance. + */ + RestTestClient build(); + + /** + * Configure a base URI as described in + * {@link RestClient#create(String) + * WebClient.create(String)}. + */ + Builder baseUrl(String baseUrl); + } + + interface MockServerBuilder extends Builder> { + MockServerBuilder configureServer(Consumer consumer); + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/StatusAssertions.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/StatusAssertions.java new file mode 100644 index 000000000000..3fb58d6dd85d --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/StatusAssertions.java @@ -0,0 +1,250 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client; + +import java.util.function.Consumer; + +import org.hamcrest.Matcher; +import org.hamcrest.MatcherAssert; + +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.test.util.AssertionErrors; +import org.springframework.test.web.servlet.client.RestTestClient.ResponseSpec; + +import static org.springframework.test.util.AssertionErrors.assertNotNull; + +/** + * Assertions on the response status. + * + * @author Rob Worsnop + * + * @see ResponseSpec#expectStatus() + */ +public class StatusAssertions { + + private final ExchangeResult exchangeResult; + + private final ResponseSpec responseSpec; + + public StatusAssertions(ExchangeResult exchangeResult, ResponseSpec responseSpec) { + this.exchangeResult = exchangeResult; + this.responseSpec = responseSpec; + } + + + /** + * Assert the response status as an {@link HttpStatusCode}. + */ + public RestTestClient.ResponseSpec isEqualTo(HttpStatusCode status) { + HttpStatusCode actual = this.exchangeResult.getStatus(); + this.exchangeResult.assertWithDiagnostics(() -> AssertionErrors.assertEquals("Status", status, actual)); + return this.responseSpec; + } + + /** + * Assert the response status as an integer. + */ + public RestTestClient.ResponseSpec isEqualTo(int status) { + return isEqualTo(HttpStatusCode.valueOf(status)); + } + + /** + * Assert the response status code is {@code HttpStatus.OK} (200). + */ + public RestTestClient.ResponseSpec isOk() { + return assertStatusAndReturn(HttpStatus.OK); + } + + /** + * Assert the response status code is {@code HttpStatus.CREATED} (201). + */ + public RestTestClient.ResponseSpec isCreated() { + return assertStatusAndReturn(HttpStatus.CREATED); + } + + /** + * Assert the response status code is {@code HttpStatus.ACCEPTED} (202). + */ + public RestTestClient.ResponseSpec isAccepted() { + return assertStatusAndReturn(HttpStatus.ACCEPTED); + } + + /** + * Assert the response status code is {@code HttpStatus.NO_CONTENT} (204). + */ + public RestTestClient.ResponseSpec isNoContent() { + return assertStatusAndReturn(HttpStatus.NO_CONTENT); + } + + /** + * Assert the response status code is {@code HttpStatus.FOUND} (302). + */ + public RestTestClient.ResponseSpec isFound() { + return assertStatusAndReturn(HttpStatus.FOUND); + } + + /** + * Assert the response status code is {@code HttpStatus.SEE_OTHER} (303). + */ + public RestTestClient.ResponseSpec isSeeOther() { + return assertStatusAndReturn(HttpStatus.SEE_OTHER); + } + + /** + * Assert the response status code is {@code HttpStatus.NOT_MODIFIED} (304). + */ + public RestTestClient.ResponseSpec isNotModified() { + return assertStatusAndReturn(HttpStatus.NOT_MODIFIED); + } + + /** + * Assert the response status code is {@code HttpStatus.TEMPORARY_REDIRECT} (307). + */ + public RestTestClient.ResponseSpec isTemporaryRedirect() { + return assertStatusAndReturn(HttpStatus.TEMPORARY_REDIRECT); + } + + /** + * Assert the response status code is {@code HttpStatus.PERMANENT_REDIRECT} (308). + */ + public RestTestClient.ResponseSpec isPermanentRedirect() { + return assertStatusAndReturn(HttpStatus.PERMANENT_REDIRECT); + } + + /** + * Assert the response status code is {@code HttpStatus.BAD_REQUEST} (400). + */ + public RestTestClient.ResponseSpec isBadRequest() { + return assertStatusAndReturn(HttpStatus.BAD_REQUEST); + } + + /** + * Assert the response status code is {@code HttpStatus.UNAUTHORIZED} (401). + */ + public RestTestClient.ResponseSpec isUnauthorized() { + return assertStatusAndReturn(HttpStatus.UNAUTHORIZED); + } + + /** + * Assert the response status code is {@code HttpStatus.FORBIDDEN} (403). + * @since 5.0.2 + */ + public RestTestClient.ResponseSpec isForbidden() { + return assertStatusAndReturn(HttpStatus.FORBIDDEN); + } + + /** + * Assert the response status code is {@code HttpStatus.NOT_FOUND} (404). + */ + public RestTestClient.ResponseSpec isNotFound() { + return assertStatusAndReturn(HttpStatus.NOT_FOUND); + } + + /** + * Assert the response error message. + */ + public RestTestClient.ResponseSpec reasonEquals(String reason) { + String actual = getReasonPhrase(this.exchangeResult.getStatus()); + this.exchangeResult.assertWithDiagnostics(() -> + AssertionErrors.assertEquals("Response status reason", reason, actual)); + return this.responseSpec; + } + + private static String getReasonPhrase(HttpStatusCode statusCode) { + if (statusCode instanceof HttpStatus status) { + return status.getReasonPhrase(); + } + else { + return ""; + } + } + + + /** + * Assert the response status code is in the 1xx range. + */ + public RestTestClient.ResponseSpec is1xxInformational() { + return assertSeriesAndReturn(HttpStatus.Series.INFORMATIONAL); + } + + /** + * Assert the response status code is in the 2xx range. + */ + public RestTestClient.ResponseSpec is2xxSuccessful() { + return assertSeriesAndReturn(HttpStatus.Series.SUCCESSFUL); + } + + /** + * Assert the response status code is in the 3xx range. + */ + public RestTestClient.ResponseSpec is3xxRedirection() { + return assertSeriesAndReturn(HttpStatus.Series.REDIRECTION); + } + + /** + * Assert the response status code is in the 4xx range. + */ + public RestTestClient.ResponseSpec is4xxClientError() { + return assertSeriesAndReturn(HttpStatus.Series.CLIENT_ERROR); + } + + /** + * Assert the response status code is in the 5xx range. + */ + public RestTestClient.ResponseSpec is5xxServerError() { + return assertSeriesAndReturn(HttpStatus.Series.SERVER_ERROR); + } + + /** + * Match the response status value with a Hamcrest matcher. + * @param matcher the matcher to use + * @since 5.1 + */ + public RestTestClient.ResponseSpec value(Matcher matcher) { + int actual = this.exchangeResult.getStatus().value(); + this.exchangeResult.assertWithDiagnostics(() -> MatcherAssert.assertThat("Response status", actual, matcher)); + return this.responseSpec; + } + + /** + * Consume the response status value as an integer. + * @param consumer the consumer to use + * @since 5.1 + */ + public RestTestClient.ResponseSpec value(Consumer consumer) { + int actual = this.exchangeResult.getStatus().value(); + this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(actual)); + return this.responseSpec; + } + + + private ResponseSpec assertStatusAndReturn(HttpStatus expected) { + assertNotNull("exchangeResult unexpectedly null", this.exchangeResult); + HttpStatusCode actual = this.exchangeResult.getStatus(); + this.exchangeResult.assertWithDiagnostics(() -> AssertionErrors.assertEquals("Status", expected, actual)); + return this.responseSpec; + } + + private RestTestClient.ResponseSpec assertSeriesAndReturn(HttpStatus.Series expected) { + HttpStatusCode status = this.exchangeResult.getStatus(); + HttpStatus.Series series = HttpStatus.Series.resolve(status.value()); + this.exchangeResult.assertWithDiagnostics(() -> + AssertionErrors.assertEquals("Range for response status value " + status, expected, series)); + return this.responseSpec; + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/XpathAssertions.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/XpathAssertions.java new file mode 100644 index 000000000000..f52ea100a272 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/XpathAssertions.java @@ -0,0 +1,205 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; + +import javax.xml.xpath.XPathExpressionException; + +import org.hamcrest.Matcher; +import org.jspecify.annotations.Nullable; + +import org.springframework.http.HttpHeaders; +import org.springframework.test.util.XpathExpectationsHelper; +import org.springframework.util.Assert; +import org.springframework.util.MimeType; + +/** + * XPath assertions for the {@link RestTestClient}. + * + * @author Rob Worsnop + */ +public class XpathAssertions { + + private final RestTestClient.BodyContentSpec bodySpec; + + private final XpathExpectationsHelper xpathHelper; + + + XpathAssertions(RestTestClient.BodyContentSpec spec, + String expression, @Nullable Map namespaces, Object... args) { + + this.bodySpec = spec; + this.xpathHelper = initXpathHelper(expression, namespaces, args); + } + + private static XpathExpectationsHelper initXpathHelper( + String expression, @Nullable Map namespaces, Object[] args) { + + try { + return new XpathExpectationsHelper(expression, namespaces, args); + } + catch (XPathExpressionException ex) { + throw new AssertionError("XML parsing error", ex); + } + } + + + /** + * Delegates to {@link XpathExpectationsHelper#assertString(byte[], String, String)}. + */ + public RestTestClient.BodyContentSpec isEqualTo(String expectedValue) { + return assertWith(() -> this.xpathHelper.assertString(getContent(), getCharset(), expectedValue)); + } + + /** + * Delegates to {@link XpathExpectationsHelper#assertNumber(byte[], String, Double)}. + */ + public RestTestClient.BodyContentSpec isEqualTo(Double expectedValue) { + return assertWith(() -> this.xpathHelper.assertNumber(getContent(), getCharset(), expectedValue)); + } + + /** + * Delegates to {@link XpathExpectationsHelper#assertBoolean(byte[], String, boolean)}. + */ + public RestTestClient.BodyContentSpec isEqualTo(boolean expectedValue) { + return assertWith(() -> this.xpathHelper.assertBoolean(getContent(), getCharset(), expectedValue)); + } + + /** + * Delegates to {@link XpathExpectationsHelper#exists(byte[], String)}. + */ + public RestTestClient.BodyContentSpec exists() { + return assertWith(() -> this.xpathHelper.exists(getContent(), getCharset())); + } + + /** + * Delegates to {@link XpathExpectationsHelper#doesNotExist(byte[], String)}. + */ + public RestTestClient.BodyContentSpec doesNotExist() { + return assertWith(() -> this.xpathHelper.doesNotExist(getContent(), getCharset())); + } + + /** + * Delegates to {@link XpathExpectationsHelper#assertNodeCount(byte[], String, int)}. + */ + public RestTestClient.BodyContentSpec nodeCount(int expectedCount) { + return assertWith(() -> this.xpathHelper.assertNodeCount(getContent(), getCharset(), expectedCount)); + } + + /** + * Delegates to {@link XpathExpectationsHelper#assertString(byte[], String, Matcher)}. + */ + public RestTestClient.BodyContentSpec string(Matcher matcher){ + return assertWith(() -> this.xpathHelper.assertString(getContent(), getCharset(), matcher)); + } + + /** + * Delegates to {@link XpathExpectationsHelper#assertNumber(byte[], String, Matcher)}. + */ + public RestTestClient.BodyContentSpec number(Matcher matcher){ + return assertWith(() -> this.xpathHelper.assertNumber(getContent(), getCharset(), matcher)); + } + + /** + * Delegates to {@link XpathExpectationsHelper#assertNodeCount(byte[], String, Matcher)}. + */ + public RestTestClient.BodyContentSpec nodeCount(Matcher matcher){ + return assertWith(() -> this.xpathHelper.assertNodeCount(getContent(), getCharset(), matcher)); + } + + /** + * Consume the result of the XPath evaluation as a String. + */ + public RestTestClient.BodyContentSpec string(Consumer consumer){ + return assertWith(() -> { + String value = this.xpathHelper.evaluateXpath(getContent(), getCharset(), String.class); + consumer.accept(value); + }); + } + + /** + * Consume the result of the XPath evaluation as a Double. + */ + public RestTestClient.BodyContentSpec number(Consumer consumer){ + return assertWith(() -> { + Double value = this.xpathHelper.evaluateXpath(getContent(), getCharset(), Double.class); + consumer.accept(value); + }); + } + + /** + * Consume the count of nodes as result of the XPath evaluation. + */ + public RestTestClient.BodyContentSpec nodeCount(Consumer consumer){ + return assertWith(() -> { + Integer value = this.xpathHelper.evaluateXpath(getContent(), getCharset(), Integer.class); + consumer.accept(value); + }); + } + + private RestTestClient.BodyContentSpec assertWith(CheckedExceptionTask task) { + try { + task.run(); + } + catch (Exception ex) { + throw new AssertionError("XML parsing error", ex); + } + return this.bodySpec; + } + + private byte[] getContent() { + byte[] body = this.bodySpec.returnResult().getResponseBody(); + Assert.notNull(body, "Expected body content"); + return body; + } + + private String getCharset() { + return Optional.of(this.bodySpec.returnResult()) + .map(EntityExchangeResult::getResponseHeaders) + .map(HttpHeaders::getContentType) + .map(MimeType::getCharset) + .orElse(StandardCharsets.UTF_8) + .name(); + } + + + @Override + public boolean equals(@Nullable Object obj) { + throw new AssertionError("Object#equals is disabled " + + "to avoid being used in error instead of XPathAssertions#isEqualTo(String)."); + } + + @Override + public int hashCode() { + return super.hashCode(); + } + + + /** + * Lets us be able to use lambda expressions that could throw checked exceptions, since + * {@link XpathExpectationsHelper} throws {@link Exception} on its methods. + */ + private interface CheckedExceptionTask { + + void run() throws Exception; + + } +} diff --git a/spring-test/src/test/java/org/springframework/test/web/client/samples/MockMvcClientHttpRequestFactoryTests.java b/spring-test/src/test/java/org/springframework/test/web/client/samples/MockMvcClientHttpRequestFactoryTests.java index 2dc9e473949e..948f2d83331e 100644 --- a/spring-test/src/test/java/org/springframework/test/web/client/samples/MockMvcClientHttpRequestFactoryTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/client/samples/MockMvcClientHttpRequestFactoryTests.java @@ -55,6 +55,7 @@ @ExtendWith(SpringExtension.class) @WebAppConfiguration @ContextConfiguration +@SuppressWarnings("deprecation") public class MockMvcClientHttpRequestFactoryTests { @Autowired diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/CookieAssertionsTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/CookieAssertionsTests.java new file mode 100644 index 000000000000..51783fd3bc0b --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/CookieAssertionsTests.java @@ -0,0 +1,147 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.BDDMockito.mock; +import static org.mockito.BDDMockito.when; + +/** + * Tests for {@link CookieAssertions} + * + * @author Rob Worsnop + */ +public class CookieAssertionsTests { + + private final ResponseCookie cookie = ResponseCookie.from("foo", "bar") + .maxAge(Duration.ofMinutes(30)) + .domain("foo.com") + .path("/foo") + .secure(true) + .httpOnly(true) + .partitioned(true) + .sameSite("Lax") + .build(); + + private final CookieAssertions assertions = cookieAssertions(cookie); + + + @Test + void valueEquals() { + assertions.valueEquals("foo", "bar"); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.valueEquals("what?!", "bar")); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.valueEquals("foo", "what?!")); + } + + @Test + void value() { + assertions.value("foo", equalTo("bar")); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.value("foo", equalTo("what?!"))); + } + + @Test + void valueConsumer() { + assertions.value("foo", input -> assertThat(input).isEqualTo("bar")); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.value("foo", input -> assertThat(input).isEqualTo("what?!"))); + } + + @Test + void exists() { + assertions.exists("foo"); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.exists("what?!")); + } + + @Test + void doesNotExist() { + assertions.doesNotExist("what?!"); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.doesNotExist("foo")); + } + + @Test + void maxAge() { + assertions.maxAge("foo", Duration.ofMinutes(30)); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.maxAge("foo", Duration.ofMinutes(29))); + + assertions.maxAge("foo", equalTo(Duration.ofMinutes(30).getSeconds())); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.maxAge("foo", equalTo(Duration.ofMinutes(29).getSeconds()))); + } + + @Test + void domain() { + assertions.domain("foo", "foo.com"); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.domain("foo", "what.com")); + + assertions.domain("foo", equalTo("foo.com")); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.domain("foo", equalTo("what.com"))); + } + + @Test + void path() { + assertions.path("foo", "/foo"); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.path("foo", "/what")); + + assertions.path("foo", equalTo("/foo")); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.path("foo", equalTo("/what"))); + } + + @Test + void secure() { + assertions.secure("foo", true); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.secure("foo", false)); + } + + @Test + void httpOnly() { + assertions.httpOnly("foo", true); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.httpOnly("foo", false)); + } + + @Test + void partitioned() { + assertions.partitioned("foo", true); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.partitioned("foo", false)); + } + + @Test + void sameSite() { + assertions.sameSite("foo", "Lax"); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.sameSite("foo", "Strict")); + } + + + private CookieAssertions cookieAssertions(ResponseCookie cookie) { + RestClient.RequestHeadersSpec.ConvertibleClientHttpResponse response = mock(); + var headers = new HttpHeaders(); + headers.set(HttpHeaders.SET_COOKIE, cookie.toString()); + when(response.getHeaders()).thenReturn(headers); + ExchangeResult result = new ExchangeResult(response); + return new CookieAssertions(result, mock()); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/HeaderAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/HeaderAssertionTests.java new file mode 100644 index 000000000000..200210e6eeda --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/HeaderAssertionTests.java @@ -0,0 +1,320 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client; + +import java.net.URI; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasItems; +import static org.mockito.BDDMockito.mock; +import static org.mockito.BDDMockito.when; + +/** + * Tests for {@link HeaderAssertions}. + * + * @author Rob Worsnop + */ +class HeaderAssertionTests { + + @Test + void valueEquals() { + HttpHeaders headers = new HttpHeaders(); + headers.add("foo", "bar"); + headers.add("age", "22"); + HeaderAssertions assertions = headerAssertions(headers); + + // Success + assertions.valueEquals("foo", "bar"); + assertions.value("foo", s -> assertThat(s).isEqualTo("bar")); + assertions.values("foo", strings -> assertThat(strings).containsExactly("bar")); + assertions.valueEquals("age", 22); + + // Missing header + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.valueEquals("what?!", "bar")); + + // Wrong value + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.valueEquals("foo", "what?!")); + + // Wrong # of values + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.valueEquals("foo", "bar", "what?!")); + } + + @Test + void valueEqualsWithMultipleValues() { + HttpHeaders headers = new HttpHeaders(); + headers.add("foo", "bar"); + headers.add("foo", "baz"); + HeaderAssertions assertions = headerAssertions(headers); + + // Success + assertions.valueEquals("foo", "bar", "baz"); + + // Wrong value + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.valueEquals("foo", "bar", "what?!")); + + // Too few values + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.valueEquals("foo", "bar")); + } + + @Test + void valueMatches() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType("application/json;charset=UTF-8")); + HeaderAssertions assertions = headerAssertions(headers); + + // Success + assertions.valueMatches("Content-Type", ".*UTF-8.*"); + + // Wrong pattern + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.valueMatches("Content-Type", ".*ISO-8859-1.*")) + .satisfies(ex -> assertThat(ex).hasMessage("Response header " + + "'Content-Type'=[application/json;charset=UTF-8] does not match " + + "[.*ISO-8859-1.*]")); + } + + @Test + void valueMatchesWithNonexistentHeader() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType("application/json;charset=UTF-8")); + HeaderAssertions assertions = headerAssertions(headers); + + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.valueMatches("Content-XYZ", ".*ISO-8859-1.*")) + .withMessage("Response header 'Content-XYZ' not found"); + } + + @Test + void valuesMatch() { + HttpHeaders headers = new HttpHeaders(); + headers.add("foo", "value1"); + headers.add("foo", "value2"); + headers.add("foo", "value3"); + HeaderAssertions assertions = headerAssertions(headers); + + assertions.valuesMatch("foo", "val.*1", "val.*2", "val.*3"); + + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.valuesMatch("foo", ".*", "val.*5")) + .satisfies(ex -> assertThat(ex).hasMessage( + "Response header 'foo' has fewer or more values [value1, value2, value3] " + + "than number of patterns to match with [.*, val.*5]")); + + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.valuesMatch("foo", ".*", "val.*5", ".*")) + .satisfies(ex -> assertThat(ex).hasMessage( + "Response header 'foo'[1]='value2' does not match 'val.*5'")); + } + + @Test + void valueMatcher() { + HttpHeaders headers = new HttpHeaders(); + headers.add("foo", "bar"); + HeaderAssertions assertions = headerAssertions(headers); + + assertions.value("foo", containsString("a")); + } + + @Test + void valuesMatcher() { + HttpHeaders headers = new HttpHeaders(); + headers.add("foo", "bar"); + headers.add("foo", "baz"); + HeaderAssertions assertions = headerAssertions(headers); + + assertions.values("foo", hasItems("bar", "baz")); + } + + @Test + void exists() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HeaderAssertions assertions = headerAssertions(headers); + + // Success + assertions.exists("Content-Type"); + + // Header should not exist + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.exists("Framework")) + .satisfies(ex -> assertThat(ex).hasMessage("Response header 'Framework' does not exist")); + } + + @Test + void doesNotExist() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType("application/json;charset=UTF-8")); + HeaderAssertions assertions = headerAssertions(headers); + + // Success + assertions.doesNotExist("Framework"); + + // Existing header + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.doesNotExist("Content-Type")) + .satisfies(ex -> assertThat(ex).hasMessage("Response header " + + "'Content-Type' exists with value=[application/json;charset=UTF-8]")); + } + + @Test + void contentTypeCompatibleWith() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_XML); + HeaderAssertions assertions = headerAssertions(headers); + + // Success + assertions.contentTypeCompatibleWith(MediaType.parseMediaType("application/*")); + assertions.contentTypeCompatibleWith("application/*"); + + // MediaTypes not compatible + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.contentTypeCompatibleWith(MediaType.TEXT_XML)) + .withMessage("Response header 'Content-Type'=[application/xml] is not compatible with [text/xml]"); + } + + @Test + void location() { + HttpHeaders headers = new HttpHeaders(); + headers.setLocation(URI.create("http://localhost:8080/")); + HeaderAssertions assertions = headerAssertions(headers); + + // Success + assertions.location("http://localhost:8080/"); + + // Wrong value + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.location("http://localhost:8081/")); + } + + @Test + void cacheControl() { + CacheControl control = CacheControl.maxAge(1, TimeUnit.HOURS).noTransform(); + + HttpHeaders headers = new HttpHeaders(); + headers.setCacheControl(control.getHeaderValue()); + HeaderAssertions assertions = headerAssertions(headers); + + // Success + assertions.cacheControl(control); + + // Wrong value + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.cacheControl(CacheControl.noStore())); + } + + @Test + void contentDisposition() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentDispositionFormData("foo", "bar"); + HeaderAssertions assertions = headerAssertions(headers); + assertions.contentDisposition(ContentDisposition.formData().name("foo").filename("bar").build()); + + // Wrong value + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.contentDisposition(ContentDisposition.attachment().build())); + } + + @Test + void contentLength() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentLength(100); + HeaderAssertions assertions = headerAssertions(headers); + assertions.contentLength(100); + + // Wrong value + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.contentLength(200)); + } + + @Test + void contentType() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HeaderAssertions assertions = headerAssertions(headers); + assertions.contentType(MediaType.APPLICATION_JSON); + assertions.contentType("application/json"); + + // Wrong value + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.contentType(MediaType.APPLICATION_XML)); + } + + + @Test + void expires() { + HttpHeaders headers = new HttpHeaders(); + ZonedDateTime expires = ZonedDateTime.of(2018, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC")); + headers.setExpires(expires); + HeaderAssertions assertions = headerAssertions(headers); + assertions.expires(expires.toInstant().toEpochMilli()); + + // Wrong value + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.expires(expires.toInstant().toEpochMilli() + 1)); + } + + @Test + void lastModified() { + HttpHeaders headers = new HttpHeaders(); + ZonedDateTime lastModified = ZonedDateTime.of(2018, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC")); + headers.setLastModified(lastModified.toInstant().toEpochMilli()); + HeaderAssertions assertions = headerAssertions(headers); + assertions.lastModified(lastModified.toInstant().toEpochMilli()); + + // Wrong value + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.lastModified(lastModified.toInstant().toEpochMilli() + 1)); + } + + @Test + void equalsDate() { + HttpHeaders headers = new HttpHeaders(); + headers.setDate("foo", 1000); + HeaderAssertions assertions = headerAssertions(headers); + assertions.valueEqualsDate("foo", 1000); + + // Wrong value + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.valueEqualsDate("foo", 2000)); + } + + private HeaderAssertions headerAssertions(HttpHeaders responseHeaders) { + RestClient.RequestHeadersSpec.ConvertibleClientHttpResponse response = mock(); + when(response.getHeaders()).thenReturn(responseHeaders); + ExchangeResult result = new ExchangeResult(response); + return new HeaderAssertions(result, mock()); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/JsonPathAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/JsonPathAssertionTests.java new file mode 100644 index 000000000000..c4993e0a1f2a --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/JsonPathAssertionTests.java @@ -0,0 +1,218 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.Person; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.in; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.startsWith; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests JSON Path assertions with {@link RestTestClient}. + * + * @author Rob Worsnop + */ +class JsonPathAssertionTests { + + private final RestTestClient client = + RestTestClient.standaloneSetup(new MusicController()) + .configureServer(builder -> + builder.alwaysExpect(status().isOk()) + .alwaysExpect(content().contentType(MediaType.APPLICATION_JSON)) + ) + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + + + @Test + void exists() { + String composerByName = "$.composers[?(@.name == '%s')]"; + String performerByName = "$.performers[?(@.name == '%s')]"; + + client.get().uri("/music/people") + .exchange() + .expectBody() + .jsonPath(composerByName.formatted("Johann Sebastian Bach")).exists() + .jsonPath(composerByName.formatted("Johannes Brahms")).exists() + .jsonPath(composerByName.formatted("Edvard Grieg")).exists() + .jsonPath(composerByName.formatted("Robert Schumann")).exists() + .jsonPath(performerByName.formatted("Vladimir Ashkenazy")).exists() + .jsonPath(performerByName.formatted("Yehudi Menuhin")).exists() + .jsonPath("$.composers[0]").exists() + .jsonPath("$.composers[1]").exists() + .jsonPath("$.composers[2]").exists() + .jsonPath("$.composers[3]").exists(); + } + + @Test + void doesNotExist() { + client.get().uri("/music/people") + .exchange() + .expectBody() + .jsonPath("$.composers[?(@.name == 'Edvard Grieeeeeeg')]").doesNotExist() + .jsonPath("$.composers[?(@.name == 'Robert Schuuuuuuman')]").doesNotExist() + .jsonPath("$.composers[4]").doesNotExist(); + } + + @Test + void equality() { + client.get().uri("/music/people") + .exchange() + .expectBody() + .jsonPath("$.composers[0].name").isEqualTo("Johann Sebastian Bach") + .jsonPath("$.performers[1].name").isEqualTo("Yehudi Menuhin"); + + // Hamcrest matchers... + client.get().uri("/music/people") + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody() + .jsonPath("$.composers[0].name").value(equalTo("Johann Sebastian Bach")) + .jsonPath("$.performers[1].name").value(equalTo("Yehudi Menuhin")); + } + + @Test + void hamcrestMatcher() { + client.get().uri("/music/people") + .exchange() + .expectBody() + .jsonPath("$.composers[0].name").value(startsWith("Johann")) + .jsonPath("$.performers[0].name").value(endsWith("Ashkenazy")) + .jsonPath("$.performers[1].name").value(containsString("di Me")) + .jsonPath("$.composers[1].name").value(is(in(Arrays.asList("Johann Sebastian Bach", "Johannes Brahms")))); + } + + @Test + void hamcrestMatcherWithParameterizedJsonPath() { + client.get().uri("/music/people") + .exchange() + .expectBody() + .jsonPath("$.composers[0].name").value(String.class, startsWith("Johann")) + .jsonPath("$.composers[0].name").value(String.class, s -> assertThat(s).startsWith("Johann")) + .jsonPath("$.composers[0].name").value(o -> assertThat((String) o).startsWith("Johann")) + .jsonPath("$.performers[1].name").value(containsString("di Me")) + .jsonPath("$.composers[1].name").value(is(in(Arrays.asList("Johann Sebastian Bach", "Johannes Brahms")))); + } + + @Test + void isEmpty() { + client.get().uri("/music/instruments") + .exchange() + .expectBody() + .jsonPath("$.clarinets").isEmpty(); + } + + @Test + void isNotEmpty() { + client.get().uri("/music/people") + .exchange() + .expectBody() + .jsonPath("$.composers").isNotEmpty(); + } + + @Test + void hasJsonPath() { + client.get().uri("/music/people") + .exchange() + .expectBody() + .jsonPath("$.composers").hasJsonPath(); + } + + @Test + void doesNotHaveJsonPath() { + client.get().uri("/music/people") + .exchange() + .expectBody() + .jsonPath("$.audience").doesNotHaveJsonPath(); + } + + @Test + void isBoolean() { + client.get().uri("/music/people") + .exchange() + .expectBody() + .jsonPath("$.composers[0].someBoolean").isBoolean(); + } + + @Test + void isNumber() { + client.get().uri("/music/people") + .exchange() + .expectBody() + .jsonPath("$.composers[0].someDouble").isNumber(); + } + + @Test + void isMap() { + client.get().uri("/music/people") + .exchange() + .expectBody() + .jsonPath("$").isMap(); + } + + @Test + void isArray() { + client.get().uri("/music/people") + .exchange() + .expectBody() + .jsonPath("$.composers").isArray(); + } + + @RestController + private static class MusicController { + @GetMapping("/music/instruments") + public Map getInstruments() { + return Map.of("clarinets", List.of()); + } + + @GetMapping("/music/people") + public MultiValueMap get() { + MultiValueMap map = new LinkedMultiValueMap<>(); + + map.add("composers", new Person("Johann Sebastian Bach")); + map.add("composers", new Person("Johannes Brahms")); + map.add("composers", new Person("Edvard Grieg")); + map.add("composers", new Person("Robert Schumann")); + + map.add("performers", new Person("Vladimir Ashkenazy")); + map.add("performers", new Person("Yehudi Menuhin")); + + return map; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/MockMvcClientHttpRequestFactoryTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/MockMvcClientHttpRequestFactoryTests.java new file mode 100644 index 000000000000..2de075d88af6 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/MockMvcClientHttpRequestFactoryTests.java @@ -0,0 +1,103 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client; + +import java.io.IOException; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.client.MockMvcClientHttpRequestFactory; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Tests that use a {@link RestTestClient} configured with a + * {@link MockMvcClientHttpRequestFactory} that is in turn configured with a + * {@link MockMvc} instance that uses a standalone controller + * + * @author Rob Worsnop + */ +@ExtendWith(SpringExtension.class) +public class MockMvcClientHttpRequestFactoryTests { + + private RestTestClient client; + + @BeforeEach + public void setup() { + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TestController()).build(); + this.client = RestTestClient.bindTo(mockMvc).build(); + } + + @Test + public void withResult() { + client.get() + .uri("/foo") + .cookie("session", "12345") + .exchange() + .expectCookie().valueEquals("session", "12345") + .expectBody(String.class) + .isEqualTo("bar"); + } + + @Test + public void withError() { + client.get() + .uri("/error") + .exchange() + .expectStatus().isBadRequest() + .expectBody().isEmpty(); + } + + @Test + public void withErrorAndBody() { + client.get().uri("/errorbody") + .exchange() + .expectStatus().isBadRequest() + .expectBody(String.class) + .isEqualTo("some really bad request"); + } + + @RestController + static class TestController { + + @GetMapping(value = "/foo") + public void foo(@CookieValue("session") String session, HttpServletResponse response) throws IOException { + response.getWriter().write("bar"); + response.addCookie(new Cookie("session", session)); + } + + @GetMapping(value = "/error") + public void handleError(HttpServletResponse response) throws Exception { + response.sendError(400); + } + + @GetMapping(value = "/errorbody") + public void handleErrorWithBody(HttpServletResponse response) throws Exception { + response.sendError(400); + response.getWriter().write("some really bad request"); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/StatusAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/StatusAssertionTests.java new file mode 100644 index 000000000000..ed2a836d2951 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/StatusAssertionTests.java @@ -0,0 +1,266 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; +import static org.mockito.BDDMockito.mock; +import static org.mockito.BDDMockito.when; + +/** + * Tests for {@link StatusAssertions}. + * + * @author Rob Worsnop + */ +class StatusAssertionTests { + + @Test + void isEqualTo() { + StatusAssertions assertions = statusAssertions(HttpStatus.CONFLICT); + + // Success + assertions.isEqualTo(HttpStatus.CONFLICT); + assertions.isEqualTo(409); + + // Wrong status + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.isEqualTo(HttpStatus.REQUEST_TIMEOUT)); + + // Wrong status value + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.isEqualTo(408)); + } + + @Test + void isEqualToWithCustomStatus() { + StatusAssertions assertions = statusAssertions(600); + + // Success + assertions.isEqualTo(600); + + // Wrong status + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + statusAssertions(601).isEqualTo(600)); + + } + + @Test + void reasonEquals() { + StatusAssertions assertions = statusAssertions(HttpStatus.CONFLICT); + + // Success + assertions.reasonEquals("Conflict"); + + // Wrong reason + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).reasonEquals("Conflict")); + } + + @Test + void statusSeries1xx() { + StatusAssertions assertions = statusAssertions(HttpStatus.CONTINUE); + + // Success + assertions.is1xxInformational(); + + // Wrong series + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + statusAssertions(HttpStatus.OK).is1xxInformational()); + } + + @Test + void statusSeries2xx() { + StatusAssertions assertions = statusAssertions(HttpStatus.OK); + + // Success + assertions.is2xxSuccessful(); + + // Wrong series + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).is2xxSuccessful()); + } + + @Test + void statusSeries3xx() { + StatusAssertions assertions = statusAssertions(HttpStatus.PERMANENT_REDIRECT); + + // Success + assertions.is3xxRedirection(); + + // Wrong series + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).is3xxRedirection()); + } + + @Test + void statusSeries4xx() { + StatusAssertions assertions = statusAssertions(HttpStatus.BAD_REQUEST); + + // Success + assertions.is4xxClientError(); + + // Wrong series + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).is4xxClientError()); + } + + @Test + void statusSeries5xx() { + StatusAssertions assertions = statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR); + + // Success + assertions.is5xxServerError(); + + // Wrong series + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + statusAssertions(HttpStatus.OK).is5xxServerError()); + } + + @Test + void matchesStatusValue() { + StatusAssertions assertions = statusAssertions(HttpStatus.CONFLICT); + + // Success + assertions.value(equalTo(409)); + assertions.value(greaterThan(400)); + + // Wrong status + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.value(equalTo(200))); + } + + @Test + void matchesCustomStatusValue() { + statusAssertions(600).value(equalTo(600)); + } + + @Test + void consumesStatusValue() { + StatusAssertions assertions = statusAssertions(HttpStatus.CONFLICT); + + // Success + assertions.value((Integer value) -> assertThat(value).isEqualTo(409)); + } + + @Test + void statusIsAccepted() { + // Success + statusAssertions(HttpStatus.ACCEPTED).isAccepted(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isAccepted()); + } + + @Test + void statusIsNoContent() { + // Success + statusAssertions(HttpStatus.NO_CONTENT).isNoContent(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isNoContent()); + } + + @Test + void statusIsFound() { + // Success + statusAssertions(HttpStatus.FOUND).isFound(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isFound()); + } + + @Test + void statusIsSeeOther() { + // Success + statusAssertions(HttpStatus.SEE_OTHER).isSeeOther(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isSeeOther()); + } + + @Test + void statusIsNotModified() { + // Success + statusAssertions(HttpStatus.NOT_MODIFIED).isNotModified(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isNotModified()); + } + + @Test + void statusIsTemporaryRedirect() { + // Success + statusAssertions(HttpStatus.TEMPORARY_REDIRECT).isTemporaryRedirect(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isTemporaryRedirect()); + } + + @Test + void statusIsPermanentRedirect() { + // Success + statusAssertions(HttpStatus.PERMANENT_REDIRECT).isPermanentRedirect(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isPermanentRedirect()); + } + + @Test + void statusIsUnauthorized() { + // Success + statusAssertions(HttpStatus.UNAUTHORIZED).isUnauthorized(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isUnauthorized()); + } + + @Test + void statusIsForbidden() { + // Success + statusAssertions(HttpStatus.FORBIDDEN).isForbidden(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isForbidden()); + } + + private StatusAssertions statusAssertions(HttpStatus status) { + return statusAssertions(status.value()); + } + + private StatusAssertions statusAssertions(int status) { + try { + RestClient.RequestHeadersSpec.ConvertibleClientHttpResponse response = mock(); + when(response.getStatusCode()).thenReturn(HttpStatusCode.valueOf(status)); + ExchangeResult result = new ExchangeResult(response); + return new StatusAssertions(result, mock()); + } + catch (IOException ex) { + throw new AssertionError(ex); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ErrorTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ErrorTests.java new file mode 100644 index 000000000000..9c28d6ee55b1 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ErrorTests.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client.samples; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpStatus; +import org.springframework.test.web.servlet.client.RestTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Tests with error status codes or error conditions. + * + * @author Rob Worsnop + */ +class ErrorTests { + + private final RestTestClient client = RestTestClient.standaloneSetup(new TestController()).build(); + + + @Test + void notFound(){ + this.client.get().uri("/invalid") + .exchange() + .expectStatus().isNotFound(); + } + + @Test + void serverException() { + this.client.get().uri("/server-error") + .exchange() + .expectStatus().isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + } + + @RestController + static class TestController { + + @GetMapping("/server-error") + void handleAndThrowException() { + throw new IllegalStateException("server error"); + } + + } +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/HeaderAndCookieTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/HeaderAndCookieTests.java new file mode 100644 index 000000000000..df60e53ff1ef --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/HeaderAndCookieTests.java @@ -0,0 +1,86 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client.samples; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.servlet.client.RestTestClient; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +/** + * Tests with headers and cookies. + * + * @author Rob Worsnop + */ +class HeaderAndCookieTests { + + private final RestTestClient client = RestTestClient.standaloneSetup(new TestController()).build(); + + @Test + void requestResponseHeaderPair() { + this.client.get().uri("/header-echo") + .header("h1", "in") + .exchange() + .expectStatus().isOk() + .expectHeader().valueEquals("h1", "in-out"); + } + + @Test + void headerMultipleValues() { + this.client.get().uri("/header-multi-value") + .exchange() + .expectStatus().isOk() + .expectHeader().valueEquals("h1", "v1", "v2", "v3"); + } + + @Test + void setCookies() { + this.client.get().uri("/cookie-echo") + .cookies(cookies -> cookies.add("k1", "v1")) + .exchange() + .expectHeader().valueMatches("Set-Cookie", "k1=v1"); + } + + @RestController + static class TestController { + + @GetMapping("header-echo") + ResponseEntity handleHeader(@RequestHeader("h1") String myHeader) { + String value = myHeader + "-out"; + return ResponseEntity.ok().header("h1", value).build(); + } + + @GetMapping("header-multi-value") + ResponseEntity multiValue() { + return ResponseEntity.ok().header("h1", "v1", "v2", "v3").build(); + } + + @GetMapping("cookie-echo") + ResponseEntity handleCookie(@CookieValue("k1") String cookieValue) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Set-Cookie", "k1=" + cookieValue); + return new ResponseEntity<>(headers, HttpStatus.OK); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/JsonContentTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/JsonContentTests.java new file mode 100644 index 000000000000..cefb95be673e --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/JsonContentTests.java @@ -0,0 +1,171 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client.samples; + +import java.net.URI; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.json.JsonCompareMode; +import org.springframework.test.web.servlet.client.RestTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.hamcrest.Matchers.containsString; + +/** + * Samples of tests using {@link RestTestClient} with serialized JSON content. + * + * @author Rob Worsnop + */ +class JsonContentTests { + + private final RestTestClient client = RestTestClient.standaloneSetup(new PersonController()).build(); + + + @Test + void jsonContentWithDefaultLenientMode() { + this.client.get().uri("/persons") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectBody().json(""" + [ + {"firstName":"Jane"}, + {"firstName":"Jason"}, + {"firstName":"John"} + ] + """); + } + + @Test + void jsonContentWithStrictMode() { + this.client.get().uri("/persons") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectBody().json(""" + [ + {"firstName":"Jane", "lastName":"Williams"}, + {"firstName":"Jason","lastName":"Johnson"}, + {"firstName":"John", "lastName":"Smith"} + ] + """, + JsonCompareMode.STRICT); + } + + @Test + void jsonContentWithStrictModeAndMissingAttributes() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> this.client.get().uri("/persons") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectBody().json(""" + [ + {"firstName":"Jane"}, + {"firstName":"Jason"}, + {"firstName":"John"} + ] + """, + JsonCompareMode.STRICT) + ); + } + + @Test + void jsonPathIsEqualTo() { + this.client.get().uri("/persons") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$[0].firstName").isEqualTo("Jane") + .jsonPath("$[1].firstName").isEqualTo("Jason") + .jsonPath("$[2].firstName").isEqualTo("John"); + } + + @Test + void jsonPathMatches() { + this.client.get().uri("/persons/John/Smith") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.firstName").value(containsString("oh")); + } + + @Test + void postJsonContent() { + this.client.post().uri("/persons") + .contentType(MediaType.APPLICATION_JSON) + .body(""" + {"firstName":"John", "lastName":"Smith"} + """) + .exchange() + .expectStatus().isCreated() + .expectBody().isEmpty(); + } + + + @RestController + @RequestMapping("/persons") + static class PersonController { + + @GetMapping + List getPersons() { + return List.of(new Person("Jane", "Williams"), new Person("Jason", "Johnson"), new Person("John", "Smith")); + } + + @GetMapping("/{firstName}/{lastName}") + Person getPerson(@PathVariable String firstName, @PathVariable String lastName) { + return new Person(firstName, lastName); + } + + @PostMapping + ResponseEntity savePerson(@RequestBody Person person) { + return ResponseEntity.created(URI.create(String.format("/persons/%s/%s", person.getFirstName(), person.getLastName()))).build(); + } + } + + static class Person { + private String firstName; + private String lastName; + + public Person() { + } + + public Person(String firstName, String lastName) { + this.firstName = firstName; + this.lastName = lastName; + } + + public String getFirstName() { + return this.firstName; + } + + public String getLastName() { + return this.lastName; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/Person.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/Person.java new file mode 100644 index 000000000000..e056b644882a --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/Person.java @@ -0,0 +1,69 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client.samples; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.xml.bind.annotation.XmlRootElement; +import org.jspecify.annotations.Nullable; + +@XmlRootElement +class Person { + + private String name; + + + // No-arg constructor for XML + public Person() { + } + + @JsonCreator + public Person(@JsonProperty("name") String name) { + this.name = name; + } + + public void setName(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + Person person = (Person) other; + return getName().equals(person.getName()); + } + + @Override + public int hashCode() { + return getName().hashCode(); + } + + @Override + public String toString() { + return "Person[name='" + name + "']"; + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ResponseEntityTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ResponseEntityTests.java new file mode 100644 index 000000000000..20d2c5385acf --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ResponseEntityTests.java @@ -0,0 +1,156 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client.samples; + +import java.net.URI; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.servlet.client.RestTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.startsWith; + +/** + * Annotated controllers accepting and returning typed Objects. + * + * @author Rob Worsnop + */ +class ResponseEntityTests { + private final RestTestClient client = RestTestClient.standaloneSetup(new PersonController()) + .baseUrl("/persons") + .build(); + + @Test + void entity() { + this.client.get().uri("/John") + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody(Person.class).isEqualTo(new Person("John")); + } + + @Test + void entityMatcher() { + this.client.get().uri("/John") + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody(Person.class).value(Person::getName, startsWith("Joh")); + } + + @Test + void entityWithConsumer() { + this.client.get().uri("/John") + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody(Person.class) + .consumeWith(result -> assertThat(result.getResponseBody()).isEqualTo(new Person("John"))); + } + + @Test + void entityList() { + List expected = List.of( + new Person("Jane"), new Person("Jason"), new Person("John")); + + this.client.get() + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody(new ParameterizedTypeReference>() {}).isEqualTo(expected); + } + + @Test + void entityListWithConsumer() { + this.client.get() + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody(new ParameterizedTypeReference>() {}) + .value(people -> + assertThat(people).contains(new Person("Jason")) + ); + } + + @Test + void entityMap() { + Map map = new LinkedHashMap<>(); + map.put("Jane", new Person("Jane")); + map.put("Jason", new Person("Jason")); + map.put("John", new Person("John")); + + this.client.get().uri("?map=true") + .exchange() + .expectStatus().isOk() + .expectBody(new ParameterizedTypeReference>() {}).isEqualTo(map); + } + + @Test + void postEntity() { + this.client.post() + .contentType(MediaType.APPLICATION_JSON) + .body(new Person("John")) + .exchange() + .expectStatus().isCreated() + .expectHeader().valueEquals("location", "/persons/John") + .expectBody().isEmpty(); + } + + + @RestController + @RequestMapping("/persons") + static class PersonController { + + @GetMapping(path = "/{name}", produces = MediaType.APPLICATION_JSON_VALUE) + Person getPerson(@PathVariable String name) { + return new Person(name); + } + + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + List getPersons() { + return List.of(new Person("Jane"), new Person("Jason"), new Person("John")); + } + + @GetMapping(params = "map", produces = MediaType.APPLICATION_JSON_VALUE) + Map getPersonsAsMap() { + Map map = new LinkedHashMap<>(); + map.put("Jane", new Person("Jane")); + map.put("Jason", new Person("Jason")); + map.put("John", new Person("John")); + return map; + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + ResponseEntity savePerson(@RequestBody Person person) { + return ResponseEntity.created(URI.create("/persons/" + person.getName())).build(); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/RestTestClientTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/RestTestClientTests.java new file mode 100644 index 000000000000..5477ec670cf1 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/RestTestClientTests.java @@ -0,0 +1,330 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client.samples; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Map; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.client.RestTestClient; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.DefaultUriBuilderFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests using the {@link RestTestClient} API. + */ +class RestTestClientTests { + + private RestTestClient client; + + @BeforeEach + void setUp() { + this.client = RestTestClient.standaloneSetup(new TestController()).build(); + } + + @Nested + class HttpMethods { + + @ParameterizedTest + @ValueSource(strings = {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"}) + void testMethod(String method) { + RestTestClientTests.this.client.method(HttpMethod.valueOf(method)).uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.method").isEqualTo(method); + } + + @Test + void testGet() { + RestTestClientTests.this.client.get().uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.method").isEqualTo("GET"); + } + + @Test + void testPost() { + RestTestClientTests.this.client.post().uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.method").isEqualTo("POST"); + } + + @Test + void testPut() { + RestTestClientTests.this.client.put().uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.method").isEqualTo("PUT"); + } + + @Test + void testDelete() { + RestTestClientTests.this.client.delete().uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.method").isEqualTo("DELETE"); + } + + @Test + void testPatch() { + RestTestClientTests.this.client.patch().uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.method").isEqualTo("PATCH"); + } + + @Test + void testHead() { + RestTestClientTests.this.client.head().uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.method").isEqualTo("HEAD"); + } + + @Test + void testOptions() { + RestTestClientTests.this.client.options().uri("/test") + .exchange() + .expectStatus().isOk() + .expectHeader().valueEquals("Allow", "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS") + .expectBody().isEmpty(); + } + + } + + @Nested + class Mutation { + + @Test + void test() { + RestTestClientTests.this.client.mutate() + .apply(builder -> builder.defaultHeader("foo", "bar")) + .uriBuilderFactory(new DefaultUriBuilderFactory("/test")) + .defaultCookie("foo", "bar") + .defaultCookies(cookies -> cookies.add("a", "b")) + .defaultHeaders(headers -> headers.set("a", "b")) + .build().get() + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.uri").isEqualTo("/test") + .jsonPath("$.headers.Cookie").isEqualTo("foo=bar; a=b") + .jsonPath("$.headers.foo").isEqualTo("bar") + .jsonPath("$.headers.a").isEqualTo("b"); + } + } + + @Nested + class Uris { + + @Test + void test() { + RestTestClientTests.this.client.get().uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.uri").isEqualTo("/test"); + } + + @Test + void testWithPathVariables() { + RestTestClientTests.this.client.get().uri("/test/{id}", 1) + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.uri").isEqualTo("/test/1"); + } + + @Test + void testWithParameterMap() { + RestTestClientTests.this.client.get().uri("/test/{id}", Map.of("id", 1)) + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.uri").isEqualTo("/test/1"); + } + + @Test + void testWithUrlBuilder() { + RestTestClientTests.this.client.get().uri(builder -> builder.path("/test/{id}").build(1)) + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.uri").isEqualTo("/test/1"); + } + + @Test + void testURI() { + RestTestClientTests.this.client.get().uri(URI.create("/test")) + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.uri").isEqualTo("/test"); + } + } + + @Nested + class Cookies { + @Test + void testCookie() { + RestTestClientTests.this.client.get().uri("/test") + .cookie("foo", "bar") + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.headers.Cookie").isEqualTo("foo=bar"); + } + + @Test + void testCookies() { + RestTestClientTests.this.client.get().uri("/test") + .cookies(cookies -> cookies.add("foo", "bar")) + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.headers.Cookie").isEqualTo("foo=bar"); + } + } + + @Nested + class Headers { + @Test + void testHeader() { + RestTestClientTests.this.client.get().uri("/test") + .header("foo", "bar") + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.headers.foo").isEqualTo("bar"); + } + + @Test + void testHeaders() { + RestTestClientTests.this.client.get().uri("/test") + .headers(headers -> headers.set("foo", "bar")) + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.headers.foo").isEqualTo("bar"); + } + + @Test + void testContentType() { + RestTestClientTests.this.client.post().uri("/test") + .contentType(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.headers.Content-Type").isEqualTo("application/json"); + } + + @Test + void testAcceptCharset() { + RestTestClientTests.this.client.get().uri("/test") + .acceptCharset(StandardCharsets.UTF_8) + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.headers.Accept-Charset").isEqualTo("utf-8"); + } + + @Test + void testIfModifiedSince() { + RestTestClientTests.this.client.get().uri("/test") + .ifModifiedSince(ZonedDateTime.of(1970, 1, 1, 0, 0, 0, 0, ZoneId.of("GMT"))) + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.headers.If-Modified-Since").isEqualTo("Thu, 01 Jan 1970 00:00:00 GMT"); + } + + @Test + void testIfNoneMatch() { + RestTestClientTests.this.client.get().uri("/test") + .ifNoneMatch("foo") + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.headers.If-None-Match").isEqualTo("foo"); + } + } + + @Nested + class Expectations { + @Test + void testExpectCookie() { + RestTestClientTests.this.client.get().uri("/test") + .exchange() + .expectCookie().value("session", Matchers.equalTo("abc")); + } + } + + @Nested + class ReturnResults { + @Test + void testBodyReturnResult() { + var result = RestTestClientTests.this.client.get().uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody(Map.class).returnResult(); + assertThat(result.getResponseBody().get("uri")).isEqualTo("/test"); + } + + @Test + void testReturnResultClass() { + var result = RestTestClientTests.this.client.get().uri("/test") + .exchange() + .expectStatus().isOk() + .returnResult(Map.class); + assertThat(result.getResponseBody().get("uri")).isEqualTo("/test"); + } + + @Test + void testReturnResultParameterizedTypeReference() { + var result = RestTestClientTests.this.client.get().uri("/test") + .exchange() + .expectStatus().isOk() + .returnResult(new ParameterizedTypeReference>() { + }); + assertThat(result.getResponseBody().get("uri")).isEqualTo("/test"); + } + } + + @RestController + static class TestController { + + @RequestMapping(path = {"/test", "/test/*"}, produces = "application/json") + public Map handle( + @RequestHeader HttpHeaders headers, + HttpServletRequest request, HttpServletResponse response) { + response.addCookie(new Cookie("session", "abc")); + return Map.of( + "method", request.getMethod(), + "uri", request.getRequestURI(), + "headers", headers.toSingleValueMap() + ); + } + } +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/SoftAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/SoftAssertionTests.java new file mode 100644 index 000000000000..a9f433c21ece --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/SoftAssertionTests.java @@ -0,0 +1,72 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client.samples; + +import org.junit.jupiter.api.Test; + +import org.springframework.test.web.servlet.client.RestTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Integration tests for {@link RestTestClient} with soft assertions. + * + */ +class SoftAssertionTests { + + private final RestTestClient restTestClient = RestTestClient.standaloneSetup(new TestController()).build(); + + + @Test + void expectAll() { + this.restTestClient.get().uri("/test").exchange() + .expectAll( + responseSpec -> responseSpec.expectStatus().isOk(), + responseSpec -> responseSpec.expectBody(String.class).isEqualTo("hello") + ); + } + + @Test + void expectAllWithMultipleFailures() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> + this.restTestClient.get().uri("/test").exchange() + .expectAll( + responseSpec -> responseSpec.expectStatus().isBadRequest(), + responseSpec -> responseSpec.expectStatus().isOk(), + responseSpec -> responseSpec.expectBody(String.class).isEqualTo("bogus") + ) + ) + .withMessage(""" + Multiple Exceptions (2): + Status expected:<400 BAD_REQUEST> but was:<200 OK> + Response body expected: but was:"""); + } + + + @RestController + static class TestController { + + @GetMapping("/test") + String handle() { + return "hello"; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/XmlContentTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/XmlContentTests.java new file mode 100644 index 000000000000..8950f51bed48 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/XmlContentTests.java @@ -0,0 +1,196 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client.samples; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import jakarta.xml.bind.annotation.XmlAccessType; +import jakarta.xml.bind.annotation.XmlAccessorType; +import jakarta.xml.bind.annotation.XmlElement; +import jakarta.xml.bind.annotation.XmlRootElement; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.servlet.client.RestTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.startsWith; + +/** + * Samples of tests using {@link RestTestClient} with XML content. + * + * @author Rob Worsnop + */ +class XmlContentTests { + + private static final String persons_XML = """ + + + Jane + Jason + John + + """; + + + private final RestTestClient client = RestTestClient.standaloneSetup(new PersonController()).build(); + + + @Test + void xmlContent() { + this.client.get().uri("/persons") + .accept(MediaType.APPLICATION_XML) + .exchange() + .expectStatus().isOk() + .expectBody().xml(persons_XML); + } + + @Test + void xpathIsEqualTo() { + this.client.get().uri("/persons") + .accept(MediaType.APPLICATION_XML) + .exchange() + .expectStatus().isOk() + .expectBody() + .xpath("/").exists() + .xpath("/persons").exists() + .xpath("/persons/person").exists() + .xpath("/persons/person").nodeCount(3) + .xpath("/persons/person[1]/name").isEqualTo("Jane") + .xpath("/persons/person[2]/name").isEqualTo("Jason") + .xpath("/persons/person[3]/name").isEqualTo("John"); + } + + @Test + void xpathDoesNotExist() { + this.client.get().uri("/persons") + .accept(MediaType.APPLICATION_XML) + .exchange() + .expectStatus().isOk() + .expectBody() + .xpath("/persons/person[4]").doesNotExist(); + } + + @Test + void xpathNodeCount() { + this.client.get().uri("/persons") + .accept(MediaType.APPLICATION_XML) + .exchange() + .expectStatus().isOk() + .expectBody() + .xpath("/persons/person").nodeCount(3) + .xpath("/persons/person").nodeCount(equalTo(3)); + } + + @Test + void xpathMatches() { + this.client.get().uri("/persons") + .accept(MediaType.APPLICATION_XML) + .exchange() + .expectStatus().isOk() + .expectBody() + .xpath("//person/name").string(startsWith("J")) + .xpath("//person/name").string(s -> { + if (!s.startsWith("J")) { + throw new AssertionError("Name does not start with J: " + s); + } + }); + } + + @Test + void xpathContainsSubstringViaRegex() { + this.client.get().uri("/persons/John") + .accept(MediaType.APPLICATION_XML) + .exchange() + .expectStatus().isOk() + .expectBody() + .xpath("//name[contains(text(), 'oh')]").exists(); + } + + @Test + void postXmlContent() { + String content = + "" + + "John"; + + this.client.post().uri("/persons") + .contentType(MediaType.APPLICATION_XML) + .body(content) + .exchange() + .expectStatus().isCreated() + .expectHeader().valueEquals(HttpHeaders.LOCATION, "/persons/John") + .expectBody().isEmpty(); + } + + + @SuppressWarnings("unused") + @XmlRootElement(name="persons") + @XmlAccessorType(XmlAccessType.FIELD) + private static class PersonsWrapper { + + @XmlElement(name="person") + private final List persons = new ArrayList<>(); + + public PersonsWrapper() { + } + + public PersonsWrapper(List persons) { + this.persons.addAll(persons); + } + + public PersonsWrapper(Person... persons) { + this.persons.addAll(Arrays.asList(persons)); + } + + public List getpersons() { + return this.persons; + } + } + + @RestController + @RequestMapping("/persons") + static class PersonController { + + @GetMapping(produces = MediaType.APPLICATION_XML_VALUE) + PersonsWrapper getPersons() { + return new PersonsWrapper(new Person("Jane"), new Person("Jason"), new Person("John")); + } + + @GetMapping(path = "/{name}", produces = MediaType.APPLICATION_XML_VALUE) + Person getPerson(@PathVariable String name) { + return new Person(name); + } + + @PostMapping(consumes = MediaType.APPLICATION_XML_VALUE) + ResponseEntity savepersons(@RequestBody Person person) { + URI location = URI.create(String.format("/persons/%s", person.getName())); + return ResponseEntity.created(location).build(); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/ApplicationContextTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/ApplicationContextTests.java new file mode 100644 index 000000000000..e49c223ecca4 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/ApplicationContextTests.java @@ -0,0 +1,79 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client.samples.bind; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; +import org.springframework.test.web.servlet.client.RestTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.WebApplicationContext; + +/** + * Sample tests demonstrating "mock" server tests binding to server infrastructure + * declared in a Spring ApplicationContext. + * + * @author Rob Worsnop + */ +@SpringJUnitWebConfig(ApplicationContextTests.WebConfig.class) +class ApplicationContextTests { + + private RestTestClient client; + private final WebApplicationContext context; + + public ApplicationContextTests(WebApplicationContext context) { + this.context = context; + } + + @BeforeEach + void setUp() { + this.client = RestTestClient.bindToApplicationContext(context).build(); + } + + @Test + void test() { + this.client.get().uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody(String.class).isEqualTo("It works!"); + } + + + @Configuration + static class WebConfig { + + @Bean + public TestController controller() { + return new TestController(); + } + + } + + @RestController + static class TestController { + + @GetMapping("/test") + public String handle() { + return "It works!"; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/ControllerTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/ControllerTests.java new file mode 100644 index 000000000000..2f2aaee064cb --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/ControllerTests.java @@ -0,0 +1,61 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client.samples.bind; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.test.web.servlet.client.RestTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Sample tests demonstrating "mock" server tests binding to an annotated + * controller. + * + * @author Rob Worsnop + */ +class ControllerTests { + + private RestTestClient client; + + + @BeforeEach + void setUp() { + this.client = RestTestClient.standaloneSetup(new TestController()).build(); + } + + + @Test + void test() { + this.client.get().uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody(String.class).isEqualTo("It works!"); + } + + + @RestController + static class TestController { + + @GetMapping("/test") + public String handle() { + return "It works!"; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/FilterTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/FilterTests.java new file mode 100644 index 000000000000..fe6bb3d7edd2 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/FilterTests.java @@ -0,0 +1,63 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client.samples.bind; + +import java.io.IOException; +import java.util.Optional; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpFilter; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; + +import org.springframework.test.web.servlet.client.RestTestClient; +import org.springframework.web.servlet.function.ServerResponse; + +import static org.springframework.http.HttpStatus.I_AM_A_TEAPOT; + + +/** + * Tests for a {@link Filter}. + * @author Rob Worsnop + */ +class FilterTests { + + @Test + void filter() { + + Filter filter = new HttpFilter() { + @Override + protected void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException { + res.getWriter().write("It works!"); + } + }; + + RestTestClient client = RestTestClient.bindToRouterFunction( + request -> Optional.of(req -> ServerResponse.status(I_AM_A_TEAPOT).build())) + .configureServer(builder -> builder.addFilters(filter)) + .build(); + + client.get().uri("/") + .exchange() + .expectStatus().isOk() + .expectBody(String.class).isEqualTo("It works!"); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/HttpServerTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/HttpServerTests.java new file mode 100644 index 000000000000..12e45899cd8d --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/HttpServerTests.java @@ -0,0 +1,73 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client.samples.bind; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.test.web.servlet.client.RestTestClient; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.testfixture.http.server.reactive.bootstrap.ReactorHttpServer; + +import static org.springframework.web.reactive.function.server.RequestPredicates.GET; +import static org.springframework.web.reactive.function.server.RouterFunctions.route; + +/** + * Sample tests demonstrating live server integration tests. + * + * @author Rob Worsnop + */ +class HttpServerTests { + + private ReactorHttpServer server; + + private RestTestClient client; + + + @BeforeEach + void start() throws Exception { + HttpHandler httpHandler = RouterFunctions.toHttpHandler( + route(GET("/test"), request -> ServerResponse.ok().bodyValue("It works!"))); + + this.server = new ReactorHttpServer(); + this.server.setHandler(httpHandler); + this.server.afterPropertiesSet(); + this.server.start(); + + this.client = RestTestClient.bindToServer() + .baseUrl("http://localhost:" + this.server.getPort()) + .build(); + } + + @AfterEach + void stop() { + this.server.stop(); + } + + + @Test + void test() { + this.client.get().uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody(String.class).isEqualTo("It works!"); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/RouterFunctionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/RouterFunctionTests.java new file mode 100644 index 000000000000..c17ef2464596 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/RouterFunctionTests.java @@ -0,0 +1,56 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client.samples.bind; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.test.web.servlet.client.RestTestClient; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.ServerResponse; + +import static org.springframework.web.servlet.function.RequestPredicates.GET; +import static org.springframework.web.servlet.function.RouterFunctions.route; + +/** + * Sample tests demonstrating "mock" server tests binding to a RouterFunction. + * + * @author Rob Worsnop + */ +class RouterFunctionTests { + + private RestTestClient testClient; + + + @BeforeEach + void setUp() throws Exception { + + RouterFunction route = route(GET("/test"), request -> + ServerResponse.ok().body("It works!")); + + this.testClient = RestTestClient.bindToRouterFunction(route).build(); + } + + @Test + void test() throws Exception { + this.testClient.get().uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody(String.class).isEqualTo("It works!"); + } + +} diff --git a/src/checkstyle/checkstyle-suppressions.xml b/src/checkstyle/checkstyle-suppressions.xml index 3fe0922a8317..92021b59598c 100644 --- a/src/checkstyle/checkstyle-suppressions.xml +++ b/src/checkstyle/checkstyle-suppressions.xml @@ -89,6 +89,7 @@ + From 934b8fc7999aa6c0800c9d81f2745be502190fed Mon Sep 17 00:00:00 2001 From: Rob Worsnop Date: Thu, 24 Jul 2025 13:59:57 -0400 Subject: [PATCH 022/591] Common base classes for WebTestClient/RestTestClient Assertions Fixes gh-31275 Signed-off-by: Rob Worsnop --- .../web/reactive/server/CookieAssertions.java | 218 +----------- .../web/reactive/server/HeaderAssertions.java | 299 +---------------- .../reactive/server/JsonPathAssertions.java | 182 +--------- .../web/reactive/server/StatusAssertions.java | 223 +------------ .../web/reactive/server/XpathAssertions.java | 180 +--------- .../web/servlet/client/CookieAssertions.java | 213 +----------- .../web/servlet/client/HeaderAssertions.java | 285 +--------------- .../servlet/client/JsonPathAssertions.java | 173 +--------- .../web/servlet/client/StatusAssertions.java | 223 +------------ .../web/servlet/client/XpathAssertions.java | 171 +--------- .../web/support/AbstractCookieAssertions.java | 245 ++++++++++++++ .../web/support/AbstractHeaderAssertions.java | 310 ++++++++++++++++++ .../support/AbstractJsonPathAssertions.java | 195 +++++++++++ .../web/support/AbstractStatusAssertions.java | 247 ++++++++++++++ .../web/support/AbstractXpathAssertions.java | 191 +++++++++++ .../test/web/support/package-info.java | 4 + src/checkstyle/checkstyle-suppressions.xml | 1 + 17 files changed, 1279 insertions(+), 2081 deletions(-) create mode 100644 spring-test/src/main/java/org/springframework/test/web/support/AbstractCookieAssertions.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/support/AbstractHeaderAssertions.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/support/AbstractJsonPathAssertions.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/support/AbstractStatusAssertions.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/support/AbstractXpathAssertions.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/support/package-info.java diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/CookieAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/CookieAssertions.java index 0efd957834b0..6deca1e015b8 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/CookieAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/CookieAssertions.java @@ -16,226 +16,30 @@ package org.springframework.test.web.reactive.server; -import java.time.Duration; -import java.util.function.Consumer; - -import org.hamcrest.Matcher; -import org.hamcrest.MatcherAssert; - import org.springframework.http.ResponseCookie; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.springframework.test.util.AssertionErrors.assertEquals; -import static org.springframework.test.util.AssertionErrors.fail; +import org.springframework.test.web.support.AbstractCookieAssertions; +import org.springframework.util.MultiValueMap; /** * Assertions on cookies of the response. * * @author Rossen Stoyanchev + * @author Rob Worsnop * @since 5.3 */ -public class CookieAssertions { - - private final ExchangeResult exchangeResult; - - private final WebTestClient.ResponseSpec responseSpec; - +public class CookieAssertions extends AbstractCookieAssertions { public CookieAssertions(ExchangeResult exchangeResult, WebTestClient.ResponseSpec responseSpec) { - this.exchangeResult = exchangeResult; - this.responseSpec = responseSpec; - } - - - /** - * Expect a response cookie with the given name to match the specified value. - */ - public WebTestClient.ResponseSpec valueEquals(String name, String value) { - String cookieValue = getCookie(name).getValue(); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name); - assertEquals(message, value, cookieValue); - }); - return this.responseSpec; - } - - /** - * Assert the value of the response cookie with the given name with a Hamcrest - * {@link Matcher}. - */ - public WebTestClient.ResponseSpec value(String name, Matcher matcher) { - String value = getCookie(name).getValue(); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name); - MatcherAssert.assertThat(message, value, matcher); - }); - return this.responseSpec; - } - - /** - * Consume the value of the response cookie with the given name. - */ - public WebTestClient.ResponseSpec value(String name, Consumer consumer) { - String value = getCookie(name).getValue(); - this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(value)); - return this.responseSpec; - } - - /** - * Expect that the cookie with the given name is present. - */ - public WebTestClient.ResponseSpec exists(String name) { - getCookie(name); - return this.responseSpec; + super(exchangeResult, responseSpec); } - /** - * Expect that the cookie with the given name is not present. - */ - public WebTestClient.ResponseSpec doesNotExist(String name) { - ResponseCookie cookie = this.exchangeResult.getResponseCookies().getFirst(name); - if (cookie != null) { - String message = getMessage(name) + " exists with value=[" + cookie.getValue() + "]"; - this.exchangeResult.assertWithDiagnostics(() -> fail(message)); - } - return this.responseSpec; + @Override + protected void assertWithDiagnostics(Runnable assertion) { + exchangeResult.assertWithDiagnostics(assertion); } - /** - * Assert a cookie's "Max-Age" attribute. - */ - public WebTestClient.ResponseSpec maxAge(String name, Duration expected) { - Duration maxAge = getCookie(name).getMaxAge(); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name) + " maxAge"; - assertEquals(message, expected, maxAge); - }); - return this.responseSpec; + @Override + protected MultiValueMap getResponseCookies() { + return exchangeResult.getResponseCookies(); } - - /** - * Assert a cookie's "Max-Age" attribute with a Hamcrest {@link Matcher}. - */ - public WebTestClient.ResponseSpec maxAge(String name, Matcher matcher) { - long maxAge = getCookie(name).getMaxAge().getSeconds(); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name) + " maxAge"; - assertThat(message, maxAge, matcher); - }); - return this.responseSpec; - } - - /** - * Assert a cookie's "Path" attribute. - */ - public WebTestClient.ResponseSpec path(String name, String expected) { - String path = getCookie(name).getPath(); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name) + " path"; - assertEquals(message, expected, path); - }); - return this.responseSpec; - } - - /** - * Assert a cookie's "Path" attribute with a Hamcrest {@link Matcher}. - */ - public WebTestClient.ResponseSpec path(String name, Matcher matcher) { - String path = getCookie(name).getPath(); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name) + " path"; - assertThat(message, path, matcher); - }); - return this.responseSpec; - } - - /** - * Assert a cookie's "Domain" attribute. - */ - public WebTestClient.ResponseSpec domain(String name, String expected) { - String path = getCookie(name).getDomain(); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name) + " domain"; - assertEquals(message, expected, path); - }); - return this.responseSpec; - } - - /** - * Assert a cookie's "Domain" attribute with a Hamcrest {@link Matcher}. - */ - public WebTestClient.ResponseSpec domain(String name, Matcher matcher) { - String domain = getCookie(name).getDomain(); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name) + " domain"; - assertThat(message, domain, matcher); - }); - return this.responseSpec; - } - - /** - * Assert a cookie's "Secure" attribute. - */ - public WebTestClient.ResponseSpec secure(String name, boolean expected) { - boolean isSecure = getCookie(name).isSecure(); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name) + " secure"; - assertEquals(message, expected, isSecure); - }); - return this.responseSpec; - } - - /** - * Assert a cookie's "HttpOnly" attribute. - */ - public WebTestClient.ResponseSpec httpOnly(String name, boolean expected) { - boolean isHttpOnly = getCookie(name).isHttpOnly(); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name) + " httpOnly"; - assertEquals(message, expected, isHttpOnly); - }); - return this.responseSpec; - } - - /** - * Assert a cookie's "Partitioned" attribute. - * @since 6.2 - */ - public WebTestClient.ResponseSpec partitioned(String name, boolean expected) { - boolean isPartitioned = getCookie(name).isPartitioned(); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name) + " isPartitioned"; - assertEquals(message, expected, isPartitioned); - }); - return this.responseSpec; - } - - /** - * Assert a cookie's "SameSite" attribute. - */ - public WebTestClient.ResponseSpec sameSite(String name, String expected) { - String sameSite = getCookie(name).getSameSite(); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name) + " sameSite"; - assertEquals(message, expected, sameSite); - }); - return this.responseSpec; - } - - - private ResponseCookie getCookie(String name) { - ResponseCookie cookie = this.exchangeResult.getResponseCookies().getFirst(name); - if (cookie != null) { - return cookie; - } - else { - this.exchangeResult.assertWithDiagnostics(() -> fail("No cookie with name '" + name + "'")); - } - throw new IllegalStateException("This code path should not be reachable"); - } - - private static String getMessage(String cookie) { - return "Response cookie '" + cookie + "'"; - } - } diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/HeaderAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/HeaderAssertions.java index ce008c059d3f..5cf42730b57e 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/HeaderAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/HeaderAssertions.java @@ -16,25 +16,8 @@ package org.springframework.test.web.reactive.server; -import java.net.URI; -import java.util.Arrays; -import java.util.List; -import java.util.function.Consumer; - -import org.hamcrest.Matcher; -import org.jspecify.annotations.Nullable; - -import org.springframework.http.CacheControl; -import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.util.CollectionUtils; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.springframework.test.util.AssertionErrors.assertEquals; -import static org.springframework.test.util.AssertionErrors.assertNotNull; -import static org.springframework.test.util.AssertionErrors.assertTrue; -import static org.springframework.test.util.AssertionErrors.fail; +import org.springframework.test.web.support.AbstractHeaderAssertions; /** * Assertions on headers of the response. @@ -42,285 +25,23 @@ * @author Rossen Stoyanchev * @author Brian Clozel * @author Sam Brannen + * @author Rob Worsnop * @since 5.0 * @see WebTestClient.ResponseSpec#expectHeader() */ -public class HeaderAssertions { - - private final ExchangeResult exchangeResult; - - private final WebTestClient.ResponseSpec responseSpec; - +public class HeaderAssertions extends AbstractHeaderAssertions { HeaderAssertions(ExchangeResult result, WebTestClient.ResponseSpec spec) { - this.exchangeResult = result; - this.responseSpec = spec; - } - - - /** - * Expect a header with the given name to match the specified values. - */ - public WebTestClient.ResponseSpec valueEquals(String headerName, String... values) { - return assertHeader(headerName, Arrays.asList(values), getHeaders().getOrEmpty(headerName)); - } - - /** - * Expect a header with the given name to match the given long value. - * @since 5.3 - */ - public WebTestClient.ResponseSpec valueEquals(String headerName, long value) { - String actual = getHeaders().getFirst(headerName); - this.exchangeResult.assertWithDiagnostics(() -> - assertNotNull("Response does not contain header '" + headerName + "'", actual)); - return assertHeader(headerName, value, Long.parseLong(actual)); - } - - /** - * Expect a header with the given name to match the specified long value - * parsed into a date using the preferred date format described in RFC 7231. - *

An {@link AssertionError} is thrown if the response does not contain - * the specified header, or if the supplied {@code value} does not match the - * primary header value. - * @since 5.3 - */ - public WebTestClient.ResponseSpec valueEqualsDate(String headerName, long value) { - this.exchangeResult.assertWithDiagnostics(() -> { - String headerValue = getHeaders().getFirst(headerName); - assertNotNull("Response does not contain header '" + headerName + "'", headerValue); - - HttpHeaders headers = new HttpHeaders(); - headers.setDate("expected", value); - headers.set("actual", headerValue); - - assertEquals(getMessage(headerName) + "='" + headerValue + "' " + - "does not match expected value '" + headers.getFirst("expected") + "'", - headers.getFirstDate("expected"), headers.getFirstDate("actual")); - }); - return this.responseSpec; + super(result, spec); } - /** - * Match the first value of the response header with a regex. - * @param name the header name - * @param pattern the regex pattern - */ - public WebTestClient.ResponseSpec valueMatches(String name, String pattern) { - String value = getRequiredValue(name); - String message = getMessage(name) + "=[" + value + "] does not match [" + pattern + "]"; - this.exchangeResult.assertWithDiagnostics(() -> assertTrue(message, value.matches(pattern))); - return this.responseSpec; + @Override + protected void assertWithDiagnostics(Runnable assertion) { + exchangeResult.assertWithDiagnostics(assertion); } - /** - * Match all values of the response header with the given regex - * patterns which are applied to the values of the header in the - * same order. Note that the number of patterns must match the - * number of actual values. - * @param name the header name - * @param patterns one or more regex patterns, one per expected value - * @since 5.3 - */ - public WebTestClient.ResponseSpec valuesMatch(String name, String... patterns) { - List values = getRequiredValues(name); - this.exchangeResult.assertWithDiagnostics(() -> { - assertTrue( - getMessage(name) + " has fewer or more values " + values + - " than number of patterns to match with " + Arrays.toString(patterns), - values.size() == patterns.length); - for (int i = 0; i < values.size(); i++) { - String value = values.get(i); - String pattern = patterns[i]; - assertTrue( - getMessage(name) + "[" + i + "]='" + value + "' does not match '" + pattern + "'", - value.matches(pattern)); - } - }); - return this.responseSpec; + @Override + protected HttpHeaders getResponseHeaders() { + return exchangeResult.getResponseHeaders(); } - - /** - * Assert the first value of the response header with a Hamcrest {@link Matcher}. - * @param name the header name - * @param matcher the matcher to use - * @since 5.1 - */ - public WebTestClient.ResponseSpec value(String name, Matcher matcher) { - String value = getHeaders().getFirst(name); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name); - assertThat(message, value, matcher); - }); - return this.responseSpec; - } - - /** - * Assert all values of the response header with a Hamcrest {@link Matcher}. - * @param name the header name - * @param matcher the matcher to use - * @since 5.3 - */ - public WebTestClient.ResponseSpec values(String name, Matcher> matcher) { - List values = getHeaders().get(name); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name); - assertThat(message, values, matcher); - }); - return this.responseSpec; - } - - /** - * Consume the first value of the named response header. - * @param name the header name - * @param consumer the consumer to use - * @since 5.1 - */ - public WebTestClient.ResponseSpec value(String name, Consumer consumer) { - String value = getRequiredValue(name); - this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(value)); - return this.responseSpec; - } - - /** - * Consume all values of the named response header. - * @param name the header name - * @param consumer the consumer to use - * @since 5.3 - */ - public WebTestClient.ResponseSpec values(String name, Consumer> consumer) { - List values = getRequiredValues(name); - this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(values)); - return this.responseSpec; - } - - private String getRequiredValue(String name) { - return getRequiredValues(name).get(0); - } - - private List getRequiredValues(String name) { - List values = getHeaders().get(name); - if (!CollectionUtils.isEmpty(values)) { - return values; - } - else { - this.exchangeResult.assertWithDiagnostics(() -> fail(getMessage(name) + " not found")); - } - throw new IllegalStateException("This code path should not be reachable"); - } - - /** - * Expect that the header with the given name is present. - * @since 5.0.3 - */ - public WebTestClient.ResponseSpec exists(String name) { - if (!getHeaders().containsHeader(name)) { - String message = getMessage(name) + " does not exist"; - this.exchangeResult.assertWithDiagnostics(() -> fail(message)); - } - return this.responseSpec; - } - - /** - * Expect that the header with the given name is not present. - */ - public WebTestClient.ResponseSpec doesNotExist(String name) { - if (getHeaders().containsHeader(name)) { - String message = getMessage(name) + " exists with value=[" + getHeaders().getFirst(name) + "]"; - this.exchangeResult.assertWithDiagnostics(() -> fail(message)); - } - return this.responseSpec; - } - - /** - * Expect a "Cache-Control" header with the given value. - */ - public WebTestClient.ResponseSpec cacheControl(CacheControl cacheControl) { - return assertHeader("Cache-Control", cacheControl.getHeaderValue(), getHeaders().getCacheControl()); - } - - /** - * Expect a "Content-Disposition" header with the given value. - */ - public WebTestClient.ResponseSpec contentDisposition(ContentDisposition contentDisposition) { - return assertHeader("Content-Disposition", contentDisposition, getHeaders().getContentDisposition()); - } - - /** - * Expect a "Content-Length" header with the given value. - */ - public WebTestClient.ResponseSpec contentLength(long contentLength) { - return assertHeader("Content-Length", contentLength, getHeaders().getContentLength()); - } - - /** - * Expect a "Content-Type" header with the given value. - */ - public WebTestClient.ResponseSpec contentType(MediaType mediaType) { - return assertHeader("Content-Type", mediaType, getHeaders().getContentType()); - } - - /** - * Expect a "Content-Type" header with the given value. - */ - public WebTestClient.ResponseSpec contentType(String mediaType) { - return contentType(MediaType.parseMediaType(mediaType)); - } - - /** - * Expect a "Content-Type" header compatible with the given value. - */ - public WebTestClient.ResponseSpec contentTypeCompatibleWith(MediaType mediaType) { - MediaType actual = getHeaders().getContentType(); - String message = getMessage("Content-Type") + "=[" + actual + "] is not compatible with [" + mediaType + "]"; - this.exchangeResult.assertWithDiagnostics(() -> - assertTrue(message, (actual != null && actual.isCompatibleWith(mediaType)))); - return this.responseSpec; - } - - /** - * Expect a "Content-Type" header compatible with the given value. - */ - public WebTestClient.ResponseSpec contentTypeCompatibleWith(String mediaType) { - return contentTypeCompatibleWith(MediaType.parseMediaType(mediaType)); - } - - /** - * Expect an "Expires" header with the given value. - */ - public WebTestClient.ResponseSpec expires(long expires) { - return assertHeader("Expires", expires, getHeaders().getExpires()); - } - - /** - * Expect a "Last-Modified" header with the given value. - */ - public WebTestClient.ResponseSpec lastModified(long lastModified) { - return assertHeader("Last-Modified", lastModified, getHeaders().getLastModified()); - } - - /** - * Expect a "Location" header with the given value. - * @since 5.3 - */ - public WebTestClient.ResponseSpec location(String location) { - return assertHeader("Location", URI.create(location), getHeaders().getLocation()); - } - - - private HttpHeaders getHeaders() { - return this.exchangeResult.getResponseHeaders(); - } - - private WebTestClient.ResponseSpec assertHeader(String name, @Nullable Object expected, @Nullable Object actual) { - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name); - assertEquals(message, expected, actual); - }); - return this.responseSpec; - } - - private static String getMessage(String headerName) { - return "Response header '" + headerName + "'"; - } - } diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonPathAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonPathAssertions.java index 7de0e7ae8e0d..5e5e5da78992 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonPathAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonPathAssertions.java @@ -16,200 +16,26 @@ package org.springframework.test.web.reactive.server; -import java.util.function.Consumer; - import com.jayway.jsonpath.Configuration; -import org.hamcrest.Matcher; import org.jspecify.annotations.Nullable; -import org.springframework.core.ParameterizedTypeReference; import org.springframework.test.util.JsonPathExpectationsHelper; -import org.springframework.util.Assert; +import org.springframework.test.web.support.AbstractJsonPathAssertions; /** * JsonPath assertions. * * @author Rossen Stoyanchev * @author Stephane Nicoll + * @author Rob Worsnop * @since 5.0 * @see https://github.com/jayway/JsonPath * @see JsonPathExpectationsHelper */ -public class JsonPathAssertions { - - private final WebTestClient.BodyContentSpec bodySpec; - - private final String content; - - private final JsonPathExpectationsHelper pathHelper; - +public class JsonPathAssertions extends AbstractJsonPathAssertions { JsonPathAssertions(WebTestClient.BodyContentSpec spec, String content, String expression, @Nullable Configuration configuration) { - Assert.hasText(expression, "expression must not be null or empty"); - this.bodySpec = spec; - this.content = content; - this.pathHelper = new JsonPathExpectationsHelper(expression, configuration); - } - - - /** - * Applies {@link JsonPathExpectationsHelper#assertValue(String, Object)}. - */ - public WebTestClient.BodyContentSpec isEqualTo(Object expectedValue) { - this.pathHelper.assertValue(this.content, expectedValue); - return this.bodySpec; - } - - /** - * Applies {@link JsonPathExpectationsHelper#exists(String)}. - */ - public WebTestClient.BodyContentSpec exists() { - this.pathHelper.exists(this.content); - return this.bodySpec; - } - - /** - * Applies {@link JsonPathExpectationsHelper#doesNotExist(String)}. - */ - public WebTestClient.BodyContentSpec doesNotExist() { - this.pathHelper.doesNotExist(this.content); - return this.bodySpec; + super(spec, content, expression, configuration); } - - /** - * Applies {@link JsonPathExpectationsHelper#assertValueIsEmpty(String)}. - */ - public WebTestClient.BodyContentSpec isEmpty() { - this.pathHelper.assertValueIsEmpty(this.content); - return this.bodySpec; - } - - /** - * Applies {@link JsonPathExpectationsHelper#assertValueIsNotEmpty(String)}. - */ - public WebTestClient.BodyContentSpec isNotEmpty() { - this.pathHelper.assertValueIsNotEmpty(this.content); - return this.bodySpec; - } - - /** - * Applies {@link JsonPathExpectationsHelper#hasJsonPath}. - * @since 5.0.3 - */ - public WebTestClient.BodyContentSpec hasJsonPath() { - this.pathHelper.hasJsonPath(this.content); - return this.bodySpec; - } - - /** - * Applies {@link JsonPathExpectationsHelper#doesNotHaveJsonPath}. - * @since 5.0.3 - */ - public WebTestClient.BodyContentSpec doesNotHaveJsonPath() { - this.pathHelper.doesNotHaveJsonPath(this.content); - return this.bodySpec; - } - - /** - * Applies {@link JsonPathExpectationsHelper#assertValueIsBoolean(String)}. - */ - public WebTestClient.BodyContentSpec isBoolean() { - this.pathHelper.assertValueIsBoolean(this.content); - return this.bodySpec; - } - - /** - * Applies {@link JsonPathExpectationsHelper#assertValueIsNumber(String)}. - */ - public WebTestClient.BodyContentSpec isNumber() { - this.pathHelper.assertValueIsNumber(this.content); - return this.bodySpec; - } - - /** - * Applies {@link JsonPathExpectationsHelper#assertValueIsArray(String)}. - */ - public WebTestClient.BodyContentSpec isArray() { - this.pathHelper.assertValueIsArray(this.content); - return this.bodySpec; - } - - /** - * Applies {@link JsonPathExpectationsHelper#assertValueIsMap(String)}. - */ - public WebTestClient.BodyContentSpec isMap() { - this.pathHelper.assertValueIsMap(this.content); - return this.bodySpec; - } - - /** - * Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher)}. - * @since 5.1 - */ - public WebTestClient.BodyContentSpec value(Matcher matcher) { - this.pathHelper.assertValue(this.content, matcher); - return this.bodySpec; - } - - /** - * Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher, Class)}. - * @since 6.2 - */ - public WebTestClient.BodyContentSpec value(Class targetType, Matcher matcher) { - this.pathHelper.assertValue(this.content, matcher, targetType); - return this.bodySpec; - } - - /** - * Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher, ParameterizedTypeReference)}. - * @since 6.2 - */ - public WebTestClient.BodyContentSpec value(ParameterizedTypeReference targetType, Matcher matcher) { - this.pathHelper.assertValue(this.content, matcher, targetType); - return this.bodySpec; - } - - /** - * Consume the result of the JSONPath evaluation. - * @since 5.1 - */ - @SuppressWarnings("unchecked") - public WebTestClient.BodyContentSpec value(Consumer consumer) { - Object value = this.pathHelper.evaluateJsonPath(this.content); - consumer.accept((T) value); - return this.bodySpec; - } - - /** - * Consume the result of the JSONPath evaluation and provide a target class. - * @since 6.2 - */ - public WebTestClient.BodyContentSpec value(Class targetType, Consumer consumer) { - T value = this.pathHelper.evaluateJsonPath(this.content, targetType); - consumer.accept(value); - return this.bodySpec; - } - - /** - * Consume the result of the JSONPath evaluation and provide a parameterized type. - * @since 6.2 - */ - public WebTestClient.BodyContentSpec value(ParameterizedTypeReference targetType, Consumer consumer) { - T value = this.pathHelper.evaluateJsonPath(this.content, targetType); - consumer.accept(value); - return this.bodySpec; - } - - @Override - public boolean equals(@Nullable Object obj) { - throw new AssertionError("Object#equals is disabled " + - "to avoid being used in error instead of JsonPathAssertions#isEqualTo(String)."); - } - - @Override - public int hashCode() { - return super.hashCode(); - } - } diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/StatusAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/StatusAssertions.java index e34204e4a373..0c0d87e82fa8 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/StatusAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/StatusAssertions.java @@ -16,233 +16,30 @@ package org.springframework.test.web.reactive.server; -import java.util.function.Consumer; - -import org.hamcrest.Matcher; -import org.hamcrest.MatcherAssert; - -import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; -import org.springframework.test.util.AssertionErrors; +import org.springframework.test.web.support.AbstractStatusAssertions; /** * Assertions on the response status. * * @author Rossen Stoyanchev + * @author Rob Worsnop * @since 5.0 * @see WebTestClient.ResponseSpec#expectStatus() */ -public class StatusAssertions { - - private final ExchangeResult exchangeResult; - - private final WebTestClient.ResponseSpec responseSpec; - +public class StatusAssertions extends AbstractStatusAssertions { StatusAssertions(ExchangeResult result, WebTestClient.ResponseSpec spec) { - this.exchangeResult = result; - this.responseSpec = spec; - } - - - /** - * Assert the response status as an {@link HttpStatusCode}. - */ - public WebTestClient.ResponseSpec isEqualTo(HttpStatusCode status) { - HttpStatusCode actual = this.exchangeResult.getStatus(); - this.exchangeResult.assertWithDiagnostics(() -> AssertionErrors.assertEquals("Status", status, actual)); - return this.responseSpec; + super(result, spec); } - /** - * Assert the response status as an integer. - */ - public WebTestClient.ResponseSpec isEqualTo(int status) { - return isEqualTo(HttpStatusCode.valueOf(status)); + @Override + protected void assertWithDiagnostics(Runnable assertion) { + exchangeResult.assertWithDiagnostics(assertion); } - /** - * Assert the response status code is {@code HttpStatus.OK} (200). - */ - public WebTestClient.ResponseSpec isOk() { - return assertStatusAndReturn(HttpStatus.OK); + @Override + protected HttpStatusCode getStatus() { + return exchangeResult.getStatus(); } - - /** - * Assert the response status code is {@code HttpStatus.CREATED} (201). - */ - public WebTestClient.ResponseSpec isCreated() { - return assertStatusAndReturn(HttpStatus.CREATED); - } - - /** - * Assert the response status code is {@code HttpStatus.ACCEPTED} (202). - */ - public WebTestClient.ResponseSpec isAccepted() { - return assertStatusAndReturn(HttpStatus.ACCEPTED); - } - - /** - * Assert the response status code is {@code HttpStatus.NO_CONTENT} (204). - */ - public WebTestClient.ResponseSpec isNoContent() { - return assertStatusAndReturn(HttpStatus.NO_CONTENT); - } - - /** - * Assert the response status code is {@code HttpStatus.FOUND} (302). - */ - public WebTestClient.ResponseSpec isFound() { - return assertStatusAndReturn(HttpStatus.FOUND); - } - - /** - * Assert the response status code is {@code HttpStatus.SEE_OTHER} (303). - */ - public WebTestClient.ResponseSpec isSeeOther() { - return assertStatusAndReturn(HttpStatus.SEE_OTHER); - } - - /** - * Assert the response status code is {@code HttpStatus.NOT_MODIFIED} (304). - */ - public WebTestClient.ResponseSpec isNotModified() { - return assertStatusAndReturn(HttpStatus.NOT_MODIFIED); - } - - /** - * Assert the response status code is {@code HttpStatus.TEMPORARY_REDIRECT} (307). - */ - public WebTestClient.ResponseSpec isTemporaryRedirect() { - return assertStatusAndReturn(HttpStatus.TEMPORARY_REDIRECT); - } - - /** - * Assert the response status code is {@code HttpStatus.PERMANENT_REDIRECT} (308). - */ - public WebTestClient.ResponseSpec isPermanentRedirect() { - return assertStatusAndReturn(HttpStatus.PERMANENT_REDIRECT); - } - - /** - * Assert the response status code is {@code HttpStatus.BAD_REQUEST} (400). - */ - public WebTestClient.ResponseSpec isBadRequest() { - return assertStatusAndReturn(HttpStatus.BAD_REQUEST); - } - - /** - * Assert the response status code is {@code HttpStatus.UNAUTHORIZED} (401). - */ - public WebTestClient.ResponseSpec isUnauthorized() { - return assertStatusAndReturn(HttpStatus.UNAUTHORIZED); - } - - /** - * Assert the response status code is {@code HttpStatus.FORBIDDEN} (403). - * @since 5.0.2 - */ - public WebTestClient.ResponseSpec isForbidden() { - return assertStatusAndReturn(HttpStatus.FORBIDDEN); - } - - /** - * Assert the response status code is {@code HttpStatus.NOT_FOUND} (404). - */ - public WebTestClient.ResponseSpec isNotFound() { - return assertStatusAndReturn(HttpStatus.NOT_FOUND); - } - - /** - * Assert the response error message. - */ - public WebTestClient.ResponseSpec reasonEquals(String reason) { - String actual = getReasonPhrase(this.exchangeResult.getStatus()); - this.exchangeResult.assertWithDiagnostics(() -> - AssertionErrors.assertEquals("Response status reason", reason, actual)); - return this.responseSpec; - } - - private static String getReasonPhrase(HttpStatusCode statusCode) { - if (statusCode instanceof HttpStatus status) { - return status.getReasonPhrase(); - } - else { - return ""; - } - } - - - /** - * Assert the response status code is in the 1xx range. - */ - public WebTestClient.ResponseSpec is1xxInformational() { - return assertSeriesAndReturn(HttpStatus.Series.INFORMATIONAL); - } - - /** - * Assert the response status code is in the 2xx range. - */ - public WebTestClient.ResponseSpec is2xxSuccessful() { - return assertSeriesAndReturn(HttpStatus.Series.SUCCESSFUL); - } - - /** - * Assert the response status code is in the 3xx range. - */ - public WebTestClient.ResponseSpec is3xxRedirection() { - return assertSeriesAndReturn(HttpStatus.Series.REDIRECTION); - } - - /** - * Assert the response status code is in the 4xx range. - */ - public WebTestClient.ResponseSpec is4xxClientError() { - return assertSeriesAndReturn(HttpStatus.Series.CLIENT_ERROR); - } - - /** - * Assert the response status code is in the 5xx range. - */ - public WebTestClient.ResponseSpec is5xxServerError() { - return assertSeriesAndReturn(HttpStatus.Series.SERVER_ERROR); - } - - /** - * Match the response status value with a Hamcrest matcher. - * @param matcher the matcher to use - * @since 5.1 - */ - public WebTestClient.ResponseSpec value(Matcher matcher) { - int actual = this.exchangeResult.getStatus().value(); - this.exchangeResult.assertWithDiagnostics(() -> MatcherAssert.assertThat("Response status", actual, matcher)); - return this.responseSpec; - } - - /** - * Consume the response status value as an integer. - * @param consumer the consumer to use - * @since 5.1 - */ - public WebTestClient.ResponseSpec value(Consumer consumer) { - int actual = this.exchangeResult.getStatus().value(); - this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(actual)); - return this.responseSpec; - } - - - private WebTestClient.ResponseSpec assertStatusAndReturn(HttpStatusCode expected) { - HttpStatusCode actual = this.exchangeResult.getStatus(); - this.exchangeResult.assertWithDiagnostics(() -> AssertionErrors.assertEquals("Status", expected, actual)); - return this.responseSpec; - } - - private WebTestClient.ResponseSpec assertSeriesAndReturn(HttpStatus.Series expected) { - HttpStatusCode status = this.exchangeResult.getStatus(); - HttpStatus.Series series = HttpStatus.Series.resolve(status.value()); - this.exchangeResult.assertWithDiagnostics(() -> - AssertionErrors.assertEquals("Range for response status value " + status, expected, series)); - return this.responseSpec; - } - } diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/XpathAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/XpathAssertions.java index 56c89bd4bf3c..1f7846928a1d 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/XpathAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/XpathAssertions.java @@ -16,198 +16,40 @@ package org.springframework.test.web.reactive.server; -import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.Optional; -import java.util.function.Consumer; -import javax.xml.xpath.XPathExpressionException; - -import org.hamcrest.Matcher; import org.jspecify.annotations.Nullable; import org.springframework.http.HttpHeaders; -import org.springframework.test.util.XpathExpectationsHelper; +import org.springframework.test.web.support.AbstractXpathAssertions; import org.springframework.util.Assert; -import org.springframework.util.MimeType; /** * XPath assertions for the {@link WebTestClient}. * * @author Eric Deandrea * @author Rossen Stoyanchev + * @author Rob Worsnop * @since 5.1 */ -public class XpathAssertions { - - private final WebTestClient.BodyContentSpec bodySpec; - - private final XpathExpectationsHelper xpathHelper; - +public class XpathAssertions extends AbstractXpathAssertions { XpathAssertions(WebTestClient.BodyContentSpec spec, - String expression, @Nullable Map namespaces, Object... args) { - - this.bodySpec = spec; - this.xpathHelper = initXpathHelper(expression, namespaces, args); - } - - private static XpathExpectationsHelper initXpathHelper( - String expression, @Nullable Map namespaces, Object[] args) { - - try { - return new XpathExpectationsHelper(expression, namespaces, args); - } - catch (XPathExpressionException ex) { - throw new AssertionError("XML parsing error", ex); - } - } - - - /** - * Delegates to {@link XpathExpectationsHelper#assertString(byte[], String, String)}. - */ - public WebTestClient.BodyContentSpec isEqualTo(String expectedValue) { - return assertWith(() -> this.xpathHelper.assertString(getContent(), getCharset(), expectedValue)); - } - - /** - * Delegates to {@link XpathExpectationsHelper#assertNumber(byte[], String, Double)}. - */ - public WebTestClient.BodyContentSpec isEqualTo(Double expectedValue) { - return assertWith(() -> this.xpathHelper.assertNumber(getContent(), getCharset(), expectedValue)); - } - - /** - * Delegates to {@link XpathExpectationsHelper#assertBoolean(byte[], String, boolean)}. - */ - public WebTestClient.BodyContentSpec isEqualTo(boolean expectedValue) { - return assertWith(() -> this.xpathHelper.assertBoolean(getContent(), getCharset(), expectedValue)); - } - - /** - * Delegates to {@link XpathExpectationsHelper#exists(byte[], String)}. - */ - public WebTestClient.BodyContentSpec exists() { - return assertWith(() -> this.xpathHelper.exists(getContent(), getCharset())); - } - - /** - * Delegates to {@link XpathExpectationsHelper#doesNotExist(byte[], String)}. - */ - public WebTestClient.BodyContentSpec doesNotExist() { - return assertWith(() -> this.xpathHelper.doesNotExist(getContent(), getCharset())); - } - - /** - * Delegates to {@link XpathExpectationsHelper#assertNodeCount(byte[], String, int)}. - */ - public WebTestClient.BodyContentSpec nodeCount(int expectedCount) { - return assertWith(() -> this.xpathHelper.assertNodeCount(getContent(), getCharset(), expectedCount)); - } - - /** - * Delegates to {@link XpathExpectationsHelper#assertString(byte[], String, Matcher)}. - * @since 5.1 - */ - public WebTestClient.BodyContentSpec string(Matcher matcher){ - return assertWith(() -> this.xpathHelper.assertString(getContent(), getCharset(), matcher)); - } - - /** - * Delegates to {@link XpathExpectationsHelper#assertNumber(byte[], String, Matcher)}. - * @since 5.1 - */ - public WebTestClient.BodyContentSpec number(Matcher matcher){ - return assertWith(() -> this.xpathHelper.assertNumber(getContent(), getCharset(), matcher)); + String expression, @Nullable Map namespaces, Object... args) { + super(spec, expression, namespaces, args); } - /** - * Delegates to {@link XpathExpectationsHelper#assertNodeCount(byte[], String, Matcher)}. - * @since 5.1 - */ - public WebTestClient.BodyContentSpec nodeCount(Matcher matcher){ - return assertWith(() -> this.xpathHelper.assertNodeCount(getContent(), getCharset(), matcher)); - } - - /** - * Consume the result of the XPath evaluation as a String. - * @since 5.1 - */ - public WebTestClient.BodyContentSpec string(Consumer consumer){ - return assertWith(() -> { - String value = this.xpathHelper.evaluateXpath(getContent(), getCharset(), String.class); - consumer.accept(value); - }); - } - - /** - * Consume the result of the XPath evaluation as a Double. - * @since 5.1 - */ - public WebTestClient.BodyContentSpec number(Consumer consumer){ - return assertWith(() -> { - Double value = this.xpathHelper.evaluateXpath(getContent(), getCharset(), Double.class); - consumer.accept(value); - }); - } - - /** - * Consume the count of nodes as result of the XPath evaluation. - * @since 5.1 - */ - public WebTestClient.BodyContentSpec nodeCount(Consumer consumer){ - return assertWith(() -> { - Integer value = this.xpathHelper.evaluateXpath(getContent(), getCharset(), Integer.class); - consumer.accept(value); - }); - } - - private WebTestClient.BodyContentSpec assertWith(CheckedExceptionTask task) { - try { - task.run(); - } - catch (Exception ex) { - throw new AssertionError("XML parsing error", ex); - } - return this.bodySpec; + @Override + protected Optional getResponseHeaders() { + return Optional.of(bodySpec.returnResult()) + .map(ExchangeResult::getResponseHeaders); } - private byte[] getContent() { + @Override + protected byte[] getContent() { byte[] body = this.bodySpec.returnResult().getResponseBody(); Assert.notNull(body, "Expected body content"); return body; } - - private String getCharset() { - return Optional.of(this.bodySpec.returnResult()) - .map(EntityExchangeResult::getResponseHeaders) - .map(HttpHeaders::getContentType) - .map(MimeType::getCharset) - .orElse(StandardCharsets.UTF_8) - .name(); - } - - - @Override - public boolean equals(@Nullable Object obj) { - throw new AssertionError("Object#equals is disabled " + - "to avoid being used in error instead of XPathAssertions#isEqualTo(String)."); - } - - @Override - public int hashCode() { - return super.hashCode(); - } - - - /** - * Lets us be able to use lambda expressions that could throw checked exceptions, since - * {@link XpathExpectationsHelper} throws {@link Exception} on its methods. - */ - private interface CheckedExceptionTask { - - void run() throws Exception; - - } } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/CookieAssertions.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/CookieAssertions.java index 3bfb787cd4d1..8ca598d30b7b 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/CookieAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/CookieAssertions.java @@ -16,221 +16,28 @@ package org.springframework.test.web.servlet.client; -import java.time.Duration; -import java.util.function.Consumer; - -import org.hamcrest.Matcher; -import org.hamcrest.MatcherAssert; - import org.springframework.http.ResponseCookie; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.springframework.test.util.AssertionErrors.assertEquals; -import static org.springframework.test.util.AssertionErrors.fail; +import org.springframework.test.web.support.AbstractCookieAssertions; +import org.springframework.util.MultiValueMap; /** * Assertions on cookies of the response. * * @author Rob Worsnop */ -public class CookieAssertions { - - private final ExchangeResult exchangeResult; - - private final RestTestClient.ResponseSpec responseSpec; +public class CookieAssertions extends AbstractCookieAssertions { public CookieAssertions(ExchangeResult exchangeResult, RestTestClient.ResponseSpec responseSpec) { - this.exchangeResult = exchangeResult; - this.responseSpec = responseSpec; - } - - - /** - * Expect a response cookie with the given name to match the specified value. - */ - public RestTestClient.ResponseSpec valueEquals(String name, String value) { - String cookieValue = getCookie(name).getValue(); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name); - assertEquals(message, value, cookieValue); - }); - return this.responseSpec; - } - - /** - * Assert the value of the response cookie with the given name with a Hamcrest - * {@link Matcher}. - */ - public RestTestClient.ResponseSpec value(String name, Matcher matcher) { - String value = getCookie(name).getValue(); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name); - MatcherAssert.assertThat(message, value, matcher); - }); - return this.responseSpec; - } - - /** - * Consume the value of the response cookie with the given name. - */ - public RestTestClient.ResponseSpec value(String name, Consumer consumer) { - String value = getCookie(name).getValue(); - this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(value)); - return this.responseSpec; - } - - /** - * Expect that the cookie with the given name is present. - */ - public RestTestClient.ResponseSpec exists(String name) { - getCookie(name); - return this.responseSpec; - } - - /** - * Expect that the cookie with the given name is not present. - */ - public RestTestClient.ResponseSpec doesNotExist(String name) { - ResponseCookie cookie = this.exchangeResult.getResponseCookies().getFirst(name); - if (cookie != null) { - String message = getMessage(name) + " exists with value=[" + cookie.getValue() + "]"; - this.exchangeResult.assertWithDiagnostics(() -> fail(message)); - } - return this.responseSpec; - } - - /** - * Assert a cookie's "Max-Age" attribute. - */ - public RestTestClient.ResponseSpec maxAge(String name, Duration expected) { - Duration maxAge = getCookie(name).getMaxAge(); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name) + " maxAge"; - assertEquals(message, expected, maxAge); - }); - return this.responseSpec; - } - - /** - * Assert a cookie's "Max-Age" attribute with a Hamcrest {@link Matcher}. - */ - public RestTestClient.ResponseSpec maxAge(String name, Matcher matcher) { - long maxAge = getCookie(name).getMaxAge().getSeconds(); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name) + " maxAge"; - assertThat(message, maxAge, matcher); - }); - return this.responseSpec; - } - - /** - * Assert a cookie's "Path" attribute. - */ - public RestTestClient.ResponseSpec path(String name, String expected) { - String path = getCookie(name).getPath(); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name) + " path"; - assertEquals(message, expected, path); - }); - return this.responseSpec; - } - - /** - * Assert a cookie's "Path" attribute with a Hamcrest {@link Matcher}. - */ - public RestTestClient.ResponseSpec path(String name, Matcher matcher) { - String path = getCookie(name).getPath(); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name) + " path"; - assertThat(message, path, matcher); - }); - return this.responseSpec; - } - - /** - * Assert a cookie's "Domain" attribute. - */ - public RestTestClient.ResponseSpec domain(String name, String expected) { - String path = getCookie(name).getDomain(); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name) + " domain"; - assertEquals(message, expected, path); - }); - return this.responseSpec; - } - - /** - * Assert a cookie's "Domain" attribute with a Hamcrest {@link Matcher}. - */ - public RestTestClient.ResponseSpec domain(String name, Matcher matcher) { - String domain = getCookie(name).getDomain(); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name) + " domain"; - assertThat(message, domain, matcher); - }); - return this.responseSpec; - } - - /** - * Assert a cookie's "Secure" attribute. - */ - public RestTestClient.ResponseSpec secure(String name, boolean expected) { - boolean isSecure = getCookie(name).isSecure(); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name) + " secure"; - assertEquals(message, expected, isSecure); - }); - return this.responseSpec; - } - - /** - * Assert a cookie's "HttpOnly" attribute. - */ - public RestTestClient.ResponseSpec httpOnly(String name, boolean expected) { - boolean isHttpOnly = getCookie(name).isHttpOnly(); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name) + " httpOnly"; - assertEquals(message, expected, isHttpOnly); - }); - return this.responseSpec; - } - - /** - * Assert a cookie's "Partitioned" attribute. - */ - public RestTestClient.ResponseSpec partitioned(String name, boolean expected) { - boolean isPartitioned = getCookie(name).isPartitioned(); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name) + " isPartitioned"; - assertEquals(message, expected, isPartitioned); - }); - return this.responseSpec; - } - - /** - * Assert a cookie's "SameSite" attribute. - */ - public RestTestClient.ResponseSpec sameSite(String name, String expected) { - String sameSite = getCookie(name).getSameSite(); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name) + " sameSite"; - assertEquals(message, expected, sameSite); - }); - return this.responseSpec; + super(exchangeResult, responseSpec); } - private ResponseCookie getCookie(String name) { - ResponseCookie cookie = this.exchangeResult.getResponseCookies().getFirst(name); - if (cookie != null) { - return cookie; - } - else { - this.exchangeResult.assertWithDiagnostics(() -> fail("No cookie with name '" + name + "'")); - } - throw new IllegalStateException("This code path should not be reachable"); + @Override + protected void assertWithDiagnostics(Runnable assertion) { + exchangeResult.assertWithDiagnostics(assertion); } - private static String getMessage(String cookie) { - return "Response cookie '" + cookie + "'"; + @Override + protected MultiValueMap getResponseCookies() { + return exchangeResult.getResponseCookies(); } } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/HeaderAssertions.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/HeaderAssertions.java index 577e6abef90a..9429ae85b369 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/HeaderAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/HeaderAssertions.java @@ -16,25 +16,8 @@ package org.springframework.test.web.servlet.client; -import java.net.URI; -import java.util.Arrays; -import java.util.List; -import java.util.function.Consumer; - -import org.hamcrest.Matcher; -import org.jspecify.annotations.Nullable; - -import org.springframework.http.CacheControl; -import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.util.CollectionUtils; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.springframework.test.util.AssertionErrors.assertEquals; -import static org.springframework.test.util.AssertionErrors.assertNotNull; -import static org.springframework.test.util.AssertionErrors.assertTrue; -import static org.springframework.test.util.AssertionErrors.fail; +import org.springframework.test.web.support.AbstractHeaderAssertions; /** * Assertions on headers of the response. @@ -42,270 +25,20 @@ * @author Rob Worsnop * @see RestTestClient.ResponseSpec#expectHeader() */ -public class HeaderAssertions { - - private final ExchangeResult exchangeResult; +public class HeaderAssertions extends AbstractHeaderAssertions { - private final RestTestClient.ResponseSpec responseSpec; public HeaderAssertions(ExchangeResult exchangeResult, RestTestClient.ResponseSpec responseSpec) { - this.exchangeResult = exchangeResult; - this.responseSpec = responseSpec; - } - - /** - * Expect a header with the given name to match the specified values. - */ - public RestTestClient.ResponseSpec valueEquals(String headerName, String... values) { - return assertHeader(headerName, Arrays.asList(values), getHeaders().getOrEmpty(headerName)); - } - - /** - * Expect a header with the given name to match the given long value. - */ - public RestTestClient.ResponseSpec valueEquals(String headerName, long value) { - String actual = getHeaders().getFirst(headerName); - this.exchangeResult.assertWithDiagnostics(() -> - assertNotNull("Response does not contain header '" + headerName + "'", actual)); - return assertHeader(headerName, value, Long.parseLong(actual)); - } - - /** - * Expect a header with the given name to match the specified long value - * parsed into a date using the preferred date format described in RFC 7231. - *

An {@link AssertionError} is thrown if the response does not contain - * the specified header, or if the supplied {@code value} does not match the - * primary header value. - */ - public RestTestClient.ResponseSpec valueEqualsDate(String headerName, long value) { - this.exchangeResult.assertWithDiagnostics(() -> { - String headerValue = getHeaders().getFirst(headerName); - assertNotNull("Response does not contain header '" + headerName + "'", headerValue); - - HttpHeaders headers = new HttpHeaders(); - headers.setDate("expected", value); - headers.set("actual", headerValue); - - assertEquals(getMessage(headerName) + "='" + headerValue + "' " + - "does not match expected value '" + headers.getFirst("expected") + "'", - headers.getFirstDate("expected"), headers.getFirstDate("actual")); - }); - return this.responseSpec; - } - - /** - * Match the first value of the response header with a regex. - * @param name the header name - * @param pattern the regex pattern - */ - public RestTestClient.ResponseSpec valueMatches(String name, String pattern) { - String value = getRequiredValue(name); - String message = getMessage(name) + "=[" + value + "] does not match [" + pattern + "]"; - this.exchangeResult.assertWithDiagnostics(() -> assertTrue(message, value.matches(pattern))); - return this.responseSpec; - } - - /** - * Match all values of the response header with the given regex - * patterns which are applied to the values of the header in the - * same order. Note that the number of patterns must match the - * number of actual values. - * @param name the header name - * @param patterns one or more regex patterns, one per expected value - */ - public RestTestClient.ResponseSpec valuesMatch(String name, String... patterns) { - List values = getRequiredValues(name); - this.exchangeResult.assertWithDiagnostics(() -> { - assertTrue( - getMessage(name) + " has fewer or more values " + values + - " than number of patterns to match with " + Arrays.toString(patterns), - values.size() == patterns.length); - for (int i = 0; i < values.size(); i++) { - String value = values.get(i); - String pattern = patterns[i]; - assertTrue( - getMessage(name) + "[" + i + "]='" + value + "' does not match '" + pattern + "'", - value.matches(pattern)); - } - }); - return this.responseSpec; - } - - /** - * Assert the first value of the response header with a Hamcrest {@link Matcher}. - * @param name the header name - * @param matcher the matcher to use - */ - public RestTestClient.ResponseSpec value(String name, Matcher matcher) { - String value = getHeaders().getFirst(name); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name); - assertThat(message, value, matcher); - }); - return this.responseSpec; - } - - /** - * Assert all values of the response header with a Hamcrest {@link Matcher}. - * @param name the header name - * @param matcher the matcher to use - */ - public RestTestClient.ResponseSpec values(String name, Matcher> matcher) { - List values = getHeaders().get(name); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name); - assertThat(message, values, matcher); - }); - return this.responseSpec; - } - - /** - * Consume the first value of the named response header. - * @param name the header name - * @param consumer the consumer to use - */ - public RestTestClient.ResponseSpec value(String name, Consumer consumer) { - String value = getRequiredValue(name); - this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(value)); - return this.responseSpec; - } - - /** - * Consume all values of the named response header. - * @param name the header name - * @param consumer the consumer to use - */ - public RestTestClient.ResponseSpec values(String name, Consumer> consumer) { - List values = getRequiredValues(name); - this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(values)); - return this.responseSpec; - } - - /** - * Expect that the header with the given name is present. - */ - public RestTestClient.ResponseSpec exists(String name) { - if (!this.exchangeResult.getResponseHeaders().containsHeader(name)) { - String message = getMessage(name) + " does not exist"; - this.exchangeResult.assertWithDiagnostics(() -> fail(message)); - } - return this.responseSpec; - } - - /** - * Expect that the header with the given name is not present. - */ - public RestTestClient.ResponseSpec doesNotExist(String name) { - if (getHeaders().containsHeader(name)) { - String message = getMessage(name) + " exists with value=[" + getHeaders().getFirst(name) + "]"; - this.exchangeResult.assertWithDiagnostics(() -> fail(message)); - } - return this.responseSpec; - } - - /** - * Expect a "Cache-Control" header with the given value. - */ - public RestTestClient.ResponseSpec cacheControl(CacheControl cacheControl) { - return assertHeader("Cache-Control", cacheControl.getHeaderValue(), getHeaders().getCacheControl()); - } - - /** - * Expect a "Content-Disposition" header with the given value. - */ - public RestTestClient.ResponseSpec contentDisposition(ContentDisposition contentDisposition) { - return assertHeader("Content-Disposition", contentDisposition, getHeaders().getContentDisposition()); - } - - /** - * Expect a "Content-Length" header with the given value. - */ - public RestTestClient.ResponseSpec contentLength(long contentLength) { - return assertHeader("Content-Length", contentLength, getHeaders().getContentLength()); - } - - /** - * Expect a "Content-Type" header with the given value. - */ - public RestTestClient.ResponseSpec contentType(MediaType mediaType) { - return assertHeader("Content-Type", mediaType, getHeaders().getContentType()); - } - - /** - * Expect a "Content-Type" header with the given value. - */ - public RestTestClient.ResponseSpec contentType(String mediaType) { - return contentType(MediaType.parseMediaType(mediaType)); - } - - /** - * Expect a "Content-Type" header compatible with the given value. - */ - public RestTestClient.ResponseSpec contentTypeCompatibleWith(MediaType mediaType) { - MediaType actual = getHeaders().getContentType(); - String message = getMessage("Content-Type") + "=[" + actual + "] is not compatible with [" + mediaType + "]"; - this.exchangeResult.assertWithDiagnostics(() -> - assertTrue(message, (actual != null && actual.isCompatibleWith(mediaType)))); - return this.responseSpec; - } - - /** - * Expect a "Content-Type" header compatible with the given value. - */ - public RestTestClient.ResponseSpec contentTypeCompatibleWith(String mediaType) { - return contentTypeCompatibleWith(MediaType.parseMediaType(mediaType)); - } - - /** - * Expect an "Expires" header with the given value. - */ - public RestTestClient.ResponseSpec expires(long expires) { - return assertHeader("Expires", expires, getHeaders().getExpires()); - } - - /** - * Expect a "Last-Modified" header with the given value. - */ - public RestTestClient.ResponseSpec lastModified(long lastModified) { - return assertHeader("Last-Modified", lastModified, getHeaders().getLastModified()); - } - - /** - * Expect a "Location" header with the given value. - */ - public RestTestClient.ResponseSpec location(String location) { - return assertHeader("Location", URI.create(location), getHeaders().getLocation()); - } - - - private HttpHeaders getHeaders() { - return this.exchangeResult.getResponseHeaders(); - } - - private String getRequiredValue(String name) { - return getRequiredValues(name).get(0); - } - - private List getRequiredValues(String name) { - List values = getHeaders().get(name); - if (!CollectionUtils.isEmpty(values)) { - return values; - } - else { - this.exchangeResult.assertWithDiagnostics(() -> fail(getMessage(name) + " not found")); - } - throw new IllegalStateException("This code path should not be reachable"); + super(exchangeResult, responseSpec); } - private RestTestClient.ResponseSpec assertHeader(String name, @Nullable Object expected, @Nullable Object actual) { - this.exchangeResult.assertWithDiagnostics(() -> { - String message = getMessage(name); - assertEquals(message, expected, actual); - }); - return this.responseSpec; + @Override + protected void assertWithDiagnostics(Runnable assertion) { + exchangeResult.assertWithDiagnostics(assertion); } - private static String getMessage(String headerName) { - return "Response header '" + headerName + "'"; + @Override + protected HttpHeaders getResponseHeaders() { + return exchangeResult.getResponseHeaders(); } } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/JsonPathAssertions.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/JsonPathAssertions.java index cf6174caa330..cb487bdd4cc8 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/JsonPathAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/JsonPathAssertions.java @@ -16,15 +16,11 @@ package org.springframework.test.web.servlet.client; -import java.util.function.Consumer; - import com.jayway.jsonpath.Configuration; -import org.hamcrest.Matcher; import org.jspecify.annotations.Nullable; -import org.springframework.core.ParameterizedTypeReference; import org.springframework.test.util.JsonPathExpectationsHelper; -import org.springframework.util.Assert; +import org.springframework.test.web.support.AbstractJsonPathAssertions; /** * JsonPath assertions. @@ -34,172 +30,9 @@ * @see https://github.com/jayway/JsonPath * @see JsonPathExpectationsHelper */ -public class JsonPathAssertions { - - private final RestTestClient.BodyContentSpec bodySpec; - - private final String content; - - private final JsonPathExpectationsHelper pathHelper; - +public class JsonPathAssertions extends AbstractJsonPathAssertions { JsonPathAssertions(RestTestClient.BodyContentSpec spec, String content, String expression, @Nullable Configuration configuration) { - Assert.hasText(expression, "expression must not be null or empty"); - this.bodySpec = spec; - this.content = content; - this.pathHelper = new JsonPathExpectationsHelper(expression, configuration); - } - - - /** - * Applies {@link JsonPathExpectationsHelper#assertValue(String, Object)}. - */ - public RestTestClient.BodyContentSpec isEqualTo(Object expectedValue) { - this.pathHelper.assertValue(this.content, expectedValue); - return this.bodySpec; - } - - /** - * Applies {@link JsonPathExpectationsHelper#exists(String)}. - */ - public RestTestClient.BodyContentSpec exists() { - this.pathHelper.exists(this.content); - return this.bodySpec; - } - - /** - * Applies {@link JsonPathExpectationsHelper#doesNotExist(String)}. - */ - public RestTestClient.BodyContentSpec doesNotExist() { - this.pathHelper.doesNotExist(this.content); - return this.bodySpec; + super(spec, content, expression, configuration); } - - /** - * Applies {@link JsonPathExpectationsHelper#assertValueIsEmpty(String)}. - */ - public RestTestClient.BodyContentSpec isEmpty() { - this.pathHelper.assertValueIsEmpty(this.content); - return this.bodySpec; - } - - /** - * Applies {@link JsonPathExpectationsHelper#assertValueIsNotEmpty(String)}. - */ - public RestTestClient.BodyContentSpec isNotEmpty() { - this.pathHelper.assertValueIsNotEmpty(this.content); - return this.bodySpec; - } - - /** - * Applies {@link JsonPathExpectationsHelper#hasJsonPath}. - */ - public RestTestClient.BodyContentSpec hasJsonPath() { - this.pathHelper.hasJsonPath(this.content); - return this.bodySpec; - } - - /** - * Applies {@link JsonPathExpectationsHelper#doesNotHaveJsonPath}. - */ - public RestTestClient.BodyContentSpec doesNotHaveJsonPath() { - this.pathHelper.doesNotHaveJsonPath(this.content); - return this.bodySpec; - } - - /** - * Applies {@link JsonPathExpectationsHelper#assertValueIsBoolean(String)}. - */ - public RestTestClient.BodyContentSpec isBoolean() { - this.pathHelper.assertValueIsBoolean(this.content); - return this.bodySpec; - } - - /** - * Applies {@link JsonPathExpectationsHelper#assertValueIsNumber(String)}. - */ - public RestTestClient.BodyContentSpec isNumber() { - this.pathHelper.assertValueIsNumber(this.content); - return this.bodySpec; - } - - /** - * Applies {@link JsonPathExpectationsHelper#assertValueIsArray(String)}. - */ - public RestTestClient.BodyContentSpec isArray() { - this.pathHelper.assertValueIsArray(this.content); - return this.bodySpec; - } - - /** - * Applies {@link JsonPathExpectationsHelper#assertValueIsMap(String)}. - */ - public RestTestClient.BodyContentSpec isMap() { - this.pathHelper.assertValueIsMap(this.content); - return this.bodySpec; - } - - /** - * Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher)}. - */ - public RestTestClient.BodyContentSpec value(Matcher matcher) { - this.pathHelper.assertValue(this.content, matcher); - return this.bodySpec; - } - - /** - * Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher, Class)}. - */ - public RestTestClient.BodyContentSpec value(Class targetType, Matcher matcher) { - this.pathHelper.assertValue(this.content, matcher, targetType); - return this.bodySpec; - } - - /** - * Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher, ParameterizedTypeReference)}. - */ - public RestTestClient.BodyContentSpec value(ParameterizedTypeReference targetType, Matcher matcher) { - this.pathHelper.assertValue(this.content, matcher, targetType); - return this.bodySpec; - } - - /** - * Consume the result of the JSONPath evaluation. - */ - @SuppressWarnings("unchecked") - public RestTestClient.BodyContentSpec value(Consumer consumer) { - Object value = this.pathHelper.evaluateJsonPath(this.content); - consumer.accept((T) value); - return this.bodySpec; - } - - /** - * Consume the result of the JSONPath evaluation and provide a target class. - */ - public RestTestClient.BodyContentSpec value(Class targetType, Consumer consumer) { - T value = this.pathHelper.evaluateJsonPath(this.content, targetType); - consumer.accept(value); - return this.bodySpec; - } - - /** - * Consume the result of the JSONPath evaluation and provide a parameterized type. - */ - public RestTestClient.BodyContentSpec value(ParameterizedTypeReference targetType, Consumer consumer) { - T value = this.pathHelper.evaluateJsonPath(this.content, targetType); - consumer.accept(value); - return this.bodySpec; - } - - @Override - public boolean equals(@Nullable Object obj) { - throw new AssertionError("Object#equals is disabled " + - "to avoid being used in error instead of JsonPathAssertions#isEqualTo(String)."); - } - - @Override - public int hashCode() { - return super.hashCode(); - } - } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/StatusAssertions.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/StatusAssertions.java index 3fb58d6dd85d..debc3a4d3e6f 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/StatusAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/StatusAssertions.java @@ -16,17 +16,9 @@ package org.springframework.test.web.servlet.client; -import java.util.function.Consumer; - -import org.hamcrest.Matcher; -import org.hamcrest.MatcherAssert; - -import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; -import org.springframework.test.util.AssertionErrors; import org.springframework.test.web.servlet.client.RestTestClient.ResponseSpec; - -import static org.springframework.test.util.AssertionErrors.assertNotNull; +import org.springframework.test.web.support.AbstractStatusAssertions; /** * Assertions on the response status. @@ -35,216 +27,19 @@ * * @see ResponseSpec#expectStatus() */ -public class StatusAssertions { - - private final ExchangeResult exchangeResult; - - private final ResponseSpec responseSpec; +public class StatusAssertions extends AbstractStatusAssertions { public StatusAssertions(ExchangeResult exchangeResult, ResponseSpec responseSpec) { - this.exchangeResult = exchangeResult; - this.responseSpec = responseSpec; - } - - - /** - * Assert the response status as an {@link HttpStatusCode}. - */ - public RestTestClient.ResponseSpec isEqualTo(HttpStatusCode status) { - HttpStatusCode actual = this.exchangeResult.getStatus(); - this.exchangeResult.assertWithDiagnostics(() -> AssertionErrors.assertEquals("Status", status, actual)); - return this.responseSpec; - } - - /** - * Assert the response status as an integer. - */ - public RestTestClient.ResponseSpec isEqualTo(int status) { - return isEqualTo(HttpStatusCode.valueOf(status)); - } - - /** - * Assert the response status code is {@code HttpStatus.OK} (200). - */ - public RestTestClient.ResponseSpec isOk() { - return assertStatusAndReturn(HttpStatus.OK); - } - - /** - * Assert the response status code is {@code HttpStatus.CREATED} (201). - */ - public RestTestClient.ResponseSpec isCreated() { - return assertStatusAndReturn(HttpStatus.CREATED); - } - - /** - * Assert the response status code is {@code HttpStatus.ACCEPTED} (202). - */ - public RestTestClient.ResponseSpec isAccepted() { - return assertStatusAndReturn(HttpStatus.ACCEPTED); - } - - /** - * Assert the response status code is {@code HttpStatus.NO_CONTENT} (204). - */ - public RestTestClient.ResponseSpec isNoContent() { - return assertStatusAndReturn(HttpStatus.NO_CONTENT); - } - - /** - * Assert the response status code is {@code HttpStatus.FOUND} (302). - */ - public RestTestClient.ResponseSpec isFound() { - return assertStatusAndReturn(HttpStatus.FOUND); - } - - /** - * Assert the response status code is {@code HttpStatus.SEE_OTHER} (303). - */ - public RestTestClient.ResponseSpec isSeeOther() { - return assertStatusAndReturn(HttpStatus.SEE_OTHER); - } - - /** - * Assert the response status code is {@code HttpStatus.NOT_MODIFIED} (304). - */ - public RestTestClient.ResponseSpec isNotModified() { - return assertStatusAndReturn(HttpStatus.NOT_MODIFIED); - } - - /** - * Assert the response status code is {@code HttpStatus.TEMPORARY_REDIRECT} (307). - */ - public RestTestClient.ResponseSpec isTemporaryRedirect() { - return assertStatusAndReturn(HttpStatus.TEMPORARY_REDIRECT); - } - - /** - * Assert the response status code is {@code HttpStatus.PERMANENT_REDIRECT} (308). - */ - public RestTestClient.ResponseSpec isPermanentRedirect() { - return assertStatusAndReturn(HttpStatus.PERMANENT_REDIRECT); + super(exchangeResult, responseSpec); } - /** - * Assert the response status code is {@code HttpStatus.BAD_REQUEST} (400). - */ - public RestTestClient.ResponseSpec isBadRequest() { - return assertStatusAndReturn(HttpStatus.BAD_REQUEST); - } - - /** - * Assert the response status code is {@code HttpStatus.UNAUTHORIZED} (401). - */ - public RestTestClient.ResponseSpec isUnauthorized() { - return assertStatusAndReturn(HttpStatus.UNAUTHORIZED); - } - - /** - * Assert the response status code is {@code HttpStatus.FORBIDDEN} (403). - * @since 5.0.2 - */ - public RestTestClient.ResponseSpec isForbidden() { - return assertStatusAndReturn(HttpStatus.FORBIDDEN); - } - - /** - * Assert the response status code is {@code HttpStatus.NOT_FOUND} (404). - */ - public RestTestClient.ResponseSpec isNotFound() { - return assertStatusAndReturn(HttpStatus.NOT_FOUND); - } - - /** - * Assert the response error message. - */ - public RestTestClient.ResponseSpec reasonEquals(String reason) { - String actual = getReasonPhrase(this.exchangeResult.getStatus()); - this.exchangeResult.assertWithDiagnostics(() -> - AssertionErrors.assertEquals("Response status reason", reason, actual)); - return this.responseSpec; - } - - private static String getReasonPhrase(HttpStatusCode statusCode) { - if (statusCode instanceof HttpStatus status) { - return status.getReasonPhrase(); - } - else { - return ""; - } - } - - - /** - * Assert the response status code is in the 1xx range. - */ - public RestTestClient.ResponseSpec is1xxInformational() { - return assertSeriesAndReturn(HttpStatus.Series.INFORMATIONAL); - } - - /** - * Assert the response status code is in the 2xx range. - */ - public RestTestClient.ResponseSpec is2xxSuccessful() { - return assertSeriesAndReturn(HttpStatus.Series.SUCCESSFUL); - } - - /** - * Assert the response status code is in the 3xx range. - */ - public RestTestClient.ResponseSpec is3xxRedirection() { - return assertSeriesAndReturn(HttpStatus.Series.REDIRECTION); - } - - /** - * Assert the response status code is in the 4xx range. - */ - public RestTestClient.ResponseSpec is4xxClientError() { - return assertSeriesAndReturn(HttpStatus.Series.CLIENT_ERROR); - } - - /** - * Assert the response status code is in the 5xx range. - */ - public RestTestClient.ResponseSpec is5xxServerError() { - return assertSeriesAndReturn(HttpStatus.Series.SERVER_ERROR); - } - - /** - * Match the response status value with a Hamcrest matcher. - * @param matcher the matcher to use - * @since 5.1 - */ - public RestTestClient.ResponseSpec value(Matcher matcher) { - int actual = this.exchangeResult.getStatus().value(); - this.exchangeResult.assertWithDiagnostics(() -> MatcherAssert.assertThat("Response status", actual, matcher)); - return this.responseSpec; - } - - /** - * Consume the response status value as an integer. - * @param consumer the consumer to use - * @since 5.1 - */ - public RestTestClient.ResponseSpec value(Consumer consumer) { - int actual = this.exchangeResult.getStatus().value(); - this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(actual)); - return this.responseSpec; - } - - - private ResponseSpec assertStatusAndReturn(HttpStatus expected) { - assertNotNull("exchangeResult unexpectedly null", this.exchangeResult); - HttpStatusCode actual = this.exchangeResult.getStatus(); - this.exchangeResult.assertWithDiagnostics(() -> AssertionErrors.assertEquals("Status", expected, actual)); - return this.responseSpec; + @Override + protected void assertWithDiagnostics(Runnable assertion) { + exchangeResult.assertWithDiagnostics(assertion); } - private RestTestClient.ResponseSpec assertSeriesAndReturn(HttpStatus.Series expected) { - HttpStatusCode status = this.exchangeResult.getStatus(); - HttpStatus.Series series = HttpStatus.Series.resolve(status.value()); - this.exchangeResult.assertWithDiagnostics(() -> - AssertionErrors.assertEquals("Range for response status value " + status, expected, series)); - return this.responseSpec; + @Override + protected HttpStatusCode getStatus() { + return exchangeResult.getStatus(); } } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/XpathAssertions.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/XpathAssertions.java index f52ea100a272..d4bbaa27406f 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/XpathAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/XpathAssertions.java @@ -16,190 +16,37 @@ package org.springframework.test.web.servlet.client; -import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.Optional; -import java.util.function.Consumer; -import javax.xml.xpath.XPathExpressionException; - -import org.hamcrest.Matcher; import org.jspecify.annotations.Nullable; import org.springframework.http.HttpHeaders; -import org.springframework.test.util.XpathExpectationsHelper; +import org.springframework.test.web.support.AbstractXpathAssertions; import org.springframework.util.Assert; -import org.springframework.util.MimeType; /** * XPath assertions for the {@link RestTestClient}. * * @author Rob Worsnop */ -public class XpathAssertions { - - private final RestTestClient.BodyContentSpec bodySpec; - - private final XpathExpectationsHelper xpathHelper; - +public class XpathAssertions extends AbstractXpathAssertions { XpathAssertions(RestTestClient.BodyContentSpec spec, String expression, @Nullable Map namespaces, Object... args) { - - this.bodySpec = spec; - this.xpathHelper = initXpathHelper(expression, namespaces, args); - } - - private static XpathExpectationsHelper initXpathHelper( - String expression, @Nullable Map namespaces, Object[] args) { - - try { - return new XpathExpectationsHelper(expression, namespaces, args); - } - catch (XPathExpressionException ex) { - throw new AssertionError("XML parsing error", ex); - } - } - - - /** - * Delegates to {@link XpathExpectationsHelper#assertString(byte[], String, String)}. - */ - public RestTestClient.BodyContentSpec isEqualTo(String expectedValue) { - return assertWith(() -> this.xpathHelper.assertString(getContent(), getCharset(), expectedValue)); - } - - /** - * Delegates to {@link XpathExpectationsHelper#assertNumber(byte[], String, Double)}. - */ - public RestTestClient.BodyContentSpec isEqualTo(Double expectedValue) { - return assertWith(() -> this.xpathHelper.assertNumber(getContent(), getCharset(), expectedValue)); - } - - /** - * Delegates to {@link XpathExpectationsHelper#assertBoolean(byte[], String, boolean)}. - */ - public RestTestClient.BodyContentSpec isEqualTo(boolean expectedValue) { - return assertWith(() -> this.xpathHelper.assertBoolean(getContent(), getCharset(), expectedValue)); - } - - /** - * Delegates to {@link XpathExpectationsHelper#exists(byte[], String)}. - */ - public RestTestClient.BodyContentSpec exists() { - return assertWith(() -> this.xpathHelper.exists(getContent(), getCharset())); - } - - /** - * Delegates to {@link XpathExpectationsHelper#doesNotExist(byte[], String)}. - */ - public RestTestClient.BodyContentSpec doesNotExist() { - return assertWith(() -> this.xpathHelper.doesNotExist(getContent(), getCharset())); - } - - /** - * Delegates to {@link XpathExpectationsHelper#assertNodeCount(byte[], String, int)}. - */ - public RestTestClient.BodyContentSpec nodeCount(int expectedCount) { - return assertWith(() -> this.xpathHelper.assertNodeCount(getContent(), getCharset(), expectedCount)); - } - - /** - * Delegates to {@link XpathExpectationsHelper#assertString(byte[], String, Matcher)}. - */ - public RestTestClient.BodyContentSpec string(Matcher matcher){ - return assertWith(() -> this.xpathHelper.assertString(getContent(), getCharset(), matcher)); - } - - /** - * Delegates to {@link XpathExpectationsHelper#assertNumber(byte[], String, Matcher)}. - */ - public RestTestClient.BodyContentSpec number(Matcher matcher){ - return assertWith(() -> this.xpathHelper.assertNumber(getContent(), getCharset(), matcher)); + super(spec, expression, namespaces, args); } - /** - * Delegates to {@link XpathExpectationsHelper#assertNodeCount(byte[], String, Matcher)}. - */ - public RestTestClient.BodyContentSpec nodeCount(Matcher matcher){ - return assertWith(() -> this.xpathHelper.assertNodeCount(getContent(), getCharset(), matcher)); - } - - /** - * Consume the result of the XPath evaluation as a String. - */ - public RestTestClient.BodyContentSpec string(Consumer consumer){ - return assertWith(() -> { - String value = this.xpathHelper.evaluateXpath(getContent(), getCharset(), String.class); - consumer.accept(value); - }); - } - - /** - * Consume the result of the XPath evaluation as a Double. - */ - public RestTestClient.BodyContentSpec number(Consumer consumer){ - return assertWith(() -> { - Double value = this.xpathHelper.evaluateXpath(getContent(), getCharset(), Double.class); - consumer.accept(value); - }); - } - - /** - * Consume the count of nodes as result of the XPath evaluation. - */ - public RestTestClient.BodyContentSpec nodeCount(Consumer consumer){ - return assertWith(() -> { - Integer value = this.xpathHelper.evaluateXpath(getContent(), getCharset(), Integer.class); - consumer.accept(value); - }); - } - - private RestTestClient.BodyContentSpec assertWith(CheckedExceptionTask task) { - try { - task.run(); - } - catch (Exception ex) { - throw new AssertionError("XML parsing error", ex); - } - return this.bodySpec; + @Override + protected Optional getResponseHeaders() { + return Optional.of(bodySpec.returnResult()) + .map(ExchangeResult::getResponseHeaders); } - private byte[] getContent() { + @Override + protected byte[] getContent() { byte[] body = this.bodySpec.returnResult().getResponseBody(); Assert.notNull(body, "Expected body content"); return body; } - - private String getCharset() { - return Optional.of(this.bodySpec.returnResult()) - .map(EntityExchangeResult::getResponseHeaders) - .map(HttpHeaders::getContentType) - .map(MimeType::getCharset) - .orElse(StandardCharsets.UTF_8) - .name(); - } - - - @Override - public boolean equals(@Nullable Object obj) { - throw new AssertionError("Object#equals is disabled " + - "to avoid being used in error instead of XPathAssertions#isEqualTo(String)."); - } - - @Override - public int hashCode() { - return super.hashCode(); - } - - - /** - * Lets us be able to use lambda expressions that could throw checked exceptions, since - * {@link XpathExpectationsHelper} throws {@link Exception} on its methods. - */ - private interface CheckedExceptionTask { - - void run() throws Exception; - - } } diff --git a/spring-test/src/main/java/org/springframework/test/web/support/AbstractCookieAssertions.java b/spring-test/src/main/java/org/springframework/test/web/support/AbstractCookieAssertions.java new file mode 100644 index 000000000000..0527572610f0 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/support/AbstractCookieAssertions.java @@ -0,0 +1,245 @@ +/* + * Copyright 2002-present 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.test.web.support; + +import java.time.Duration; +import java.util.function.Consumer; + +import org.hamcrest.Matcher; +import org.hamcrest.MatcherAssert; + +import org.springframework.http.ResponseCookie; +import org.springframework.test.util.AssertionErrors; +import org.springframework.util.MultiValueMap; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.fail; + +/** + * Assertions on cookies of the response. + * + * @author Rob Worsnop + * @since 7.0 + * @param the type of the exchange result + * @param the type of the response spec + */ +public abstract class AbstractCookieAssertions { + protected final E exchangeResult; + private final R responseSpec; + + protected AbstractCookieAssertions(E exchangeResult, R responseSpec) { + this.exchangeResult = exchangeResult; + this.responseSpec = responseSpec; + } + + /** + * Expect a response cookie with the given name to match the specified value. + */ + public R valueEquals(String name, String value) { + ResponseCookie cookie = getCookie(name); + String cookieValue = cookie.getValue(); + assertWithDiagnostics(() -> { + String message = getMessage(name); + AssertionErrors.assertEquals(message, value, cookieValue); + }); + return this.responseSpec; + } + + /** + * Assert the value of the response cookie with the given name with a Hamcrest + * {@link Matcher}. + */ + public R value(String name, Matcher matcher) { + String value = getCookie(name).getValue(); + assertWithDiagnostics(() -> { + String message = getMessage(name); + MatcherAssert.assertThat(message, value, matcher); + }); + return this.responseSpec; + } + + /** + * Consume the value of the response cookie with the given name. + */ + public R value(String name, Consumer consumer) { + String value = getCookie(name).getValue(); + assertWithDiagnostics(() -> consumer.accept(value)); + return this.responseSpec; + } + + /** + * Expect that the cookie with the given name is present. + */ + public R exists(String name) { + getCookie(name); + return this.responseSpec; + } + + /** + * Expect that the cookie with the given name is not present. + */ + public R doesNotExist(String name) { + ResponseCookie cookie = getResponseCookies().getFirst(name); + if (cookie != null) { + String message = getMessage(name) + " exists with value=[" + cookie.getValue() + "]"; + assertWithDiagnostics(() -> fail(message)); + } + return this.responseSpec; + } + + /** + * Assert a cookie's "Max-Age" attribute. + */ + public R maxAge(String name, Duration expected) { + Duration maxAge = getCookie(name).getMaxAge(); + assertWithDiagnostics(() -> { + String message = getMessage(name) + " maxAge"; + assertEquals(message, expected, maxAge); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "Max-Age" attribute with a Hamcrest {@link Matcher}. + */ + public R maxAge(String name, Matcher matcher) { + long maxAge = getCookie(name).getMaxAge().getSeconds(); + assertWithDiagnostics(() -> { + String message = getMessage(name) + " maxAge"; + assertThat(message, maxAge, matcher); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "Path" attribute. + */ + public R path(String name, String expected) { + String path = getCookie(name).getPath(); + assertWithDiagnostics(() -> { + String message = getMessage(name) + " path"; + assertEquals(message, expected, path); + }); + return this.responseSpec; + } + + + + /** + * Assert a cookie's "Path" attribute with a Hamcrest {@link Matcher}. + */ + public R path(String name, Matcher matcher) { + String path = getCookie(name).getPath(); + assertWithDiagnostics(() -> { + String message = getMessage(name) + " path"; + assertThat(message, path, matcher); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "Domain" attribute. + */ + public R domain(String name, String expected) { + String path = getCookie(name).getDomain(); + assertWithDiagnostics(() -> { + String message = getMessage(name) + " domain"; + assertEquals(message, expected, path); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "Domain" attribute with a Hamcrest {@link Matcher}. + */ + public R domain(String name, Matcher matcher) { + String domain = getCookie(name).getDomain(); + assertWithDiagnostics(() -> { + String message = getMessage(name) + " domain"; + assertThat(message, domain, matcher); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "Secure" attribute. + */ + public R secure(String name, boolean expected) { + boolean isSecure = getCookie(name).isSecure(); + assertWithDiagnostics(() -> { + String message = getMessage(name) + " secure"; + assertEquals(message, expected, isSecure); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "HttpOnly" attribute. + */ + public R httpOnly(String name, boolean expected) { + boolean isHttpOnly = getCookie(name).isHttpOnly(); + assertWithDiagnostics(() -> { + String message = getMessage(name) + " httpOnly"; + assertEquals(message, expected, isHttpOnly); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "Partitioned" attribute. + */ + public R partitioned(String name, boolean expected) { + boolean isPartitioned = getCookie(name).isPartitioned(); + assertWithDiagnostics(() -> { + String message = getMessage(name) + " isPartitioned"; + assertEquals(message, expected, isPartitioned); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "SameSite" attribute. + */ + public R sameSite(String name, String expected) { + String sameSite = getCookie(name).getSameSite(); + assertWithDiagnostics(() -> { + String message = getMessage(name) + " sameSite"; + assertEquals(message, expected, sameSite); + }); + return this.responseSpec; + } + + protected abstract void assertWithDiagnostics(Runnable assertion); + + protected abstract MultiValueMap getResponseCookies(); + + private ResponseCookie getCookie(String name) { + ResponseCookie cookie = getResponseCookies().getFirst(name); + if (cookie != null) { + return cookie; + } + else { + assertWithDiagnostics(() -> fail("No cookie with name '" + name + "'")); + } + throw new IllegalStateException("This code path should not be reachable"); + } + + private static String getMessage(String cookie) { + return "Response cookie '" + cookie + "'"; + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/support/AbstractHeaderAssertions.java b/spring-test/src/main/java/org/springframework/test/web/support/AbstractHeaderAssertions.java new file mode 100644 index 000000000000..a10aada91ce0 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/support/AbstractHeaderAssertions.java @@ -0,0 +1,310 @@ +/* + * Copyright 2002-present 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.test.web.support; + +import java.net.URI; +import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; + +import org.hamcrest.Matcher; +import org.jspecify.annotations.Nullable; + +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.util.CollectionUtils; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.assertNotNull; +import static org.springframework.test.util.AssertionErrors.assertTrue; +import static org.springframework.test.util.AssertionErrors.fail; + +/** + * Assertions on headers of the response. + * + * @author Rob Worsnop + * @since 7.0 + * @param the type of the exchange result + * @param the type of the response spec + */ +public abstract class AbstractHeaderAssertions { + protected final E exchangeResult; + private final R responseSpec; + + protected AbstractHeaderAssertions(E exchangeResult, R responseSpec) { + this.exchangeResult = exchangeResult; + this.responseSpec = responseSpec; + } + + /** + * Expect a header with the given name to match the specified values. + */ + public R valueEquals(String headerName, String... values) { + return assertHeader(headerName, Arrays.asList(values), getResponseHeaders().getOrEmpty(headerName)); + } + + /** + * Expect a header with the given name to match the given long value. + */ + public R valueEquals(String headerName, long value) { + String actual = getResponseHeaders().getFirst(headerName); + assertWithDiagnostics(() -> + assertNotNull("Response does not contain header '" + headerName + "'", actual)); + return assertHeader(headerName, value, Long.parseLong(actual)); + } + + /** + * Expect a header with the given name to match the specified long value + * parsed into a date using the preferred date format described in RFC 7231. + *

An {@link AssertionError} is thrown if the response does not contain + * the specified header, or if the supplied {@code value} does not match the + * primary header value. + */ + public R valueEqualsDate(String headerName, long value) { + assertWithDiagnostics(() -> { + String headerValue = getResponseHeaders().getFirst(headerName); + assertNotNull("Response does not contain header '" + headerName + "'", headerValue); + + HttpHeaders headers = new HttpHeaders(); + headers.setDate("expected", value); + headers.set("actual", headerValue); + + assertEquals(getMessage(headerName) + "='" + headerValue + "' " + + "does not match expected value '" + headers.getFirst("expected") + "'", + headers.getFirstDate("expected"), headers.getFirstDate("actual")); + }); + return this.responseSpec; + } + + /** + * Match the first value of the response header with a regex. + * @param name the header name + * @param pattern the regex pattern + */ + public R valueMatches(String name, String pattern) { + String value = getRequiredValue(name); + String message = getMessage(name) + "=[" + value + "] does not match [" + pattern + "]"; + assertWithDiagnostics(() -> assertTrue(message, value.matches(pattern))); + return this.responseSpec; + } + + /** + * Match all values of the response header with the given regex + * patterns which are applied to the values of the header in the + * same order. Note that the number of patterns must match the + * number of actual values. + * @param name the header name + * @param patterns one or more regex patterns, one per expected value + */ + public R valuesMatch(String name, String... patterns) { + List values = getRequiredValues(name); + assertWithDiagnostics(() -> { + assertTrue( + getMessage(name) + " has fewer or more values " + values + + " than number of patterns to match with " + Arrays.toString(patterns), + values.size() == patterns.length); + for (int i = 0; i < values.size(); i++) { + String value = values.get(i); + String pattern = patterns[i]; + assertTrue( + getMessage(name) + "[" + i + "]='" + value + "' does not match '" + pattern + "'", + value.matches(pattern)); + } + }); + return this.responseSpec; + } + + /** + * Assert the first value of the response header with a Hamcrest {@link Matcher}. + * @param name the header name + * @param matcher the matcher to use + */ + public R value(String name, Matcher matcher) { + String value = getResponseHeaders().getFirst(name); + assertWithDiagnostics(() -> { + String message = getMessage(name); + assertThat(message, value, matcher); + }); + return this.responseSpec; + } + + /** + * Assert all values of the response header with a Hamcrest {@link Matcher}. + * @param name the header name + * @param matcher the matcher to use + */ + public R values(String name, Matcher> matcher) { + List values = getResponseHeaders().get(name); + assertWithDiagnostics(() -> { + String message = getMessage(name); + assertThat(message, values, matcher); + }); + return this.responseSpec; + } + + /** + * Consume the first value of the named response header. + * @param name the header name + * @param consumer the consumer to use + */ + public R value(String name, Consumer consumer) { + String value = getRequiredValue(name); + assertWithDiagnostics(() -> consumer.accept(value)); + return this.responseSpec; + } + + /** + * Consume all values of the named response header. + * @param name the header name + * @param consumer the consumer to use + */ + public R values(String name, Consumer> consumer) { + List values = getRequiredValues(name); + assertWithDiagnostics(() -> consumer.accept(values)); + return this.responseSpec; + } + + /** + * Expect that the header with the given name is present. + */ + public R exists(String name) { + if (!getResponseHeaders().containsHeader(name)) { + String message = getMessage(name) + " does not exist"; + assertWithDiagnostics(() -> fail(message)); + } + return this.responseSpec; + } + + /** + * Expect that the header with the given name is not present. + */ + public R doesNotExist(String name) { + if (getResponseHeaders().containsHeader(name)) { + String message = getMessage(name) + " exists with value=[" + getResponseHeaders().getFirst(name) + "]"; + assertWithDiagnostics(() -> fail(message)); + } + return this.responseSpec; + } + + /** + * Expect a "Cache-Control" header with the given value. + */ + public R cacheControl(CacheControl cacheControl) { + return assertHeader("Cache-Control", cacheControl.getHeaderValue(), getResponseHeaders().getCacheControl()); + } + + /** + * Expect a "Content-Disposition" header with the given value. + */ + public R contentDisposition(ContentDisposition contentDisposition) { + return assertHeader("Content-Disposition", contentDisposition, getResponseHeaders().getContentDisposition()); + } + + /** + * Expect a "Content-Length" header with the given value. + */ + public R contentLength(long contentLength) { + return assertHeader("Content-Length", contentLength, getResponseHeaders().getContentLength()); + } + + /** + * Expect a "Content-Type" header with the given value. + */ + public R contentType(MediaType mediaType) { + return assertHeader("Content-Type", mediaType, getResponseHeaders().getContentType()); + } + + /** + * Expect a "Content-Type" header with the given value. + */ + public R contentType(String mediaType) { + return contentType(MediaType.parseMediaType(mediaType)); + } + + /** + * Expect a "Content-Type" header compatible with the given value. + */ + public R contentTypeCompatibleWith(MediaType mediaType) { + MediaType actual = getResponseHeaders().getContentType(); + String message = getMessage("Content-Type") + "=[" + actual + "] is not compatible with [" + mediaType + "]"; + assertWithDiagnostics(() -> + assertTrue(message, (actual != null && actual.isCompatibleWith(mediaType)))); + return this.responseSpec; + } + + /** + * Expect a "Content-Type" header compatible with the given value. + */ + public R contentTypeCompatibleWith(String mediaType) { + return contentTypeCompatibleWith(MediaType.parseMediaType(mediaType)); + } + + /** + * Expect an "Expires" header with the given value. + */ + public R expires(long expires) { + return assertHeader("Expires", expires, getResponseHeaders().getExpires()); + } + + /** + * Expect a "Last-Modified" header with the given value. + */ + public R lastModified(long lastModified) { + return assertHeader("Last-Modified", lastModified, getResponseHeaders().getLastModified()); + } + + /** + * Expect a "Location" header with the given value. + */ + public R location(String location) { + return assertHeader("Location", URI.create(location), getResponseHeaders().getLocation()); + } + + protected abstract void assertWithDiagnostics(Runnable assertion); + + protected abstract HttpHeaders getResponseHeaders(); + + private R assertHeader(String name, @Nullable Object expected, @Nullable Object actual) { + assertWithDiagnostics(() -> { + String message = getMessage(name); + assertEquals(message, expected, actual); + }); + return this.responseSpec; + } + + private String getRequiredValue(String name) { + return getRequiredValues(name).get(0); + } + + private List getRequiredValues(String name) { + List values = getResponseHeaders().get(name); + if (!CollectionUtils.isEmpty(values)) { + return values; + } + else { + assertWithDiagnostics(() -> fail(getMessage(name) + " not found")); + } + throw new IllegalStateException("This code path should not be reachable"); + } + + private static String getMessage(String headerName) { + return "Response header '" + headerName + "'"; + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/support/AbstractJsonPathAssertions.java b/spring-test/src/main/java/org/springframework/test/web/support/AbstractJsonPathAssertions.java new file mode 100644 index 000000000000..a34facd138cd --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/support/AbstractJsonPathAssertions.java @@ -0,0 +1,195 @@ +/* + * Copyright 2002-present 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.test.web.support; + +import java.util.function.Consumer; + +import com.jayway.jsonpath.Configuration; +import org.hamcrest.Matcher; +import org.jspecify.annotations.Nullable; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.test.util.JsonPathExpectationsHelper; +import org.springframework.util.Assert; + +public abstract class AbstractJsonPathAssertions { + + private final B bodySpec; + + private final String content; + + private final JsonPathExpectationsHelper pathHelper; + + protected AbstractJsonPathAssertions(B spec, String content, String expression, @Nullable Configuration configuration) { + Assert.hasText(expression, "expression must not be null or empty"); + this.bodySpec = spec; + this.content = content; + this.pathHelper = new JsonPathExpectationsHelper(expression, configuration); + } + + /** + * Applies {@link JsonPathExpectationsHelper#assertValue(String, Object)}. + */ + public B isEqualTo(Object expectedValue) { + this.pathHelper.assertValue(this.content, expectedValue); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#exists(String)}. + */ + public B exists() { + this.pathHelper.exists(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#doesNotExist(String)}. + */ + public B doesNotExist() { + this.pathHelper.doesNotExist(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#assertValueIsEmpty(String)}. + */ + public B isEmpty() { + this.pathHelper.assertValueIsEmpty(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#assertValueIsNotEmpty(String)}. + */ + public B isNotEmpty() { + this.pathHelper.assertValueIsNotEmpty(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#hasJsonPath}. + */ + public B hasJsonPath() { + this.pathHelper.hasJsonPath(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#doesNotHaveJsonPath}. + */ + public B doesNotHaveJsonPath() { + this.pathHelper.doesNotHaveJsonPath(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#assertValueIsBoolean(String)}. + */ + public B isBoolean() { + this.pathHelper.assertValueIsBoolean(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#assertValueIsNumber(String)}. + */ + public B isNumber() { + this.pathHelper.assertValueIsNumber(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#assertValueIsArray(String)}. + */ + public B isArray() { + this.pathHelper.assertValueIsArray(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#assertValueIsMap(String)}. + */ + public B isMap() { + this.pathHelper.assertValueIsMap(this.content); + return this.bodySpec; + } + + /** + * Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher)}. + */ + public B value(Matcher matcher) { + this.pathHelper.assertValue(this.content, matcher); + return this.bodySpec; + } + + /** + * Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher, Class)}. + */ + public B value(Class targetType, Matcher matcher) { + this.pathHelper.assertValue(this.content, matcher, targetType); + return this.bodySpec; + } + + /** + * Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher, ParameterizedTypeReference)}. + */ + public B value(ParameterizedTypeReference targetType, Matcher matcher) { + this.pathHelper.assertValue(this.content, matcher, targetType); + return this.bodySpec; + } + + /** + * Consume the result of the JSONPath evaluation. + */ + @SuppressWarnings("unchecked") + public B value(Consumer consumer) { + Object value = this.pathHelper.evaluateJsonPath(this.content); + consumer.accept((T) value); + return this.bodySpec; + } + + /** + * Consume the result of the JSONPath evaluation and provide a target class. + */ + public B value(Class targetType, Consumer consumer) { + T value = this.pathHelper.evaluateJsonPath(this.content, targetType); + consumer.accept(value); + return this.bodySpec; + } + + /** + * Consume the result of the JSONPath evaluation and provide a parameterized type. + */ + public B value(ParameterizedTypeReference targetType, Consumer consumer) { + T value = this.pathHelper.evaluateJsonPath(this.content, targetType); + consumer.accept(value); + return this.bodySpec; + } + + @Override + public boolean equals(@Nullable Object obj) { + throw new AssertionError("Object#equals is disabled " + + "to avoid being used in error instead of JsonPathAssertions#isEqualTo(String)."); + } + + @Override + public int hashCode() { + return super.hashCode(); + } +} + diff --git a/spring-test/src/main/java/org/springframework/test/web/support/AbstractStatusAssertions.java b/spring-test/src/main/java/org/springframework/test/web/support/AbstractStatusAssertions.java new file mode 100644 index 000000000000..719ba8cedf40 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/support/AbstractStatusAssertions.java @@ -0,0 +1,247 @@ +/* + * Copyright 2002-present 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.test.web.support; + +import java.util.function.Consumer; + +import org.hamcrest.Matcher; +import org.hamcrest.MatcherAssert; + +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.test.util.AssertionErrors; + +import static org.springframework.test.util.AssertionErrors.assertNotNull; + +/** + * Assertions on the response status. + * + * @author Rob Worsnop + * @param the type of the exchange result + * @param the type of the response spec + */ +public abstract class AbstractStatusAssertions { + protected final E exchangeResult; + private final R responseSpec; + + protected AbstractStatusAssertions(E exchangeResult, R responseSpec) { + this.exchangeResult = exchangeResult; + this.responseSpec = responseSpec; + } + + /** + * Assert the response status as an {@link HttpStatusCode}. + */ + public R isEqualTo(HttpStatusCode status) { + HttpStatusCode actual = getStatus(); + assertWithDiagnostics(() -> AssertionErrors.assertEquals("Status", status, actual)); + return this.responseSpec; + } + + /** + * Assert the response status as an integer. + */ + public R isEqualTo(int status) { + return isEqualTo(HttpStatusCode.valueOf(status)); + } + + /** + * Assert the response status code is {@code HttpStatus.OK} (200). + */ + public R isOk() { + return assertStatusAndReturn(HttpStatus.OK); + } + + /** + * Assert the response status code is {@code HttpStatus.CREATED} (201). + */ + public R isCreated() { + return assertStatusAndReturn(HttpStatus.CREATED); + } + + /** + * Assert the response status code is {@code HttpStatus.ACCEPTED} (202). + */ + public R isAccepted() { + return assertStatusAndReturn(HttpStatus.ACCEPTED); + } + + /** + * Assert the response status code is {@code HttpStatus.NO_CONTENT} (204). + */ + public R isNoContent() { + return assertStatusAndReturn(HttpStatus.NO_CONTENT); + } + + /** + * Assert the response status code is {@code HttpStatus.FOUND} (302). + */ + public R isFound() { + return assertStatusAndReturn(HttpStatus.FOUND); + } + + /** + * Assert the response status code is {@code HttpStatus.SEE_OTHER} (303). + */ + public R isSeeOther() { + return assertStatusAndReturn(HttpStatus.SEE_OTHER); + } + + /** + * Assert the response status code is {@code HttpStatus.NOT_MODIFIED} (304). + */ + public R isNotModified() { + return assertStatusAndReturn(HttpStatus.NOT_MODIFIED); + } + + /** + * Assert the response status code is {@code HttpStatus.TEMPORARY_REDIRECT} (307). + */ + public R isTemporaryRedirect() { + return assertStatusAndReturn(HttpStatus.TEMPORARY_REDIRECT); + } + + /** + * Assert the response status code is {@code HttpStatus.PERMANENT_REDIRECT} (308). + */ + public R isPermanentRedirect() { + return assertStatusAndReturn(HttpStatus.PERMANENT_REDIRECT); + } + + /** + * Assert the response status code is {@code HttpStatus.BAD_REQUEST} (400). + */ + public R isBadRequest() { + return assertStatusAndReturn(HttpStatus.BAD_REQUEST); + } + + /** + * Assert the response status code is {@code HttpStatus.UNAUTHORIZED} (401). + */ + public R isUnauthorized() { + return assertStatusAndReturn(HttpStatus.UNAUTHORIZED); + } + + /** + * Assert the response status code is {@code HttpStatus.FORBIDDEN} (403). + */ + public R isForbidden() { + return assertStatusAndReturn(HttpStatus.FORBIDDEN); + } + + /** + * Assert the response status code is {@code HttpStatus.NOT_FOUND} (404). + */ + public R isNotFound() { + return assertStatusAndReturn(HttpStatus.NOT_FOUND); + } + + /** + * Assert the response error message. + */ + public R reasonEquals(String reason) { + String actual = getReasonPhrase(getStatus()); + assertWithDiagnostics(() -> + AssertionErrors.assertEquals("Response status reason", reason, actual)); + return this.responseSpec; + } + + private static String getReasonPhrase(HttpStatusCode statusCode) { + if (statusCode instanceof HttpStatus status) { + return status.getReasonPhrase(); + } + else { + return ""; + } + } + + + /** + * Assert the response status code is in the 1xx range. + */ + public R is1xxInformational() { + return assertSeriesAndReturn(HttpStatus.Series.INFORMATIONAL); + } + + /** + * Assert the response status code is in the 2xx range. + */ + public R is2xxSuccessful() { + return assertSeriesAndReturn(HttpStatus.Series.SUCCESSFUL); + } + + /** + * Assert the response status code is in the 3xx range. + */ + public R is3xxRedirection() { + return assertSeriesAndReturn(HttpStatus.Series.REDIRECTION); + } + + /** + * Assert the response status code is in the 4xx range. + */ + public R is4xxClientError() { + return assertSeriesAndReturn(HttpStatus.Series.CLIENT_ERROR); + } + + /** + * Assert the response status code is in the 5xx range. + */ + public R is5xxServerError() { + return assertSeriesAndReturn(HttpStatus.Series.SERVER_ERROR); + } + + /** + * Match the response status value with a Hamcrest matcher. + * @param matcher the matcher to use + */ + public R value(Matcher matcher) { + int actual = getStatus().value(); + assertWithDiagnostics(() -> MatcherAssert.assertThat("Response status", actual, matcher)); + return this.responseSpec; + } + + /** + * Consume the response status value as an integer. + * @param consumer the consumer to use + * @since 5.1 + */ + public R value(Consumer consumer) { + int actual = getStatus().value(); + assertWithDiagnostics(() -> consumer.accept(actual)); + return this.responseSpec; + } + + protected abstract void assertWithDiagnostics(Runnable assertion); + + protected abstract HttpStatusCode getStatus(); + + private R assertStatusAndReturn(HttpStatus expected) { + assertNotNull("exchangeResult unexpectedly null", this.exchangeResult); + HttpStatusCode actual = getStatus(); + assertWithDiagnostics(() -> AssertionErrors.assertEquals("Status", expected, actual)); + return this.responseSpec; + } + + private R assertSeriesAndReturn(HttpStatus.Series expected) { + HttpStatusCode status = getStatus(); + HttpStatus.Series series = HttpStatus.Series.resolve(status.value()); + assertWithDiagnostics(() -> + AssertionErrors.assertEquals("Range for response status value " + status, expected, series)); + return this.responseSpec; + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/support/AbstractXpathAssertions.java b/spring-test/src/main/java/org/springframework/test/web/support/AbstractXpathAssertions.java new file mode 100644 index 000000000000..138c2de92d8f --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/support/AbstractXpathAssertions.java @@ -0,0 +1,191 @@ +/* + * Copyright 2002-present 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.test.web.support; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; + +import javax.xml.xpath.XPathExpressionException; + +import org.hamcrest.Matcher; +import org.jspecify.annotations.Nullable; + +import org.springframework.http.HttpHeaders; +import org.springframework.test.util.XpathExpectationsHelper; +import org.springframework.util.MimeType; + +public abstract class AbstractXpathAssertions { + protected final B bodySpec; + + private final XpathExpectationsHelper xpathHelper; + + public AbstractXpathAssertions(B spec, String expression, @Nullable Map namespaces, Object... args) { + this.bodySpec = spec; + this.xpathHelper = initXpathHelper(expression, namespaces, args); + } + + private static XpathExpectationsHelper initXpathHelper( + String expression, @Nullable Map namespaces, Object[] args) { + + try { + return new XpathExpectationsHelper(expression, namespaces, args); + } + catch (XPathExpressionException ex) { + throw new AssertionError("XML parsing error", ex); + } + } + + + /** + * Delegates to {@link XpathExpectationsHelper#assertString(byte[], String, String)}. + */ + public B isEqualTo(String expectedValue) { + return assertWith(() -> this.xpathHelper.assertString(getContent(), getCharset(), expectedValue)); + } + + /** + * Delegates to {@link XpathExpectationsHelper#assertNumber(byte[], String, Double)}. + */ + public B isEqualTo(Double expectedValue) { + return assertWith(() -> this.xpathHelper.assertNumber(getContent(), getCharset(), expectedValue)); + } + + /** + * Delegates to {@link XpathExpectationsHelper#assertBoolean(byte[], String, boolean)}. + */ + public B isEqualTo(boolean expectedValue) { + return assertWith(() -> this.xpathHelper.assertBoolean(getContent(), getCharset(), expectedValue)); + } + + /** + * Delegates to {@link XpathExpectationsHelper#exists(byte[], String)}. + */ + public B exists() { + return assertWith(() -> this.xpathHelper.exists(getContent(), getCharset())); + } + + /** + * Delegates to {@link XpathExpectationsHelper#doesNotExist(byte[], String)}. + */ + public B doesNotExist() { + return assertWith(() -> this.xpathHelper.doesNotExist(getContent(), getCharset())); + } + + /** + * Delegates to {@link XpathExpectationsHelper#assertNodeCount(byte[], String, int)}. + */ + public B nodeCount(int expectedCount) { + return assertWith(() -> this.xpathHelper.assertNodeCount(getContent(), getCharset(), expectedCount)); + } + + /** + * Delegates to {@link XpathExpectationsHelper#assertString(byte[], String, Matcher)}. + */ + public B string(Matcher matcher){ + return assertWith(() -> this.xpathHelper.assertString(getContent(), getCharset(), matcher)); + } + + /** + * Delegates to {@link XpathExpectationsHelper#assertNumber(byte[], String, Matcher)}. + */ + public B number(Matcher matcher){ + return assertWith(() -> this.xpathHelper.assertNumber(getContent(), getCharset(), matcher)); + } + + /** + * Delegates to {@link XpathExpectationsHelper#assertNodeCount(byte[], String, Matcher)}. + */ + public B nodeCount(Matcher matcher){ + return assertWith(() -> this.xpathHelper.assertNodeCount(getContent(), getCharset(), matcher)); + } + + /** + * Consume the result of the XPath evaluation as a String. + */ + public B string(Consumer consumer){ + return assertWith(() -> { + String value = this.xpathHelper.evaluateXpath(getContent(), getCharset(), String.class); + consumer.accept(value); + }); + } + + /** + * Consume the result of the XPath evaluation as a Double. + */ + public B number(Consumer consumer){ + return assertWith(() -> { + Double value = this.xpathHelper.evaluateXpath(getContent(), getCharset(), Double.class); + consumer.accept(value); + }); + } + + /** + * Consume the count of nodes as result of the XPath evaluation. + */ + public B nodeCount(Consumer consumer){ + return assertWith(() -> { + Integer value = this.xpathHelper.evaluateXpath(getContent(), getCharset(), Integer.class); + consumer.accept(value); + }); + } + + private B assertWith(CheckedExceptionTask task) { + try { + task.run(); + } + catch (Exception ex) { + throw new AssertionError("XML parsing error", ex); + } + return this.bodySpec; + } + + private String getCharset() { + return getResponseHeaders() + .map(HttpHeaders::getContentType) + .map(MimeType::getCharset) + .orElse(StandardCharsets.UTF_8) + .name(); + } + + + @Override + public boolean equals(@Nullable Object obj) { + throw new AssertionError("Object#equals is disabled " + + "to avoid being used in error instead of XPathAssertions#isEqualTo(String)."); + } + + @Override + public int hashCode() { + return super.hashCode(); + } + + protected abstract Optional getResponseHeaders(); + + protected abstract byte[] getContent(); + + /** + * Lets us be able to use lambda expressions that could throw checked exceptions, since + * {@link XpathExpectationsHelper} throws {@link Exception} on its methods. + */ + private interface CheckedExceptionTask { + + void run() throws Exception; + + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/support/package-info.java b/spring-test/src/main/java/org/springframework/test/web/support/package-info.java new file mode 100644 index 000000000000..c6ee3579691a --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/support/package-info.java @@ -0,0 +1,4 @@ +/** + * Support classes for testing web applications. + */ +package org.springframework.test.web.support; diff --git a/src/checkstyle/checkstyle-suppressions.xml b/src/checkstyle/checkstyle-suppressions.xml index 92021b59598c..7b93a7ca858a 100644 --- a/src/checkstyle/checkstyle-suppressions.xml +++ b/src/checkstyle/checkstyle-suppressions.xml @@ -90,6 +90,7 @@ + From db4696ceae2a6f31146f90331b58edcfdb86c621 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Tue, 29 Jul 2025 06:23:54 +0100 Subject: [PATCH 023/591] Align RestTestClient and WebTestClient structure See gh-34428 --- .../reactive/server/DefaultWebTestClient.java | 24 +- .../web/reactive/server/WebTestClient.java | 3 +- .../web/servlet/client/CookieAssertions.java | 1 + .../servlet/client/DefaultRestTestClient.java | 197 +++---- .../client/DefaultRestTestClientBuilder.java | 33 +- .../web/servlet/client/HeaderAssertions.java | 1 + .../servlet/client/JsonPathAssertions.java | 2 +- .../web/servlet/client/RestTestClient.java | 511 +++++++++--------- .../web/servlet/client/StatusAssertions.java | 2 +- .../web/servlet/client/XpathAssertions.java | 1 + 10 files changed, 410 insertions(+), 365 deletions(-) diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java index e033410cd706..0659241cae2b 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java @@ -261,18 +261,6 @@ public RequestBodySpec headers(Consumer headersConsumer) { return this; } - @Override - public RequestBodySpec attribute(String name, Object value) { - this.attributes.put(name, value); - return this; - } - - @Override - public RequestBodySpec attributes(Consumer> attributesConsumer) { - attributesConsumer.accept(this.attributes); - return this; - } - @Override public RequestBodySpec accept(MediaType... acceptableMediaTypes) { getHeaders().setAccept(Arrays.asList(acceptableMediaTypes)); @@ -321,6 +309,18 @@ public RequestBodySpec ifNoneMatch(String... ifNoneMatches) { return this; } + @Override + public RequestBodySpec attribute(String name, Object value) { + this.attributes.put(name, value); + return this; + } + + @Override + public RequestBodySpec attributes(Consumer> attributesConsumer) { + attributesConsumer.accept(this.attributes); + return this; + } + @Override public RequestBodySpec apiVersion(Object version) { this.apiVersion = version; diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java index 16127ae8d138..210146bc83fd 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java @@ -820,6 +820,7 @@ > RequestHeadersSpec body( interface RequestHeadersUriSpec> extends UriSpec, RequestHeadersSpec { } + /** * Specification for providing the body and the URI of a request. */ @@ -932,7 +933,6 @@ interface ResponseSpec { @FunctionalInterface interface ResponseSpecConsumer extends Consumer { } - } @@ -1014,7 +1014,6 @@ interface ListBodySpec extends BodySpec, ListBodySpec> { * Spec for expectations on the response body content. */ interface BodyContentSpec { - /** * Assert the response body is empty and return the exchange result. */ diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/CookieAssertions.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/CookieAssertions.java index 8ca598d30b7b..b4f8cced077c 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/CookieAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/CookieAssertions.java @@ -24,6 +24,7 @@ * Assertions on cookies of the response. * * @author Rob Worsnop + * @since 7.0 */ public class CookieAssertions extends AbstractCookieAssertions { diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java index 10bff023a62d..d831a68af033 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java @@ -50,6 +50,8 @@ * Default implementation of {@link RestTestClient}. * * @author Rob Worsnop + * @author Rossen Stoyanchev + * @since 7.0 */ class DefaultRestTestClient implements RestTestClient { @@ -59,11 +61,13 @@ class DefaultRestTestClient implements RestTestClient { private final RestClient.Builder restClientBuilder; + DefaultRestTestClient(RestClient.Builder restClientBuilder) { this.restClient = restClientBuilder.build(); this.restClientBuilder = restClientBuilder; } + @Override public RequestHeadersUriSpec get() { return methodInternal(HttpMethod.GET); @@ -104,74 +108,75 @@ public RequestBodyUriSpec method(HttpMethod method) { return methodInternal(method); } + private RequestBodyUriSpec methodInternal(HttpMethod httpMethod) { + return new DefaultRequestBodyUriSpec(this.restClient.method(httpMethod)); + } + @Override public > Builder mutate() { return new DefaultRestTestClientBuilder<>(this.restClientBuilder); } - private RequestBodyUriSpec methodInternal(HttpMethod httpMethod) { - return new DefaultRequestBodyUriSpec(this.restClient.method(httpMethod)); - } - private class DefaultRequestBodyUriSpec implements RequestBodyUriSpec { private final RestClient.RequestBodyUriSpec requestHeadersUriSpec; + private RestClient.RequestBodySpec requestBodySpec; - private final String requestId; + private final String requestId; - public DefaultRequestBodyUriSpec(RestClient.RequestBodyUriSpec spec) { + DefaultRequestBodyUriSpec(RestClient.RequestBodyUriSpec spec) { this.requestHeadersUriSpec = spec; this.requestBodySpec = spec; this.requestId = String.valueOf(requestIndex.incrementAndGet()); } @Override - public RequestBodySpec accept(MediaType... acceptableMediaTypes) { - this.requestBodySpec = this.requestHeadersUriSpec.accept(acceptableMediaTypes); + public RequestBodySpec uri(String uriTemplate, Object... uriVariables) { + this.requestBodySpec = this.requestHeadersUriSpec.uri(uriTemplate, uriVariables); return this; } @Override - public RequestBodySpec uri(URI uri) { - this.requestBodySpec = this.requestHeadersUriSpec.uri(uri); + public RequestBodySpec uri(String uri, Map uriVariables) { + this.requestBodySpec = this.requestHeadersUriSpec.uri(uri, uriVariables); return this; } @Override - public RequestBodySpec uri(String uriTemplate, Object... uriVariables) { - this.requestBodySpec = this.requestHeadersUriSpec.uri(uriTemplate, uriVariables); + public RequestBodySpec uri(Function uriFunction) { + this.requestBodySpec = this.requestHeadersUriSpec.uri(uriFunction); return this; } @Override - public RequestBodySpec uri(String uri, Map uriVariables) { - this.requestBodySpec = this.requestHeadersUriSpec.uri(uri, uriVariables); + public RequestBodySpec uri(URI uri) { + this.requestBodySpec = this.requestHeadersUriSpec.uri(uri); return this; } @Override - public RequestBodySpec uri(Function uriFunction) { - this.requestBodySpec = this.requestHeadersUriSpec.uri(uriFunction); + public RequestBodySpec header(String headerName, String... headerValues) { + this.requestBodySpec = this.requestHeadersUriSpec.header(headerName, headerValues); return this; } @Override - public RequestBodySpec cookie(String name, String value) { - this.requestBodySpec = this.requestHeadersUriSpec.cookie(name, value); + public RequestBodySpec headers(Consumer headersConsumer) { + this.requestBodySpec = this.requestHeadersUriSpec.headers(headersConsumer); return this; } @Override - public RequestBodySpec cookies(Consumer> cookiesConsumer) { - this.requestBodySpec = this.requestHeadersUriSpec.cookies(cookiesConsumer); + public RequestBodySpec accept(MediaType... acceptableMediaTypes) { + this.requestBodySpec = this.requestHeadersUriSpec.accept(acceptableMediaTypes); return this; } @Override - public RequestBodySpec header(String headerName, String... headerValues) { - this.requestBodySpec = this.requestHeadersUriSpec.header(headerName, headerValues); + public RequestBodySpec acceptCharset(Charset... acceptableCharsets) { + this.requestBodySpec = this.requestHeadersUriSpec.acceptCharset(acceptableCharsets); return this; } @@ -182,14 +187,14 @@ public RequestBodySpec contentType(MediaType contentType) { } @Override - public RequestHeadersSpec body(Object body) { - this.requestHeadersUriSpec.body(body); + public RequestBodySpec cookie(String name, String value) { + this.requestBodySpec = this.requestHeadersUriSpec.cookie(name, value); return this; } @Override - public RequestBodySpec acceptCharset(Charset... acceptableCharsets) { - this.requestBodySpec = this.requestHeadersUriSpec.acceptCharset(acceptableCharsets); + public RequestBodySpec cookies(Consumer> cookiesConsumer) { + this.requestBodySpec = this.requestHeadersUriSpec.cookies(cookiesConsumer); return this; } @@ -205,12 +210,6 @@ public RequestBodySpec ifNoneMatch(String... ifNoneMatches) { return this; } - @Override - public RequestBodySpec headers(Consumer headersConsumer) { - this.requestBodySpec = this.requestHeadersUriSpec.headers(headersConsumer); - return this; - } - @Override public RequestBodySpec attribute(String name, Object value) { this.requestBodySpec = this.requestHeadersUriSpec.attribute(name, value); @@ -223,6 +222,12 @@ public RequestBodySpec attributes(Consumer> attributesConsum return this; } + @Override + public RequestHeadersSpec body(Object body) { + this.requestHeadersUriSpec.body(body); + return this; + } + @Override public ResponseSpec exchange() { this.requestBodySpec = this.requestBodySpec.header(RESTTESTCLIENT_REQUEST_ID, this.requestId); @@ -233,11 +238,12 @@ public ResponseSpec exchange() { } } + private static class DefaultResponseSpec implements ResponseSpec { private final ExchangeResult exchangeResult; - public DefaultResponseSpec(ExchangeResult exchangeResult) { + DefaultResponseSpec(ExchangeResult exchangeResult) { this.exchangeResult = exchangeResult; } @@ -247,9 +253,13 @@ public StatusAssertions expectStatus() { } @Override - public BodyContentSpec expectBody() { - byte[] body = this.exchangeResult.getBody(byte[].class); - return new DefaultBodyContentSpec( new EntityExchangeResult<>(this.exchangeResult, body)); + public HeaderAssertions expectHeader() { + return new HeaderAssertions(this.exchangeResult, this); + } + + @Override + public CookieAssertions expectCookie() { + return new CookieAssertions(this.exchangeResult, this); } @Override @@ -265,13 +275,19 @@ public BodyContentSpec expectBody() { } @Override - public CookieAssertions expectCookie() { - return new CookieAssertions(this.exchangeResult, this); + public BodyContentSpec expectBody() { + byte[] body = this.exchangeResult.getBody(byte[].class); + return new DefaultBodyContentSpec( new EntityExchangeResult<>(this.exchangeResult, body)); } @Override - public HeaderAssertions expectHeader() { - return new HeaderAssertions(this.exchangeResult, this); + public EntityExchangeResult returnResult(Class elementClass) { + return new EntityExchangeResult<>(this.exchangeResult, this.exchangeResult.getBody(elementClass)); + } + + @Override + public EntityExchangeResult returnResult(ParameterizedTypeReference elementTypeRef) { + return new EntityExchangeResult<>(this.exchangeResult, this.exchangeResult.getBody(elementTypeRef)); } @Override @@ -295,22 +311,63 @@ public ResponseSpec expectAll(ResponseSpecConsumer... consumers) { } return this; } + } + + + private static class DefaultBodySpec> implements BodySpec { + + private final EntityExchangeResult result; + + DefaultBodySpec(@Nullable EntityExchangeResult result) { + this.result = Objects.requireNonNull(result, "exchangeResult must be non-null"); + } @Override - public EntityExchangeResult returnResult(Class elementClass) { - return new EntityExchangeResult<>(this.exchangeResult, this.exchangeResult.getBody(elementClass)); + public T isEqualTo(B expected) { + this.result.assertWithDiagnostics(() -> + AssertionErrors.assertEquals("Response body", expected, this.result.getResponseBody())); + return self(); } @Override - public EntityExchangeResult returnResult(ParameterizedTypeReference elementTypeRef) { - return new EntityExchangeResult<>(this.exchangeResult, this.exchangeResult.getBody(elementTypeRef)); + @SuppressWarnings("NullAway") // https://github.com/uber/NullAway/issues/1129 + public T value(Function bodyMapper, Matcher matcher) { + this.result.assertWithDiagnostics(() -> { + B body = this.result.getResponseBody(); + MatcherAssert.assertThat(bodyMapper.apply(body), matcher); + }); + return self(); + } + + @Override + public T value(Consumer consumer) { + this.result.assertWithDiagnostics(() -> consumer.accept(this.result.getResponseBody())); + return self(); + } + + @Override + public T consumeWith(Consumer> consumer) { + this.result.assertWithDiagnostics(() -> consumer.accept(this.result)); + return self(); + } + + @SuppressWarnings("unchecked") + private T self() { + return (T) this; + } + + @Override + public EntityExchangeResult returnResult() { + return this.result; } } + private static class DefaultBodyContentSpec implements BodyContentSpec { + private final EntityExchangeResult result; - public DefaultBodyContentSpec(EntityExchangeResult result) { + DefaultBodyContentSpec(EntityExchangeResult result) { this.result = result; } @@ -374,56 +431,14 @@ private String getBodyAsString() { } @Override - public EntityExchangeResult returnResult() { - return this.result; - } - } - - private static class DefaultBodySpec> implements BodySpec { - - private final EntityExchangeResult result; - - public DefaultBodySpec(@Nullable EntityExchangeResult result) { - this.result = Objects.requireNonNull(result, "exchangeResult must be non-null"); + public BodyContentSpec consumeWith(Consumer> consumer) { + this.result.assertWithDiagnostics(() -> consumer.accept(this.result)); + return this; } @Override - public EntityExchangeResult returnResult() { + public EntityExchangeResult returnResult() { return this.result; } - - @Override - public T isEqualTo(B expected) { - this.result.assertWithDiagnostics(() -> - AssertionErrors.assertEquals("Response body", expected, this.result.getResponseBody())); - return self(); - } - - @Override - @SuppressWarnings("NullAway") // https://github.com/uber/NullAway/issues/1129 - public T value(Function bodyMapper, Matcher matcher) { - this.result.assertWithDiagnostics(() -> { - B body = this.result.getResponseBody(); - MatcherAssert.assertThat(bodyMapper.apply(body), matcher); - }); - return self(); - } - - @Override - public T value(Consumer consumer) { - this.result.assertWithDiagnostics(() -> consumer.accept(this.result.getResponseBody())); - return self(); - } - - @Override - public T consumeWith(Consumer> consumer) { - this.result.assertWithDiagnostics(() -> consumer.accept(this.result)); - return self(); - } - - @SuppressWarnings("unchecked") - private T self() { - return (T) this; - } } } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java index dcd05e779b49..4e4b722e30af 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java @@ -25,13 +25,17 @@ /** * Default implementation of {@link RestTestClient.Builder}. + * * @author Rob Worsnop + * @author Rossen Stoyanchev * @param the type of the builder + * @since 7.0 */ class DefaultRestTestClientBuilder> implements RestTestClient.Builder { protected final RestClient.Builder restClientBuilder; + DefaultRestTestClientBuilder() { this.restClientBuilder = RestClient.builder(); } @@ -40,45 +44,46 @@ class DefaultRestTestClientBuilder> implemen this.restClientBuilder = restClientBuilder; } + @Override - public RestTestClient.Builder apply(Consumer> builderConsumer) { - builderConsumer.accept(this); + public RestTestClient.Builder baseUrl(String baseUrl) { + this.restClientBuilder.baseUrl(baseUrl); return this; } @Override - public RestTestClient.Builder baseUrl(String baseUrl) { - this.restClientBuilder.baseUrl(baseUrl); + public RestTestClient.Builder uriBuilderFactory(UriBuilderFactory uriFactory) { + this.restClientBuilder.uriBuilderFactory(uriFactory); return this; } @Override - public RestTestClient.Builder defaultCookie(String cookieName, String... cookieValues) { - this.restClientBuilder.defaultCookie(cookieName, cookieValues); + public RestTestClient.Builder defaultHeader(String headerName, String... headerValues) { + this.restClientBuilder.defaultHeader(headerName, headerValues); return this; } @Override - public RestTestClient.Builder defaultCookies(Consumer> cookiesConsumer) { - this.restClientBuilder.defaultCookies(cookiesConsumer); + public RestTestClient.Builder defaultHeaders(Consumer headersConsumer) { + this.restClientBuilder.defaultHeaders(headersConsumer); return this; } @Override - public RestTestClient.Builder defaultHeader(String headerName, String... headerValues) { - this.restClientBuilder.defaultHeader(headerName, headerValues); + public RestTestClient.Builder defaultCookie(String cookieName, String... cookieValues) { + this.restClientBuilder.defaultCookie(cookieName, cookieValues); return this; } @Override - public RestTestClient.Builder defaultHeaders(Consumer headersConsumer) { - this.restClientBuilder.defaultHeaders(headersConsumer); + public RestTestClient.Builder defaultCookies(Consumer> cookiesConsumer) { + this.restClientBuilder.defaultCookies(cookiesConsumer); return this; } @Override - public RestTestClient.Builder uriBuilderFactory(UriBuilderFactory uriFactory) { - this.restClientBuilder.uriBuilderFactory(uriFactory); + public RestTestClient.Builder apply(Consumer> builderConsumer) { + builderConsumer.accept(this); return this; } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/HeaderAssertions.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/HeaderAssertions.java index 9429ae85b369..89e557b93e53 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/HeaderAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/HeaderAssertions.java @@ -23,6 +23,7 @@ * Assertions on headers of the response. * * @author Rob Worsnop + * @since 7.0 * @see RestTestClient.ResponseSpec#expectHeader() */ public class HeaderAssertions extends AbstractHeaderAssertions { diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/JsonPathAssertions.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/JsonPathAssertions.java index cb487bdd4cc8..b5eda5ba59ae 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/JsonPathAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/JsonPathAssertions.java @@ -26,7 +26,7 @@ * JsonPath assertions. * * @author Rob Worsnop - * + * @since 7.0 * @see https://github.com/jayway/JsonPath * @see JsonPathExpectationsHelper */ diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java index 23314716fe6f..4dcd74026f01 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java @@ -63,6 +63,7 @@ public interface RestTestClient { */ String RESTTESTCLIENT_REQUEST_ID = "RestTestClient-Request-Id"; + /** * Prepare an HTTP GET request. * @return a spec for specifying the target URL @@ -111,11 +112,13 @@ public interface RestTestClient { */ RequestBodyUriSpec method(HttpMethod method); + /** * Return a builder to mutate properties of this test client. */ > Builder mutate(); + /** * Begin creating a {@link RestTestClient} by providing the {@code @Controller} * instance(s) to handle requests with. @@ -184,239 +187,78 @@ static > Builder bindToServer(ClientHttpRequestFactory r return new DefaultRestTestClientBuilder<>(RestClient.builder().requestFactory(requestFactory)); } - /** - * Specification for providing request headers and the URI of a request. - * - * @param a self reference to the spec type - */ - interface RequestHeadersUriSpec> extends UriSpec, RequestHeadersSpec { - } - - /** - * Specification for providing the body and the URI of a request. - */ - interface RequestBodyUriSpec extends RequestBodySpec, RequestHeadersUriSpec { - } - - /** - * Chained API for applying assertions to a response. - */ - interface ResponseSpec { - /** - * Assertions on the response status. - */ - StatusAssertions expectStatus(); - /** - * Consume and decode the response body to {@code byte[]} and then apply - * assertions on the raw content (for example, isEmpty, JSONPath, etc.). - */ - BodyContentSpec expectBody(); + interface Builder> { /** - * Consume and decode the response body to a single object of type - * {@code } and then apply assertions. - * @param bodyType the expected body type + * Configure a base URI as described in + * {@link RestClient#create(String) + * WebClient.create(String)}. */ - BodySpec expectBody(Class bodyType); + Builder baseUrl(String baseUrl); /** - * Alternative to {@link #expectBody(Class)} that accepts information - * about a target type with generics. + * Provide a pre-configured {@link UriBuilderFactory} instance as an + * alternative to and effectively overriding {@link #baseUrl(String)}. */ - BodySpec expectBody(ParameterizedTypeReference bodyType); + Builder uriBuilderFactory(UriBuilderFactory uriBuilderFactory); /** - * Assertions on the cookies of the response. + * Add the given header to all requests that haven't added it. + * @param headerName the header name + * @param headerValues the header values */ - CookieAssertions expectCookie(); + Builder defaultHeader(String headerName, String... headerValues); /** - * Assertions on the headers of the response. + * Manipulate the default headers with the given consumer. The + * headers provided to the consumer are "live", so that the consumer can be used to + * {@linkplain HttpHeaders#set(String, String) overwrite} existing header values, + * {@linkplain HttpHeaders#remove(String) remove} values, or use any of the other + * {@link HttpHeaders} methods. + * @param headersConsumer a function that consumes the {@code HttpHeaders} + * @return this builder */ - HeaderAssertions expectHeader(); + Builder defaultHeaders(Consumer headersConsumer); /** - * Apply multiple assertions to a response with the given - * {@linkplain RestTestClient.ResponseSpec.ResponseSpecConsumer consumers}, with the guarantee that - * all assertions will be applied even if one or more assertions fails - * with an exception. - *

If a single {@link Error} or {@link RuntimeException} is thrown, - * it will be rethrown. - *

If multiple exceptions are thrown, this method will throw an - * {@link AssertionError} whose error message is a summary of all the - * exceptions. In addition, each exception will be added as a - * {@linkplain Throwable#addSuppressed(Throwable) suppressed exception} to - * the {@code AssertionError}. - *

This feature is similar to the {@code SoftAssertions} support in - * AssertJ and the {@code assertAll()} support in JUnit Jupiter. - * - *

Example

- *
-		 * restTestClient.get().uri("/hello").exchange()
-		 *     .expectAll(
-		 *         responseSpec -> responseSpec.expectStatus().isOk(),
-		 *         responseSpec -> responseSpec.expectBody(String.class).isEqualTo("Hello, World!")
-		 *     );
-		 * 
- * @param consumers the list of {@code ResponseSpec} consumers + * Add the given cookie to all requests. + * @param cookieName the cookie name + * @param cookieValues the cookie values */ - ResponseSpec expectAll(ResponseSpecConsumer... consumers); + Builder defaultCookie(String cookieName, String... cookieValues); /** - * Exit the chained flow in order to consume the response body - * externally. + * Manipulate the default cookies with the given consumer. The + * map provided to the consumer is "live", so that the consumer can be used to + * {@linkplain MultiValueMap#set(Object, Object) overwrite} existing header values, + * {@linkplain MultiValueMap#remove(Object) remove} values, or use any of the other + * {@link MultiValueMap} methods. + * @param cookiesConsumer a function that consumes the cookies map + * @return this builder */ - EntityExchangeResult returnResult(Class elementClass); + Builder defaultCookies(Consumer> cookiesConsumer); /** - * Alternative to {@link #returnResult(Class)} that accepts information - * about a target type with generics. + * Apply the given {@code Consumer} to this builder instance. + *

This can be useful for applying pre-packaged customizations. + * @param builderConsumer the consumer to apply */ - EntityExchangeResult returnResult(ParameterizedTypeReference elementTypeRef); + Builder apply(Consumer> builderConsumer); /** - * {@link Consumer} of a {@link RestTestClient.ResponseSpec}. - * @see RestTestClient.ResponseSpec#expectAll(RestTestClient.ResponseSpec.ResponseSpecConsumer...) + * Build the {@link RestTestClient} instance. */ - @FunctionalInterface - interface ResponseSpecConsumer extends Consumer { - } + RestTestClient build(); } - /** - * Spec for expectations on the response body content. - */ - interface BodyContentSpec { - /** - * Assert the response body is empty and return the exchange result. - */ - EntityExchangeResult isEmpty(); - /** - * Parse the expected and actual response content as JSON and perform a - * comparison verifying that they contain the same attribute-value pairs - * regardless of formatting with lenient checking (extensible - * and non-strict array ordering). - *

Use of this method requires the - * JSONassert library - * to be on the classpath. - * @param expectedJson the expected JSON content - * @see #json(String, JsonCompareMode) - */ - default BodyContentSpec json(String expectedJson) { - return json(expectedJson, JsonCompareMode.LENIENT); - } - - /** - * Parse the expected and actual response content as JSON and perform a - * comparison using the given {@linkplain JsonCompareMode mode}. If the - * comparison failed, throws an {@link AssertionError} with the message - * of the {@link JsonComparison}. - *

Use of this method requires the - * JSONassert library - * to be on the classpath. - * @param expectedJson the expected JSON content - * @param compareMode the compare mode - * @see #json(String) - */ - BodyContentSpec json(String expectedJson, JsonCompareMode compareMode); - - /** - * Parse the expected and actual response content as JSON and perform a - * comparison using the given {@link JsonComparator}. If the comparison - * failed, throws an {@link AssertionError} with the message of the - * {@link JsonComparison}. - * @param expectedJson the expected JSON content - * @param comparator the comparator to use - */ - BodyContentSpec json(String expectedJson, JsonComparator comparator); - - /** - * Parse expected and actual response content as XML and assert that - * the two are "similar", i.e. they contain the same elements and - * attributes regardless of order. - *

Use of this method requires the - * XMLUnit library on - * the classpath. - * @param expectedXml the expected XML content. - * @see org.springframework.test.util.XmlExpectationsHelper#assertXmlEqual(String, String) - */ - BodyContentSpec xml(String expectedXml); - - /** - * Access to response body assertions using an XPath expression to - * inspect a specific subset of the body. - *

The XPath expression can be a parameterized string using - * formatting specifiers as defined in {@link String#format}. - * @param expression the XPath expression - * @param args arguments to parameterize the expression - * @see #xpath(String, Map, Object...) - */ - default XpathAssertions xpath(String expression, Object... args) { - return xpath(expression, null, args); - } - - /** - * Access to response body assertions with specific namespaces using an - * XPath expression to inspect a specific subset of the body. - *

The XPath expression can be a parameterized string using - * formatting specifiers as defined in {@link String#format}. - * @param expression the XPath expression - * @param namespaces the namespaces to use - * @param args arguments to parameterize the expression - */ - XpathAssertions xpath(String expression, @Nullable Map namespaces, Object... args); + interface MockServerBuilder extends Builder> { - /** - * Access to response body assertions using a - * JsonPath expression - * to inspect a specific subset of the body. - * @param expression the JsonPath expression - */ - JsonPathAssertions jsonPath(String expression); + MockServerBuilder configureServer(Consumer consumer); - /** - * Exit the chained API and return an {@code ExchangeResult} with the - * raw response content. - */ - EntityExchangeResult returnResult(); } - /** - * Spec for expectations on the response body decoded to a single Object. - * - * @param a self reference to the spec type - * @param the body type - */ - interface BodySpec> { - /** - * Transform the extracted the body with a function, for example, extracting a - * property, and assert the mapped value with a {@link Matcher}. - */ - T value(Function bodyMapper, Matcher matcher); - - /** - * Assert the extracted body with a {@link Consumer}. - */ - T value(Consumer consumer); - - /** - * Assert the exchange result with the given {@link Consumer}. - */ - T consumeWith(Consumer> consumer); - - /** - * Exit the chained API and return an {@code EntityExchangeResult} with the - * decoded response content. - */ - EntityExchangeResult returnResult(); - - /** - * Assert the extracted body is equal to the given value. - */ - T isEqualTo(B expected); - } /** * Specification for providing the URI of a request. @@ -424,6 +266,7 @@ interface BodySpec> { * @param a self reference to the spec type */ interface UriSpec> { + /** * Specify the URI using an absolute, fully constructed {@link java.net.URI}. *

If a {@link UriBuilderFactory} was configured for the client with @@ -457,12 +300,9 @@ interface UriSpec> { * @return spec to add headers or perform the exchange */ S uri(Function uriFunction); - } - - /** * Specification for adding request headers and performing an exchange. * @@ -564,6 +404,7 @@ interface RequestHeadersSpec> { ResponseSpec exchange(); } + /** * Specification for providing body of a request. */ @@ -587,70 +428,252 @@ interface RequestBodySpec extends RequestHeadersSpec { RequestHeadersSpec body(Object body); } - interface Builder> { + + /** + * Specification for providing request headers and the URI of a request. + * + * @param a self reference to the spec type + */ + interface RequestHeadersUriSpec> extends UriSpec, RequestHeadersSpec { + } + + + /** + * Specification for providing the body and the URI of a request. + */ + interface RequestBodyUriSpec extends RequestBodySpec, RequestHeadersUriSpec { + } + + + /** + * Chained API for applying assertions to a response. + */ + interface ResponseSpec { + /** - * Apply the given {@code Consumer} to this builder instance. - *

This can be useful for applying pre-packaged customizations. - * @param builderConsumer the consumer to apply + * Apply multiple assertions to a response with the given + * {@linkplain RestTestClient.ResponseSpec.ResponseSpecConsumer consumers}, with the guarantee that + * all assertions will be applied even if one or more assertions fails + * with an exception. + *

If a single {@link Error} or {@link RuntimeException} is thrown, + * it will be rethrown. + *

If multiple exceptions are thrown, this method will throw an + * {@link AssertionError} whose error message is a summary of all the + * exceptions. In addition, each exception will be added as a + * {@linkplain Throwable#addSuppressed(Throwable) suppressed exception} to + * the {@code AssertionError}. + *

This feature is similar to the {@code SoftAssertions} support in + * AssertJ and the {@code assertAll()} support in JUnit Jupiter. + * + *

Example

+ *
+		 * restTestClient.get().uri("/hello").exchange()
+		 *     .expectAll(
+		 *         responseSpec -> responseSpec.expectStatus().isOk(),
+		 *         responseSpec -> responseSpec.expectBody(String.class).isEqualTo("Hello, World!")
+		 *     );
+		 * 
+ * @param consumers the list of {@code ResponseSpec} consumers */ - Builder apply(Consumer> builderConsumer); + ResponseSpec expectAll(ResponseSpecConsumer... consumers); /** - * Add the given cookie to all requests. - * @param cookieName the cookie name - * @param cookieValues the cookie values + * Assertions on the response status. */ - Builder defaultCookie(String cookieName, String... cookieValues); + StatusAssertions expectStatus(); /** - * Manipulate the default cookies with the given consumer. The - * map provided to the consumer is "live", so that the consumer can be used to - * {@linkplain MultiValueMap#set(Object, Object) overwrite} existing header values, - * {@linkplain MultiValueMap#remove(Object) remove} values, or use any of the other - * {@link MultiValueMap} methods. - * @param cookiesConsumer a function that consumes the cookies map - * @return this builder + * Assertions on the headers of the response. */ - Builder defaultCookies(Consumer> cookiesConsumer); + HeaderAssertions expectHeader(); /** - * Add the given header to all requests that haven't added it. - * @param headerName the header name - * @param headerValues the header values + * Assertions on the cookies of the response. */ - Builder defaultHeader(String headerName, String... headerValues); + CookieAssertions expectCookie(); /** - * Manipulate the default headers with the given consumer. The - * headers provided to the consumer are "live", so that the consumer can be used to - * {@linkplain HttpHeaders#set(String, String) overwrite} existing header values, - * {@linkplain HttpHeaders#remove(String) remove} values, or use any of the other - * {@link HttpHeaders} methods. - * @param headersConsumer a function that consumes the {@code HttpHeaders} - * @return this builder + * Consume and decode the response body to a single object of type + * {@code } and then apply assertions. + * @param bodyType the expected body type */ - Builder defaultHeaders(Consumer headersConsumer); + BodySpec expectBody(Class bodyType); /** - * Provide a pre-configured {@link UriBuilderFactory} instance as an - * alternative to and effectively overriding {@link #baseUrl(String)}. + * Alternative to {@link #expectBody(Class)} that accepts information + * about a target type with generics. */ - Builder uriBuilderFactory(UriBuilderFactory uriFactory); + BodySpec expectBody(ParameterizedTypeReference bodyType); /** - * Build the {@link RestTestClient} instance. + * Consume and decode the response body to {@code byte[]} and then apply + * assertions on the raw content (for example, isEmpty, JSONPath, etc.). */ - RestTestClient build(); + BodyContentSpec expectBody(); /** - * Configure a base URI as described in - * {@link RestClient#create(String) - * WebClient.create(String)}. + * Exit the chained flow in order to consume the response body + * externally. */ - Builder baseUrl(String baseUrl); + EntityExchangeResult returnResult(Class elementClass); + + /** + * Alternative to {@link #returnResult(Class)} that accepts information + * about a target type with generics. + */ + EntityExchangeResult returnResult(ParameterizedTypeReference elementTypeRef); + + /** + * {@link Consumer} of a {@link RestTestClient.ResponseSpec}. + * @see RestTestClient.ResponseSpec#expectAll(RestTestClient.ResponseSpec.ResponseSpecConsumer...) + */ + @FunctionalInterface + interface ResponseSpecConsumer extends Consumer { + } } - interface MockServerBuilder extends Builder> { - MockServerBuilder configureServer(Consumer consumer); + + /** + * Spec for expectations on the response body decoded to a single Object. + * + * @param a self reference to the spec type + * @param the body type + */ + interface BodySpec> { + + /** + * Assert the extracted body is equal to the given value. + */ + T isEqualTo(B expected); + + /** + * Transform the extracted the body with a function, for example, extracting a + * property, and assert the mapped value with a {@link Matcher}. + */ + T value(Function bodyMapper, Matcher matcher); + + /** + * Assert the extracted body with a {@link Consumer}. + */ + T value(Consumer consumer); + + /** + * Assert the exchange result with the given {@link Consumer}. + */ + T consumeWith(Consumer> consumer); + + /** + * Exit the chained API and return an {@code EntityExchangeResult} with the + * decoded response content. + */ + EntityExchangeResult returnResult(); + } + + + /** + * Spec for expectations on the response body content. + */ + interface BodyContentSpec { + /** + * Assert the response body is empty and return the exchange result. + */ + EntityExchangeResult isEmpty(); + + /** + * Parse the expected and actual response content as JSON and perform a + * comparison verifying that they contain the same attribute-value pairs + * regardless of formatting with lenient checking (extensible + * and non-strict array ordering). + *

Use of this method requires the + * JSONassert library + * to be on the classpath. + * @param expectedJson the expected JSON content + * @see #json(String, JsonCompareMode) + */ + default BodyContentSpec json(String expectedJson) { + return json(expectedJson, JsonCompareMode.LENIENT); + } + + /** + * Parse the expected and actual response content as JSON and perform a + * comparison using the given {@linkplain JsonCompareMode mode}. If the + * comparison failed, throws an {@link AssertionError} with the message + * of the {@link JsonComparison}. + *

Use of this method requires the + * JSONassert library + * to be on the classpath. + * @param expectedJson the expected JSON content + * @param compareMode the compare mode + * @see #json(String) + */ + BodyContentSpec json(String expectedJson, JsonCompareMode compareMode); + + /** + * Parse the expected and actual response content as JSON and perform a + * comparison using the given {@link JsonComparator}. If the comparison + * failed, throws an {@link AssertionError} with the message of the + * {@link JsonComparison}. + * @param expectedJson the expected JSON content + * @param comparator the comparator to use + */ + BodyContentSpec json(String expectedJson, JsonComparator comparator); + + /** + * Parse expected and actual response content as XML and assert that + * the two are "similar", i.e. they contain the same elements and + * attributes regardless of order. + *

Use of this method requires the + * XMLUnit library on + * the classpath. + * @param expectedXml the expected XML content. + * @see org.springframework.test.util.XmlExpectationsHelper#assertXmlEqual(String, String) + */ + BodyContentSpec xml(String expectedXml); + + /** + * Access to response body assertions using a + * JsonPath expression + * to inspect a specific subset of the body. + * @param expression the JsonPath expression + */ + JsonPathAssertions jsonPath(String expression); + + /** + * Access to response body assertions using an XPath expression to + * inspect a specific subset of the body. + *

The XPath expression can be a parameterized string using + * formatting specifiers as defined in {@link String#format}. + * @param expression the XPath expression + * @param args arguments to parameterize the expression + * @see #xpath(String, Map, Object...) + */ + default XpathAssertions xpath(String expression, Object... args) { + return xpath(expression, null, args); + } + + /** + * Access to response body assertions with specific namespaces using an + * XPath expression to inspect a specific subset of the body. + *

The XPath expression can be a parameterized string using + * formatting specifiers as defined in {@link String#format}. + * @param expression the XPath expression + * @param namespaces the namespaces to use + * @param args arguments to parameterize the expression + */ + XpathAssertions xpath(String expression, @Nullable Map namespaces, Object... args); + + /** + * Assert the response body content with the given {@link Consumer}. + * @param consumer the consumer for the response body; the input + * {@code byte[]} may be {@code null} if there was no response body. + */ + BodyContentSpec consumeWith(Consumer> consumer); + + /** + * Exit the chained API and return an {@code ExchangeResult} with the + * raw response content. + */ + EntityExchangeResult returnResult(); } + } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/StatusAssertions.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/StatusAssertions.java index debc3a4d3e6f..c2f76ef22b07 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/StatusAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/StatusAssertions.java @@ -24,7 +24,7 @@ * Assertions on the response status. * * @author Rob Worsnop - * + * @since 7.0 * @see ResponseSpec#expectStatus() */ public class StatusAssertions extends AbstractStatusAssertions { diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/XpathAssertions.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/XpathAssertions.java index d4bbaa27406f..cfec64a17eeb 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/XpathAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/XpathAssertions.java @@ -29,6 +29,7 @@ * XPath assertions for the {@link RestTestClient}. * * @author Rob Worsnop + * @since 7.0 */ public class XpathAssertions extends AbstractXpathAssertions { From 2732b603dcd1af6c4b3667d371545fded8bc75a3 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Tue, 29 Jul 2025 10:17:43 +0100 Subject: [PATCH 024/591] Update RestTestClient builder hierarchy Add concrete classes with specified generics for each MockMvc setup Ensure Builder methods return the concrete class See gh-34428 --- .../web/reactive/server/WebTestClient.java | 4 +- .../client/DefaultMockServerBuilder.java | 49 --------- .../client/DefaultRestTestClientBuilder.java | 102 +++++++++++++++--- .../web/servlet/client/RestTestClient.java | 54 ++++++---- .../client/JsonPathAssertionTests.java | 2 +- .../servlet/client/samples/ErrorTests.java | 2 +- .../client/samples/HeaderAndCookieTests.java | 2 +- .../client/samples/JsonContentTests.java | 2 +- .../client/samples/ResponseEntityTests.java | 2 +- .../client/samples/RestTestClientTests.java | 2 +- .../client/samples/SoftAssertionTests.java | 2 +- .../client/samples/XmlContentTests.java | 2 +- .../client/samples/bind/ControllerTests.java | 2 +- 13 files changed, 129 insertions(+), 98 deletions(-) delete mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultMockServerBuilder.java diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java index 210146bc83fd..4d9c30fbc1b2 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java @@ -155,7 +155,7 @@ public interface WebTestClient { /** - * Return a builder to mutate properties of this web test client. + * Return a builder to mutate properties of this test client. */ Builder mutate(); @@ -171,8 +171,6 @@ public interface WebTestClient { WebTestClient mutateWith(WebTestClientConfigurer configurer); - // Static factory methods - /** * Use this server setup to test one {@code @Controller} at a time. * This option loads the default configuration of diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultMockServerBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultMockServerBuilder.java deleted file mode 100644 index 9cfdf87f9faf..000000000000 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultMockServerBuilder.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2002-present 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.test.web.servlet.client; - -import java.util.function.Consumer; - -import org.springframework.test.web.servlet.MockMvcBuilder; - -/** - * Default implementation of {@link RestTestClient.MockServerBuilder}. - * @author Rob Worsnop - * @param the type of the {@link MockMvcBuilder} to use for building the mock server - */ -class DefaultMockServerBuilder - extends DefaultRestTestClientBuilder> - implements RestTestClient.MockServerBuilder { - - private final M builder; - - public DefaultMockServerBuilder(M builder) { - this.builder = builder; - } - - @Override - public RestTestClient.MockServerBuilder configureServer(Consumer consumer) { - consumer.accept(this.builder); - return this; - } - - @Override - public RestTestClient build() { - this.restClientBuilder.requestFactory(new MockMvcClientHttpRequestFactory(this.builder.build())); - return super.build(); - } -} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java index 4e4b722e30af..48c1d255b07e 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java @@ -18,9 +18,20 @@ import java.util.function.Consumer; +import org.jspecify.annotations.Nullable; + import org.springframework.http.HttpHeaders; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MockMvcBuilder; +import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.test.web.servlet.setup.RouterFunctionMockMvcBuilder; +import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder; import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestClient; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.servlet.function.RouterFunction; import org.springframework.web.util.UriBuilderFactory; /** @@ -33,7 +44,7 @@ */ class DefaultRestTestClientBuilder> implements RestTestClient.Builder { - protected final RestClient.Builder restClientBuilder; + private final RestClient.Builder restClientBuilder; DefaultRestTestClientBuilder() { @@ -46,49 +57,110 @@ class DefaultRestTestClientBuilder> implemen @Override - public RestTestClient.Builder baseUrl(String baseUrl) { + public T baseUrl(String baseUrl) { this.restClientBuilder.baseUrl(baseUrl); - return this; + return self(); } @Override - public RestTestClient.Builder uriBuilderFactory(UriBuilderFactory uriFactory) { + public T uriBuilderFactory(UriBuilderFactory uriFactory) { this.restClientBuilder.uriBuilderFactory(uriFactory); - return this; + return self(); } @Override - public RestTestClient.Builder defaultHeader(String headerName, String... headerValues) { + public T defaultHeader(String headerName, String... headerValues) { this.restClientBuilder.defaultHeader(headerName, headerValues); - return this; + return self(); } @Override - public RestTestClient.Builder defaultHeaders(Consumer headersConsumer) { + public T defaultHeaders(Consumer headersConsumer) { this.restClientBuilder.defaultHeaders(headersConsumer); - return this; + return self(); } @Override - public RestTestClient.Builder defaultCookie(String cookieName, String... cookieValues) { + public T defaultCookie(String cookieName, String... cookieValues) { this.restClientBuilder.defaultCookie(cookieName, cookieValues); - return this; + return self(); } @Override - public RestTestClient.Builder defaultCookies(Consumer> cookiesConsumer) { + public T defaultCookies(Consumer> cookiesConsumer) { this.restClientBuilder.defaultCookies(cookiesConsumer); - return this; + return self(); } @Override - public RestTestClient.Builder apply(Consumer> builderConsumer) { + public T apply(Consumer> builderConsumer) { builderConsumer.accept(this); - return this; + return self(); + } + + @SuppressWarnings("unchecked") + protected T self() { + return (T) this; + } + + protected void setClientHttpRequestFactory(ClientHttpRequestFactory requestFactory) { + this.restClientBuilder.requestFactory(requestFactory); } @Override public RestTestClient build() { return new DefaultRestTestClient(this.restClientBuilder); } + + + static class AbstractMockMvcSetupBuilder, M extends MockMvcBuilder> + extends DefaultRestTestClientBuilder implements RestTestClient.MockMvcSetupBuilder { + + private final M mockMvcBuilder; + + public AbstractMockMvcSetupBuilder(M mockMvcBuilder) { + this.mockMvcBuilder = mockMvcBuilder; + } + + public T configureServer(Consumer consumer) { + consumer.accept(this.mockMvcBuilder); + return self(); + } + + @Override + public RestTestClient build() { + MockMvc mockMvc = this.mockMvcBuilder.build(); + setClientHttpRequestFactory(new MockMvcClientHttpRequestFactory(mockMvc)); + return super.build(); + } + } + + + static class DefaultStandaloneSetupBuilder extends AbstractMockMvcSetupBuilder + implements RestTestClient.StandaloneSetupBuilder { + + DefaultStandaloneSetupBuilder(Object... controllers) { + super(MockMvcBuilders.standaloneSetup(controllers)); + } + } + + + static class DefaultRouterFunctionSetupBuilder extends AbstractMockMvcSetupBuilder + implements RestTestClient.RouterFunctionSetupBuilder { + + DefaultRouterFunctionSetupBuilder(RouterFunction... routerFunctions) { + super(MockMvcBuilders.routerFunctions(routerFunctions)); + } + + } + + + static class DefaultWebAppContextSetupBuilder extends AbstractMockMvcSetupBuilder + implements RestTestClient.WebAppContextSetupBuilder { + + DefaultWebAppContextSetupBuilder(WebApplicationContext context) { + super(MockMvcBuilders.webAppContextSetup(context)); + } + } + } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java index 4dcd74026f01..32bbed7535ab 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java @@ -37,7 +37,6 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvcBuilder; import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.web.servlet.setup.RouterFunctionMockMvcBuilder; import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder; import org.springframework.util.MultiValueMap; @@ -51,6 +50,8 @@ * Client for testing web servers. * * @author Rob Worsnop + * @author Rossen Stoyanchev + * @since 7.0 */ public interface RestTestClient { @@ -126,9 +127,8 @@ public interface RestTestClient { * {@link org.springframework.test.web.servlet.setup.MockMvcBuilders#standaloneSetup(Object...)} * to initialize {@link MockMvc}. */ - static MockServerBuilder standaloneSetup(Object... controllers) { - StandaloneMockMvcBuilder builder = MockMvcBuilders.standaloneSetup(controllers); - return new DefaultMockServerBuilder<>(builder); + static StandaloneSetupBuilder bindToController(Object... controllers) { + return new DefaultRestTestClientBuilder.DefaultStandaloneSetupBuilder(controllers); } /** @@ -138,9 +138,8 @@ static MockServerBuilder standaloneSetup(Object... con * {@link org.springframework.test.web.servlet.setup.MockMvcBuilders#routerFunctions(RouterFunction[])} * to initialize {@link MockMvc}. */ - static MockServerBuilder bindToRouterFunction(RouterFunction... routerFunctions) { - RouterFunctionMockMvcBuilder builder = MockMvcBuilders.routerFunctions(routerFunctions); - return new DefaultMockServerBuilder<>(builder); + static RouterFunctionSetupBuilder bindToRouterFunction(RouterFunction... routerFunctions) { + return new DefaultRestTestClientBuilder.DefaultRouterFunctionSetupBuilder(routerFunctions); } /** @@ -151,16 +150,15 @@ static MockServerBuilder bindToRouterFunction(Rout * {@link org.springframework.test.web.servlet.setup.MockMvcBuilders#webAppContextSetup(WebApplicationContext)} * to initialize {@code MockMvc}. */ - static MockServerBuilder bindToApplicationContext(WebApplicationContext context) { - DefaultMockMvcBuilder builder = MockMvcBuilders.webAppContextSetup(context); - return new DefaultMockServerBuilder<>(builder); + static WebAppContextSetupBuilder bindToApplicationContext(WebApplicationContext context) { + return new DefaultRestTestClientBuilder.DefaultWebAppContextSetupBuilder(context); } /** * Begin creating a {@link RestTestClient} by providing an already * initialized {@link MockMvc} instance to use as the server. */ - static > Builder bindTo(MockMvc mockMvc) { + static Builder bindTo(MockMvc mockMvc) { ClientHttpRequestFactory requestFactory = new MockMvcClientHttpRequestFactory(mockMvc); return RestTestClient.bindToServer(requestFactory); } @@ -175,7 +173,7 @@ static > Builder bindTo(MockMvc mockMvc) { * * @return chained API to customize client config */ - static > Builder bindToServer() { + static Builder bindToServer() { return new DefaultRestTestClientBuilder<>(); } @@ -183,7 +181,7 @@ static > Builder bindToServer() { * A variant of {@link #bindToServer()} with a pre-configured request factory. * @return chained API to customize client config */ - static > Builder bindToServer(ClientHttpRequestFactory requestFactory) { + static Builder bindToServer(ClientHttpRequestFactory requestFactory) { return new DefaultRestTestClientBuilder<>(RestClient.builder().requestFactory(requestFactory)); } @@ -195,20 +193,20 @@ interface Builder> { * {@link RestClient#create(String) * WebClient.create(String)}. */ - Builder baseUrl(String baseUrl); + T baseUrl(String baseUrl); /** * Provide a pre-configured {@link UriBuilderFactory} instance as an * alternative to and effectively overriding {@link #baseUrl(String)}. */ - Builder uriBuilderFactory(UriBuilderFactory uriBuilderFactory); + T uriBuilderFactory(UriBuilderFactory uriBuilderFactory); /** * Add the given header to all requests that haven't added it. * @param headerName the header name * @param headerValues the header values */ - Builder defaultHeader(String headerName, String... headerValues); + T defaultHeader(String headerName, String... headerValues); /** * Manipulate the default headers with the given consumer. The @@ -219,14 +217,14 @@ interface Builder> { * @param headersConsumer a function that consumes the {@code HttpHeaders} * @return this builder */ - Builder defaultHeaders(Consumer headersConsumer); + T defaultHeaders(Consumer headersConsumer); /** * Add the given cookie to all requests. * @param cookieName the cookie name * @param cookieValues the cookie values */ - Builder defaultCookie(String cookieName, String... cookieValues); + T defaultCookie(String cookieName, String... cookieValues); /** * Manipulate the default cookies with the given consumer. The @@ -237,29 +235,41 @@ interface Builder> { * @param cookiesConsumer a function that consumes the cookies map * @return this builder */ - Builder defaultCookies(Consumer> cookiesConsumer); + T defaultCookies(Consumer> cookiesConsumer); /** * Apply the given {@code Consumer} to this builder instance. *

This can be useful for applying pre-packaged customizations. * @param builderConsumer the consumer to apply */ - Builder apply(Consumer> builderConsumer); + T apply(Consumer> builderConsumer); /** * Build the {@link RestTestClient} instance. */ RestTestClient build(); + } + + + interface MockMvcSetupBuilder, M extends MockMvcBuilder> extends Builder { + + T configureServer(Consumer consumer); + } + + + interface StandaloneSetupBuilder extends MockMvcSetupBuilder { } - interface MockServerBuilder extends Builder> { + interface RouterFunctionSetupBuilder extends MockMvcSetupBuilder { + } - MockServerBuilder configureServer(Consumer consumer); + interface WebAppContextSetupBuilder extends MockMvcSetupBuilder { } + /** * Specification for providing the URI of a request. * diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/JsonPathAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/JsonPathAssertionTests.java index c4993e0a1f2a..a63e649418d3 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/JsonPathAssertionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/JsonPathAssertionTests.java @@ -48,7 +48,7 @@ class JsonPathAssertionTests { private final RestTestClient client = - RestTestClient.standaloneSetup(new MusicController()) + RestTestClient.bindToController(new MusicController()) .configureServer(builder -> builder.alwaysExpect(status().isOk()) .alwaysExpect(content().contentType(MediaType.APPLICATION_JSON)) diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ErrorTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ErrorTests.java index 9c28d6ee55b1..656da349f85b 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ErrorTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ErrorTests.java @@ -30,7 +30,7 @@ */ class ErrorTests { - private final RestTestClient client = RestTestClient.standaloneSetup(new TestController()).build(); + private final RestTestClient client = RestTestClient.bindToController(new TestController()).build(); @Test diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/HeaderAndCookieTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/HeaderAndCookieTests.java index df60e53ff1ef..28a3c99cacb1 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/HeaderAndCookieTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/HeaderAndCookieTests.java @@ -34,7 +34,7 @@ */ class HeaderAndCookieTests { - private final RestTestClient client = RestTestClient.standaloneSetup(new TestController()).build(); + private final RestTestClient client = RestTestClient.bindToController(new TestController()).build(); @Test void requestResponseHeaderPair() { diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/JsonContentTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/JsonContentTests.java index cefb95be673e..fc035c1e8057 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/JsonContentTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/JsonContentTests.java @@ -42,7 +42,7 @@ */ class JsonContentTests { - private final RestTestClient client = RestTestClient.standaloneSetup(new PersonController()).build(); + private final RestTestClient client = RestTestClient.bindToController(new PersonController()).build(); @Test diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ResponseEntityTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ResponseEntityTests.java index 20d2c5385acf..12596a25936a 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ResponseEntityTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ResponseEntityTests.java @@ -43,7 +43,7 @@ * @author Rob Worsnop */ class ResponseEntityTests { - private final RestTestClient client = RestTestClient.standaloneSetup(new PersonController()) + private final RestTestClient client = RestTestClient.bindToController(new PersonController()) .baseUrl("/persons") .build(); diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/RestTestClientTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/RestTestClientTests.java index 5477ec670cf1..381c520ca7f7 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/RestTestClientTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/RestTestClientTests.java @@ -53,7 +53,7 @@ class RestTestClientTests { @BeforeEach void setUp() { - this.client = RestTestClient.standaloneSetup(new TestController()).build(); + this.client = RestTestClient.bindToController(new TestController()).build(); } @Nested diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/SoftAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/SoftAssertionTests.java index a9f433c21ece..d13154e857f2 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/SoftAssertionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/SoftAssertionTests.java @@ -30,7 +30,7 @@ */ class SoftAssertionTests { - private final RestTestClient restTestClient = RestTestClient.standaloneSetup(new TestController()).build(); + private final RestTestClient restTestClient = RestTestClient.bindToController(new TestController()).build(); @Test diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/XmlContentTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/XmlContentTests.java index 8950f51bed48..f9af1bcaa45e 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/XmlContentTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/XmlContentTests.java @@ -58,7 +58,7 @@ class XmlContentTests { """; - private final RestTestClient client = RestTestClient.standaloneSetup(new PersonController()).build(); + private final RestTestClient client = RestTestClient.bindToController(new PersonController()).build(); @Test diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/ControllerTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/ControllerTests.java index 2f2aaee064cb..227bdc0d3d7a 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/ControllerTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/ControllerTests.java @@ -36,7 +36,7 @@ class ControllerTests { @BeforeEach void setUp() { - this.client = RestTestClient.standaloneSetup(new TestController()).build(); + this.client = RestTestClient.bindToController(new TestController()).build(); } From 34f259778e23265c6f5c5a31cde02de3e69fcc67 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Tue, 29 Jul 2025 19:07:16 +0100 Subject: [PATCH 025/591] Further alignment of RestTestClient and WebTestClient See gh-34428 --- .../web/reactive/server/CookieAssertions.java | 4 +- .../reactive/server/DefaultWebTestClient.java | 16 +-- .../web/reactive/server/HeaderAssertions.java | 2 + .../reactive/server/JsonPathAssertions.java | 5 +- .../web/reactive/server/StatusAssertions.java | 2 + .../web/reactive/server/WebTestClient.java | 50 +++---- .../web/servlet/client/CookieAssertions.java | 4 +- .../servlet/client/DefaultRestTestClient.java | 101 +++++++------- .../client/DefaultRestTestClientBuilder.java | 45 ++++--- .../web/servlet/client/ExchangeResult.java | 78 ++++++----- .../web/servlet/client/HeaderAssertions.java | 2 +- .../servlet/client/JsonPathAssertions.java | 8 +- .../web/servlet/client/RestTestClient.java | 124 +++++++++++------- .../web/servlet/client/StatusAssertions.java | 4 +- .../web/servlet/client/XpathAssertions.java | 8 +- .../client/samples/RestTestClientTests.java | 2 +- 16 files changed, 264 insertions(+), 191 deletions(-) diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/CookieAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/CookieAssertions.java index 6deca1e015b8..dd47cec18bb4 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/CookieAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/CookieAssertions.java @@ -29,10 +29,12 @@ */ public class CookieAssertions extends AbstractCookieAssertions { - public CookieAssertions(ExchangeResult exchangeResult, WebTestClient.ResponseSpec responseSpec) { + + CookieAssertions(ExchangeResult exchangeResult, WebTestClient.ResponseSpec responseSpec) { super(exchangeResult, responseSpec); } + @Override protected void assertWithDiagnostics(Runnable assertion) { exchangeResult.assertWithDiagnostics(assertion); diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java index 0659241cae2b..78e287b4248f 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java @@ -438,12 +438,12 @@ private static class DefaultResponseSpec implements ResponseSpec { DefaultResponseSpec( - ExchangeResult exchangeResult, ClientResponse response, + ExchangeResult result, ClientResponse response, @Nullable JsonEncoderDecoder jsonEncoderDecoder, Consumer> entityResultConsumer, Duration timeout) { - this.exchangeResult = exchangeResult; + this.exchangeResult = result; this.response = response; this.jsonEncoderDecoder = jsonEncoderDecoder; this.entityResultConsumer = entityResultConsumer; @@ -468,15 +468,15 @@ public CookieAssertions expectCookie() { @Override public BodySpec expectBody(Class bodyType) { B body = this.response.bodyToMono(bodyType).block(this.timeout); - EntityExchangeResult entityResult = initEntityExchangeResult(body); - return new DefaultBodySpec<>(entityResult); + EntityExchangeResult result = initEntityExchangeResult(body); + return new DefaultBodySpec<>(result); } @Override public BodySpec expectBody(ParameterizedTypeReference bodyType) { B body = this.response.bodyToMono(bodyType).block(this.timeout); - EntityExchangeResult entityResult = initEntityExchangeResult(body); - return new DefaultBodySpec<>(entityResult); + EntityExchangeResult result = initEntityExchangeResult(body); + return new DefaultBodySpec<>(result); } @Override @@ -500,8 +500,8 @@ private ListBodySpec getListBodySpec(Flux flux) { public BodyContentSpec expectBody() { ByteArrayResource resource = this.response.bodyToMono(ByteArrayResource.class).block(this.timeout); byte[] body = (resource != null ? resource.getByteArray() : null); - EntityExchangeResult entityResult = initEntityExchangeResult(body); - return new DefaultBodyContentSpec(entityResult, this.jsonEncoderDecoder); + EntityExchangeResult result = initEntityExchangeResult(body); + return new DefaultBodyContentSpec(result, this.jsonEncoderDecoder); } private EntityExchangeResult initEntityExchangeResult(@Nullable B body) { diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/HeaderAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/HeaderAssertions.java index 5cf42730b57e..d8ca7dadab94 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/HeaderAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/HeaderAssertions.java @@ -31,10 +31,12 @@ */ public class HeaderAssertions extends AbstractHeaderAssertions { + HeaderAssertions(ExchangeResult result, WebTestClient.ResponseSpec spec) { super(result, spec); } + @Override protected void assertWithDiagnostics(Runnable assertion) { exchangeResult.assertWithDiagnostics(assertion); diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonPathAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonPathAssertions.java index 5e5e5da78992..44602c185a9c 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonPathAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonPathAssertions.java @@ -34,8 +34,11 @@ */ public class JsonPathAssertions extends AbstractJsonPathAssertions { - JsonPathAssertions(WebTestClient.BodyContentSpec spec, String content, String expression, + + JsonPathAssertions( + WebTestClient.BodyContentSpec spec, String content, String expression, @Nullable Configuration configuration) { + super(spec, content, expression, configuration); } } diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/StatusAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/StatusAssertions.java index 0c0d87e82fa8..91fa59ee527c 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/StatusAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/StatusAssertions.java @@ -29,10 +29,12 @@ */ public class StatusAssertions extends AbstractStatusAssertions { + StatusAssertions(ExchangeResult result, WebTestClient.ResponseSpec spec) { super(result, spec); } + @Override protected void assertWithDiagnostics(Runnable assertion) { exchangeResult.assertWithDiagnostics(assertion); diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java index 4d9c30fbc1b2..2ffaa16168fd 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java @@ -73,7 +73,7 @@ * Client for testing web servers that uses {@link WebClient} internally to * perform requests while also providing a fluent API to verify responses. * This client can connect to any server over HTTP, or to a WebFlux application - * via mock request and response objects. + * with a mock request and response. * *

Use one of the bindToXxx methods to create an instance. For example: *

    @@ -89,9 +89,6 @@ * @author Sam Brannen * @author Michał Rowicki * @since 5.0 - * @see StatusAssertions - * @see HeaderAssertions - * @see JsonPathAssertions */ public interface WebTestClient { @@ -172,12 +169,10 @@ public interface WebTestClient { /** - * Use this server setup to test one {@code @Controller} at a time. - * This option loads the default configuration of - * {@link org.springframework.web.reactive.config.EnableWebFlux @EnableWebFlux}. - * There are builder methods to customize the Java config. The resulting - * WebFlux application will be tested without an HTTP server using a mock - * request and response. + * Begin creating a {@link WebTestClient} with a mock server setup that + * tests one {@code @Controller} at a time with + * {@link org.springframework.web.reactive.config.EnableWebFlux @EnableWebFlux} + * equivalent configuration. * @param controllers one or more controller instances to test * (specified {@code Class} will be turned into instance) * @return chained API to customize server and client config; use @@ -188,10 +183,10 @@ static ControllerSpec bindToController(Object... controllers) { } /** - * Use this option to set up a server from a {@link RouterFunction}. - * Internally the provided configuration is passed to - * {@code RouterFunctions#toWebHandler}. The resulting WebFlux application - * will be tested without an HTTP server using a mock request and response. + * Begin creating a {@link WebTestClient} with a mock server setup that + * tests one {@code RouterFunction} at a time with + * {@link org.springframework.web.reactive.config.EnableWebFlux @EnableWebFlux} + * equivalent configuration. * @param routerFunction the RouterFunction to test * @return chained API to customize server and client config; use * {@link MockServerSpec#configureClient()} to transition to client config @@ -229,8 +224,7 @@ static MockServerSpec bindToWebHandler(WebHandler webHandler) { } /** - * This server setup option allows you to connect to a live server through - * a Reactor Netty client connector. + * This server setup option allows you to connect to a live server. *

     	 * WebTestClient client = WebTestClient.bindToServer()
     	 *         .baseUrl("http://localhost:8080")
    @@ -389,17 +383,12 @@ interface RouterFunctionSpec extends MockServerSpec {
     
     
     	/**
    -	 * Steps for customizing the {@link WebClient} used to test with,
    -	 * internally delegating to a
    -	 * {@link org.springframework.web.reactive.function.client.WebClient.Builder
    -	 * WebClient.Builder}.
    +	 * Steps to customize the underlying {@link WebClient} via {@link WebClient.Builder}.
     	 */
     	interface Builder {
     
     		/**
    -		 * Configure a base URI as described in
    -		 * {@link org.springframework.web.reactive.function.client.WebClient#create(String)
    -		 * WebClient.create(String)}.
    +		 * Configure a base URI as described in {@link WebClient#create(String)}.
     		 */
     		Builder baseUrl(String baseUrl);
     
    @@ -428,7 +417,7 @@ interface Builder {
     		Builder defaultHeaders(Consumer headersConsumer);
     
     		/**
    -		 * Add the given header to all requests that haven't added it.
    +		 * Add the given cookie to all requests that haven't already added it.
     		 * @param cookieName the cookie name
     		 * @param cookieValues the cookie values
     		 */
    @@ -718,6 +707,7 @@ interface RequestHeadersSpec> {
     	 * Specification for providing body of a request.
     	 */
     	interface RequestBodySpec extends RequestHeadersSpec {
    +
     		/**
     		 * Set the length of the body in bytes, as specified by the
     		 * {@code Content-Length} header.
    @@ -738,7 +728,7 @@ interface RequestBodySpec extends RequestHeadersSpec {
     
     		/**
     		 * Set the body to the given {@code Object} value. This method invokes the
    -		 * {@link org.springframework.web.reactive.function.client.WebClient.RequestBodySpec#bodyValue(Object)
    +		 * {@link WebClient.RequestBodySpec#bodyValue(Object)
     		 * bodyValue} method on the underlying {@code WebClient}.
     		 * @param body the value to write to the request body
     		 * @return spec for further declaration of the request
    @@ -773,7 +763,7 @@ > RequestHeadersSpec body(
     
     		/**
     		 * Set the body from the given producer. This method invokes the
    -		 * {@link org.springframework.web.reactive.function.client.WebClient.RequestBodySpec#body(Object, Class)
    +		 * {@link WebClient.RequestBodySpec#body(Object, Class)
     		 * body(Object, Class)} method on the underlying {@code WebClient}.
     		 * @param producer the producer to write to the request. This must be a
     		 * {@link Publisher} or another producer adaptable to a
    @@ -786,7 +776,7 @@ > RequestHeadersSpec body(
     
     		/**
     		 * Set the body from the given producer. This method invokes the
    -		 * {@link org.springframework.web.reactive.function.client.WebClient.RequestBodySpec#body(Object, ParameterizedTypeReference)
    +		 * {@link WebClient.RequestBodySpec#body(Object, ParameterizedTypeReference)
     		 * body(Object, ParameterizedTypeReference)} method on the underlying {@code WebClient}.
     		 * @param producer the producer to write to the request. This must be a
     		 * {@link Publisher} or another producer adaptable to a
    @@ -800,7 +790,7 @@ > RequestHeadersSpec body(
     		/**
     		 * Set the body of the request to the given {@code BodyInserter}.
     		 * This method invokes the
    -		 * {@link org.springframework.web.reactive.function.client.WebClient.RequestBodySpec#body(BodyInserter)
    +		 * {@link WebClient.RequestBodySpec#body(BodyInserter)
     		 * body(BodyInserter)} method on the underlying {@code WebClient}.
     		 * @param inserter the body inserter to use
     		 * @return spec for further declaration of the request
    @@ -908,8 +898,8 @@ interface ResponseSpec {
     		BodyContentSpec expectBody();
     
     		/**
    -		 * Exit the chained flow in order to consume the response body
    -		 * externally, for example, via {@link reactor.test.StepVerifier}.
    +		 * Exit the chained flow in order to consume the response body externally,
    +		 * for example, via {@link reactor.test.StepVerifier}.
     		 * 

    Note that when {@code Void.class} is passed in, the response body * is consumed and released. If no content is expected, then consider * using {@code .expectBody().isEmpty()} instead which asserts that diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/CookieAssertions.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/CookieAssertions.java index b4f8cced077c..e5a033f8252f 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/CookieAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/CookieAssertions.java @@ -28,10 +28,12 @@ */ public class CookieAssertions extends AbstractCookieAssertions { - public CookieAssertions(ExchangeResult exchangeResult, RestTestClient.ResponseSpec responseSpec) { + + CookieAssertions(ExchangeResult exchangeResult, RestTestClient.ResponseSpec responseSpec) { super(exchangeResult, responseSpec); } + @Override protected void assertWithDiagnostics(Runnable assertion) { exchangeResult.assertWithDiagnostics(assertion); diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java index d831a68af033..4e69ffdc1346 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java @@ -21,7 +21,6 @@ import java.nio.charset.StandardCharsets; import java.time.ZonedDateTime; import java.util.Map; -import java.util.Objects; import java.util.Optional; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; @@ -59,12 +58,9 @@ class DefaultRestTestClient implements RestTestClient { private final AtomicLong requestIndex = new AtomicLong(); - private final RestClient.Builder restClientBuilder; - - DefaultRestTestClient(RestClient.Builder restClientBuilder) { - this.restClient = restClientBuilder.build(); - this.restClientBuilder = restClientBuilder; + DefaultRestTestClient(RestClient.Builder builder) { + this.restClient = builder.build(); } @@ -104,8 +100,8 @@ public RequestHeadersUriSpec options() { } @Override - public RequestBodyUriSpec method(HttpMethod method) { - return methodInternal(method); + public RequestBodyUriSpec method(HttpMethod httpMethod) { + return methodInternal(httpMethod); } private RequestBodyUriSpec methodInternal(HttpMethod httpMethod) { @@ -114,7 +110,7 @@ private RequestBodyUriSpec methodInternal(HttpMethod httpMethod) { @Override public > Builder mutate() { - return new DefaultRestTestClientBuilder<>(this.restClientBuilder); + return new DefaultRestTestClientBuilder<>(this.restClient.mutate()); } @@ -122,103 +118,105 @@ private class DefaultRequestBodyUriSpec implements RequestBodyUriSpec { private final RestClient.RequestBodyUriSpec requestHeadersUriSpec; - private RestClient.RequestBodySpec requestBodySpec; - - private final String requestId; - DefaultRequestBodyUriSpec(RestClient.RequestBodyUriSpec spec) { this.requestHeadersUriSpec = spec; - this.requestBodySpec = spec; - this.requestId = String.valueOf(requestIndex.incrementAndGet()); + String requestId = String.valueOf(requestIndex.incrementAndGet()); + this.requestHeadersUriSpec.header(RESTTESTCLIENT_REQUEST_ID, requestId); } @Override - public RequestBodySpec uri(String uriTemplate, Object... uriVariables) { - this.requestBodySpec = this.requestHeadersUriSpec.uri(uriTemplate, uriVariables); + public RequestBodySpec uri(String uriTemplate, @Nullable Object... uriVariables) { + this.requestHeadersUriSpec.uri(uriTemplate, uriVariables); return this; } @Override public RequestBodySpec uri(String uri, Map uriVariables) { - this.requestBodySpec = this.requestHeadersUriSpec.uri(uri, uriVariables); + this.requestHeadersUriSpec.uri(uri, uriVariables); return this; } @Override public RequestBodySpec uri(Function uriFunction) { - this.requestBodySpec = this.requestHeadersUriSpec.uri(uriFunction); + this.requestHeadersUriSpec.uri(uriFunction); return this; } @Override public RequestBodySpec uri(URI uri) { - this.requestBodySpec = this.requestHeadersUriSpec.uri(uri); + this.requestHeadersUriSpec.uri(uri); return this; } @Override public RequestBodySpec header(String headerName, String... headerValues) { - this.requestBodySpec = this.requestHeadersUriSpec.header(headerName, headerValues); + this.requestHeadersUriSpec.header(headerName, headerValues); return this; } @Override public RequestBodySpec headers(Consumer headersConsumer) { - this.requestBodySpec = this.requestHeadersUriSpec.headers(headersConsumer); + this.requestHeadersUriSpec.headers(headersConsumer); return this; } @Override public RequestBodySpec accept(MediaType... acceptableMediaTypes) { - this.requestBodySpec = this.requestHeadersUriSpec.accept(acceptableMediaTypes); + this.requestHeadersUriSpec.accept(acceptableMediaTypes); return this; } @Override public RequestBodySpec acceptCharset(Charset... acceptableCharsets) { - this.requestBodySpec = this.requestHeadersUriSpec.acceptCharset(acceptableCharsets); + this.requestHeadersUriSpec.acceptCharset(acceptableCharsets); return this; } @Override public RequestBodySpec contentType(MediaType contentType) { - this.requestBodySpec = this.requestHeadersUriSpec.contentType(contentType); + this.requestHeadersUriSpec.contentType(contentType); + return this; + } + + @Override + public RequestBodySpec contentLength(long contentLength) { + this.requestHeadersUriSpec.contentLength(contentLength); return this; } @Override public RequestBodySpec cookie(String name, String value) { - this.requestBodySpec = this.requestHeadersUriSpec.cookie(name, value); + this.requestHeadersUriSpec.cookie(name, value); return this; } @Override public RequestBodySpec cookies(Consumer> cookiesConsumer) { - this.requestBodySpec = this.requestHeadersUriSpec.cookies(cookiesConsumer); + this.requestHeadersUriSpec.cookies(cookiesConsumer); return this; } @Override public RequestBodySpec ifModifiedSince(ZonedDateTime ifModifiedSince) { - this.requestBodySpec = this.requestHeadersUriSpec.ifModifiedSince(ifModifiedSince); + this.requestHeadersUriSpec.ifModifiedSince(ifModifiedSince); return this; } @Override public RequestBodySpec ifNoneMatch(String... ifNoneMatches) { - this.requestBodySpec = this.requestHeadersUriSpec.ifNoneMatch(ifNoneMatches); + this.requestHeadersUriSpec.ifNoneMatch(ifNoneMatches); return this; } @Override public RequestBodySpec attribute(String name, Object value) { - this.requestBodySpec = this.requestHeadersUriSpec.attribute(name, value); + this.requestHeadersUriSpec.attribute(name, value); return this; } @Override public RequestBodySpec attributes(Consumer> attributesConsumer) { - this.requestBodySpec = this.requestHeadersUriSpec.attributes(attributesConsumer); + this.requestHeadersUriSpec.attributes(attributesConsumer); return this; } @@ -230,11 +228,9 @@ public RequestHeadersSpec body(Object body) { @Override public ResponseSpec exchange() { - this.requestBodySpec = this.requestBodySpec.header(RESTTESTCLIENT_REQUEST_ID, this.requestId); - ExchangeResult exchangeResult = this.requestBodySpec.exchange( - (clientRequest, clientResponse) -> new ExchangeResult(clientResponse), - false); - return new DefaultResponseSpec(Objects.requireNonNull(exchangeResult)); + return new DefaultResponseSpec( + this.requestHeadersUriSpec.exchangeForRequiredValue( + (request, response) -> new ExchangeResult(response), false)); } } @@ -243,8 +239,8 @@ private static class DefaultResponseSpec implements ResponseSpec { private final ExchangeResult exchangeResult; - DefaultResponseSpec(ExchangeResult exchangeResult) { - this.exchangeResult = exchangeResult; + DefaultResponseSpec(ExchangeResult result) { + this.exchangeResult = result; } @Override @@ -265,19 +261,22 @@ public CookieAssertions expectCookie() { @Override public BodySpec expectBody(Class bodyType) { B body = this.exchangeResult.getBody(bodyType); - return new DefaultBodySpec<>(new EntityExchangeResult<>(this.exchangeResult, body)); + EntityExchangeResult result = new EntityExchangeResult<>(this.exchangeResult, body); + return new DefaultBodySpec<>(result); } @Override public BodySpec expectBody(ParameterizedTypeReference bodyType) { B body = this.exchangeResult.getBody(bodyType); - return new DefaultBodySpec<>(new EntityExchangeResult<>(this.exchangeResult, body)); + EntityExchangeResult result = new EntityExchangeResult<>(this.exchangeResult, body); + return new DefaultBodySpec<>(result); } @Override public BodyContentSpec expectBody() { byte[] body = this.exchangeResult.getBody(byte[].class); - return new DefaultBodyContentSpec( new EntityExchangeResult<>(this.exchangeResult, body)); + EntityExchangeResult result = new EntityExchangeResult<>(this.exchangeResult, body); + return new DefaultBodyContentSpec(result); } @Override @@ -318,20 +317,26 @@ private static class DefaultBodySpec> implements Bod private final EntityExchangeResult result; - DefaultBodySpec(@Nullable EntityExchangeResult result) { - this.result = Objects.requireNonNull(result, "exchangeResult must be non-null"); + DefaultBodySpec(EntityExchangeResult result) { + this.result = result; } @Override - public T isEqualTo(B expected) { + public T isEqualTo(@Nullable B expected) { this.result.assertWithDiagnostics(() -> AssertionErrors.assertEquals("Response body", expected, this.result.getResponseBody())); return self(); } + @Override + public T value(Matcher matcher) { + this.result.assertWithDiagnostics(() -> MatcherAssert.assertThat(this.result.getResponseBody(), matcher)); + return self(); + } + @Override @SuppressWarnings("NullAway") // https://github.com/uber/NullAway/issues/1129 - public T value(Function bodyMapper, Matcher matcher) { + public T value(Function<@Nullable B, @Nullable R> bodyMapper, Matcher matcher) { this.result.assertWithDiagnostics(() -> { B body = this.result.getResponseBody(); MatcherAssert.assertThat(bodyMapper.apply(body), matcher); @@ -340,7 +345,8 @@ public T value(Function bodyMapper, Matcher ma } @Override - public T value(Consumer consumer) { + @SuppressWarnings("NullAway") // https://github.com/uber/NullAway/issues/1129 + public T value(Consumer<@Nullable B> consumer) { this.result.assertWithDiagnostics(() -> consumer.accept(this.result.getResponseBody())); return self(); } @@ -374,8 +380,7 @@ private static class DefaultBodyContentSpec implements BodyContentSpec { @Override public EntityExchangeResult isEmpty() { this.result.assertWithDiagnostics(() -> - AssertionErrors.assertTrue("Expected empty body", - this.result.getBody(byte[].class) == null)); + AssertionErrors.assertTrue("Expected empty body", this.result.getBody(byte[].class) == null)); return new EntityExchangeResult<>(this.result, null); } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java index 48c1d255b07e..748deb67aaf7 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java @@ -18,12 +18,14 @@ import java.util.function.Consumer; -import org.jspecify.annotations.Nullable; - import org.springframework.http.HttpHeaders; import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvcBuilder; +import org.springframework.test.web.servlet.client.RestTestClient.MockMvcSetupBuilder; +import org.springframework.test.web.servlet.client.RestTestClient.RouterFunctionSetupBuilder; +import org.springframework.test.web.servlet.client.RestTestClient.StandaloneSetupBuilder; +import org.springframework.test.web.servlet.client.RestTestClient.WebAppContextSetupBuilder; import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.web.servlet.setup.RouterFunctionMockMvcBuilder; @@ -39,8 +41,8 @@ * * @author Rob Worsnop * @author Rossen Stoyanchev - * @param the type of the builder * @since 7.0 + * @param the type of the builder */ class DefaultRestTestClientBuilder> implements RestTestClient.Builder { @@ -92,12 +94,6 @@ public T defaultCookies(Consumer> co return self(); } - @Override - public T apply(Consumer> builderConsumer) { - builderConsumer.accept(this); - return self(); - } - @SuppressWarnings("unchecked") protected T self() { return (T) this; @@ -113,8 +109,13 @@ public RestTestClient build() { } + /** + * Base class for implementations for {@link MockMvcSetupBuilder}. + * @param the "self" type of the builder + * @param the type of {@link MockMvc} builder + */ static class AbstractMockMvcSetupBuilder, M extends MockMvcBuilder> - extends DefaultRestTestClientBuilder implements RestTestClient.MockMvcSetupBuilder { + extends DefaultRestTestClientBuilder implements MockMvcSetupBuilder { private final M mockMvcBuilder; @@ -136,8 +137,12 @@ public RestTestClient build() { } - static class DefaultStandaloneSetupBuilder extends AbstractMockMvcSetupBuilder - implements RestTestClient.StandaloneSetupBuilder { + /** + * Default implementation of {@link StandaloneSetupBuilder}. + */ + static class DefaultStandaloneSetupBuilder + extends AbstractMockMvcSetupBuilder + implements StandaloneSetupBuilder { DefaultStandaloneSetupBuilder(Object... controllers) { super(MockMvcBuilders.standaloneSetup(controllers)); @@ -145,8 +150,12 @@ static class DefaultStandaloneSetupBuilder extends AbstractMockMvcSetupBuilder - implements RestTestClient.RouterFunctionSetupBuilder { + /** + * Default implementation of {@link RouterFunctionSetupBuilder}. + */ + static class DefaultRouterFunctionSetupBuilder + extends AbstractMockMvcSetupBuilder + implements RouterFunctionSetupBuilder { DefaultRouterFunctionSetupBuilder(RouterFunction... routerFunctions) { super(MockMvcBuilders.routerFunctions(routerFunctions)); @@ -155,8 +164,12 @@ static class DefaultRouterFunctionSetupBuilder extends AbstractMockMvcSetupBuild } - static class DefaultWebAppContextSetupBuilder extends AbstractMockMvcSetupBuilder - implements RestTestClient.WebAppContextSetupBuilder { + /** + * Default implementation of {@link WebAppContextSetupBuilder}. + */ + static class DefaultWebAppContextSetupBuilder + extends AbstractMockMvcSetupBuilder + implements WebAppContextSetupBuilder { DefaultWebAppContextSetupBuilder(WebApplicationContext context) { super(MockMvcBuilders.webAppContextSetup(context)); diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/ExchangeResult.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/ExchangeResult.java index bb899d2a8b02..a440f016d451 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/ExchangeResult.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/ExchangeResult.java @@ -19,7 +19,6 @@ import java.io.IOException; import java.net.HttpCookie; import java.util.List; -import java.util.Objects; import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -32,6 +31,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseCookie; +import org.springframework.util.Assert; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestClient.RequestHeadersSpec.ConvertibleClientHttpResponse; @@ -41,21 +41,28 @@ * {@link RestTestClient}. * * @author Rob Worsnop + * @author Rossen Stoyanchev + * @since 7.0 */ public class ExchangeResult { + private static final Pattern SAME_SITE_PATTERN = Pattern.compile("(?i).*SameSite=(Strict|Lax|None).*"); + private static final Pattern PARTITIONED_PATTERN = Pattern.compile("(?i).*;\\s*Partitioned(\\s*;.*|\\s*)$"); private static final Log logger = LogFactory.getLog(ExchangeResult.class); + + private final ConvertibleClientHttpResponse clientResponse; + /** Ensure single logging; for example, for expectAll. */ private boolean diagnosticsLogged; - private final ConvertibleClientHttpResponse clientResponse; - ExchangeResult(@Nullable ConvertibleClientHttpResponse clientResponse) { - this.clientResponse = Objects.requireNonNull(clientResponse, "clientResponse must be non-null"); + ExchangeResult(@Nullable ConvertibleClientHttpResponse response) { + Assert.notNull(response, "Response must not be null"); + this.clientResponse = response; } ExchangeResult(ExchangeResult result) { @@ -63,6 +70,10 @@ public class ExchangeResult { this.diagnosticsLogged = result.diagnosticsLogged; } + + /** + * Return the HTTP status code as an {@link HttpStatusCode} value. + */ public HttpStatusCode getStatus() { try { return this.clientResponse.getStatusCode(); @@ -72,10 +83,41 @@ public HttpStatusCode getStatus() { } } + /** + * Return the response headers received from the server. + */ public HttpHeaders getResponseHeaders() { return this.clientResponse.getHeaders(); } + /** + * Return response cookies received from the server. + */ + public MultiValueMap getResponseCookies() { + return Optional.ofNullable(this.clientResponse.getHeaders().get(HttpHeaders.SET_COOKIE)).orElse(List.of()).stream() + .flatMap(header -> { + Matcher matcher = SAME_SITE_PATTERN.matcher(header); + String sameSite = (matcher.matches() ? matcher.group(1) : null); + boolean partitioned = PARTITIONED_PATTERN.matcher(header).matches(); + return HttpCookie.parse(header).stream().map(cookie -> toResponseCookie(cookie, sameSite, partitioned)); + }) + .collect(LinkedMultiValueMap::new, + (cookies, cookie) -> cookies.add(cookie.getName(), cookie), + LinkedMultiValueMap::addAll); + } + + private static ResponseCookie toResponseCookie(HttpCookie cookie, @Nullable String sameSite, boolean partitioned) { + return ResponseCookie.from(cookie.getName(), cookie.getValue()) + .domain(cookie.getDomain()) + .httpOnly(cookie.isHttpOnly()) + .maxAge(cookie.getMaxAge()) + .path(cookie.getPath()) + .secure(cookie.getSecure()) + .sameSite(sameSite) + .partitioned(partitioned) + .build(); + } + @Nullable public T getBody(Class bodyType) { return this.clientResponse.bodyTo(bodyType); @@ -86,7 +128,6 @@ public T getBody(ParameterizedTypeReference bodyType) { return this.clientResponse.bodyTo(bodyType); } - /** * Execute the given Runnable, catch any {@link AssertionError}, log details * about the request and response at ERROR level under the class log @@ -105,31 +146,4 @@ public void assertWithDiagnostics(Runnable assertion) { } } - /** - * Return response cookies received from the server. - */ - public MultiValueMap getResponseCookies() { - return Optional.ofNullable(this.clientResponse.getHeaders().get(HttpHeaders.SET_COOKIE)).orElse(List.of()).stream() - .flatMap(header -> { - Matcher matcher = SAME_SITE_PATTERN.matcher(header); - String sameSite = (matcher.matches() ? matcher.group(1) : null); - boolean partitioned = PARTITIONED_PATTERN.matcher(header).matches(); - return HttpCookie.parse(header).stream().map(cookie -> toResponseCookie(cookie, sameSite, partitioned)); - }) - .collect(LinkedMultiValueMap::new, - (cookies, cookie) -> cookies.add(cookie.getName(), cookie), - LinkedMultiValueMap::addAll); - } - - private static ResponseCookie toResponseCookie(HttpCookie cookie, @Nullable String sameSite, boolean partitioned) { - return ResponseCookie.from(cookie.getName(), cookie.getValue()) - .domain(cookie.getDomain()) - .httpOnly(cookie.isHttpOnly()) - .maxAge(cookie.getMaxAge()) - .path(cookie.getPath()) - .secure(cookie.getSecure()) - .sameSite(sameSite) - .partitioned(partitioned) - .build(); - } } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/HeaderAssertions.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/HeaderAssertions.java index 89e557b93e53..777097e67d6f 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/HeaderAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/HeaderAssertions.java @@ -29,7 +29,7 @@ public class HeaderAssertions extends AbstractHeaderAssertions { - public HeaderAssertions(ExchangeResult exchangeResult, RestTestClient.ResponseSpec responseSpec) { + HeaderAssertions(ExchangeResult exchangeResult, RestTestClient.ResponseSpec responseSpec) { super(exchangeResult, responseSpec); } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/JsonPathAssertions.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/JsonPathAssertions.java index b5eda5ba59ae..efe57d0b3d2f 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/JsonPathAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/JsonPathAssertions.java @@ -26,13 +26,19 @@ * JsonPath assertions. * * @author Rob Worsnop + * @author Rossen Stoyanchev * @since 7.0 * @see https://github.com/jayway/JsonPath * @see JsonPathExpectationsHelper */ public class JsonPathAssertions extends AbstractJsonPathAssertions { - JsonPathAssertions(RestTestClient.BodyContentSpec spec, String content, String expression, @Nullable Configuration configuration) { + + JsonPathAssertions( + RestTestClient.BodyContentSpec spec, String content, String expression, + @Nullable Configuration configuration) { + super(spec, content, expression, configuration); } + } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java index 32bbed7535ab..2394060227a2 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java @@ -37,6 +37,7 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvcBuilder; import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.web.servlet.setup.RouterFunctionMockMvcBuilder; import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder; import org.springframework.util.MultiValueMap; @@ -47,7 +48,19 @@ import org.springframework.web.util.UriBuilderFactory; /** - * Client for testing web servers. + * Client for testing web servers that uses {@link RestClient} internally to + * perform requests while also providing a fluent API to verify responses. + * This client can connect to any server over HTTP or to a {@link MockMvc} server + * with a mock request and response. + * + *

    Use one of the bindToXxx methods to create an instance. For example: + *

      + *
    • {@link #bindToController(Object...)} + *
    • {@link #bindToRouterFunction(RouterFunction[])} + *
    • {@link #bindToApplicationContext(WebApplicationContext)} + *
    • {@link #bindToServer()} + *
    • ... + *
    * * @author Rob Worsnop * @author Rossen Stoyanchev @@ -121,34 +134,24 @@ public interface RestTestClient { /** - * Begin creating a {@link RestTestClient} by providing the {@code @Controller} - * instance(s) to handle requests with. - *

    Internally this is delegated to and equivalent to using - * {@link org.springframework.test.web.servlet.setup.MockMvcBuilders#standaloneSetup(Object...)} - * to initialize {@link MockMvc}. + * Begin creating a {@link RestTestClient} with a {@link MockMvcBuilders#standaloneSetup + * Standalone MockMvc setup}. */ static StandaloneSetupBuilder bindToController(Object... controllers) { return new DefaultRestTestClientBuilder.DefaultStandaloneSetupBuilder(controllers); } /** - * Begin creating a {@link RestTestClient} by providing the {@link RouterFunction} - * instance(s) to handle requests with. - *

    Internally this is delegated to and equivalent to using - * {@link org.springframework.test.web.servlet.setup.MockMvcBuilders#routerFunctions(RouterFunction[])} - * to initialize {@link MockMvc}. + * Begin creating a {@link RestTestClient} with a {@link MockMvcBuilders#routerFunctions} + * RouterFunction's MockMvc setup}. */ static RouterFunctionSetupBuilder bindToRouterFunction(RouterFunction... routerFunctions) { return new DefaultRestTestClientBuilder.DefaultRouterFunctionSetupBuilder(routerFunctions); } /** - * Begin creating a {@link RestTestClient} by providing a - * {@link WebApplicationContext} with Spring MVC infrastructure and - * controllers. - *

    Internally this is delegated to and equivalent to using - * {@link org.springframework.test.web.servlet.setup.MockMvcBuilders#webAppContextSetup(WebApplicationContext)} - * to initialize {@code MockMvc}. + * Begin creating a {@link RestTestClient} with a {@link MockMvcBuilders#webAppContextSetup} + * WebAppContext MockMvc setup}. */ static WebAppContextSetupBuilder bindToApplicationContext(WebApplicationContext context) { return new DefaultRestTestClientBuilder.DefaultWebAppContextSetupBuilder(context); @@ -164,8 +167,7 @@ static Builder bindTo(MockMvc mockMvc) { } /** - * This server setup option allows you to connect to a live server through - * a client connector. + * This server setup option allows you to connect to a live server. *

     	 * RestTestClient client = RestTestClient.bindToServer()
     	 *         .baseUrl("http://localhost:8080")
    @@ -186,12 +188,14 @@ static Builder bindToServer(ClientHttpRequestFactory requestFactory) {
     	}
     
     
    +	/**
    +	 * Steps to customize the underlying {@link RestClient} via {@link RestClient.Builder}.
    +	 * @param  the type of builder
    +	 */
     	interface Builder> {
     
     		/**
    -		 * Configure a base URI as described in
    -		 * {@link RestClient#create(String)
    -		 * WebClient.create(String)}.
    +		 * Configure a base URI as described in {@link RestClient#create(String)}.
     		 */
     		 T baseUrl(String baseUrl);
     
    @@ -203,7 +207,7 @@ interface Builder> {
     
     		/**
     		 * Add the given header to all requests that haven't added it.
    -		 * @param headerName   the header name
    +		 * @param headerName the header name
     		 * @param headerValues the header values
     		 */
     		 T defaultHeader(String headerName, String... headerValues);
    @@ -220,8 +224,8 @@ interface Builder> {
     		 T defaultHeaders(Consumer headersConsumer);
     
     		/**
    -		 * Add the given cookie to all requests.
    -		 * @param cookieName   the cookie name
    +		 * Add the given cookie to all requests that haven't already added it.
    +		 * @param cookieName the cookie name
     		 * @param cookieValues the cookie values
     		 */
     		 T defaultCookie(String cookieName, String... cookieValues);
    @@ -237,39 +241,48 @@ interface Builder> {
     		 */
     		 T defaultCookies(Consumer> cookiesConsumer);
     
    -		/**
    -		 * Apply the given {@code Consumer} to this builder instance.
    -		 * 

    This can be useful for applying pre-packaged customizations. - * @param builderConsumer the consumer to apply - */ - T apply(Consumer> builderConsumer); - /** * Build the {@link RestTestClient} instance. */ RestTestClient build(); - } + } - interface MockMvcSetupBuilder, M extends MockMvcBuilder> extends Builder { + /** + * Extension of {@link Builder} for tests against a MockMvc server. + * @param the builder type + * @param the type of {@link MockMvc} setup + */ + interface MockMvcSetupBuilder, M extends MockMvcBuilder> extends Builder { - T configureServer(Consumer consumer); + T configureServer(Consumer consumer); } + /** + * Extension of {@link Builder} for tests витх а + * {@link MockMvcBuilders#standaloneSetup(Object...) standalone MockMvc setup}. + */ interface StandaloneSetupBuilder extends MockMvcSetupBuilder { } + /** + * Extension of {@link Builder} for tests витх а + * {@link MockMvcBuilders#routerFunctions(RouterFunction[]) RouterFunction MockMvc setup}. + */ interface RouterFunctionSetupBuilder extends MockMvcSetupBuilder { } + /** + * Extension of {@link Builder} for tests витх а + * {@link MockMvcBuilders#webAppContextSetup(WebApplicationContext) WebAppContext MockMvc setup}. + */ interface WebAppContextSetupBuilder extends MockMvcSetupBuilder { } - /** * Specification for providing the URI of a request. * @@ -294,7 +307,7 @@ interface UriSpec> { * with a base URI) it will be used to expand the URI template. * @return spec to add headers or perform the exchange */ - S uri(String uri, Object... uriVariables); + S uri(String uri, @Nullable Object... uriVariables); /** * Specify the URI for the request using a URI template and URI variables. @@ -302,7 +315,7 @@ interface UriSpec> { * with a base URI) it will be used to expand the URI template. * @return spec to add headers or perform the exchange */ - S uri(String uri, Map uriVariables); + S uri(String uri, Map uriVariables); /** * Build the URI for the request with a {@link UriBuilder} obtained @@ -419,6 +432,16 @@ interface RequestHeadersSpec> { * Specification for providing body of a request. */ interface RequestBodySpec extends RequestHeadersSpec { + + /** + * Set the length of the body in bytes, as specified by the + * {@code Content-Length} header. + * @param contentLength the content length + * @return the same instance + * @see HttpHeaders#setContentLength(long) + */ + RequestBodySpec contentLength(long contentLength); + /** * Set the {@linkplain MediaType media type} of the body, as specified * by the {@code Content-Type} header. @@ -430,7 +453,7 @@ interface RequestBodySpec extends RequestHeadersSpec { /** * Set the body to the given {@code Object} value. This method invokes the - * {@link org.springframework.web.client.RestClient.RequestBodySpec#body(Object)} (Object) + * {@link RestClient.RequestBodySpec#body(Object)} (Object) * bodyValue} method on the underlying {@code RestClient}. * @param body the value to write to the request body * @return spec for further declaration of the request @@ -461,8 +484,8 @@ interface RequestBodyUriSpec extends RequestBodySpec, RequestHeadersUriSpecIf a single {@link Error} or {@link RuntimeException} is thrown, @@ -522,8 +545,7 @@ interface ResponseSpec { BodyContentSpec expectBody(); /** - * Exit the chained flow in order to consume the response body - * externally. + * Exit the chained flow in order to consume the response body externally. */ EntityExchangeResult returnResult(Class elementClass); @@ -534,8 +556,8 @@ interface ResponseSpec { EntityExchangeResult returnResult(ParameterizedTypeReference elementTypeRef); /** - * {@link Consumer} of a {@link RestTestClient.ResponseSpec}. - * @see RestTestClient.ResponseSpec#expectAll(RestTestClient.ResponseSpec.ResponseSpecConsumer...) + * {@link Consumer} of a {@link ResponseSpec}. + * @see ResponseSpec#expectAll(ResponseSpecConsumer...) */ @FunctionalInterface interface ResponseSpecConsumer extends Consumer { @@ -554,18 +576,24 @@ interface BodySpec> { /** * Assert the extracted body is equal to the given value. */ - T isEqualTo(B expected); + T isEqualTo(@Nullable B expected); + + /** + * Assert the extracted body with a {@link Matcher}. + * @since 5.1 + */ + T value(Matcher matcher); /** * Transform the extracted the body with a function, for example, extracting a * property, and assert the mapped value with a {@link Matcher}. */ - T value(Function bodyMapper, Matcher matcher); + T value(Function<@Nullable B, @Nullable R> bodyMapper, Matcher matcher); /** * Assert the extracted body with a {@link Consumer}. */ - T value(Consumer consumer); + T value(Consumer<@Nullable B> consumer); /** * Assert the exchange result with the given {@link Consumer}. diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/StatusAssertions.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/StatusAssertions.java index c2f76ef22b07..f29df0975abc 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/StatusAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/StatusAssertions.java @@ -29,10 +29,12 @@ */ public class StatusAssertions extends AbstractStatusAssertions { - public StatusAssertions(ExchangeResult exchangeResult, ResponseSpec responseSpec) { + + StatusAssertions(ExchangeResult exchangeResult, ResponseSpec responseSpec) { super(exchangeResult, responseSpec); } + @Override protected void assertWithDiagnostics(Runnable assertion) { exchangeResult.assertWithDiagnostics(assertion); diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/XpathAssertions.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/XpathAssertions.java index cfec64a17eeb..253bb0fbc633 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/XpathAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/XpathAssertions.java @@ -33,11 +33,15 @@ */ public class XpathAssertions extends AbstractXpathAssertions { - XpathAssertions(RestTestClient.BodyContentSpec spec, - String expression, @Nullable Map namespaces, Object... args) { + + XpathAssertions( + RestTestClient.BodyContentSpec spec, + String expression, @Nullable Map namespaces, Object... args) { + super(spec, expression, namespaces, args); } + @Override protected Optional getResponseHeaders() { return Optional.of(bodySpec.returnResult()) diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/RestTestClientTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/RestTestClientTests.java index 381c520ca7f7..af1be6b11bc8 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/RestTestClientTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/RestTestClientTests.java @@ -133,7 +133,7 @@ class Mutation { @Test void test() { RestTestClientTests.this.client.mutate() - .apply(builder -> builder.defaultHeader("foo", "bar")) + .defaultHeader("foo", "bar") .uriBuilderFactory(new DefaultUriBuilderFactory("/test")) .defaultCookie("foo", "bar") .defaultCookies(cookies -> cookies.add("a", "b")) From 862ffee3853de14e95a12825af08f1e73fabfa59 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Tue, 29 Jul 2025 20:02:10 +0100 Subject: [PATCH 026/591] Update RestTestClient ExchangeResult to expose request and URI template information and to have toString See gh-34428 --- .../servlet/client/DefaultRestTestClient.java | 10 ++- .../web/servlet/client/ExchangeResult.java | 72 ++++++++++++++++++- .../servlet/client/CookieAssertionsTests.java | 16 ++++- .../servlet/client/HeaderAssertionTests.java | 11 ++- .../servlet/client/StatusAssertionTests.java | 5 +- 5 files changed, 104 insertions(+), 10 deletions(-) diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java index 4e69ffdc1346..1289c8af4499 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java @@ -118,6 +118,8 @@ private class DefaultRequestBodyUriSpec implements RequestBodyUriSpec { private final RestClient.RequestBodyUriSpec requestHeadersUriSpec; + private @Nullable String uriTemplate; + DefaultRequestBodyUriSpec(RestClient.RequestBodyUriSpec spec) { this.requestHeadersUriSpec = spec; String requestId = String.valueOf(requestIndex.incrementAndGet()); @@ -126,24 +128,28 @@ private class DefaultRequestBodyUriSpec implements RequestBodyUriSpec { @Override public RequestBodySpec uri(String uriTemplate, @Nullable Object... uriVariables) { + this.uriTemplate = uriTemplate; this.requestHeadersUriSpec.uri(uriTemplate, uriVariables); return this; } @Override public RequestBodySpec uri(String uri, Map uriVariables) { + this.uriTemplate = uri; this.requestHeadersUriSpec.uri(uri, uriVariables); return this; } @Override public RequestBodySpec uri(Function uriFunction) { + this.uriTemplate = null; this.requestHeadersUriSpec.uri(uriFunction); return this; } @Override public RequestBodySpec uri(URI uri) { + this.uriTemplate = null; this.requestHeadersUriSpec.uri(uri); return this; } @@ -229,8 +235,8 @@ public RequestHeadersSpec body(Object body) { @Override public ResponseSpec exchange() { return new DefaultResponseSpec( - this.requestHeadersUriSpec.exchangeForRequiredValue( - (request, response) -> new ExchangeResult(response), false)); + this.requestHeadersUriSpec.exchangeForRequiredValue((request, response) -> + new ExchangeResult(request, response, this.uriTemplate), false)); } } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/ExchangeResult.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/ExchangeResult.java index a440f016d451..def8eee72b6f 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/ExchangeResult.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/ExchangeResult.java @@ -18,10 +18,12 @@ import java.io.IOException; import java.net.HttpCookie; +import java.net.URI; import java.util.List; import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -29,6 +31,9 @@ import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpRequest; +import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseCookie; import org.springframework.util.Assert; @@ -54,23 +59,60 @@ public class ExchangeResult { private static final Log logger = LogFactory.getLog(ExchangeResult.class); + private final HttpRequest request; + private final ConvertibleClientHttpResponse clientResponse; + private final @Nullable String uriTemplate; + /** Ensure single logging; for example, for expectAll. */ private boolean diagnosticsLogged; - ExchangeResult(@Nullable ConvertibleClientHttpResponse response) { - Assert.notNull(response, "Response must not be null"); + ExchangeResult( + HttpRequest request, ConvertibleClientHttpResponse response, @Nullable String uriTemplate) { + + Assert.notNull(request, "HttpRequest must not be null"); + Assert.notNull(response, "ClientHttpResponse must not be null"); + this.request = request; this.clientResponse = response; + this.uriTemplate = uriTemplate; } ExchangeResult(ExchangeResult result) { - this(result.clientResponse); + this(result.request, result.clientResponse, result.uriTemplate); this.diagnosticsLogged = result.diagnosticsLogged; } + /** + * Return the method of the request. + */ + public HttpMethod getMethod() { + return this.request.getMethod(); + } + + /** + * Return the URI of the request. + */ + public URI getUrl() { + return this.request.getURI(); + } + + /** + * Return the original URI template used to prepare the request, if any. + */ + public @Nullable String getUriTemplate() { + return this.uriTemplate; + } + + /** + * Return the request headers sent to the server. + */ + public HttpHeaders getRequestHeaders() { + return this.request.getHeaders(); + } + /** * Return the HTTP status code as an {@link HttpStatusCode} value. */ @@ -146,4 +188,28 @@ public void assertWithDiagnostics(Runnable assertion) { } } + @Override + public String toString() { + return "\n" + + "> " + getMethod() + " " + getUrl() + "\n" + + "> " + formatHeaders(getRequestHeaders(), "\n> ") + "\n" + + "\n" + + "< " + formatStatus(getStatus()) + "\n" + + "< " + formatHeaders(getResponseHeaders(), "\n< ") + "\n"; + } + + private String formatStatus(HttpStatusCode statusCode) { + String result = statusCode.toString(); + if (statusCode instanceof HttpStatus status) { + result += " " + status.getReasonPhrase(); + } + return result; + } + + private String formatHeaders(HttpHeaders headers, String delimiter) { + return headers.headerSet().stream() + .map(entry -> entry.getKey() + ": " + entry.getValue()) + .collect(Collectors.joining(delimiter)); + } + } diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/CookieAssertionsTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/CookieAssertionsTests.java index 51783fd3bc0b..4412adba0299 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/CookieAssertionsTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/CookieAssertionsTests.java @@ -16,12 +16,16 @@ package org.springframework.test.web.servlet.client; +import java.io.IOException; import java.time.Duration; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseCookie; +import org.springframework.mock.http.client.MockClientHttpRequest; import org.springframework.web.client.RestClient; import static org.assertj.core.api.Assertions.assertThat; @@ -47,9 +51,14 @@ public class CookieAssertionsTests { .sameSite("Lax") .build(); - private final CookieAssertions assertions = cookieAssertions(cookie); + private CookieAssertions assertions; + @BeforeEach + void setUp() throws IOException { + this.assertions = cookieAssertions(cookie); + } + @Test void valueEquals() { assertions.valueEquals("foo", "bar"); @@ -135,12 +144,13 @@ void sameSite() { } - private CookieAssertions cookieAssertions(ResponseCookie cookie) { + private CookieAssertions cookieAssertions(ResponseCookie cookie) throws IOException { RestClient.RequestHeadersSpec.ConvertibleClientHttpResponse response = mock(); var headers = new HttpHeaders(); headers.set(HttpHeaders.SET_COOKIE, cookie.toString()); when(response.getHeaders()).thenReturn(headers); - ExchangeResult result = new ExchangeResult(response); + when(response.getStatusCode()).thenReturn(HttpStatusCode.valueOf(200)); + ExchangeResult result = new ExchangeResult(new MockClientHttpRequest(), response, null); return new CookieAssertions(result, mock()); } diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/HeaderAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/HeaderAssertionTests.java index 200210e6eeda..3dd307acab4d 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/HeaderAssertionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/HeaderAssertionTests.java @@ -16,6 +16,7 @@ package org.springframework.test.web.servlet.client; +import java.io.IOException; import java.net.URI; import java.time.ZoneId; import java.time.ZonedDateTime; @@ -26,7 +27,9 @@ import org.springframework.http.CacheControl; import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; +import org.springframework.mock.http.client.MockClientHttpRequest; import org.springframework.web.client.RestClient; import static org.assertj.core.api.Assertions.assertThat; @@ -311,10 +314,16 @@ void equalsDate() { } private HeaderAssertions headerAssertions(HttpHeaders responseHeaders) { + try { RestClient.RequestHeadersSpec.ConvertibleClientHttpResponse response = mock(); + when(response.getStatusCode()).thenReturn(HttpStatusCode.valueOf(200)); when(response.getHeaders()).thenReturn(responseHeaders); - ExchangeResult result = new ExchangeResult(response); + ExchangeResult result = new ExchangeResult(new MockClientHttpRequest(), response, null); return new HeaderAssertions(result, mock()); + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } } } diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/StatusAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/StatusAssertionTests.java index ed2a836d2951..099ca06f5bfd 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/StatusAssertionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/StatusAssertionTests.java @@ -20,8 +20,10 @@ import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; +import org.springframework.mock.http.client.MockClientHttpRequest; import org.springframework.web.client.RestClient; import static org.assertj.core.api.Assertions.assertThat; @@ -255,7 +257,8 @@ private StatusAssertions statusAssertions(int status) { try { RestClient.RequestHeadersSpec.ConvertibleClientHttpResponse response = mock(); when(response.getStatusCode()).thenReturn(HttpStatusCode.valueOf(status)); - ExchangeResult result = new ExchangeResult(response); + when(response.getHeaders()).thenReturn(new HttpHeaders()); + ExchangeResult result = new ExchangeResult(new MockClientHttpRequest(), response, null); return new StatusAssertions(result, mock()); } catch (IOException ex) { From 6cc131027404dd0564c7be81706321ca8420fb38 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Tue, 29 Jul 2025 20:54:16 +0100 Subject: [PATCH 027/591] Add API versioning to RestTestClient See gh-34428 --- .../servlet/client/DefaultRestTestClient.java | 10 +- .../client/DefaultRestTestClientBuilder.java | 13 +++ .../web/servlet/client/RestTestClient.java | 31 +++++ .../client/samples/ApiVersionTests.java | 110 ++++++++++++++++++ 4 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ApiVersionTests.java diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java index 1289c8af4499..45177fb058cc 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java @@ -226,6 +226,12 @@ public RequestBodySpec attributes(Consumer> attributesConsum return this; } + @Override + public RequestBodySpec apiVersion(Object version) { + this.requestHeadersUriSpec.apiVersion(version); + return this; + } + @Override public RequestHeadersSpec body(Object body) { this.requestHeadersUriSpec.body(body); @@ -235,8 +241,8 @@ public RequestHeadersSpec body(Object body) { @Override public ResponseSpec exchange() { return new DefaultResponseSpec( - this.requestHeadersUriSpec.exchangeForRequiredValue((request, response) -> - new ExchangeResult(request, response, this.uriTemplate), false)); + this.requestHeadersUriSpec.exchangeForRequiredValue( + (request, response) -> new ExchangeResult(request, response, this.uriTemplate), false)); } } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java index 748deb67aaf7..14b37ff04420 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java @@ -31,6 +31,7 @@ import org.springframework.test.web.servlet.setup.RouterFunctionMockMvcBuilder; import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder; import org.springframework.util.MultiValueMap; +import org.springframework.web.client.ApiVersionInserter; import org.springframework.web.client.RestClient; import org.springframework.web.context.WebApplicationContext; import org.springframework.web.servlet.function.RouterFunction; @@ -94,6 +95,18 @@ public T defaultCookies(Consumer> co return self(); } + @Override + public T defaultApiVersion(Object version) { + this.restClientBuilder.defaultApiVersion(version); + return self(); + } + + @Override + public T apiVersionInserter(ApiVersionInserter apiVersionInserter) { + this.restClientBuilder.apiVersionInserter(apiVersionInserter); + return self(); + } + @SuppressWarnings("unchecked") protected T self() { return (T) this; diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java index 2394060227a2..836979aa25a0 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java @@ -41,6 +41,8 @@ import org.springframework.test.web.servlet.setup.RouterFunctionMockMvcBuilder; import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder; import org.springframework.util.MultiValueMap; +import org.springframework.web.client.ApiVersionFormatter; +import org.springframework.web.client.ApiVersionInserter; import org.springframework.web.client.RestClient; import org.springframework.web.context.WebApplicationContext; import org.springframework.web.servlet.function.RouterFunction; @@ -241,6 +243,24 @@ interface Builder> { */ T defaultCookies(Consumer> cookiesConsumer); + /** + * Global option to specify an API version to add to every request, + * if not already set. + * @param version the version to use + * @return this builder + * @since 7.0 + */ + T defaultApiVersion(Object version); + + /** + * Configure an {@link ApiVersionInserter} to abstract how an API version + * specified via {@link RequestHeadersSpec#apiVersion(Object)} + * is inserted into the request. + * @param apiVersionInserter the inserter to use + * @since 7.0 + */ + T apiVersionInserter(ApiVersionInserter apiVersionInserter); + /** * Build the {@link RestTestClient} instance. */ @@ -403,6 +423,17 @@ interface RequestHeadersSpec> { */ S headers(Consumer headersConsumer); + /** + * Set an API version for the request. The version is inserted into the + * request by the {@linkplain Builder#apiVersionInserter(ApiVersionInserter) + * configured} {@code ApiVersionInserter}. + * @param version the API version of the request; this can be a String or + * some Object that can be formatted by the inserter — for example, + * through an {@link ApiVersionFormatter} + * @since 7.0 + */ + S apiVersion(Object version); + /** * Set the attribute with the given name to the given value. * @param name the name of the attribute to add diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ApiVersionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ApiVersionTests.java new file mode 100644 index 000000000000..4ea6e81a1f02 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ApiVersionTests.java @@ -0,0 +1,110 @@ +/* + * Copyright 2002-present 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.test.web.servlet.client.samples; + + +import java.util.List; +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Test; + +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.client.RestTestClient; +import org.springframework.web.accept.ApiVersionResolver; +import org.springframework.web.accept.DefaultApiVersionStrategy; +import org.springframework.web.accept.PathApiVersionResolver; +import org.springframework.web.accept.SemanticApiVersionParser; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.ApiVersionInserter; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@link RestTestClient} tests for sending API versions. + * + * @author Rossen Stoyanchev + */ +public class ApiVersionTests { + + @Test + void header() { + String header = "X-API-Version"; + + Map result = performRequest( + request -> request.getHeader(header), ApiVersionInserter.useHeader(header)); + + assertThat(result.get(header)).isEqualTo("1.2"); + } + + @Test + void queryParam() { + String param = "api-version"; + + Map result = performRequest( + request -> request.getParameter(param), ApiVersionInserter.useQueryParam(param)); + + assertThat(result.get("query")).isEqualTo(param + "=1.2"); + } + + @Test + void pathSegment() { + Map result = performRequest( + new PathApiVersionResolver(0), ApiVersionInserter.usePathSegment(0)); + + assertThat(result.get("path")).isEqualTo("/1.2/path"); + } + + @SuppressWarnings("unchecked") + private Map performRequest( + ApiVersionResolver versionResolver, ApiVersionInserter inserter) { + + DefaultApiVersionStrategy versionStrategy = new DefaultApiVersionStrategy( + List.of(versionResolver), new SemanticApiVersionParser(), + true, null, true, null); + + RestTestClient client = RestTestClient.bindToController(new TestController()) + .configureServer(mockMvcBuilder -> mockMvcBuilder.setApiVersionStrategy(versionStrategy)) + .baseUrl("/path") + .apiVersionInserter(inserter) + .build(); + + return client.get() + .accept(MediaType.APPLICATION_JSON) + .apiVersion(1.2) + .exchange() + .returnResult(Map.class) + .getResponseBody(); + } + + + @RestController + static class TestController { + + private static final String HEADER = "X-API-Version"; + + @GetMapping(path = "/**", version = "1.2") + Map handle(HttpServletRequest request) { + String query = request.getQueryString(); + String versionHeader = request.getHeader(HEADER); + return Map.of("path", request.getRequestURI(), + "query", (query != null ? query : ""), + HEADER, (versionHeader != null ? versionHeader : "")); + } + } +} From 88ddc9d45df381bcb66acd1621efd1146caf816e Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Tue, 29 Jul 2025 21:09:20 +0100 Subject: [PATCH 028/591] Polishing in [Rest|Web]TestClient Assertions See gh-34428 --- .../web/reactive/server/CookieAssertions.java | 9 +- .../web/reactive/server/HeaderAssertions.java | 9 +- .../web/reactive/server/StatusAssertions.java | 9 +- .../web/reactive/server/XpathAssertions.java | 13 +- .../web/servlet/client/CookieAssertions.java | 9 +- .../servlet/client/EntityExchangeResult.java | 1 + .../web/servlet/client/HeaderAssertions.java | 10 +- .../web/servlet/client/StatusAssertions.java | 9 +- .../web/servlet/client/XpathAssertions.java | 5 +- .../test/web/servlet/client/package-info.java | 5 +- .../web/support/AbstractCookieAssertions.java | 29 +- .../web/support/AbstractHeaderAssertions.java | 29 +- .../support/AbstractJsonPathAssertions.java | 14 + .../web/support/AbstractStatusAssertions.java | 30 +- .../web/support/AbstractXpathAssertions.java | 37 ++- .../server/CookieAssertionsTests.java | 145 --------- .../reactive/server/HeaderAssertionTests.java | 259 ---------------- .../reactive/server/StatusAssertionTests.java | 176 ----------- .../servlet/client/StatusAssertionTests.java | 269 ----------------- .../CookieAssertionsTests.java | 74 +++-- .../HeaderAssertionTests.java | 141 ++++----- .../web/support/StatusAssertionTests.java | 280 ++++++++++++++++++ src/checkstyle/checkstyle-suppressions.xml | 2 +- 23 files changed, 560 insertions(+), 1004 deletions(-) delete mode 100644 spring-test/src/test/java/org/springframework/test/web/reactive/server/CookieAssertionsTests.java delete mode 100644 spring-test/src/test/java/org/springframework/test/web/reactive/server/HeaderAssertionTests.java delete mode 100644 spring-test/src/test/java/org/springframework/test/web/reactive/server/StatusAssertionTests.java delete mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/client/StatusAssertionTests.java rename spring-test/src/test/java/org/springframework/test/web/{servlet/client => support}/CookieAssertionsTests.java (73%) rename spring-test/src/test/java/org/springframework/test/web/{servlet/client => support}/HeaderAssertionTests.java (63%) create mode 100644 spring-test/src/test/java/org/springframework/test/web/support/StatusAssertionTests.java diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/CookieAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/CookieAssertions.java index dd47cec18bb4..e3328c8f5c2b 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/CookieAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/CookieAssertions.java @@ -36,12 +36,13 @@ public class CookieAssertions extends AbstractCookieAssertions getResponseCookies() { + return getExchangeResult().getResponseCookies(); } @Override - protected MultiValueMap getResponseCookies() { - return exchangeResult.getResponseCookies(); + protected void assertWithDiagnostics(Runnable assertion) { + getExchangeResult().assertWithDiagnostics(assertion); } + } diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/HeaderAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/HeaderAssertions.java index d8ca7dadab94..c2c3a3a7fbbb 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/HeaderAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/HeaderAssertions.java @@ -38,12 +38,13 @@ public class HeaderAssertions extends AbstractHeaderAssertions { - XpathAssertions(WebTestClient.BodyContentSpec spec, - String expression, @Nullable Map namespaces, Object... args) { + + XpathAssertions( + WebTestClient.BodyContentSpec spec, + String expression, @Nullable Map namespaces, Object... args) { + super(spec, expression, namespaces, args); } + @Override protected Optional getResponseHeaders() { - return Optional.of(bodySpec.returnResult()) - .map(ExchangeResult::getResponseHeaders); + return Optional.of(getBodySpec().returnResult()).map(ExchangeResult::getResponseHeaders); } @Override protected byte[] getContent() { - byte[] body = this.bodySpec.returnResult().getResponseBody(); + byte[] body = getBodySpec().returnResult().getResponseBody(); Assert.notNull(body, "Expected body content"); return body; } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/CookieAssertions.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/CookieAssertions.java index e5a033f8252f..8f9bd624ab3e 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/CookieAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/CookieAssertions.java @@ -35,12 +35,13 @@ public class CookieAssertions extends AbstractCookieAssertions getResponseCookies() { + return getExchangeResult().getResponseCookies(); } @Override - protected MultiValueMap getResponseCookies() { - return exchangeResult.getResponseCookies(); + protected void assertWithDiagnostics(Runnable assertion) { + getExchangeResult().assertWithDiagnostics(assertion); } + } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/EntityExchangeResult.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/EntityExchangeResult.java index f9c9702f8bbc..2999fffcf561 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/EntityExchangeResult.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/EntityExchangeResult.java @@ -23,6 +23,7 @@ * extracted to a representation of type {@code }. * * @author Rob Worsnop + * @since 7.0 * @param the response body type */ public class EntityExchangeResult extends ExchangeResult { diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/HeaderAssertions.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/HeaderAssertions.java index 777097e67d6f..1a3dfab4adad 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/HeaderAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/HeaderAssertions.java @@ -33,13 +33,15 @@ public class HeaderAssertions extends AbstractHeaderAssertions getResponseHeaders() { - return Optional.of(bodySpec.returnResult()) - .map(ExchangeResult::getResponseHeaders); + return Optional.of(getBodySpec().returnResult()).map(ExchangeResult::getResponseHeaders); } @Override protected byte[] getContent() { - byte[] body = this.bodySpec.returnResult().getResponseBody(); + byte[] body = getBodySpec().returnResult().getResponseBody(); Assert.notNull(body, "Expected body content"); return body; } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/package-info.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/package-info.java index ed071659d10b..94c3686561ec 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/package-info.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/package-info.java @@ -1,8 +1,7 @@ /** * Support for testing Spring MVC applications via - * {@link org.springframework.test.web.reactive.server.WebTestClient} - * with {@link org.springframework.test.web.servlet.MockMvc} for server request - * handling. + * {@link org.springframework.test.web.servlet.client.RestTestClient} with + * {@link org.springframework.test.web.servlet.MockMvc} for server request handling. */ @NullMarked diff --git a/spring-test/src/main/java/org/springframework/test/web/support/AbstractCookieAssertions.java b/spring-test/src/main/java/org/springframework/test/web/support/AbstractCookieAssertions.java index 0527572610f0..e564b8b9117a 100644 --- a/spring-test/src/main/java/org/springframework/test/web/support/AbstractCookieAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/support/AbstractCookieAssertions.java @@ -34,19 +34,42 @@ * Assertions on cookies of the response. * * @author Rob Worsnop + * @author Rossen Stoyanchev * @since 7.0 * @param the type of the exchange result * @param the type of the response spec */ public abstract class AbstractCookieAssertions { - protected final E exchangeResult; + + private final E exchangeResult; + private final R responseSpec; + protected AbstractCookieAssertions(E exchangeResult, R responseSpec) { this.exchangeResult = exchangeResult; this.responseSpec = responseSpec; } + + /** + * Return the exchange result. + */ + protected E getExchangeResult() { + return this.exchangeResult; + } + + /** + * Subclasses must implement this to provide access to response cookies. + */ + protected abstract MultiValueMap getResponseCookies(); + + /** + * Subclasses must implement this to assert with diagnostics. + */ + protected abstract void assertWithDiagnostics(Runnable assertion); + + /** * Expect a response cookie with the given name to match the specified value. */ @@ -224,10 +247,6 @@ public R sameSite(String name, String expected) { return this.responseSpec; } - protected abstract void assertWithDiagnostics(Runnable assertion); - - protected abstract MultiValueMap getResponseCookies(); - private ResponseCookie getCookie(String name) { ResponseCookie cookie = getResponseCookies().getFirst(name); if (cookie != null) { diff --git a/spring-test/src/main/java/org/springframework/test/web/support/AbstractHeaderAssertions.java b/spring-test/src/main/java/org/springframework/test/web/support/AbstractHeaderAssertions.java index a10aada91ce0..166de6e3860b 100644 --- a/spring-test/src/main/java/org/springframework/test/web/support/AbstractHeaderAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/support/AbstractHeaderAssertions.java @@ -40,19 +40,42 @@ * Assertions on headers of the response. * * @author Rob Worsnop + * @author Rossen Stoyanchev * @since 7.0 * @param the type of the exchange result * @param the type of the response spec */ public abstract class AbstractHeaderAssertions { - protected final E exchangeResult; + + private final E exchangeResult; + private final R responseSpec; + protected AbstractHeaderAssertions(E exchangeResult, R responseSpec) { this.exchangeResult = exchangeResult; this.responseSpec = responseSpec; } + + /** + * Return the exchange result. + */ + protected E getExchangeResult() { + return this.exchangeResult; + } + + /** + * Subclasses must implement this to provide access to response headers. + */ + protected abstract HttpHeaders getResponseHeaders(); + + /** + * Subclasses must implement this to assert with diagnostics. + */ + protected abstract void assertWithDiagnostics(Runnable assertion); + + /** * Expect a header with the given name to match the specified values. */ @@ -277,10 +300,6 @@ public R location(String location) { return assertHeader("Location", URI.create(location), getResponseHeaders().getLocation()); } - protected abstract void assertWithDiagnostics(Runnable assertion); - - protected abstract HttpHeaders getResponseHeaders(); - private R assertHeader(String name, @Nullable Object expected, @Nullable Object actual) { assertWithDiagnostics(() -> { String message = getMessage(name); diff --git a/spring-test/src/main/java/org/springframework/test/web/support/AbstractJsonPathAssertions.java b/spring-test/src/main/java/org/springframework/test/web/support/AbstractJsonPathAssertions.java index a34facd138cd..2254d39abb02 100644 --- a/spring-test/src/main/java/org/springframework/test/web/support/AbstractJsonPathAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/support/AbstractJsonPathAssertions.java @@ -26,6 +26,18 @@ import org.springframework.test.util.JsonPathExpectationsHelper; import org.springframework.util.Assert; +/** + * Base class for applying + * JsonPath assertions + * in RestTestClient and WebTestClient. + * + * @author Rob Worsnop + * @author Rossen Stoyanchev + * @since 7.0 + * @param the type of body spec (RestTestClient vs WebTestClient specific) + * @see https://github.com/jayway/JsonPath + * @see JsonPathExpectationsHelper + */ public abstract class AbstractJsonPathAssertions { private final B bodySpec; @@ -34,6 +46,7 @@ public abstract class AbstractJsonPathAssertions { private final JsonPathExpectationsHelper pathHelper; + protected AbstractJsonPathAssertions(B spec, String content, String expression, @Nullable Configuration configuration) { Assert.hasText(expression, "expression must not be null or empty"); this.bodySpec = spec; @@ -41,6 +54,7 @@ protected AbstractJsonPathAssertions(B spec, String content, String expression, this.pathHelper = new JsonPathExpectationsHelper(expression, configuration); } + /** * Applies {@link JsonPathExpectationsHelper#assertValue(String, Object)}. */ diff --git a/spring-test/src/main/java/org/springframework/test/web/support/AbstractStatusAssertions.java b/spring-test/src/main/java/org/springframework/test/web/support/AbstractStatusAssertions.java index 719ba8cedf40..9573e11920f1 100644 --- a/spring-test/src/main/java/org/springframework/test/web/support/AbstractStatusAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/support/AbstractStatusAssertions.java @@ -31,18 +31,42 @@ * Assertions on the response status. * * @author Rob Worsnop + * @author Rossen Stoyanchev + * @since 7.0 * @param the type of the exchange result * @param the type of the response spec */ public abstract class AbstractStatusAssertions { - protected final E exchangeResult; + + private final E exchangeResult; + private final R responseSpec; + protected AbstractStatusAssertions(E exchangeResult, R responseSpec) { this.exchangeResult = exchangeResult; this.responseSpec = responseSpec; } + + /** + * Return the exchange result. + */ + protected E getExchangeResult() { + return this.exchangeResult; + } + + /** + * Subclasses must implement this to provide access to the response status. + */ + protected abstract HttpStatusCode getStatus(); + + /** + * Subclasses must implement this to assert with diagnostics. + */ + protected abstract void assertWithDiagnostics(Runnable assertion); + + /** * Assert the response status as an {@link HttpStatusCode}. */ @@ -226,10 +250,6 @@ public R value(Consumer consumer) { return this.responseSpec; } - protected abstract void assertWithDiagnostics(Runnable assertion); - - protected abstract HttpStatusCode getStatus(); - private R assertStatusAndReturn(HttpStatus expected) { assertNotNull("exchangeResult unexpectedly null", this.exchangeResult); HttpStatusCode actual = getStatus(); diff --git a/spring-test/src/main/java/org/springframework/test/web/support/AbstractXpathAssertions.java b/spring-test/src/main/java/org/springframework/test/web/support/AbstractXpathAssertions.java index 138c2de92d8f..4614d7dec3f2 100644 --- a/spring-test/src/main/java/org/springframework/test/web/support/AbstractXpathAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/support/AbstractXpathAssertions.java @@ -30,12 +30,24 @@ import org.springframework.test.util.XpathExpectationsHelper; import org.springframework.util.MimeType; +/** + * Base class for applying XPath assertions in RestTestClient and WebTestClient. + * + * @author Rob Worsnop + * @author Rossen Stoyanchev + * @since 7.0 + * @param the type of body spec (RestTestClient vs WebTestClient specific) + */ public abstract class AbstractXpathAssertions { - protected final B bodySpec; + + private final B bodySpec; private final XpathExpectationsHelper xpathHelper; - public AbstractXpathAssertions(B spec, String expression, @Nullable Map namespaces, Object... args) { + + public AbstractXpathAssertions( + B spec, String expression, @Nullable Map namespaces, Object... args) { + this.bodySpec = spec; this.xpathHelper = initXpathHelper(expression, namespaces, args); } @@ -52,6 +64,24 @@ private static XpathExpectationsHelper initXpathHelper( } + /** + * Return the body spec. + */ + protected B getBodySpec() { + return this.bodySpec; + } + + /** + * Subclasses must implement this to provide access to response headers. + */ + protected abstract Optional getResponseHeaders(); + + /** + * Subclasses must implement this to provide access to the response content. + */ + protected abstract byte[] getContent(); + + /** * Delegates to {@link XpathExpectationsHelper#assertString(byte[], String, String)}. */ @@ -175,9 +205,6 @@ public int hashCode() { return super.hashCode(); } - protected abstract Optional getResponseHeaders(); - - protected abstract byte[] getContent(); /** * Lets us be able to use lambda expressions that could throw checked exceptions, since diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/CookieAssertionsTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/CookieAssertionsTests.java deleted file mode 100644 index 41246500bb39..000000000000 --- a/spring-test/src/test/java/org/springframework/test/web/reactive/server/CookieAssertionsTests.java +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright 2002-present 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.test.web.reactive.server; - -import java.net.URI; -import java.time.Duration; - -import org.junit.jupiter.api.Test; -import reactor.core.publisher.Mono; - -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseCookie; -import org.springframework.mock.http.client.reactive.MockClientHttpRequest; -import org.springframework.mock.http.client.reactive.MockClientHttpResponse; - -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.hamcrest.Matchers.equalTo; -import static org.mockito.Mockito.mock; - -/** - * Tests for {@link CookieAssertions} - * - * @author Rossen Stoyanchev - */ -public class CookieAssertionsTests { - - private final ResponseCookie cookie = ResponseCookie.from("foo", "bar") - .maxAge(Duration.ofMinutes(30)) - .domain("foo.com") - .path("/foo") - .secure(true) - .httpOnly(true) - .partitioned(true) - .sameSite("Lax") - .build(); - - private final CookieAssertions assertions = cookieAssertions(cookie); - - - @Test - void valueEquals() { - assertions.valueEquals("foo", "bar"); - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.valueEquals("what?!", "bar")); - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.valueEquals("foo", "what?!")); - } - - @Test - void value() { - assertions.value("foo", equalTo("bar")); - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.value("foo", equalTo("what?!"))); - } - - @Test - void exists() { - assertions.exists("foo"); - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.exists("what?!")); - } - - @Test - void doesNotExist() { - assertions.doesNotExist("what?!"); - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.doesNotExist("foo")); - } - - @Test - void maxAge() { - assertions.maxAge("foo", Duration.ofMinutes(30)); - assertThatExceptionOfType(AssertionError.class) - .isThrownBy(() -> assertions.maxAge("foo", Duration.ofMinutes(29))); - - assertions.maxAge("foo", equalTo(Duration.ofMinutes(30).getSeconds())); - assertThatExceptionOfType(AssertionError.class) - .isThrownBy(() -> assertions.maxAge("foo", equalTo(Duration.ofMinutes(29).getSeconds()))); - } - - @Test - void domain() { - assertions.domain("foo", "foo.com"); - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.domain("foo", "what.com")); - - assertions.domain("foo", equalTo("foo.com")); - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.domain("foo", equalTo("what.com"))); - } - - @Test - void path() { - assertions.path("foo", "/foo"); - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.path("foo", "/what")); - - assertions.path("foo", equalTo("/foo")); - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.path("foo", equalTo("/what"))); - } - - @Test - void secure() { - assertions.secure("foo", true); - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.secure("foo", false)); - } - - @Test - void httpOnly() { - assertions.httpOnly("foo", true); - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.httpOnly("foo", false)); - } - - @Test - void partitioned() { - assertions.partitioned("foo", true); - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.partitioned("foo", false)); - } - - @Test - void sameSite() { - assertions.sameSite("foo", "Lax"); - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.sameSite("foo", "Strict")); - } - - - private CookieAssertions cookieAssertions(ResponseCookie cookie) { - MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, URI.create("/")); - MockClientHttpResponse response = new MockClientHttpResponse(HttpStatus.OK); - response.getCookies().add(cookie.getName(), cookie); - - ExchangeResult result = new ExchangeResult( - request, response, Mono.empty(), Mono.empty(), Duration.ZERO, null, null); - - return new CookieAssertions(result, mock()); - } - -} diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/HeaderAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/HeaderAssertionTests.java deleted file mode 100644 index aa795f13b261..000000000000 --- a/spring-test/src/test/java/org/springframework/test/web/reactive/server/HeaderAssertionTests.java +++ /dev/null @@ -1,259 +0,0 @@ -/* - * Copyright 2002-present 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.test.web.reactive.server; - -import java.net.URI; -import java.time.Duration; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.util.concurrent.TimeUnit; - -import org.junit.jupiter.api.Test; -import reactor.core.publisher.Mono; - -import org.springframework.http.CacheControl; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.mock.http.client.reactive.MockClientHttpRequest; -import org.springframework.mock.http.client.reactive.MockClientHttpResponse; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.hamcrest.Matchers.containsString; -import static org.hamcrest.Matchers.hasItems; -import static org.mockito.Mockito.mock; - -/** - * Tests for {@link HeaderAssertions}. - * - * @author Rossen Stoyanchev - * @author Sam Brannen - */ -class HeaderAssertionTests { - - @Test - void valueEquals() { - HttpHeaders headers = new HttpHeaders(); - headers.add("foo", "bar"); - HeaderAssertions assertions = headerAssertions(headers); - - // Success - assertions.valueEquals("foo", "bar"); - - // Missing header - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.valueEquals("what?!", "bar")); - - // Wrong value - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.valueEquals("foo", "what?!")); - - // Wrong # of values - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.valueEquals("foo", "bar", "what?!")); - } - - @Test - void valueEqualsWithMultipleValues() { - HttpHeaders headers = new HttpHeaders(); - headers.add("foo", "bar"); - headers.add("foo", "baz"); - HeaderAssertions assertions = headerAssertions(headers); - - // Success - assertions.valueEquals("foo", "bar", "baz"); - - // Wrong value - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.valueEquals("foo", "bar", "what?!")); - - // Too few values - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.valueEquals("foo", "bar")); - } - - @Test - void valueMatches() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.parseMediaType("application/json;charset=UTF-8")); - HeaderAssertions assertions = headerAssertions(headers); - - // Success - assertions.valueMatches("Content-Type", ".*UTF-8.*"); - - // Wrong pattern - assertThatExceptionOfType(AssertionError.class) - .isThrownBy(() -> assertions.valueMatches("Content-Type", ".*ISO-8859-1.*")) - .satisfies(ex -> assertThat(ex).hasMessage("Response header " + - "'Content-Type'=[application/json;charset=UTF-8] does not match " + - "[.*ISO-8859-1.*]")); - } - - @Test - void valueMatchesWithNonexistentHeader() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.parseMediaType("application/json;charset=UTF-8")); - HeaderAssertions assertions = headerAssertions(headers); - - assertThatExceptionOfType(AssertionError.class) - .isThrownBy(() -> assertions.valueMatches("Content-XYZ", ".*ISO-8859-1.*")) - .withMessage("Response header 'Content-XYZ' not found"); - } - - @Test - void valuesMatch() { - HttpHeaders headers = new HttpHeaders(); - headers.add("foo", "value1"); - headers.add("foo", "value2"); - headers.add("foo", "value3"); - HeaderAssertions assertions = headerAssertions(headers); - - assertions.valuesMatch("foo", "val.*1", "val.*2", "val.*3"); - - assertThatExceptionOfType(AssertionError.class) - .isThrownBy(() -> assertions.valuesMatch("foo", ".*", "val.*5")) - .satisfies(ex -> assertThat(ex).hasMessage( - "Response header 'foo' has fewer or more values [value1, value2, value3] " + - "than number of patterns to match with [.*, val.*5]")); - - assertThatExceptionOfType(AssertionError.class) - .isThrownBy(() -> assertions.valuesMatch("foo", ".*", "val.*5", ".*")) - .satisfies(ex -> assertThat(ex).hasMessage( - "Response header 'foo'[1]='value2' does not match 'val.*5'")); - } - - @Test - void valueMatcher() { - HttpHeaders headers = new HttpHeaders(); - headers.add("foo", "bar"); - HeaderAssertions assertions = headerAssertions(headers); - - assertions.value("foo", containsString("a")); - } - - @Test - void valuesMatcher() { - HttpHeaders headers = new HttpHeaders(); - headers.add("foo", "bar"); - headers.add("foo", "baz"); - HeaderAssertions assertions = headerAssertions(headers); - - assertions.values("foo", hasItems("bar", "baz")); - } - - @Test - void exists() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - HeaderAssertions assertions = headerAssertions(headers); - - // Success - assertions.exists("Content-Type"); - - // Header should not exist - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.exists("Framework")) - .satisfies(ex -> assertThat(ex).hasMessage("Response header 'Framework' does not exist")); - } - - @Test - void doesNotExist() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.parseMediaType("application/json;charset=UTF-8")); - HeaderAssertions assertions = headerAssertions(headers); - - // Success - assertions.doesNotExist("Framework"); - - // Existing header - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.doesNotExist("Content-Type")) - .satisfies(ex -> assertThat(ex).hasMessage("Response header " + - "'Content-Type' exists with value=[application/json;charset=UTF-8]")); - } - - @Test - void contentTypeCompatibleWith() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_XML); - HeaderAssertions assertions = headerAssertions(headers); - - // Success - assertions.contentTypeCompatibleWith(MediaType.parseMediaType("application/*")); - - // MediaTypes not compatible - assertThatExceptionOfType(AssertionError.class) - .isThrownBy(() -> assertions.contentTypeCompatibleWith(MediaType.TEXT_XML)) - .withMessage("Response header 'Content-Type'=[application/xml] is not compatible with [text/xml]"); - } - - @Test - void cacheControl() { - CacheControl control = CacheControl.maxAge(1, TimeUnit.HOURS).noTransform(); - - HttpHeaders headers = new HttpHeaders(); - headers.setCacheControl(control.getHeaderValue()); - HeaderAssertions assertions = headerAssertions(headers); - - // Success - assertions.cacheControl(control); - - // Wrong value - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.cacheControl(CacheControl.noStore())); - } - - @Test - void expires() { - HttpHeaders headers = new HttpHeaders(); - ZonedDateTime expires = ZonedDateTime.of(2018, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC")); - headers.setExpires(expires); - HeaderAssertions assertions = headerAssertions(headers); - assertions.expires(expires.toInstant().toEpochMilli()); - - // Wrong value - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.expires(expires.toInstant().toEpochMilli() + 1)); - } - - @Test - void lastModified() { - HttpHeaders headers = new HttpHeaders(); - ZonedDateTime lastModified = ZonedDateTime.of(2018, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC")); - headers.setLastModified(lastModified.toInstant().toEpochMilli()); - HeaderAssertions assertions = headerAssertions(headers); - assertions.lastModified(lastModified.toInstant().toEpochMilli()); - - // Wrong value - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.lastModified(lastModified.toInstant().toEpochMilli() + 1)); - } - - private HeaderAssertions headerAssertions(HttpHeaders responseHeaders) { - MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, URI.create("/")); - MockClientHttpResponse response = new MockClientHttpResponse(HttpStatus.OK); - response.getHeaders().putAll(responseHeaders); - - ExchangeResult result = new ExchangeResult( - request, response, Mono.empty(), Mono.empty(), Duration.ZERO, null, null); - - return new HeaderAssertions(result, mock()); - } - -} diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/StatusAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/StatusAssertionTests.java deleted file mode 100644 index bbb7842bca83..000000000000 --- a/spring-test/src/test/java/org/springframework/test/web/reactive/server/StatusAssertionTests.java +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Copyright 2002-present 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.test.web.reactive.server; - -import java.net.URI; -import java.time.Duration; - -import org.junit.jupiter.api.Test; -import reactor.core.publisher.Mono; - -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.mock.http.client.reactive.MockClientHttpRequest; -import org.springframework.mock.http.client.reactive.MockClientHttpResponse; - -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.greaterThan; -import static org.mockito.Mockito.mock; - -/** - * Tests for {@link StatusAssertions}. - * - * @author Rossen Stoyanchev - * @author Sam Brannen - */ -class StatusAssertionTests { - - @Test - void isEqualTo() { - StatusAssertions assertions = statusAssertions(HttpStatus.CONFLICT); - - // Success - assertions.isEqualTo(HttpStatus.CONFLICT); - assertions.isEqualTo(409); - - // Wrong status - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.isEqualTo(HttpStatus.REQUEST_TIMEOUT)); - - // Wrong status value - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.isEqualTo(408)); - } - - @Test // gh-23630, gh-29283 - void isEqualToWithCustomStatus() { - StatusAssertions assertions = statusAssertions(600); - - // Success - // assertions.isEqualTo(600); - - // Wrong status - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.isEqualTo(HttpStatus.REQUEST_TIMEOUT)); - - // Wrong status value - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.isEqualTo(408)); - } - - @Test - void reasonEquals() { - StatusAssertions assertions = statusAssertions(HttpStatus.CONFLICT); - - // Success - assertions.reasonEquals("Conflict"); - - // Wrong reason - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.reasonEquals("Request Timeout")); - } - - @Test - void statusSeries1xx() { - StatusAssertions assertions = statusAssertions(HttpStatus.CONTINUE); - - // Success - assertions.is1xxInformational(); - - // Wrong series - assertThatExceptionOfType(AssertionError.class).isThrownBy(assertions::is2xxSuccessful); - } - - @Test - void statusSeries2xx() { - StatusAssertions assertions = statusAssertions(HttpStatus.OK); - - // Success - assertions.is2xxSuccessful(); - - // Wrong series - assertThatExceptionOfType(AssertionError.class).isThrownBy(assertions::is5xxServerError); - } - - @Test - void statusSeries3xx() { - StatusAssertions assertions = statusAssertions(HttpStatus.PERMANENT_REDIRECT); - - // Success - assertions.is3xxRedirection(); - - // Wrong series - assertThatExceptionOfType(AssertionError.class).isThrownBy(assertions::is2xxSuccessful); - } - - @Test - void statusSeries4xx() { - StatusAssertions assertions = statusAssertions(HttpStatus.BAD_REQUEST); - - // Success - assertions.is4xxClientError(); - - // Wrong series - assertThatExceptionOfType(AssertionError.class).isThrownBy(assertions::is2xxSuccessful); - } - - @Test - void statusSeries5xx() { - StatusAssertions assertions = statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR); - - // Success - assertions.is5xxServerError(); - - // Wrong series - assertThatExceptionOfType(AssertionError.class).isThrownBy(assertions::is2xxSuccessful); - } - - @Test - void matchesStatusValue() { - StatusAssertions assertions = statusAssertions(HttpStatus.CONFLICT); - - // Success - assertions.value(equalTo(409)); - assertions.value(greaterThan(400)); - - // Wrong status - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.value(equalTo(200))); - } - - @Test // gh-26658 - void matchesCustomStatusValue() { - statusAssertions(600).value(equalTo(600)); - } - - - private StatusAssertions statusAssertions(HttpStatus status) { - return statusAssertions(status.value()); - } - - private StatusAssertions statusAssertions(int status) { - MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, URI.create("/")); - MockClientHttpResponse response = new MockClientHttpResponse(status); - - ExchangeResult result = new ExchangeResult( - request, response, Mono.empty(), Mono.empty(), Duration.ZERO, null, null); - - return new StatusAssertions(result, mock()); - } - -} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/StatusAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/StatusAssertionTests.java deleted file mode 100644 index 099ca06f5bfd..000000000000 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/StatusAssertionTests.java +++ /dev/null @@ -1,269 +0,0 @@ -/* - * Copyright 2002-present 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.test.web.servlet.client; - -import java.io.IOException; - -import org.junit.jupiter.api.Test; - -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.HttpStatusCode; -import org.springframework.mock.http.client.MockClientHttpRequest; -import org.springframework.web.client.RestClient; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.greaterThan; -import static org.mockito.BDDMockito.mock; -import static org.mockito.BDDMockito.when; - -/** - * Tests for {@link StatusAssertions}. - * - * @author Rob Worsnop - */ -class StatusAssertionTests { - - @Test - void isEqualTo() { - StatusAssertions assertions = statusAssertions(HttpStatus.CONFLICT); - - // Success - assertions.isEqualTo(HttpStatus.CONFLICT); - assertions.isEqualTo(409); - - // Wrong status - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.isEqualTo(HttpStatus.REQUEST_TIMEOUT)); - - // Wrong status value - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.isEqualTo(408)); - } - - @Test - void isEqualToWithCustomStatus() { - StatusAssertions assertions = statusAssertions(600); - - // Success - assertions.isEqualTo(600); - - // Wrong status - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - statusAssertions(601).isEqualTo(600)); - - } - - @Test - void reasonEquals() { - StatusAssertions assertions = statusAssertions(HttpStatus.CONFLICT); - - // Success - assertions.reasonEquals("Conflict"); - - // Wrong reason - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).reasonEquals("Conflict")); - } - - @Test - void statusSeries1xx() { - StatusAssertions assertions = statusAssertions(HttpStatus.CONTINUE); - - // Success - assertions.is1xxInformational(); - - // Wrong series - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - statusAssertions(HttpStatus.OK).is1xxInformational()); - } - - @Test - void statusSeries2xx() { - StatusAssertions assertions = statusAssertions(HttpStatus.OK); - - // Success - assertions.is2xxSuccessful(); - - // Wrong series - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).is2xxSuccessful()); - } - - @Test - void statusSeries3xx() { - StatusAssertions assertions = statusAssertions(HttpStatus.PERMANENT_REDIRECT); - - // Success - assertions.is3xxRedirection(); - - // Wrong series - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).is3xxRedirection()); - } - - @Test - void statusSeries4xx() { - StatusAssertions assertions = statusAssertions(HttpStatus.BAD_REQUEST); - - // Success - assertions.is4xxClientError(); - - // Wrong series - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).is4xxClientError()); - } - - @Test - void statusSeries5xx() { - StatusAssertions assertions = statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR); - - // Success - assertions.is5xxServerError(); - - // Wrong series - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - statusAssertions(HttpStatus.OK).is5xxServerError()); - } - - @Test - void matchesStatusValue() { - StatusAssertions assertions = statusAssertions(HttpStatus.CONFLICT); - - // Success - assertions.value(equalTo(409)); - assertions.value(greaterThan(400)); - - // Wrong status - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.value(equalTo(200))); - } - - @Test - void matchesCustomStatusValue() { - statusAssertions(600).value(equalTo(600)); - } - - @Test - void consumesStatusValue() { - StatusAssertions assertions = statusAssertions(HttpStatus.CONFLICT); - - // Success - assertions.value((Integer value) -> assertThat(value).isEqualTo(409)); - } - - @Test - void statusIsAccepted() { - // Success - statusAssertions(HttpStatus.ACCEPTED).isAccepted(); - - // Wrong status - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isAccepted()); - } - - @Test - void statusIsNoContent() { - // Success - statusAssertions(HttpStatus.NO_CONTENT).isNoContent(); - - // Wrong status - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isNoContent()); - } - - @Test - void statusIsFound() { - // Success - statusAssertions(HttpStatus.FOUND).isFound(); - - // Wrong status - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isFound()); - } - - @Test - void statusIsSeeOther() { - // Success - statusAssertions(HttpStatus.SEE_OTHER).isSeeOther(); - - // Wrong status - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isSeeOther()); - } - - @Test - void statusIsNotModified() { - // Success - statusAssertions(HttpStatus.NOT_MODIFIED).isNotModified(); - - // Wrong status - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isNotModified()); - } - - @Test - void statusIsTemporaryRedirect() { - // Success - statusAssertions(HttpStatus.TEMPORARY_REDIRECT).isTemporaryRedirect(); - - // Wrong status - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isTemporaryRedirect()); - } - - @Test - void statusIsPermanentRedirect() { - // Success - statusAssertions(HttpStatus.PERMANENT_REDIRECT).isPermanentRedirect(); - - // Wrong status - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isPermanentRedirect()); - } - - @Test - void statusIsUnauthorized() { - // Success - statusAssertions(HttpStatus.UNAUTHORIZED).isUnauthorized(); - - // Wrong status - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isUnauthorized()); - } - - @Test - void statusIsForbidden() { - // Success - statusAssertions(HttpStatus.FORBIDDEN).isForbidden(); - - // Wrong status - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isForbidden()); - } - - private StatusAssertions statusAssertions(HttpStatus status) { - return statusAssertions(status.value()); - } - - private StatusAssertions statusAssertions(int status) { - try { - RestClient.RequestHeadersSpec.ConvertibleClientHttpResponse response = mock(); - when(response.getStatusCode()).thenReturn(HttpStatusCode.valueOf(status)); - when(response.getHeaders()).thenReturn(new HttpHeaders()); - ExchangeResult result = new ExchangeResult(new MockClientHttpRequest(), response, null); - return new StatusAssertions(result, mock()); - } - catch (IOException ex) { - throw new AssertionError(ex); - } - } - -} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/CookieAssertionsTests.java b/spring-test/src/test/java/org/springframework/test/web/support/CookieAssertionsTests.java similarity index 73% rename from spring-test/src/test/java/org/springframework/test/web/servlet/client/CookieAssertionsTests.java rename to spring-test/src/test/java/org/springframework/test/web/support/CookieAssertionsTests.java index 4412adba0299..6762e1cb8a18 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/CookieAssertionsTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/support/CookieAssertionsTests.java @@ -14,51 +14,50 @@ * limitations under the License. */ -package org.springframework.test.web.servlet.client; +package org.springframework.test.web.support; import java.io.IOException; import java.time.Duration; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseCookie; -import org.springframework.mock.http.client.MockClientHttpRequest; -import org.springframework.web.client.RestClient; +import org.springframework.util.MultiValueMap; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.hamcrest.Matchers.equalTo; -import static org.mockito.BDDMockito.mock; -import static org.mockito.BDDMockito.when; /** - * Tests for {@link CookieAssertions} + * Tests for {@link AbstractCookieAssertions}. * * @author Rob Worsnop + * @author Rossen Stoyanchev */ public class CookieAssertionsTests { - private final ResponseCookie cookie = ResponseCookie.from("foo", "bar") - .maxAge(Duration.ofMinutes(30)) - .domain("foo.com") - .path("/foo") - .secure(true) - .httpOnly(true) - .partitioned(true) - .sameSite("Lax") - .build(); - - private CookieAssertions assertions; + private TestCookieAssertions assertions; @BeforeEach void setUp() throws IOException { - this.assertions = cookieAssertions(cookie); + + ResponseCookie cookie = ResponseCookie.from("foo", "bar") + .maxAge(Duration.ofMinutes(30)) + .domain("foo.com") + .path("/foo") + .secure(true) + .httpOnly(true) + .partitioned(true) + .sameSite("Lax") + .build(); + + this.assertions = initCookieAssertions(cookie); } + @Test void valueEquals() { assertions.valueEquals("foo", "bar"); @@ -75,7 +74,8 @@ void value() { @Test void valueConsumer() { assertions.value("foo", input -> assertThat(input).isEqualTo("bar")); - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.value("foo", input -> assertThat(input).isEqualTo("what?!"))); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.value("foo", input -> assertThat(input).isEqualTo("what?!"))); } @Test @@ -143,15 +143,31 @@ void sameSite() { assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.sameSite("foo", "Strict")); } + private TestCookieAssertions initCookieAssertions(ResponseCookie cookie) throws IOException { + return new TestCookieAssertions(cookie); + } + + + private static class TestCookieAssertions extends AbstractCookieAssertions { + + TestCookieAssertions(ResponseCookie cookie) { + super(new TestExchangeResult(cookie), ""); + } + + @Override + protected MultiValueMap getResponseCookies() { + ResponseCookie cookie = getExchangeResult().cookie(); + return MultiValueMap.fromSingleValue(Map.of(cookie.getName(), cookie)); + } + + @Override + protected void assertWithDiagnostics(Runnable assertion) { + assertion.run(); + } + } + - private CookieAssertions cookieAssertions(ResponseCookie cookie) throws IOException { - RestClient.RequestHeadersSpec.ConvertibleClientHttpResponse response = mock(); - var headers = new HttpHeaders(); - headers.set(HttpHeaders.SET_COOKIE, cookie.toString()); - when(response.getHeaders()).thenReturn(headers); - when(response.getStatusCode()).thenReturn(HttpStatusCode.valueOf(200)); - ExchangeResult result = new ExchangeResult(new MockClientHttpRequest(), response, null); - return new CookieAssertions(result, mock()); + private record TestExchangeResult(ResponseCookie cookie) { } } diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/HeaderAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/support/HeaderAssertionTests.java similarity index 63% rename from spring-test/src/test/java/org/springframework/test/web/servlet/client/HeaderAssertionTests.java rename to spring-test/src/test/java/org/springframework/test/web/support/HeaderAssertionTests.java index 3dd307acab4d..01e6138adea9 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/HeaderAssertionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/support/HeaderAssertionTests.java @@ -14,9 +14,8 @@ * limitations under the License. */ -package org.springframework.test.web.servlet.client; +package org.springframework.test.web.support; -import java.io.IOException; import java.net.URI; import java.time.ZoneId; import java.time.ZonedDateTime; @@ -27,21 +26,17 @@ import org.springframework.http.CacheControl; import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; -import org.springframework.mock.http.client.MockClientHttpRequest; -import org.springframework.web.client.RestClient; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.hasItems; -import static org.mockito.BDDMockito.mock; -import static org.mockito.BDDMockito.when; /** - * Tests for {@link HeaderAssertions}. + * Tests for {@link AbstractHeaderAssertions}. * + * @author Rossen Stoyanchev * @author Rob Worsnop */ class HeaderAssertionTests { @@ -51,7 +46,7 @@ void valueEquals() { HttpHeaders headers = new HttpHeaders(); headers.add("foo", "bar"); headers.add("age", "22"); - HeaderAssertions assertions = headerAssertions(headers); + TestHeaderAssertions assertions = new TestHeaderAssertions(headers); // Success assertions.valueEquals("foo", "bar"); @@ -60,16 +55,16 @@ void valueEquals() { assertions.valueEquals("age", 22); // Missing header - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.valueEquals("what?!", "bar")); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.valueEquals("what?!", "bar")); // Wrong value - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.valueEquals("foo", "what?!")); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.valueEquals("foo", "what?!")); // Wrong # of values - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.valueEquals("foo", "bar", "what?!")); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.valueEquals("foo", "bar", "what?!")); } @Test @@ -77,25 +72,25 @@ void valueEqualsWithMultipleValues() { HttpHeaders headers = new HttpHeaders(); headers.add("foo", "bar"); headers.add("foo", "baz"); - HeaderAssertions assertions = headerAssertions(headers); + TestHeaderAssertions assertions = new TestHeaderAssertions(headers); // Success assertions.valueEquals("foo", "bar", "baz"); // Wrong value - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.valueEquals("foo", "bar", "what?!")); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.valueEquals("foo", "bar", "what?!")); // Too few values - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.valueEquals("foo", "bar")); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.valueEquals("foo", "bar")); } @Test void valueMatches() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.parseMediaType("application/json;charset=UTF-8")); - HeaderAssertions assertions = headerAssertions(headers); + TestHeaderAssertions assertions = new TestHeaderAssertions(headers); // Success assertions.valueMatches("Content-Type", ".*UTF-8.*"); @@ -112,7 +107,7 @@ void valueMatches() { void valueMatchesWithNonexistentHeader() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.parseMediaType("application/json;charset=UTF-8")); - HeaderAssertions assertions = headerAssertions(headers); + TestHeaderAssertions assertions = new TestHeaderAssertions(headers); assertThatExceptionOfType(AssertionError.class) .isThrownBy(() -> assertions.valueMatches("Content-XYZ", ".*ISO-8859-1.*")) @@ -125,7 +120,7 @@ void valuesMatch() { headers.add("foo", "value1"); headers.add("foo", "value2"); headers.add("foo", "value3"); - HeaderAssertions assertions = headerAssertions(headers); + TestHeaderAssertions assertions = new TestHeaderAssertions(headers); assertions.valuesMatch("foo", "val.*1", "val.*2", "val.*3"); @@ -145,7 +140,7 @@ void valuesMatch() { void valueMatcher() { HttpHeaders headers = new HttpHeaders(); headers.add("foo", "bar"); - HeaderAssertions assertions = headerAssertions(headers); + TestHeaderAssertions assertions = new TestHeaderAssertions(headers); assertions.value("foo", containsString("a")); } @@ -155,7 +150,7 @@ void valuesMatcher() { HttpHeaders headers = new HttpHeaders(); headers.add("foo", "bar"); headers.add("foo", "baz"); - HeaderAssertions assertions = headerAssertions(headers); + TestHeaderAssertions assertions = new TestHeaderAssertions(headers); assertions.values("foo", hasItems("bar", "baz")); } @@ -164,38 +159,38 @@ void valuesMatcher() { void exists() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); - HeaderAssertions assertions = headerAssertions(headers); + TestHeaderAssertions assertions = new TestHeaderAssertions(headers); // Success assertions.exists("Content-Type"); // Header should not exist - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.exists("Framework")) - .satisfies(ex -> assertThat(ex).hasMessage("Response header 'Framework' does not exist")); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.exists("Framework")) + .satisfies(ex -> assertThat(ex).hasMessage("Response header 'Framework' does not exist")); } @Test void doesNotExist() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.parseMediaType("application/json;charset=UTF-8")); - HeaderAssertions assertions = headerAssertions(headers); + TestHeaderAssertions assertions = new TestHeaderAssertions(headers); // Success assertions.doesNotExist("Framework"); // Existing header - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.doesNotExist("Content-Type")) - .satisfies(ex -> assertThat(ex).hasMessage("Response header " + - "'Content-Type' exists with value=[application/json;charset=UTF-8]")); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.doesNotExist("Content-Type")) + .satisfies(ex -> assertThat(ex).hasMessage("Response header " + + "'Content-Type' exists with value=[application/json;charset=UTF-8]")); } @Test void contentTypeCompatibleWith() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_XML); - HeaderAssertions assertions = headerAssertions(headers); + TestHeaderAssertions assertions = new TestHeaderAssertions(headers); // Success assertions.contentTypeCompatibleWith(MediaType.parseMediaType("application/*")); @@ -203,22 +198,21 @@ void contentTypeCompatibleWith() { // MediaTypes not compatible assertThatExceptionOfType(AssertionError.class) - .isThrownBy(() -> assertions.contentTypeCompatibleWith(MediaType.TEXT_XML)) - .withMessage("Response header 'Content-Type'=[application/xml] is not compatible with [text/xml]"); + .isThrownBy(() -> assertions.contentTypeCompatibleWith(MediaType.TEXT_XML)) + .withMessage("Response header 'Content-Type'=[application/xml] is not compatible with [text/xml]"); } @Test void location() { HttpHeaders headers = new HttpHeaders(); headers.setLocation(URI.create("http://localhost:8080/")); - HeaderAssertions assertions = headerAssertions(headers); + TestHeaderAssertions assertions = new TestHeaderAssertions(headers); // Success assertions.location("http://localhost:8080/"); // Wrong value - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.location("http://localhost:8081/")); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.location("http://localhost:8081/")); } @Test @@ -227,51 +221,50 @@ void cacheControl() { HttpHeaders headers = new HttpHeaders(); headers.setCacheControl(control.getHeaderValue()); - HeaderAssertions assertions = headerAssertions(headers); + TestHeaderAssertions assertions = new TestHeaderAssertions(headers); // Success assertions.cacheControl(control); // Wrong value - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.cacheControl(CacheControl.noStore())); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.cacheControl(CacheControl.noStore())); } @Test void contentDisposition() { HttpHeaders headers = new HttpHeaders(); headers.setContentDispositionFormData("foo", "bar"); - HeaderAssertions assertions = headerAssertions(headers); + TestHeaderAssertions assertions = new TestHeaderAssertions(headers); assertions.contentDisposition(ContentDisposition.formData().name("foo").filename("bar").build()); // Wrong value - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.contentDisposition(ContentDisposition.attachment().build())); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.contentDisposition(ContentDisposition.attachment().build())); } @Test void contentLength() { HttpHeaders headers = new HttpHeaders(); headers.setContentLength(100); - HeaderAssertions assertions = headerAssertions(headers); + TestHeaderAssertions assertions = new TestHeaderAssertions(headers); assertions.contentLength(100); // Wrong value - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.contentLength(200)); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.contentLength(200)); } @Test void contentType() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); - HeaderAssertions assertions = headerAssertions(headers); + TestHeaderAssertions assertions = new TestHeaderAssertions(headers); assertions.contentType(MediaType.APPLICATION_JSON); assertions.contentType("application/json"); // Wrong value - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.contentType(MediaType.APPLICATION_XML)); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.contentType(MediaType.APPLICATION_XML)); } @@ -280,12 +273,12 @@ void expires() { HttpHeaders headers = new HttpHeaders(); ZonedDateTime expires = ZonedDateTime.of(2018, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC")); headers.setExpires(expires); - HeaderAssertions assertions = headerAssertions(headers); + TestHeaderAssertions assertions = new TestHeaderAssertions(headers); assertions.expires(expires.toInstant().toEpochMilli()); // Wrong value - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.expires(expires.toInstant().toEpochMilli() + 1)); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.expires(expires.toInstant().toEpochMilli() + 1)); } @Test @@ -293,37 +286,45 @@ void lastModified() { HttpHeaders headers = new HttpHeaders(); ZonedDateTime lastModified = ZonedDateTime.of(2018, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC")); headers.setLastModified(lastModified.toInstant().toEpochMilli()); - HeaderAssertions assertions = headerAssertions(headers); + TestHeaderAssertions assertions = new TestHeaderAssertions(headers); assertions.lastModified(lastModified.toInstant().toEpochMilli()); // Wrong value - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.lastModified(lastModified.toInstant().toEpochMilli() + 1)); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.lastModified(lastModified.toInstant().toEpochMilli() + 1)); } @Test void equalsDate() { HttpHeaders headers = new HttpHeaders(); headers.setDate("foo", 1000); - HeaderAssertions assertions = headerAssertions(headers); + TestHeaderAssertions assertions = new TestHeaderAssertions(headers); assertions.valueEqualsDate("foo", 1000); // Wrong value - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> - assertions.valueEqualsDate("foo", 2000)); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.valueEqualsDate("foo", 2000)); } - private HeaderAssertions headerAssertions(HttpHeaders responseHeaders) { - try { - RestClient.RequestHeadersSpec.ConvertibleClientHttpResponse response = mock(); - when(response.getStatusCode()).thenReturn(HttpStatusCode.valueOf(200)); - when(response.getHeaders()).thenReturn(responseHeaders); - ExchangeResult result = new ExchangeResult(new MockClientHttpRequest(), response, null); - return new HeaderAssertions(result, mock()); + + private static class TestHeaderAssertions extends AbstractHeaderAssertions { + + TestHeaderAssertions(HttpHeaders headers) { + super(new TestExchangeResult(headers), ""); } - catch (IOException ex) { - throw new IllegalStateException(ex); + + @Override + protected HttpHeaders getResponseHeaders() { + return getExchangeResult().headers(); + } + + @Override + protected void assertWithDiagnostics(Runnable assertion) { + assertion.run(); } } + + private record TestExchangeResult(HttpHeaders headers) {} + } diff --git a/spring-test/src/test/java/org/springframework/test/web/support/StatusAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/support/StatusAssertionTests.java new file mode 100644 index 000000000000..72b3904ca0f1 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/support/StatusAssertionTests.java @@ -0,0 +1,280 @@ +/* + * Copyright 2002-present 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.test.web.support; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; + +/** + * Tests for {@link AbstractStatusAssertions}. + * + * @author Rossen Stoyanchev + * @author Rob Worsnop + */ +class StatusAssertionTests { + + @Test + void isEqualTo() { + TestStatusAssertions assertions = new TestStatusAssertions(HttpStatus.CONFLICT); + + // Success + assertions.isEqualTo(HttpStatus.CONFLICT); + assertions.isEqualTo(409); + + // Wrong status + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.isEqualTo(HttpStatus.REQUEST_TIMEOUT)); + + // Wrong status value + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.isEqualTo(408)); + } + + @Test + void isEqualToWithCustomStatus() { + TestStatusAssertions assertions = new TestStatusAssertions(600); + + // Success + assertions.isEqualTo(600); + + // Wrong status + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> new TestStatusAssertions(601).isEqualTo(600)); + + } + + @Test + void reasonEquals() { + TestStatusAssertions assertions = new TestStatusAssertions(HttpStatus.CONFLICT); + + // Success + assertions.reasonEquals("Conflict"); + + // Wrong reason + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> new TestStatusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).reasonEquals("Conflict")); + } + + @Test + void statusSeries1xx() { + TestStatusAssertions assertions = new TestStatusAssertions(HttpStatus.CONTINUE); + + // Success + assertions.is1xxInformational(); + + // Wrong series + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> new TestStatusAssertions(HttpStatus.OK).is1xxInformational()); + } + + @Test + void statusSeries2xx() { + TestStatusAssertions assertions = new TestStatusAssertions(HttpStatus.OK); + + // Success + assertions.is2xxSuccessful(); + + // Wrong series + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> new TestStatusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).is2xxSuccessful()); + } + + @Test + void statusSeries3xx() { + TestStatusAssertions assertions = new TestStatusAssertions(HttpStatus.PERMANENT_REDIRECT); + + // Success + assertions.is3xxRedirection(); + + // Wrong series + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> new TestStatusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).is3xxRedirection()); + } + + @Test + void statusSeries4xx() { + TestStatusAssertions assertions = new TestStatusAssertions(HttpStatus.BAD_REQUEST); + + // Success + assertions.is4xxClientError(); + + // Wrong series + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> new TestStatusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).is4xxClientError()); + } + + @Test + void statusSeries5xx() { + TestStatusAssertions assertions = new TestStatusAssertions(HttpStatus.INTERNAL_SERVER_ERROR); + + // Success + assertions.is5xxServerError(); + + // Wrong series + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> new TestStatusAssertions(HttpStatus.OK).is5xxServerError()); + } + + @Test + void matchesStatusValue() { + TestStatusAssertions assertions = new TestStatusAssertions(HttpStatus.CONFLICT); + + // Success + assertions.value(equalTo(409)); + assertions.value(greaterThan(400)); + + // Wrong status + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.value(equalTo(200))); + } + + @Test + void matchesCustomStatusValue() { + new TestStatusAssertions(600).value(equalTo(600)); + } + + @Test + void consumesStatusValue() { + TestStatusAssertions assertions = new TestStatusAssertions(HttpStatus.CONFLICT); + + // Success + assertions.value((Integer value) -> assertThat(value).isEqualTo(409)); + } + + @Test + void statusIsAccepted() { + // Success + new TestStatusAssertions(HttpStatus.ACCEPTED).isAccepted(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> new TestStatusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isAccepted()); + } + + @Test + void statusIsNoContent() { + // Success + new TestStatusAssertions(HttpStatus.NO_CONTENT).isNoContent(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> new TestStatusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isNoContent()); + } + + @Test + void statusIsFound() { + // Success + new TestStatusAssertions(HttpStatus.FOUND).isFound(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> new TestStatusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isFound()); + } + + @Test + void statusIsSeeOther() { + // Success + new TestStatusAssertions(HttpStatus.SEE_OTHER).isSeeOther(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> new TestStatusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isSeeOther()); + } + + @Test + void statusIsNotModified() { + // Success + new TestStatusAssertions(HttpStatus.NOT_MODIFIED).isNotModified(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> new TestStatusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isNotModified()); + } + + @Test + void statusIsTemporaryRedirect() { + // Success + new TestStatusAssertions(HttpStatus.TEMPORARY_REDIRECT).isTemporaryRedirect(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> new TestStatusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isTemporaryRedirect()); + } + + @Test + void statusIsPermanentRedirect() { + // Success + new TestStatusAssertions(HttpStatus.PERMANENT_REDIRECT).isPermanentRedirect(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> new TestStatusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isPermanentRedirect()); + } + + @Test + void statusIsUnauthorized() { + // Success + new TestStatusAssertions(HttpStatus.UNAUTHORIZED).isUnauthorized(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> new TestStatusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isUnauthorized()); + } + + @Test + void statusIsForbidden() { + // Success + new TestStatusAssertions(HttpStatus.FORBIDDEN).isForbidden(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> new TestStatusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isForbidden()); + } + + + private static class TestStatusAssertions extends AbstractStatusAssertions { + + TestStatusAssertions(HttpStatus status) { + this(status.value()); + } + + TestStatusAssertions(int status) { + super(new TestExchangeResult(HttpStatusCode.valueOf(status)), ""); + } + + @Override + protected HttpStatusCode getStatus() { + return getExchangeResult().status(); + } + + @Override + protected void assertWithDiagnostics(Runnable assertion) { + assertion.run(); + } + } + + + private record TestExchangeResult(HttpStatusCode status) { + } + +} diff --git a/src/checkstyle/checkstyle-suppressions.xml b/src/checkstyle/checkstyle-suppressions.xml index 7b93a7ca858a..da63723d681d 100644 --- a/src/checkstyle/checkstyle-suppressions.xml +++ b/src/checkstyle/checkstyle-suppressions.xml @@ -98,7 +98,7 @@ - + From f57828708a55bc3c45836d986d52ce6b16fd923c Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Wed, 30 Jul 2025 07:04:19 +0100 Subject: [PATCH 029/591] Polishing in RestTestClient tests See gh-34428 --- .../client/{samples => }/RestTestClientTests.java | 12 ++++++++++-- .../web/servlet/client/samples/ApiVersionTests.java | 2 +- .../test/web/servlet/client/samples/ErrorTests.java | 3 ++- .../servlet/client/samples/HeaderAndCookieTests.java | 4 +++- .../web/servlet/client/samples/JsonContentTests.java | 8 +++++--- .../servlet/client/samples/ResponseEntityTests.java | 4 +++- .../servlet/client/samples/SoftAssertionTests.java | 2 +- .../web/servlet/client/samples/XmlContentTests.java | 3 ++- .../client/samples/bind/ApplicationContextTests.java | 4 ++++ .../web/servlet/client/samples/bind/FilterTests.java | 3 +-- .../servlet/client/samples/bind/HttpServerTests.java | 1 + .../client/samples/bind/RouterFunctionTests.java | 9 +++------ 12 files changed, 36 insertions(+), 19 deletions(-) rename spring-test/src/test/java/org/springframework/test/web/servlet/client/{samples => }/RestTestClientTests.java (98%) diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/RestTestClientTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/RestTestClientTests.java similarity index 98% rename from spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/RestTestClientTests.java rename to spring-test/src/test/java/org/springframework/test/web/servlet/client/RestTestClientTests.java index af1be6b11bc8..6c72769348f4 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/RestTestClientTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/RestTestClientTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.test.web.servlet.client.samples; +package org.springframework.test.web.servlet.client; import java.net.URI; import java.nio.charset.StandardCharsets; @@ -36,7 +36,6 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.client.RestTestClient; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -51,11 +50,13 @@ class RestTestClientTests { private RestTestClient client; + @BeforeEach void setUp() { this.client = RestTestClient.bindToController(new TestController()).build(); } + @Nested class HttpMethods { @@ -127,6 +128,7 @@ void testOptions() { } + @Nested class Mutation { @@ -149,6 +151,7 @@ void test() { } } + @Nested class Uris { @@ -193,6 +196,7 @@ void testURI() { } } + @Nested class Cookies { @Test @@ -214,6 +218,7 @@ void testCookies() { } } + @Nested class Headers { @Test @@ -271,6 +276,7 @@ void testIfNoneMatch() { } } + @Nested class Expectations { @Test @@ -281,6 +287,7 @@ void testExpectCookie() { } } + @Nested class ReturnResults { @Test @@ -312,6 +319,7 @@ void testReturnResultParameterizedTypeReference() { } } + @RestController static class TestController { diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ApiVersionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ApiVersionTests.java index 4ea6e81a1f02..c23d60b8c8b1 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ApiVersionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ApiVersionTests.java @@ -94,7 +94,7 @@ private Map performRequest( @RestController - static class TestController { + private static class TestController { private static final String HEADER = "X-API-Version"; diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ErrorTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ErrorTests.java index 656da349f85b..d35f4b99996a 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ErrorTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ErrorTests.java @@ -47,8 +47,9 @@ void serverException() { .expectStatus().isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); } + @RestController - static class TestController { + private static class TestController { @GetMapping("/server-error") void handleAndThrowException() { diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/HeaderAndCookieTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/HeaderAndCookieTests.java index 28a3c99cacb1..17329742f7de 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/HeaderAndCookieTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/HeaderAndCookieTests.java @@ -36,6 +36,7 @@ class HeaderAndCookieTests { private final RestTestClient client = RestTestClient.bindToController(new TestController()).build(); + @Test void requestResponseHeaderPair() { this.client.get().uri("/header-echo") @@ -61,8 +62,9 @@ void setCookies() { .expectHeader().valueMatches("Set-Cookie", "k1=v1"); } + @RestController - static class TestController { + private static class TestController { @GetMapping("header-echo") ResponseEntity handleHeader(@RequestHeader("h1") String myHeader) { diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/JsonContentTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/JsonContentTests.java index fc035c1e8057..58d47dcebc6e 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/JsonContentTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/JsonContentTests.java @@ -129,7 +129,7 @@ void postJsonContent() { @RestController @RequestMapping("/persons") - static class PersonController { + private static class PersonController { @GetMapping List getPersons() { @@ -143,11 +143,13 @@ Person getPerson(@PathVariable String firstName, @PathVariable String lastName) @PostMapping ResponseEntity savePerson(@RequestBody Person person) { - return ResponseEntity.created(URI.create(String.format("/persons/%s/%s", person.getFirstName(), person.getLastName()))).build(); + URI location = URI.create(String.format("/persons/%s/%s", person.getFirstName(), person.getLastName())); + return ResponseEntity.created(location).build(); } } - static class Person { + + private static class Person { private String firstName; private String lastName; diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ResponseEntityTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ResponseEntityTests.java index 12596a25936a..40dfb6d49f0d 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ResponseEntityTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ResponseEntityTests.java @@ -43,10 +43,12 @@ * @author Rob Worsnop */ class ResponseEntityTests { + private final RestTestClient client = RestTestClient.bindToController(new PersonController()) .baseUrl("/persons") .build(); + @Test void entity() { this.client.get().uri("/John") @@ -126,7 +128,7 @@ void postEntity() { @RestController @RequestMapping("/persons") - static class PersonController { + private static class PersonController { @GetMapping(path = "/{name}", produces = MediaType.APPLICATION_JSON_VALUE) Person getPerson(@PathVariable String name) { diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/SoftAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/SoftAssertionTests.java index d13154e857f2..d9f141df3b39 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/SoftAssertionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/SoftAssertionTests.java @@ -61,7 +61,7 @@ Multiple Exceptions (2): @RestController - static class TestController { + private static class TestController { @GetMapping("/test") String handle() { diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/XmlContentTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/XmlContentTests.java index f9af1bcaa45e..d960406c732a 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/XmlContentTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/XmlContentTests.java @@ -172,9 +172,10 @@ public List getpersons() { } } + @RestController @RequestMapping("/persons") - static class PersonController { + private static class PersonController { @GetMapping(produces = MediaType.APPLICATION_XML_VALUE) PersonsWrapper getPersons() { diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/ApplicationContextTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/ApplicationContextTests.java index e49c223ecca4..4829d904e1f8 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/ApplicationContextTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/ApplicationContextTests.java @@ -37,17 +37,21 @@ class ApplicationContextTests { private RestTestClient client; + private final WebApplicationContext context; + public ApplicationContextTests(WebApplicationContext context) { this.context = context; } + @BeforeEach void setUp() { this.client = RestTestClient.bindToApplicationContext(context).build(); } + @Test void test() { this.client.get().uri("/test") diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/FilterTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/FilterTests.java index fe6bb3d7edd2..5be11ab68b23 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/FilterTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/FilterTests.java @@ -21,7 +21,6 @@ import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpFilter; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -44,7 +43,7 @@ void filter() { Filter filter = new HttpFilter() { @Override - protected void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException { + protected void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException { res.getWriter().write("It works!"); } }; diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/HttpServerTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/HttpServerTests.java index 12e45899cd8d..44c5ef28eef7 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/HttpServerTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/HttpServerTests.java @@ -43,6 +43,7 @@ class HttpServerTests { @BeforeEach void start() throws Exception { + HttpHandler httpHandler = RouterFunctions.toHttpHandler( route(GET("/test"), request -> ServerResponse.ok().bodyValue("It works!"))); diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/RouterFunctionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/RouterFunctionTests.java index c17ef2464596..ab0a4ab01339 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/RouterFunctionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/RouterFunctionTests.java @@ -37,16 +37,13 @@ class RouterFunctionTests { @BeforeEach - void setUp() throws Exception { - - RouterFunction route = route(GET("/test"), request -> - ServerResponse.ok().body("It works!")); - + void setUp() { + RouterFunction route = route(GET("/test"), request -> ServerResponse.ok().body("It works!")); this.testClient = RestTestClient.bindToRouterFunction(route).build(); } @Test - void test() throws Exception { + void test() { this.testClient.get().uri("/test") .exchange() .expectStatus().isOk() From 4ae5d0d1fe54cea5372dca4ad1e3f4ba5a09391b Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Wed, 30 Jul 2025 07:08:51 +0100 Subject: [PATCH 030/591] Polishing in RestTestClient reference docs Closes gh-34428 --- .../ROOT/pages/testing/resttestclient.adoc | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/testing/resttestclient.adoc b/framework-docs/modules/ROOT/pages/testing/resttestclient.adoc index ddae5fb295c6..5fb725a43fba 100644 --- a/framework-docs/modules/ROOT/pages/testing/resttestclient.adoc +++ b/framework-docs/modules/ROOT/pages/testing/resttestclient.adoc @@ -2,10 +2,10 @@ = RestTestClient `RestTestClient` is an HTTP client designed for testing server applications. It wraps -Spring's xref:integration/rest-clients.adoc#rest-restclient[`RestClient`] and uses it to perform requests +Spring's xref:integration/rest-clients.adoc#rest-restclient[`RestClient`] and uses it to perform requests, but exposes a testing facade for verifying responses. `RestTestClient` can be used to perform end-to-end HTTP tests. It can also be used to test Spring MVC -applications without a running server via mock server request and response objects. +applications without a running server via MockMvc. @@ -14,7 +14,7 @@ applications without a running server via mock server request and response objec == Setup To set up a `RestTestClient` you need to choose a server setup to bind to. This can be one -of several mock server setup choices or a connection to a live server. +of several MockMvc setup choices, or a connection to a live server. @@ -144,9 +144,7 @@ Kotlin:: In addition to the server setup options described earlier, you can also configure client options, including base URL, default headers, client filters, and others. These options -are readily available following `bindToServer()`. For all other configuration options, -you need to use `configureClient()` to transition from server to client configuration, as -follows: +are readily available following the initial `bindTo` call, as follows: [tabs] ====== @@ -155,7 +153,6 @@ Java:: [source,java,indent=0,subs="verbatim,quotes"] ---- client = RestTestClient.bindToController(new TestController()) - .configureClient() .baseUrl("/test") .build(); ---- @@ -165,7 +162,6 @@ Kotlin:: [source,kotlin,indent=0,subs="verbatim,quotes"] ---- client = RestTestClient.bindToController(TestController()) - .configureClient() .baseUrl("/test") .build() ---- @@ -180,7 +176,7 @@ Kotlin:: `RestTestClient` provides an API identical to xref:integration/rest-clients.adoc#rest-restclient[`RestClient`] up to the point of performing a request by using `exchange()`. -After the call to `exchange()`, `RestTestClient` diverges from the `RestClient` and +After the call to `exchange()`, `RestTestClient` diverges from `RestClient`, and instead continues with a workflow to verify responses. To assert the response status and headers, use the following: @@ -310,8 +306,7 @@ Kotlin:: ====== TIP: When you need to decode to a target type with generics, look for the overloaded methods -that accept -{spring-framework-api}/core/ParameterizedTypeReference.html[`ParameterizedTypeReference`] +that accept{spring-framework-api}/core/ParameterizedTypeReference.html[`ParameterizedTypeReference`] instead of `Class`. @@ -346,8 +341,7 @@ Kotlin:: ---- ====== -If you want to ignore the response content, the following releases the content without -any assertions: +If you want to ignore the response content, the following releases the content without any assertions: [tabs] ====== From da443020e04fc58091489f51ddbcb0925d4b535b Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Wed, 30 Jul 2025 10:36:00 +0100 Subject: [PATCH 031/591] Rename AnnotationHttpServiceRegistrar Align with the name of the import annotation. In preparation of adding a client annotation with another registrar. See gh-35244 --- .../service/registry/HttpServiceGroup.java | 2 +- .../service/registry/ImportHttpServices.java | 4 +- ....java => ImportHttpServicesRegistrar.java} | 2 +- ... => ImportHttpServicesRegistrarTests.java} | 110 ++---------------- .../web/service/registry/TestGroup.java | 56 +++++++++ .../service/registry/TestGroupRegistry.java | 79 +++++++++++++ 6 files changed, 151 insertions(+), 102 deletions(-) rename spring-web/src/main/java/org/springframework/web/service/registry/{AnnotationHttpServiceRegistrar.java => ImportHttpServicesRegistrar.java} (96%) rename spring-web/src/test/java/org/springframework/web/service/registry/{AnnotationHttpServiceRegistrarTests.java => ImportHttpServicesRegistrarTests.java} (62%) create mode 100644 spring-web/src/test/java/org/springframework/web/service/registry/TestGroup.java create mode 100644 spring-web/src/test/java/org/springframework/web/service/registry/TestGroupRegistry.java diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceGroup.java b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceGroup.java index acab4ffb924e..27a115663ea9 100644 --- a/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceGroup.java +++ b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceGroup.java @@ -30,7 +30,7 @@ public interface HttpServiceGroup { /** - * The name of the group to add HTTP Services to when a group isn't specified. + * The name of the default group to add HTTP Services to when a group is not specified. */ String DEFAULT_GROUP_NAME = "default"; diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServices.java b/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServices.java index fe4b3a9e4714..3785f6834dfb 100644 --- a/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServices.java +++ b/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServices.java @@ -53,7 +53,7 @@ @Retention(RetentionPolicy.RUNTIME) @Documented @Repeatable(ImportHttpServices.Container.class) -@Import(AnnotationHttpServiceRegistrar.class) +@Import(ImportHttpServicesRegistrar.class) public @interface ImportHttpServices { /** @@ -106,7 +106,7 @@ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented - @Import(AnnotationHttpServiceRegistrar.class) + @Import(ImportHttpServicesRegistrar.class) @interface Container { ImportHttpServices[] value(); diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/AnnotationHttpServiceRegistrar.java b/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServicesRegistrar.java similarity index 96% rename from spring-web/src/main/java/org/springframework/web/service/registry/AnnotationHttpServiceRegistrar.java rename to spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServicesRegistrar.java index 8cc29cc04d74..c23845a37847 100644 --- a/spring-web/src/main/java/org/springframework/web/service/registry/AnnotationHttpServiceRegistrar.java +++ b/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServicesRegistrar.java @@ -29,7 +29,7 @@ * @author Olga Maciaszek-Sharma * @since 7.0 */ -class AnnotationHttpServiceRegistrar extends AbstractHttpServiceRegistrar { +class ImportHttpServicesRegistrar extends AbstractHttpServiceRegistrar { @Override protected void registerHttpServices(GroupRegistry registry, AnnotationMetadata metadata) { diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/AnnotationHttpServiceRegistrarTests.java b/spring-web/src/test/java/org/springframework/web/service/registry/ImportHttpServicesRegistrarTests.java similarity index 62% rename from spring-web/src/test/java/org/springframework/web/service/registry/AnnotationHttpServiceRegistrarTests.java rename to spring-web/src/test/java/org/springframework/web/service/registry/ImportHttpServicesRegistrarTests.java index 84188de38042..d3f45d3c3ff7 100644 --- a/spring-web/src/test/java/org/springframework/web/service/registry/AnnotationHttpServiceRegistrarTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/registry/ImportHttpServicesRegistrarTests.java @@ -16,11 +16,7 @@ package org.springframework.web.service.registry; -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.Map; -import java.util.Set; import java.util.function.BiConsumer; import org.junit.jupiter.api.Test; @@ -43,12 +39,12 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * Tests for {@link AnnotationHttpServiceRegistrar}. + * Tests for {@link ImportHttpServicesRegistrar}. * * @author Rossen Stoyanchev * @author Stephane Nicoll */ -public class AnnotationHttpServiceRegistrarTests { +public class ImportHttpServicesRegistrarTests { private static final String ECHO_GROUP = "echo"; @@ -57,13 +53,13 @@ public class AnnotationHttpServiceRegistrarTests { private final TestGroupRegistry groupRegistry = new TestGroupRegistry(); - private final TestAnnotationHttpServiceRegistrar registrar = new TestAnnotationHttpServiceRegistrar(); + private final ImportHttpServicesRegistrar registrar = new ImportHttpServicesRegistrar(); @Test void basicListing() { doRegister(ListingConfig.class); - assertGroups(StubGroup.ofListing(ECHO_GROUP, EchoA.class, EchoB.class)); + assertGroups(TestGroup.ofListing(ECHO_GROUP, EchoA.class, EchoB.class)); } @Test @@ -83,8 +79,8 @@ void basicListingWithAot() { void basicScan() { doRegister(ScanConfig.class); assertGroups( - StubGroup.ofPackageClasses(ECHO_GROUP, EchoA.class), - StubGroup.ofPackageClasses(GREETING_GROUP, GreetingA.class)); + TestGroup.ofPackageClasses(ECHO_GROUP, EchoA.class), + TestGroup.ofPackageClasses(GREETING_GROUP, GreetingA.class)); } @Test @@ -105,8 +101,8 @@ void basicScanWithAot() { void clientType() { doRegister(ClientTypeConfig.class); assertGroups( - StubGroup.ofListing(ECHO_GROUP, ClientType.WEB_CLIENT, EchoA.class), - StubGroup.ofListing(GREETING_GROUP, ClientType.WEB_CLIENT, GreetingA.class)); + TestGroup.ofListing(ECHO_GROUP, ClientType.WEB_CLIENT, EchoA.class), + TestGroup.ofListing(GREETING_GROUP, ClientType.WEB_CLIENT, GreetingA.class)); } private void doRegister(Class configClass) { @@ -133,11 +129,11 @@ private GenericApplicationContext toFreshApplicationContext( return freshApplicationContext; } - private void assertGroups(StubGroup... expectedGroups) { - Map groupMap = this.groupRegistry.groupMap(); + private void assertGroups(TestGroup... expectedGroups) { + Map groupMap = this.groupRegistry.groupMap(); assertThat(groupMap.size()).isEqualTo(expectedGroups.length); - for (StubGroup expected : expectedGroups) { - StubGroup actual = groupMap.get(expected.name()); + for (TestGroup expected : expectedGroups) { + TestGroup actual = groupMap.get(expected.name()); assertThat(actual.httpServiceTypes()).isEqualTo(expected.httpServiceTypes()); assertThat(actual.clientType()).isEqualTo(expected.clientType()); assertThat(actual.packageNames()).isEqualTo(expected.packageNames()); @@ -159,86 +155,4 @@ static class ScanConfig { @ImportHttpServices(clientType = ClientType.WEB_CLIENT, group = GREETING_GROUP, types = { GreetingA.class }) static class ClientTypeConfig { } - - - private static class TestAnnotationHttpServiceRegistrar extends AnnotationHttpServiceRegistrar { - - @Override - public void registerHttpServices(GroupRegistry registry, AnnotationMetadata metadata) { - super.registerHttpServices(registry, metadata); - } - } - - - private static class TestGroupRegistry implements AbstractHttpServiceRegistrar.GroupRegistry { - - private final Map groupMap = new LinkedHashMap<>(); - - public Map groupMap() { - return this.groupMap; - } - - @Override - public GroupSpec forGroup(String name, ClientType clientType) { - return new TestGroupSpec(this.groupMap, name, clientType); - } - - - private record TestGroupSpec(Map groupMap, String groupName, - ClientType clientType) implements GroupSpec { - - @Override - public GroupSpec register(Class... serviceTypes) { - getOrCreateGroup().httpServiceTypes().addAll(Arrays.asList(serviceTypes)); - return this; - } - - @Override - public GroupSpec detectInBasePackages(Class... packageClasses) { - getOrCreateGroup().packageClasses().addAll(Arrays.asList(packageClasses)); - return this; - } - - @Override - public GroupSpec detectInBasePackages(String... packageNames) { - getOrCreateGroup().packageNames().addAll(Arrays.asList(packageNames)); - return this; - } - - private StubGroup getOrCreateGroup() { - return this.groupMap.computeIfAbsent(this.groupName, name -> new StubGroup(name, this.clientType)); - } - } - } - - - private record StubGroup( - String name, ClientType clientType, Set> httpServiceTypes, - Set> packageClasses, Set packageNames) implements HttpServiceGroup { - - StubGroup(String name, ClientType clientType) { - this(name, clientType, new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>()); - } - - public static StubGroup ofListing(String name, Class... httpServiceTypes) { - return ofListing(name, ClientType.UNSPECIFIED, httpServiceTypes); - } - - public static StubGroup ofListing(String name, ClientType clientType, Class... httpServiceTypes) { - StubGroup group = new StubGroup(name, clientType); - group.httpServiceTypes().addAll(Arrays.asList(httpServiceTypes)); - return group; - } - - public static StubGroup ofPackageClasses(String name, Class... packageClasses) { - return ofPackageClasses(name, ClientType.UNSPECIFIED, packageClasses); - } - - public static StubGroup ofPackageClasses(String name, ClientType clientType, Class... packageClasses) { - StubGroup group = new StubGroup(name, clientType); - group.packageClasses().addAll(Arrays.asList(packageClasses)); - return group; - } - } - } diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/TestGroup.java b/spring-web/src/test/java/org/springframework/web/service/registry/TestGroup.java new file mode 100644 index 000000000000..d4ed645a0b1e --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/service/registry/TestGroup.java @@ -0,0 +1,56 @@ +/* + * Copyright 2002-present 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.service.registry; + + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * A stub implementation of {@link HttpServiceGroup}. + * + * @author Rossen Stoyanchev + */ +record TestGroup( + String name, ClientType clientType, Set> httpServiceTypes, + Set> packageClasses, Set packageNames) implements HttpServiceGroup { + + TestGroup(String name, ClientType clientType) { + this(name, clientType, new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>()); + } + + public static TestGroup ofListing(String name, Class... httpServiceTypes) { + return ofListing(name, ClientType.UNSPECIFIED, httpServiceTypes); + } + + public static TestGroup ofListing(String name, ClientType clientType, Class... httpServiceTypes) { + TestGroup group = new TestGroup(name, clientType); + group.httpServiceTypes().addAll(Arrays.asList(httpServiceTypes)); + return group; + } + + public static TestGroup ofPackageClasses(String name, Class... packageClasses) { + return ofPackageClasses(name, ClientType.UNSPECIFIED, packageClasses); + } + + public static TestGroup ofPackageClasses(String name, ClientType clientType, Class... packageClasses) { + TestGroup group = new TestGroup(name, clientType); + group.packageClasses().addAll(Arrays.asList(packageClasses)); + return group; + } +} diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/TestGroupRegistry.java b/spring-web/src/test/java/org/springframework/web/service/registry/TestGroupRegistry.java new file mode 100644 index 000000000000..2f059d0674a5 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/service/registry/TestGroupRegistry.java @@ -0,0 +1,79 @@ +/* + * Copyright 2002-present 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.service.registry; + + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.util.ClassUtils; +import org.springframework.web.service.registry.AbstractHttpServiceRegistrar.GroupRegistry; + +/** + * A {@link GroupRegistry} that records the inputs given and creates {@link TestGroup}s + * + * @author Rossen Stoyanchev + */ +class TestGroupRegistry implements GroupRegistry { + + private final Map groupMap = new LinkedHashMap<>(); + + public Map groupMap() { + return this.groupMap; + } + + @Override + public GroupSpec forGroup(String name, HttpServiceGroup.ClientType clientType) { + return new TestGroupSpec(this.groupMap, name, clientType); + } + + + private record TestGroupSpec( + Map groupMap, String groupName, + HttpServiceGroup.ClientType clientType) implements GroupSpec { + + @Override + public GroupSpec register(Class... serviceTypes) { + getOrCreateGroup().httpServiceTypes().addAll(Arrays.asList(serviceTypes)); + return this; + } + + @Override + public GroupSpec registerTypeNames(String... serviceTypes) { + return register(Arrays.stream(serviceTypes) + .map(className -> ClassUtils.resolveClassName(className, getClass().getClassLoader())) + .toArray(Class[]::new)); + } + + @Override + public GroupSpec detectInBasePackages(Class... packageClasses) { + getOrCreateGroup().packageClasses().addAll(Arrays.asList(packageClasses)); + return this; + } + + @Override + public GroupSpec detectInBasePackages(String... packageNames) { + getOrCreateGroup().packageNames().addAll(Arrays.asList(packageNames)); + return this; + } + + private TestGroup getOrCreateGroup() { + return this.groupMap.computeIfAbsent(this.groupName, name -> new TestGroup(name, this.clientType)); + } + } +} From 279bce7124120c5654679573b59ca1c9b124c28c Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Wed, 30 Jul 2025 13:26:54 +0100 Subject: [PATCH 032/591] Add HttpServiceClient and registrar See gh-35244 --- .../AbstractHttpServiceRegistrar.java | 22 ++++-- .../service/registry/HttpServiceClient.java | 54 +++++++++++++ .../HttpServiceClientRegistrarSupport.java | 60 +++++++++++++++ ...ttpServiceClientRegistrarSupportTests.java | 75 +++++++++++++++++++ .../registry/client/DefaultClient.java | 30 ++++++++ .../service/registry/client/EchoClientA.java | 30 ++++++++ .../service/registry/client/EchoClientB.java | 30 ++++++++ 7 files changed, 296 insertions(+), 5 deletions(-) create mode 100644 spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceClient.java create mode 100644 spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceClientRegistrarSupport.java create mode 100644 spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceClientRegistrarSupportTests.java create mode 100644 spring-web/src/test/java/org/springframework/web/service/registry/client/DefaultClient.java create mode 100644 spring-web/src/test/java/org/springframework/web/service/registry/client/EchoClientA.java create mode 100644 spring-web/src/test/java/org/springframework/web/service/registry/client/EchoClientB.java diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java b/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java index 570a2d3cd995..d7d9b461b0df 100644 --- a/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java +++ b/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java @@ -18,6 +18,7 @@ import java.util.Arrays; import java.util.Objects; +import java.util.stream.Stream; import org.jspecify.annotations.Nullable; @@ -186,7 +187,8 @@ private RootBeanDefinition createOrGetRegistry(BeanDefinitionRegistry beanRegist protected abstract void registerHttpServices( GroupRegistry registry, AnnotationMetadata importingClassMetadata); - private ClassPathScanningCandidateComponentProvider getScanner() { + + protected Stream findHttpServices(String basePackage) { if (this.scanner == null) { Assert.state(this.environment != null, "Environment has not been set"); Assert.state(this.resourceLoader != null, "ResourceLoader has not been set"); @@ -194,7 +196,7 @@ private ClassPathScanningCandidateComponentProvider getScanner() { this.scanner.setEnvironment(this.environment); this.scanner.setResourceLoader(this.resourceLoader); } - return this.scanner; + return this.scanner.findCandidateComponents(basePackage).stream(); } private void mergeGroups(RootBeanDefinition proxyRegistryBeanDef) { @@ -244,10 +246,15 @@ default GroupSpec forDefaultGroup() { interface GroupSpec { /** - * List HTTP Service types to create proxies for. + * Register HTTP Service types to create proxies for. */ GroupSpec register(Class... serviceTypes); + /** + * Register HTTP Service types using fully qualified type names. + */ + GroupSpec registerTypeNames(String... serviceTypes); + /** * Detect HTTP Service types in the given packages, looking for * interfaces with a type and/or method {@link HttpExchange} annotation. @@ -258,7 +265,6 @@ interface GroupSpec { * Variant of {@link #detectInBasePackages(Class[])} with a String package name. */ GroupSpec detectInBasePackages(String... packageNames); - } } @@ -288,6 +294,12 @@ public GroupRegistry.GroupSpec register(Class... serviceTypes) { return this; } + @Override + public GroupRegistry.GroupSpec registerTypeNames(String... serviceTypes) { + Arrays.stream(serviceTypes).forEach(this::registerServiceTypeName); + return this; + } + @Override public GroupRegistry.GroupSpec detectInBasePackages(Class... packageClasses) { Arrays.stream(packageClasses).map(Class::getPackageName).forEach(this::detectInBasePackage); @@ -301,7 +313,7 @@ public GroupRegistry.GroupSpec detectInBasePackages(String... packageNames) { } private void detectInBasePackage(String packageName) { - getScanner().findCandidateComponents(packageName).stream() + findHttpServices(packageName) .map(BeanDefinition::getBeanClassName) .filter(Objects::nonNull) .forEach(this::registerServiceTypeName); diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceClient.java b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceClient.java new file mode 100644 index 000000000000..56fe47a93fe9 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceClient.java @@ -0,0 +1,54 @@ +/* + * Copyright 2002-present 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.service.registry; + + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.core.annotation.AliasFor; + +/** + * Annotation to mark an HTTP Service interface as a candidate client proxy creation. + * Supported by extensions of {@link HttpServiceClientRegistrarSupport}. + * + * @author Rossen Stoyanchev + * @since 7.0 + * @see HttpServiceClientRegistrarSupport + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface HttpServiceClient { + + /** + * An alias for {@link #group()}. + */ + @AliasFor("group") + String value() default HttpServiceGroup.DEFAULT_GROUP_NAME; + + /** + * The name of the HTTP Service group for this client. + *

    By default, this is {@link HttpServiceGroup#DEFAULT_GROUP_NAME}. + */ + @AliasFor("value") + String group() default HttpServiceGroup.DEFAULT_GROUP_NAME; + +} diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceClientRegistrarSupport.java b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceClientRegistrarSupport.java new file mode 100644 index 000000000000..ad9d22d2e58e --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceClientRegistrarSupport.java @@ -0,0 +1,60 @@ +/* + * Copyright 2002-present 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.service.registry; + + +import java.util.List; + +import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition; +import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.core.type.AnnotationMetadata; + +/** + * Base class for an {@link AbstractHttpServiceRegistrar} to detects and register + * {@link HttpServiceClient @HttpServiceClient} annotated interfaces. + * + *

    Subclasses need to implement + * {@link #registerHttpServices(GroupRegistry, AnnotationMetadata)} and invoke + * {@link #findAndRegisterHttpServiceClients(GroupRegistry, List)}. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +public abstract class HttpServiceClientRegistrarSupport extends AbstractHttpServiceRegistrar { + + /** + * Find all HTTP Services under the given base packages that also have an + * {@link HttpServiceClient @HttpServiceClient} annotation, and register them + * in the group specified on the annotation. + * @param registry the registry from {@link #registerHttpServices(GroupRegistry, AnnotationMetadata)} + * @param basePackages the base packages to scan + */ + protected void findAndRegisterHttpServiceClients(GroupRegistry registry, List basePackages) { + basePackages.stream() + .flatMap(this::findHttpServices) + .filter(definition -> definition instanceof AnnotatedBeanDefinition) + .map(definition -> (AnnotatedBeanDefinition) definition) + .filter(definition -> definition.getMetadata().hasAnnotation(HttpServiceClient.class.getName())) + .filter(definition -> definition.getBeanClassName() != null) + .forEach(definition -> { + MergedAnnotations annotations = definition.getMetadata().getAnnotations(); + String group = annotations.get(HttpServiceClient.class).getString("group"); + registry.forGroup(group).registerTypeNames(definition.getBeanClassName()); + }); + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceClientRegistrarSupportTests.java b/spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceClientRegistrarSupportTests.java new file mode 100644 index 000000000000..ef5b11a9524a --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceClientRegistrarSupportTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2002-present 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.service.registry; + + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.web.service.registry.client.DefaultClient; +import org.springframework.web.service.registry.client.EchoClientA; +import org.springframework.web.service.registry.client.EchoClientB; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Unit tests for {@link HttpServiceClientRegistrarSupport}. + * @author Rossen Stoyanchev + */ +public class HttpServiceClientRegistrarSupportTests { + + private final TestGroupRegistry groupRegistry = new TestGroupRegistry(); + + + @Test + void register() { + HttpServiceClientRegistrarSupport registrar = new HttpServiceClientRegistrarSupport() { + + @Override + protected void registerHttpServices(GroupRegistry registry, AnnotationMetadata importingClassMetadata) { + findAndRegisterHttpServiceClients(groupRegistry, List.of(getClass().getPackage().getName() + ".client")); + } + }; + registrar.setEnvironment(new StandardEnvironment()); + registrar.setResourceLoader(new PathMatchingResourcePatternResolver()); + + registrar.registerHttpServices(groupRegistry, mock(AnnotationMetadata.class)); + + assertGroups( + TestGroup.ofListing("echo", EchoClientA.class, EchoClientB.class), + TestGroup.ofListing("default", DefaultClient.class)); + } + + private void assertGroups(TestGroup... expectedGroups) { + Map groupMap = this.groupRegistry.groupMap(); + assertThat(groupMap.size()).isEqualTo(expectedGroups.length); + for (TestGroup expected : expectedGroups) { + TestGroup actual = groupMap.get(expected.name()); + assertThat(actual.httpServiceTypes()).isEqualTo(expected.httpServiceTypes()); + assertThat(actual.clientType()).isEqualTo(expected.clientType()); + assertThat(actual.packageNames()).isEqualTo(expected.packageNames()); + assertThat(actual.packageClasses()).isEqualTo(expected.packageClasses()); + } + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/client/DefaultClient.java b/spring-web/src/test/java/org/springframework/web/service/registry/client/DefaultClient.java new file mode 100644 index 000000000000..197f950873d3 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/service/registry/client/DefaultClient.java @@ -0,0 +1,30 @@ +/* + * Copyright 2002-present 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.service.registry.client; + + +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.registry.HttpServiceClient; + +@HttpServiceClient +public interface DefaultClient { + + @GetExchange + String handle(@RequestParam String input); + +} diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/client/EchoClientA.java b/spring-web/src/test/java/org/springframework/web/service/registry/client/EchoClientA.java new file mode 100644 index 000000000000..e339f9a35d6a --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/service/registry/client/EchoClientA.java @@ -0,0 +1,30 @@ +/* + * Copyright 2002-present 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.service.registry.client; + + +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.registry.HttpServiceClient; + +@HttpServiceClient("echo") +public interface EchoClientA { + + @GetExchange + String handle(@RequestParam String input); + +} diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/client/EchoClientB.java b/spring-web/src/test/java/org/springframework/web/service/registry/client/EchoClientB.java new file mode 100644 index 000000000000..ed28b908ff99 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/service/registry/client/EchoClientB.java @@ -0,0 +1,30 @@ +/* + * Copyright 2002-present 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.service.registry.client; + + +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.registry.HttpServiceClient; + +@HttpServiceClient("echo") +public interface EchoClientB { + + @GetExchange + String handle(@RequestParam String input); + +} From 09917fad7bca9b3997522f0a75d6319203f2127f Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Wed, 30 Jul 2025 13:55:00 +0100 Subject: [PATCH 033/591] Fix bean name for ApiVersionStrategy in WebFlux config --- .../web/reactive/config/WebFluxConfigurationSupport.java | 4 ++-- .../reactive/config/DelegatingWebFluxConfigurationTests.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 049e3f79083a..21bdef7e00f8 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 @@ -136,7 +136,7 @@ public WebExceptionHandler responseStatusExceptionHandler() { @Bean public RequestMappingHandlerMapping requestMappingHandlerMapping( @Qualifier("webFluxContentTypeResolver") RequestedContentTypeResolver contentTypeResolver, - @Qualifier("mvcApiVersionStrategy") @Nullable ApiVersionStrategy apiVersionStrategy) { + @Qualifier("webFluxApiVersionStrategy") @Nullable ApiVersionStrategy apiVersionStrategy) { RequestMappingHandlerMapping mapping = createRequestMappingHandlerMapping(); mapping.setOrder(0); @@ -188,7 +188,7 @@ protected void configureContentTypeResolver(RequestedContentTypeResolverBuilder * @since 7.0 */ @Bean - public @Nullable ApiVersionStrategy mvcApiVersionStrategy() { + public @Nullable ApiVersionStrategy webFluxApiVersionStrategy() { if (this.apiVersionStrategy == null) { ApiVersionConfigurer configurer = new ApiVersionConfigurer(); configureApiVersioning(configurer); 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 ac574bcd926c..c3bc8b75ea1b 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 @@ -87,7 +87,7 @@ void setup() { void requestMappingHandlerMapping() { delegatingConfig.setConfigurers(Collections.singletonList(webFluxConfigurer)); delegatingConfig.requestMappingHandlerMapping( - delegatingConfig.webFluxContentTypeResolver(), delegatingConfig.mvcApiVersionStrategy()); + delegatingConfig.webFluxContentTypeResolver(), delegatingConfig.webFluxApiVersionStrategy()); verify(webFluxConfigurer).configureContentTypeResolver(any(RequestedContentTypeResolverBuilder.class)); verify(webFluxConfigurer).addCorsMappings(any(CorsRegistry.class)); From 0fc9e4ec1c944c5a6e9b8b9d26d8b3b93d1c2d64 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Thu, 31 Jul 2025 05:01:34 +0100 Subject: [PATCH 034/591] Exclude HttpServiceClient from GroupRegistry scan The detect methods in the GroupRegistry that find all interfaces with HttpExchange annotations now exclude HttpServiceClient interfaces that are instead supported by a dedicated registrar. This ensures there is no overlap between the HttpServiceClient registrar scan and the ImportHttpServices registrar scan or the scan of any other custom registrar. See gh-35244 --- .../AbstractHttpServiceRegistrar.java | 19 ++++++++++++++++++- .../service/registry/ImportHttpServices.java | 9 +++++---- ...ttpServiceClientRegistrarSupportTests.java | 17 ++++++++++------- .../BasicClient.java} | 4 ++-- .../{client => echo}/EchoClientA.java | 2 +- .../{client => echo}/EchoClientB.java | 2 +- 6 files changed, 37 insertions(+), 16 deletions(-) rename spring-web/src/test/java/org/springframework/web/service/registry/{client/DefaultClient.java => basic/BasicClient.java} (90%) rename spring-web/src/test/java/org/springframework/web/service/registry/{client => echo}/EchoClientA.java (94%) rename spring-web/src/test/java/org/springframework/web/service/registry/{client => echo}/EchoClientB.java (94%) diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java b/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java index d7d9b461b0df..099e01f13fca 100644 --- a/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java +++ b/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java @@ -188,6 +188,12 @@ protected abstract void registerHttpServices( GroupRegistry registry, AnnotationMetadata importingClassMetadata); + /** + * Exposes the scan for HTTP Service types, looking for + * interfaces with type or method {@link HttpExchange} annotations. + * @param basePackage the packages to look under + * @return match bean definitions + */ protected Stream findHttpServices(String basePackage) { if (this.scanner == null) { Assert.state(this.environment != null, "Environment has not been set"); @@ -257,7 +263,10 @@ interface GroupSpec { /** * Detect HTTP Service types in the given packages, looking for - * interfaces with a type and/or method {@link HttpExchange} annotation. + * interfaces with type or method {@link HttpExchange} annotations. + *

    The performed scan, however, filters out any interfaces + * annotated with {@link HttpServiceClient} that are instead supported + * by {@link HttpServiceClientRegistrarSupport}. */ GroupSpec detectInBasePackages(Class... packageClasses); @@ -314,11 +323,19 @@ public GroupRegistry.GroupSpec detectInBasePackages(String... packageNames) { private void detectInBasePackage(String packageName) { findHttpServices(packageName) + .filter(DefaultGroupSpec::isNotHttpServiceClientAnnotated) .map(BeanDefinition::getBeanClassName) .filter(Objects::nonNull) .forEach(this::registerServiceTypeName); } + private static boolean isNotHttpServiceClientAnnotated(BeanDefinition defintion) { + if (defintion instanceof AnnotatedBeanDefinition abd) { + return !abd.getMetadata().hasAnnotation(HttpServiceClient.class.getName()); + } + return true; + } + private void registerServiceTypeName(String httpServiceTypeName) { this.registration.httpServiceTypeNames().add(httpServiceTypeName); } diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServices.java b/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServices.java index 3785f6834dfb..4dbd74507792 100644 --- a/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServices.java +++ b/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServices.java @@ -76,10 +76,11 @@ String group() default HttpServiceGroup.DEFAULT_GROUP_NAME; /** - * Detect HTTP Services in the packages of the specified classes by looking - * for interfaces with type-level or method-level - * {@link org.springframework.web.service.annotation.HttpExchange @HttpExchange} - * annotations. + * Detect HTTP Services in the packages of the specified classes, looking + * for interfaces with type or method {@link HttpExchange} annotations. + *

    The performed scan, however, filters out interfaces annotated with + * {@link HttpServiceClient} that are instead supported by + * {@link HttpServiceClientRegistrarSupport}. */ Class[] basePackageClasses() default {}; diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceClientRegistrarSupportTests.java b/spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceClientRegistrarSupportTests.java index ef5b11a9524a..3045eb97958f 100644 --- a/spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceClientRegistrarSupportTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceClientRegistrarSupportTests.java @@ -25,9 +25,9 @@ import org.springframework.core.env.StandardEnvironment; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.core.type.AnnotationMetadata; -import org.springframework.web.service.registry.client.DefaultClient; -import org.springframework.web.service.registry.client.EchoClientA; -import org.springframework.web.service.registry.client.EchoClientB; +import org.springframework.web.service.registry.basic.BasicClient; +import org.springframework.web.service.registry.echo.EchoClientA; +import org.springframework.web.service.registry.echo.EchoClientB; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; @@ -43,21 +43,24 @@ public class HttpServiceClientRegistrarSupportTests { @Test void register() { + + List basePackages = List.of( + BasicClient.class.getPackageName(), EchoClientA.class.getPackageName()); + HttpServiceClientRegistrarSupport registrar = new HttpServiceClientRegistrarSupport() { @Override protected void registerHttpServices(GroupRegistry registry, AnnotationMetadata importingClassMetadata) { - findAndRegisterHttpServiceClients(groupRegistry, List.of(getClass().getPackage().getName() + ".client")); + findAndRegisterHttpServiceClients(groupRegistry, basePackages); } }; registrar.setEnvironment(new StandardEnvironment()); registrar.setResourceLoader(new PathMatchingResourcePatternResolver()); - registrar.registerHttpServices(groupRegistry, mock(AnnotationMetadata.class)); assertGroups( - TestGroup.ofListing("echo", EchoClientA.class, EchoClientB.class), - TestGroup.ofListing("default", DefaultClient.class)); + TestGroup.ofListing("default", BasicClient.class), + TestGroup.ofListing("echo", EchoClientA.class, EchoClientB.class)); } private void assertGroups(TestGroup... expectedGroups) { diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/client/DefaultClient.java b/spring-web/src/test/java/org/springframework/web/service/registry/basic/BasicClient.java similarity index 90% rename from spring-web/src/test/java/org/springframework/web/service/registry/client/DefaultClient.java rename to spring-web/src/test/java/org/springframework/web/service/registry/basic/BasicClient.java index 197f950873d3..6b81e0e7796b 100644 --- a/spring-web/src/test/java/org/springframework/web/service/registry/client/DefaultClient.java +++ b/spring-web/src/test/java/org/springframework/web/service/registry/basic/BasicClient.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.web.service.registry.client; +package org.springframework.web.service.registry.basic; import org.springframework.web.bind.annotation.RequestParam; @@ -22,7 +22,7 @@ import org.springframework.web.service.registry.HttpServiceClient; @HttpServiceClient -public interface DefaultClient { +public interface BasicClient { @GetExchange String handle(@RequestParam String input); diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/client/EchoClientA.java b/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoClientA.java similarity index 94% rename from spring-web/src/test/java/org/springframework/web/service/registry/client/EchoClientA.java rename to spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoClientA.java index e339f9a35d6a..7ab473f58447 100644 --- a/spring-web/src/test/java/org/springframework/web/service/registry/client/EchoClientA.java +++ b/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoClientA.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.web.service.registry.client; +package org.springframework.web.service.registry.echo; import org.springframework.web.bind.annotation.RequestParam; diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/client/EchoClientB.java b/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoClientB.java similarity index 94% rename from spring-web/src/test/java/org/springframework/web/service/registry/client/EchoClientB.java rename to spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoClientB.java index ed28b908ff99..88368f0c931c 100644 --- a/spring-web/src/test/java/org/springframework/web/service/registry/client/EchoClientB.java +++ b/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoClientB.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.web.service.registry.client; +package org.springframework.web.service.registry.echo; import org.springframework.web.bind.annotation.RequestParam; From 9dbe304cf6226edc3089eb2dafac468cb637baea Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Thu, 31 Jul 2025 05:05:34 +0100 Subject: [PATCH 035/591] Revise method order in AbstractHttpServiceRegistrar See gh-35244 --- .../AbstractHttpServiceRegistrar.java | 51 +++++++++---------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java b/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java index 099e01f13fca..4b55e8091029 100644 --- a/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java +++ b/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java @@ -163,6 +163,16 @@ public final void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefin })); } + /** + * This method is called before any bean definition registrations are made. + * Subclasses must implement it to register the HTTP Services for which bean + * definitions for which proxies need to be created. + * @param registry to perform HTTP Service registrations with + * @param importingClassMetadata annotation metadata of the importing class + */ + protected abstract void registerHttpServices( + GroupRegistry registry, AnnotationMetadata importingClassMetadata); + private RootBeanDefinition createOrGetRegistry(BeanDefinitionRegistry beanRegistry) { if (!beanRegistry.containsBeanDefinition(HTTP_SERVICE_PROXY_REGISTRY_BEAN_NAME)) { RootBeanDefinition proxyRegistryBeanDef = new RootBeanDefinition(); @@ -177,21 +187,25 @@ private RootBeanDefinition createOrGetRegistry(BeanDefinitionRegistry beanRegist } } - /** - * This method is called before any bean definition registrations are made. - * Subclasses must implement it to register the HTTP Services for which bean - * definitions for which proxies need to be created. - * @param registry to perform HTTP Service registrations with - * @param importingClassMetadata annotation metadata of the importing class - */ - protected abstract void registerHttpServices( - GroupRegistry registry, AnnotationMetadata importingClassMetadata); + private void mergeGroups(RootBeanDefinition proxyRegistryBeanDef) { + ConstructorArgumentValues args = proxyRegistryBeanDef.getConstructorArgumentValues(); + ConstructorArgumentValues.ValueHolder valueHolder = args.getArgumentValue(0, GroupsMetadata.class); + Assert.state(valueHolder != null, "Expected GroupsMetadata constructor argument at index 0"); + GroupsMetadata target = (GroupsMetadata) valueHolder.getValue(); + Assert.state(target != null, "No constructor argument value"); + target.mergeWith(this.groupsMetadata); + } + private Object getProxyInstance(String groupName, String httpServiceType) { + Assert.state(this.beanFactory != null, "BeanFactory has not been set"); + HttpServiceProxyRegistry registry = this.beanFactory.getBean(HTTP_SERVICE_PROXY_REGISTRY_BEAN_NAME, HttpServiceProxyRegistry.class); + return registry.getClient(groupName, ClassUtils.resolveClassName(httpServiceType, this.beanClassLoader)); + } /** - * Exposes the scan for HTTP Service types, looking for + * Find HTTP Service types under the given base package, looking for * interfaces with type or method {@link HttpExchange} annotations. - * @param basePackage the packages to look under + * @param basePackage the names of packages to look under * @return match bean definitions */ protected Stream findHttpServices(String basePackage) { @@ -205,21 +219,6 @@ protected Stream findHttpServices(String basePackage) { return this.scanner.findCandidateComponents(basePackage).stream(); } - private void mergeGroups(RootBeanDefinition proxyRegistryBeanDef) { - ConstructorArgumentValues args = proxyRegistryBeanDef.getConstructorArgumentValues(); - ConstructorArgumentValues.ValueHolder valueHolder = args.getArgumentValue(0, GroupsMetadata.class); - Assert.state(valueHolder != null, "Expected GroupsMetadata constructor argument at index 0"); - GroupsMetadata target = (GroupsMetadata) valueHolder.getValue(); - Assert.state(target != null, "No constructor argument value"); - target.mergeWith(this.groupsMetadata); - } - - private Object getProxyInstance(String groupName, String httpServiceType) { - Assert.state(this.beanFactory != null, "BeanFactory has not been set"); - HttpServiceProxyRegistry registry = this.beanFactory.getBean(HTTP_SERVICE_PROXY_REGISTRY_BEAN_NAME, HttpServiceProxyRegistry.class); - return registry.getClient(groupName, ClassUtils.resolveClassName(httpServiceType, this.beanClassLoader)); - } - /** * Registry API to allow subclasses to register HTTP Services. From c8b2a0f830ffd909d4110c94a202656a624ccb0a Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Thu, 31 Jul 2025 05:09:34 +0100 Subject: [PATCH 036/591] Rename HttpServiceClientRegistrarSupport to AbstractClientHttpServiceRegistrar since it is a registrar and an extension of Abstract[HttpServiceRegistrar]. Also, a more friendly name for use in application configuration. See gh-35244 --- ...rt.java => AbstractClientHttpServiceRegistrar.java} | 10 ++++++---- .../service/registry/AbstractHttpServiceRegistrar.java | 7 ++++--- .../web/service/registry/HttpServiceClient.java | 4 ++-- ...sRegistrar.java => ImportHttpServiceRegistrar.java} | 2 +- .../web/service/registry/ImportHttpServices.java | 6 +++--- ...Tests.java => ClientHttpServiceRegistrarTests.java} | 6 +++--- ...Tests.java => ImportHttpServiceRegistrarTests.java} | 6 +++--- 7 files changed, 22 insertions(+), 19 deletions(-) rename spring-web/src/main/java/org/springframework/web/service/registry/{HttpServiceClientRegistrarSupport.java => AbstractClientHttpServiceRegistrar.java} (91%) rename spring-web/src/main/java/org/springframework/web/service/registry/{ImportHttpServicesRegistrar.java => ImportHttpServiceRegistrar.java} (96%) rename spring-web/src/test/java/org/springframework/web/service/registry/{HttpServiceClientRegistrarSupportTests.java => ClientHttpServiceRegistrarTests.java} (92%) rename spring-web/src/test/java/org/springframework/web/service/registry/{ImportHttpServicesRegistrarTests.java => ImportHttpServiceRegistrarTests.java} (97%) diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceClientRegistrarSupport.java b/spring-web/src/main/java/org/springframework/web/service/registry/AbstractClientHttpServiceRegistrar.java similarity index 91% rename from spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceClientRegistrarSupport.java rename to spring-web/src/main/java/org/springframework/web/service/registry/AbstractClientHttpServiceRegistrar.java index ad9d22d2e58e..e3fa444cc301 100644 --- a/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceClientRegistrarSupport.java +++ b/spring-web/src/main/java/org/springframework/web/service/registry/AbstractClientHttpServiceRegistrar.java @@ -24,17 +24,19 @@ import org.springframework.core.type.AnnotationMetadata; /** - * Base class for an {@link AbstractHttpServiceRegistrar} to detects and register - * {@link HttpServiceClient @HttpServiceClient} annotated interfaces. + * Base class for an HTTP Service registrar that detects + * {@link HttpServiceClient @HttpServiceClient} annotated interfaces and + * registers them. * *

    Subclasses need to implement * {@link #registerHttpServices(GroupRegistry, AnnotationMetadata)} and invoke - * {@link #findAndRegisterHttpServiceClients(GroupRegistry, List)}. + * {@link #findAndRegisterHttpServiceClients(GroupRegistry, List)} with the + * list of base packages to scan. * * @author Rossen Stoyanchev * @since 7.0 */ -public abstract class HttpServiceClientRegistrarSupport extends AbstractHttpServiceRegistrar { +public abstract class AbstractClientHttpServiceRegistrar extends AbstractHttpServiceRegistrar { /** * Find all HTTP Services under the given base packages that also have an diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java b/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java index 4b55e8091029..9fc72368d515 100644 --- a/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java +++ b/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java @@ -226,7 +226,8 @@ protected Stream findHttpServices(String basePackage) { protected interface GroupRegistry { /** - * Perform HTTP Service registrations for the given group. + * Perform HTTP Service registrations for the given group, either + * creating the group if it does not exist, or updating the existing one. */ default GroupSpec forGroup(String name) { return forGroup(name, HttpServiceGroup.ClientType.UNSPECIFIED); @@ -251,7 +252,7 @@ default GroupSpec forDefaultGroup() { interface GroupSpec { /** - * Register HTTP Service types to create proxies for. + * Register HTTP Service types associated with this group. */ GroupSpec register(Class... serviceTypes); @@ -265,7 +266,7 @@ interface GroupSpec { * interfaces with type or method {@link HttpExchange} annotations. *

    The performed scan, however, filters out any interfaces * annotated with {@link HttpServiceClient} that are instead supported - * by {@link HttpServiceClientRegistrarSupport}. + * by {@link AbstractClientHttpServiceRegistrar}. */ GroupSpec detectInBasePackages(Class... packageClasses); diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceClient.java b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceClient.java index 56fe47a93fe9..03c285b76294 100644 --- a/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceClient.java +++ b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceClient.java @@ -27,11 +27,11 @@ /** * Annotation to mark an HTTP Service interface as a candidate client proxy creation. - * Supported by extensions of {@link HttpServiceClientRegistrarSupport}. + * Supported through the import of an {@link AbstractClientHttpServiceRegistrar}. * * @author Rossen Stoyanchev * @since 7.0 - * @see HttpServiceClientRegistrarSupport + * @see AbstractClientHttpServiceRegistrar */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServicesRegistrar.java b/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServiceRegistrar.java similarity index 96% rename from spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServicesRegistrar.java rename to spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServiceRegistrar.java index c23845a37847..81f165e59d0f 100644 --- a/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServicesRegistrar.java +++ b/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServiceRegistrar.java @@ -29,7 +29,7 @@ * @author Olga Maciaszek-Sharma * @since 7.0 */ -class ImportHttpServicesRegistrar extends AbstractHttpServiceRegistrar { +class ImportHttpServiceRegistrar extends AbstractHttpServiceRegistrar { @Override protected void registerHttpServices(GroupRegistry registry, AnnotationMetadata metadata) { diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServices.java b/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServices.java index 4dbd74507792..b1abea0323e9 100644 --- a/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServices.java +++ b/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServices.java @@ -53,7 +53,7 @@ @Retention(RetentionPolicy.RUNTIME) @Documented @Repeatable(ImportHttpServices.Container.class) -@Import(ImportHttpServicesRegistrar.class) +@Import(ImportHttpServiceRegistrar.class) public @interface ImportHttpServices { /** @@ -80,7 +80,7 @@ * for interfaces with type or method {@link HttpExchange} annotations. *

    The performed scan, however, filters out interfaces annotated with * {@link HttpServiceClient} that are instead supported by - * {@link HttpServiceClientRegistrarSupport}. + * {@link AbstractClientHttpServiceRegistrar}. */ Class[] basePackageClasses() default {}; @@ -107,7 +107,7 @@ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented - @Import(ImportHttpServicesRegistrar.class) + @Import(ImportHttpServiceRegistrar.class) @interface Container { ImportHttpServices[] value(); diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceClientRegistrarSupportTests.java b/spring-web/src/test/java/org/springframework/web/service/registry/ClientHttpServiceRegistrarTests.java similarity index 92% rename from spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceClientRegistrarSupportTests.java rename to spring-web/src/test/java/org/springframework/web/service/registry/ClientHttpServiceRegistrarTests.java index 3045eb97958f..7f60b777538c 100644 --- a/spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceClientRegistrarSupportTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/registry/ClientHttpServiceRegistrarTests.java @@ -33,10 +33,10 @@ import static org.mockito.Mockito.mock; /** - * Unit tests for {@link HttpServiceClientRegistrarSupport}. + * Unit tests for {@link AbstractClientHttpServiceRegistrar}. * @author Rossen Stoyanchev */ -public class HttpServiceClientRegistrarSupportTests { +public class ClientHttpServiceRegistrarTests { private final TestGroupRegistry groupRegistry = new TestGroupRegistry(); @@ -47,7 +47,7 @@ void register() { List basePackages = List.of( BasicClient.class.getPackageName(), EchoClientA.class.getPackageName()); - HttpServiceClientRegistrarSupport registrar = new HttpServiceClientRegistrarSupport() { + AbstractClientHttpServiceRegistrar registrar = new AbstractClientHttpServiceRegistrar() { @Override protected void registerHttpServices(GroupRegistry registry, AnnotationMetadata importingClassMetadata) { diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/ImportHttpServicesRegistrarTests.java b/spring-web/src/test/java/org/springframework/web/service/registry/ImportHttpServiceRegistrarTests.java similarity index 97% rename from spring-web/src/test/java/org/springframework/web/service/registry/ImportHttpServicesRegistrarTests.java rename to spring-web/src/test/java/org/springframework/web/service/registry/ImportHttpServiceRegistrarTests.java index d3f45d3c3ff7..8959cb2f2de5 100644 --- a/spring-web/src/test/java/org/springframework/web/service/registry/ImportHttpServicesRegistrarTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/registry/ImportHttpServiceRegistrarTests.java @@ -39,12 +39,12 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * Tests for {@link ImportHttpServicesRegistrar}. + * Tests for {@link ImportHttpServiceRegistrar}. * * @author Rossen Stoyanchev * @author Stephane Nicoll */ -public class ImportHttpServicesRegistrarTests { +public class ImportHttpServiceRegistrarTests { private static final String ECHO_GROUP = "echo"; @@ -53,7 +53,7 @@ public class ImportHttpServicesRegistrarTests { private final TestGroupRegistry groupRegistry = new TestGroupRegistry(); - private final ImportHttpServicesRegistrar registrar = new ImportHttpServicesRegistrar(); + private final ImportHttpServiceRegistrar registrar = new ImportHttpServiceRegistrar(); @Test From d661550b4837cc99447fff9e0475cd58c7d07698 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Thu, 31 Jul 2025 05:19:36 +0100 Subject: [PATCH 037/591] Update docs for HttpServiceClient Closes gh-35244 --- .../ROOT/pages/integration/rest-clients.adoc | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc index 0417613f496e..d81436315a1a 100644 --- a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc +++ b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc @@ -1195,6 +1195,46 @@ One way to declare HTTP Service groups is via `@ImportHttpServices` annotations <1> Manually list interfaces for group "echo" <2> Detect interfaces for group "greeting" under a base package +The above lets you declare HTTP Services and groups. As an alternative, you can also +annotate HTTP interfaces as follows: + +[source,java,indent=0,subs="verbatim,quotes"] +---- + @HttpServiceClient("echo") + public class EchoServiceA { + // ... + } + + @HttpServiceClient("echo") + public class EchoServiceB { + // ... + } +---- + +The above requires a dedicated import registrar as follows: + +[source,java,indent=0,subs="verbatim,quotes"] +---- + public class MyClientHttpServiceRegistrar implements AbstractClientHttpServiceRegistrar { // <1> + + @Override + protected void registerHttpServices(GroupRegistry registry, AnnotationMetadata metadata) { + findAndRegisterHttpServiceClients(groupRegistry, List.of("org.example.echo")); // <2> + } + } + + @Configuration + @Import(MyClientHttpServiceRegistrar.class) // <3> + public class ClientConfig { + } +---- +<1> Extend dedicated `AbstractClientHttpServiceRegistrar` +<2> Specify base packages where to find client interfaces +<3> Import the registrar + +TIP: `@HttpServiceClient` interfaces are excluded from `@ImportHttpServices` scans, so there +is no overlap with scans for client interfaces when pointed at the same package. + It is also possible to declare groups programmatically by creating an HTTP Service registrar and then importing it: From 5a3bad6d61c521ef9385d2902b9fdc4b322cfa33 Mon Sep 17 00:00:00 2001 From: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com> Date: Thu, 31 Jul 2025 16:39:48 +0800 Subject: [PATCH 038/591] fix remove useless "s" Signed-off-by: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com> --- .../springframework/test/web/servlet/client/RestTestClient.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java index 836979aa25a0..426b8e5d8f55 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java @@ -515,7 +515,7 @@ interface RequestBodyUriSpec extends RequestBodySpec, RequestHeadersUriSpec Date: Thu, 31 Jul 2025 12:26:15 +0100 Subject: [PATCH 039/591] Check resolver set when API version config customized Closes gh-35256 --- .../web/reactive/config/ApiVersionConfigurer.java | 14 +++++++++++--- .../config/annotation/ApiVersionConfigurer.java | 13 +++++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java b/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java index 22119b0f14b9..b6c351d90305 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java @@ -26,6 +26,7 @@ import org.jspecify.annotations.Nullable; import org.springframework.http.MediaType; +import org.springframework.util.Assert; import org.springframework.web.accept.ApiVersionParser; import org.springframework.web.accept.InvalidApiVersionException; import org.springframework.web.accept.SemanticApiVersionParser; @@ -49,7 +50,7 @@ public class ApiVersionConfigurer { private @Nullable ApiVersionParser versionParser; - private boolean versionRequired = true; + private @Nullable Boolean versionRequired; private @Nullable String defaultVersion; @@ -188,18 +189,25 @@ public ApiVersionConfigurer setDeprecationHandler(ApiVersionDeprecationHandler h } protected @Nullable ApiVersionStrategy getApiVersionStrategy() { + if (this.versionResolvers.isEmpty()) { + Assert.state(isNotCustomized(), "API version config customized, but no ApiVersionResolver provided"); return null; } DefaultApiVersionStrategy strategy = new DefaultApiVersionStrategy(this.versionResolvers, (this.versionParser != null ? this.versionParser : new SemanticApiVersionParser()), - this.versionRequired, this.defaultVersion, this.detectSupportedVersions, - this.deprecationHandler); + (this.versionRequired != null ? this.versionRequired : true), + this.defaultVersion, this.detectSupportedVersions, this.deprecationHandler); this.supportedVersions.forEach(strategy::addSupportedVersion); return strategy; } + private boolean isNotCustomized() { + return (this.versionParser == null && this.versionRequired == null && + this.defaultVersion == null && this.supportedVersions.isEmpty()); + } + } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java index 93e54192b580..d1aea97d8ac8 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java @@ -26,6 +26,7 @@ import org.jspecify.annotations.Nullable; import org.springframework.http.MediaType; +import org.springframework.util.Assert; import org.springframework.web.accept.ApiVersionDeprecationHandler; import org.springframework.web.accept.ApiVersionParser; import org.springframework.web.accept.ApiVersionResolver; @@ -49,7 +50,7 @@ public class ApiVersionConfigurer { private @Nullable ApiVersionParser versionParser; - private boolean versionRequired = true; + private @Nullable Boolean versionRequired; private @Nullable String defaultVersion; @@ -188,13 +189,16 @@ public ApiVersionConfigurer setDeprecationHandler(ApiVersionDeprecationHandler h } protected @Nullable ApiVersionStrategy getApiVersionStrategy() { + if (this.versionResolvers.isEmpty()) { + Assert.state(isNotCustomized(), "API version config customized, but no ApiVersionResolver provided"); return null; } DefaultApiVersionStrategy strategy = new DefaultApiVersionStrategy(this.versionResolvers, (this.versionParser != null ? this.versionParser : new SemanticApiVersionParser()), - this.versionRequired, this.defaultVersion, this.detectSupportedVersions, + (this.versionRequired != null ? this.versionRequired : true), + this.defaultVersion, this.detectSupportedVersions, this.deprecationHandler); this.supportedVersions.forEach(strategy::addSupportedVersion); @@ -202,4 +206,9 @@ public ApiVersionConfigurer setDeprecationHandler(ApiVersionDeprecationHandler h return strategy; } + private boolean isNotCustomized() { + return (this.versionParser == null && this.versionRequired == null && + this.defaultVersion == null && this.supportedVersions.isEmpty()); + } + } From 08ccf46399b76bffb4b9347a8848e25d424bf167 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Thu, 31 Jul 2025 14:23:49 +0100 Subject: [PATCH 040/591] Rename request param version strategy to query param Closes gh-35263 --- .../ROOT/pages/web/webflux-versioning.adoc | 4 +- .../ROOT/pages/web/webmvc-versioning.adoc | 4 +- .../server/samples/ApiVersionTests.java | 2 +- .../web/accept/QueryApiVersionResolver.java | 53 +++++++++++++++ .../accept/QueryApiVersionResolverTests.java | 65 +++++++++++++++++++ .../reactive/config/ApiVersionConfigurer.java | 4 +- .../annotation/ApiVersionConfigurer.java | 7 +- 7 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 spring-web/src/main/java/org/springframework/web/accept/QueryApiVersionResolver.java create mode 100644 spring-web/src/test/java/org/springframework/web/accept/QueryApiVersionResolverTests.java diff --git a/framework-docs/modules/ROOT/pages/web/webflux-versioning.adoc b/framework-docs/modules/ROOT/pages/web/webflux-versioning.adoc index 6c446bcb142e..917fcc95bdf8 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux-versioning.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux-versioning.adoc @@ -46,8 +46,8 @@ directly with it. [.small]#xref:web/webmvc-versioning.adoc#mvc-versioning-resolver[See equivalent in the Servlet stack]# This strategy resolves the API version from a request. The WebFlux config provides built-in -options to resolve from a header, a request parameter, or from the URL path. -You can also use a custom `ApiVersionResolver`. +options to resolve from a header, query parameter, media type parameter, +or from the URL path. You can also use a custom `ApiVersionResolver`. diff --git a/framework-docs/modules/ROOT/pages/web/webmvc-versioning.adoc b/framework-docs/modules/ROOT/pages/web/webmvc-versioning.adoc index 2be54cd83351..f9f0afd77cc9 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc-versioning.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc-versioning.adoc @@ -46,8 +46,8 @@ directly with it. [.small]#xref:web/webflux-versioning.adoc#webflux-versioning-resolver[See equivalent in the Reactive stack]# This strategy resolves the API version from a request. The MVC config provides built-in -options to resolve from a header, from a request parameter, or from the URL path. -You can also use a custom `ApiVersionResolver`. +options to resolve from a header, query parameter, media type parameter, +or from the URL path. You can also use a custom `ApiVersionResolver`. diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/ApiVersionTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/ApiVersionTests.java index 5fd592ce5015..6efcc987caff 100644 --- a/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/ApiVersionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/ApiVersionTests.java @@ -54,7 +54,7 @@ void queryParam() { String param = "api-version"; Map result = performRequest( - configurer -> configurer.useRequestParam(param), + configurer -> configurer.useQueryParam(param), ApiVersionInserter.useQueryParam(param)); assertThat(result.get("query")).isEqualTo(param + "=1.2"); diff --git a/spring-web/src/main/java/org/springframework/web/accept/QueryApiVersionResolver.java b/spring-web/src/main/java/org/springframework/web/accept/QueryApiVersionResolver.java new file mode 100644 index 000000000000..e9d7eb227591 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/accept/QueryApiVersionResolver.java @@ -0,0 +1,53 @@ +/* + * Copyright 2002-present 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.accept; + + +import jakarta.servlet.http.HttpServletRequest; +import org.jspecify.annotations.Nullable; + +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * {@link ApiVersionResolver} that extract the version from a query parameter. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +public class QueryApiVersionResolver implements ApiVersionResolver { + + private final String queryParamName; + + + public QueryApiVersionResolver(String queryParamName) { + this.queryParamName = queryParamName; + } + + + @Override + public @Nullable String resolveVersion(HttpServletRequest request) { + String query = request.getQueryString(); + if (StringUtils.hasText(query)) { + UriComponents uri = UriComponentsBuilder.fromUriString("?" + query).build(); + return uri.getQueryParams().getFirst(this.queryParamName); + } + return null; + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/accept/QueryApiVersionResolverTests.java b/spring-web/src/test/java/org/springframework/web/accept/QueryApiVersionResolverTests.java new file mode 100644 index 000000000000..62886a78997b --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/accept/QueryApiVersionResolverTests.java @@ -0,0 +1,65 @@ +/* + * Copyright 2002-present 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.accept; + + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +import org.springframework.web.testfixture.servlet.MockHttpServletRequest; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link QueryApiVersionResolver}. + * @author Rossen Stoyanchev + */ +public class QueryApiVersionResolverTests { + + private final String queryParamName = "api-version"; + + private final QueryApiVersionResolver resolver = new QueryApiVersionResolver(queryParamName); + + + @Test + void resolve() { + MockHttpServletRequest request = initRequest("q=foo&" + queryParamName + "=1.2"); + String version = resolver.resolveVersion(request); + assertThat(version).isEqualTo("1.2"); + } + + @Test + void noQueryString() { + MockHttpServletRequest request = initRequest(null); + String version = resolver.resolveVersion(request); + assertThat(version).isNull(); + } + + @Test + void noQueryParam() { + MockHttpServletRequest request = initRequest("q=foo"); + String version = resolver.resolveVersion(request); + assertThat(version).isNull(); + } + + private static MockHttpServletRequest initRequest(@Nullable String queryString) { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/path"); + request.setQueryString(queryString); + return request; + } + +} diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java b/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java index b6c351d90305..049069acfcd3 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java @@ -71,10 +71,10 @@ public ApiVersionConfigurer useRequestHeader(String headerName) { } /** - * Add a resolver that extracts the API version from a request parameter. + * Add a resolver that extracts the API version from a query string parameter. * @param paramName the parameter name to check */ - public ApiVersionConfigurer useRequestParam(String paramName) { + public ApiVersionConfigurer useQueryParam(String paramName) { this.versionResolvers.add(exchange -> exchange.getRequest().getQueryParams().getFirst(paramName)); return this; } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java index d1aea97d8ac8..5042170e6a74 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java @@ -35,6 +35,7 @@ import org.springframework.web.accept.InvalidApiVersionException; import org.springframework.web.accept.MediaTypeParamApiVersionResolver; import org.springframework.web.accept.PathApiVersionResolver; +import org.springframework.web.accept.QueryApiVersionResolver; import org.springframework.web.accept.SemanticApiVersionParser; import org.springframework.web.accept.StandardApiVersionDeprecationHandler; @@ -71,11 +72,11 @@ public ApiVersionConfigurer useRequestHeader(String headerName) { } /** - * Add resolver to extract the version from a request parameter. + * Add resolver to extract the version from a query string parameter. * @param paramName the parameter name to check */ - public ApiVersionConfigurer useRequestParam(String paramName) { - this.versionResolvers.add(request -> request.getParameter(paramName)); + public ApiVersionConfigurer useQueryParam(String paramName) { + this.versionResolvers.add(new QueryApiVersionResolver(paramName)); return this; } From da361699a46bbe1d00e3fd9dd207f9ff2fe4148b Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Thu, 31 Jul 2025 14:58:26 +0100 Subject: [PATCH 041/591] Add MediaType parameter to ApiVersionInserter Closes gh-35259 --- .../web/client/ApiVersionInserter.java | 22 +++++++++--- .../web/client/DefaultApiVersionInserter.java | 26 +++++++++++--- .../DefaultApiVersionInserterBuilder.java | 35 +++++++++---------- .../web/client/RestClientVersionTests.java | 10 +++++- 4 files changed, 65 insertions(+), 28 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/web/client/ApiVersionInserter.java b/spring-web/src/main/java/org/springframework/web/client/ApiVersionInserter.java index 8ff0fbe5a993..2e61e54bec8e 100644 --- a/spring-web/src/main/java/org/springframework/web/client/ApiVersionInserter.java +++ b/spring-web/src/main/java/org/springframework/web/client/ApiVersionInserter.java @@ -57,7 +57,7 @@ default void insertVersion(Object version, HttpHeaders headers) { * @param header the name of a header to hold the version */ static ApiVersionInserter useHeader(@Nullable String header) { - return new DefaultApiVersionInserterBuilder(header, null, null).build(); + return new DefaultApiVersionInserterBuilder(header, null, null, null).build(); } /** @@ -65,7 +65,15 @@ static ApiVersionInserter useHeader(@Nullable String header) { * @param queryParam the name of a query parameter to hold the version */ static ApiVersionInserter useQueryParam(@Nullable String queryParam) { - return new DefaultApiVersionInserterBuilder(null, queryParam, null).build(); + return new DefaultApiVersionInserterBuilder(null, queryParam, null, null).build(); + } + + /** + * Create an inserter to set a MediaType parameter on the "Content-Type" header. + * @param mediaTypeParam the name of the media type parameter to hold the version + */ + static ApiVersionInserter useMediaTypeParam(@Nullable String mediaTypeParam) { + return new DefaultApiVersionInserterBuilder(null, null, mediaTypeParam, null).build(); } /** @@ -73,14 +81,14 @@ static ApiVersionInserter useQueryParam(@Nullable String queryParam) { * @param pathSegmentIndex the index of the path segment to hold the version */ static ApiVersionInserter usePathSegment(@Nullable Integer pathSegmentIndex) { - return new DefaultApiVersionInserterBuilder(null, null, pathSegmentIndex).build(); + return new DefaultApiVersionInserterBuilder(null, null, null, pathSegmentIndex).build(); } /** * Create a builder for an {@link ApiVersionInserter}. */ static Builder builder() { - return new DefaultApiVersionInserterBuilder(null, null, null); + return new DefaultApiVersionInserterBuilder(null, null, null, null); } @@ -101,6 +109,12 @@ interface Builder { */ Builder useQueryParam(@Nullable String queryParam); + /** + * Create an inserter to set a MediaType parameter on the "Content-Type" header. + * @param param the name of the media type parameter to hold the version + */ + Builder useMediaTypeParam(@Nullable String param); + /** * Configure the inserter to insert a path segment. * @param pathSegmentIndex the index of the path segment to hold the version diff --git a/spring-web/src/main/java/org/springframework/web/client/DefaultApiVersionInserter.java b/spring-web/src/main/java/org/springframework/web/client/DefaultApiVersionInserter.java index 903568b098f7..a625063943f0 100644 --- a/spring-web/src/main/java/org/springframework/web/client/DefaultApiVersionInserter.java +++ b/spring-web/src/main/java/org/springframework/web/client/DefaultApiVersionInserter.java @@ -18,11 +18,14 @@ import java.net.URI; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import org.jspecify.annotations.Nullable; import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.util.Assert; import org.springframework.web.util.UriComponentsBuilder; @@ -39,20 +42,23 @@ final class DefaultApiVersionInserter implements ApiVersionInserter { private final @Nullable String queryParam; + private final @Nullable String mediaTypeParam; + private final @Nullable Integer pathSegmentIndex; private final ApiVersionFormatter versionFormatter; DefaultApiVersionInserter( - @Nullable String header, @Nullable String queryParam, @Nullable Integer pathSegmentIndex, - @Nullable ApiVersionFormatter formatter) { + @Nullable String header, @Nullable String queryParam, @Nullable String mediaTypeParam, + @Nullable Integer pathSegmentIndex, @Nullable ApiVersionFormatter formatter) { - Assert.isTrue(header != null || queryParam != null || pathSegmentIndex != null, - "Expected 'header', 'queryParam', or 'pathSegmentIndex' to be configured"); + Assert.isTrue(header != null || queryParam != null || mediaTypeParam != null || pathSegmentIndex != null, + "Expected 'header', 'queryParam', 'mediaTypeParam', or 'pathSegmentIndex' to be configured"); this.header = header; this.queryParam = queryParam; + this.mediaTypeParam = mediaTypeParam; this.pathSegmentIndex = pathSegmentIndex; this.versionFormatter = (formatter != null ? formatter : Object::toString); } @@ -86,7 +92,17 @@ private void assertPathSegmentIndex(Integer index, int pathSegmentsSize, URI uri @Override public void insertVersion(Object version, HttpHeaders headers) { if (this.header != null) { - headers.set(this.header, this.versionFormatter.formatVersion(version)); + String formattedVersion = this.versionFormatter.formatVersion(version); + headers.set(this.header, formattedVersion); + } + if (this.mediaTypeParam != null) { + MediaType contentType = headers.getContentType(); + if (contentType != null) { + Map params = new LinkedHashMap<>(contentType.getParameters()); + params.put(this.mediaTypeParam, this.versionFormatter.formatVersion(version)); + contentType = new MediaType(contentType, params); + headers.setContentType(contentType); + } } } diff --git a/spring-web/src/main/java/org/springframework/web/client/DefaultApiVersionInserterBuilder.java b/spring-web/src/main/java/org/springframework/web/client/DefaultApiVersionInserterBuilder.java index d67493088cf7..5b8e60176203 100644 --- a/spring-web/src/main/java/org/springframework/web/client/DefaultApiVersionInserterBuilder.java +++ b/spring-web/src/main/java/org/springframework/web/client/DefaultApiVersionInserterBuilder.java @@ -33,16 +33,20 @@ final class DefaultApiVersionInserterBuilder implements ApiVersionInserter.Build private @Nullable String queryParam; + private @Nullable String mediaTypeParam; + private @Nullable Integer pathSegmentIndex; private @Nullable ApiVersionFormatter versionFormatter; DefaultApiVersionInserterBuilder( - @Nullable String header, @Nullable String queryParam, @Nullable Integer pathSegmentIndex) { + @Nullable String header, @Nullable String queryParam, @Nullable String mediaTypeParam, + @Nullable Integer pathSegmentIndex) { this.header = header; this.queryParam = queryParam; + this.mediaTypeParam = mediaTypeParam; this.pathSegmentIndex = pathSegmentIndex; } @@ -50,45 +54,40 @@ final class DefaultApiVersionInserterBuilder implements ApiVersionInserter.Build * Configure the inserter to set a header. * @param header the name of the header to hold the version */ + @Override public ApiVersionInserter.Builder useHeader(@Nullable String header) { this.header = header; return this; } - /** - * Configure the inserter to set a query parameter. - * @param queryParam the name of the query parameter to hold the version - */ + @Override public ApiVersionInserter.Builder useQueryParam(@Nullable String queryParam) { this.queryParam = queryParam; return this; } - /** - * Configure the inserter to insert a path segment. - * @param pathSegmentIndex the index of the path segment to hold the version - */ + @Override + public ApiVersionInserter.Builder useMediaTypeParam(@Nullable String param) { + this.mediaTypeParam = param; + return this; + } + + @Override public ApiVersionInserter.Builder usePathSegment(@Nullable Integer pathSegmentIndex) { this.pathSegmentIndex = pathSegmentIndex; return this; } - /** - * Format the version Object into a String using the given {@link ApiVersionFormatter}. - *

    By default, the version is formatted with {@link Object#toString()}. - * @param versionFormatter the formatter to use - */ + @Override public ApiVersionInserter.Builder withVersionFormatter(ApiVersionFormatter versionFormatter) { this.versionFormatter = versionFormatter; return this; } - /** - * Build the inserter. - */ public ApiVersionInserter build() { return new DefaultApiVersionInserter( - this.header, this.queryParam, this.pathSegmentIndex, this.versionFormatter); + this.header, this.queryParam, this.mediaTypeParam, this.pathSegmentIndex, + this.versionFormatter); } } diff --git a/spring-web/src/test/java/org/springframework/web/client/RestClientVersionTests.java b/spring-web/src/test/java/org/springframework/web/client/RestClientVersionTests.java index adddf6e2601b..7ea43c0ab950 100644 --- a/spring-web/src/test/java/org/springframework/web/client/RestClientVersionTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/RestClientVersionTests.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; import org.springframework.http.client.JdkClientHttpRequestFactory; import static org.assertj.core.api.Assertions.assertThat; @@ -73,6 +74,12 @@ void queryParam() { expectRequest(request -> assertThat(request.getTarget()).isEqualTo("/path?api-version=1.2")); } + @Test + void mediaTypeParam() { + performRequest(ApiVersionInserter.useMediaTypeParam("v")); + expectRequest(request -> assertThat(request.getHeaders().get("Content-Type")).isEqualTo("application/json;v=1.2")); + } + @Test void pathSegmentIndexLessThanSize() { performRequest(ApiVersionInserter.builder().usePathSegment(0).withVersionFormatter(v -> "v" + v).build()); @@ -103,7 +110,8 @@ void defaultVersion() { private void performRequest(ApiVersionInserter versionInserter) { restClientBuilder.apiVersionInserter(versionInserter).build() - .get().uri("/path") + .post().uri("/path") + .contentType(MediaType.APPLICATION_JSON) .apiVersion(1.2) .retrieve() .body(String.class); From 87838aa4c5ee4377b437616ce0c387762995af32 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Thu, 31 Jul 2025 15:31:58 +0100 Subject: [PATCH 042/591] PathApiVersionResolver is not nullable Closes gh-35265 --- .../ROOT/pages/web/webflux-versioning.adoc | 5 ++++ .../ROOT/pages/web/webmvc-versioning.adoc | 5 ++++ .../web/accept/PathApiVersionResolver.java | 25 +++++++++++-------- .../accept/PathApiVersionResolverTests.java | 6 +++++ .../accept/PathApiVersionResolver.java | 12 ++++++--- .../reactive/config/ApiVersionConfigurer.java | 22 ++++++++-------- .../accept/PathApiVersionResolverTests.java | 7 ++++++ .../annotation/ApiVersionConfigurer.java | 22 ++++++++-------- 8 files changed, 70 insertions(+), 34 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/web/webflux-versioning.adoc b/framework-docs/modules/ROOT/pages/web/webflux-versioning.adoc index 917fcc95bdf8..2065a962f1e7 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux-versioning.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux-versioning.adoc @@ -49,6 +49,11 @@ This strategy resolves the API version from a request. The WebFlux config provid options to resolve from a header, query parameter, media type parameter, or from the URL path. You can also use a custom `ApiVersionResolver`. +NOTE: The path resolver always resolves the version from the specified path segment, or +raises `InvalidApiVersionException` otherwise, and therefore it cannot yield to other +resolvers. + + diff --git a/framework-docs/modules/ROOT/pages/web/webmvc-versioning.adoc b/framework-docs/modules/ROOT/pages/web/webmvc-versioning.adoc index f9f0afd77cc9..4cb7dd94797b 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc-versioning.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc-versioning.adoc @@ -49,6 +49,11 @@ This strategy resolves the API version from a request. The MVC config provides b options to resolve from a header, query parameter, media type parameter, or from the URL path. You can also use a custom `ApiVersionResolver`. +NOTE: The path resolver always resolves the version from the specified path segment, or +raises `InvalidApiVersionException` otherwise, and therefore it cannot yield to other +resolvers. + + diff --git a/spring-web/src/main/java/org/springframework/web/accept/PathApiVersionResolver.java b/spring-web/src/main/java/org/springframework/web/accept/PathApiVersionResolver.java index 4be157e33afc..9d5a93d26eee 100644 --- a/spring-web/src/main/java/org/springframework/web/accept/PathApiVersionResolver.java +++ b/spring-web/src/main/java/org/springframework/web/accept/PathApiVersionResolver.java @@ -17,7 +17,6 @@ package org.springframework.web.accept; import jakarta.servlet.http.HttpServletRequest; -import org.jspecify.annotations.Nullable; import org.springframework.http.server.PathContainer; import org.springframework.http.server.RequestPath; @@ -27,6 +26,11 @@ /** * {@link ApiVersionResolver} that extract the version from a path segment. * + *

    Note that this resolver will either resolve the version from the specified + * path segment, or raise an {@link InvalidApiVersionException}, e.g. if there + * are not enough path segments. It never returns {@code null}, and therefore + * cannot yield to other resolvers. + * * @author Rossen Stoyanchev * @since 7.0 */ @@ -47,17 +51,18 @@ public PathApiVersionResolver(int pathSegmentIndex) { @Override - public @Nullable String resolveVersion(HttpServletRequest request) { - if (ServletRequestPathUtils.hasParsedRequestPath(request)) { - RequestPath path = ServletRequestPathUtils.getParsedRequestPath(request); - int i = 0; - for (PathContainer.Element e : path.pathWithinApplication().elements()) { - if (e instanceof PathContainer.PathSegment && i++ == this.pathSegmentIndex) { - return e.value(); - } + public String resolveVersion(HttpServletRequest request) { + if (!ServletRequestPathUtils.hasParsedRequestPath(request)) { + throw new IllegalStateException("Expected parsed request path"); + } + RequestPath path = ServletRequestPathUtils.getParsedRequestPath(request); + int i = 0; + for (PathContainer.Element element : path.pathWithinApplication().elements()) { + if (element instanceof PathContainer.PathSegment && i++ == this.pathSegmentIndex) { + return element.value(); } } - return null; + throw new InvalidApiVersionException("No path segment at index " + this.pathSegmentIndex); } } diff --git a/spring-web/src/test/java/org/springframework/web/accept/PathApiVersionResolverTests.java b/spring-web/src/test/java/org/springframework/web/accept/PathApiVersionResolverTests.java index e4fadc598536..2b5c56b78d3a 100644 --- a/spring-web/src/test/java/org/springframework/web/accept/PathApiVersionResolverTests.java +++ b/spring-web/src/test/java/org/springframework/web/accept/PathApiVersionResolverTests.java @@ -22,6 +22,7 @@ import org.springframework.web.util.ServletRequestPathUtils; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * Unit tests for {@link PathApiVersionResolver}. @@ -35,6 +36,11 @@ void resolve() { testResolve(1, "/app/1.1/path", "1.1"); } + @Test + void insufficientPathSegments() { + assertThatThrownBy(() -> testResolve(0, "/", "1.0")).isInstanceOf(InvalidApiVersionException.class); + } + private static void testResolve(int index, String requestUri, String expected) { MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri); try { diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/PathApiVersionResolver.java b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/PathApiVersionResolver.java index 777c5947b729..2da6819498bf 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/PathApiVersionResolver.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/PathApiVersionResolver.java @@ -16,15 +16,19 @@ package org.springframework.web.reactive.accept; -import org.jspecify.annotations.Nullable; - import org.springframework.http.server.PathContainer; import org.springframework.util.Assert; +import org.springframework.web.accept.InvalidApiVersionException; import org.springframework.web.server.ServerWebExchange; /** * {@link ApiVersionResolver} that extract the version from a path segment. * + *

    Note that this resolver will either resolve the version from the specified + * path segment, or raise an {@link InvalidApiVersionException}, e.g. if there + * are not enough path segments. It never returns {@code null}, and therefore + * cannot yield to other resolvers. + * * @author Rossen Stoyanchev * @since 7.0 */ @@ -45,14 +49,14 @@ public PathApiVersionResolver(int pathSegmentIndex) { @Override - public @Nullable String resolveVersion(ServerWebExchange exchange) { + public String resolveVersion(ServerWebExchange exchange) { int i = 0; for (PathContainer.Element e : exchange.getRequest().getPath().pathWithinApplication().elements()) { if (e instanceof PathContainer.PathSegment && i++ == this.pathSegmentIndex) { return e.value(); } } - return null; + throw new InvalidApiVersionException("No path segment at index " + this.pathSegmentIndex); } } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java b/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java index 049069acfcd3..0392f4dd8784 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java @@ -79,16 +79,6 @@ public ApiVersionConfigurer useQueryParam(String paramName) { return this; } - /** - * Add a resolver that extracts the API version from a path segment. - * @param index the index of the path segment to check; e.g. for URL's like - * "/{version}/..." use index 0, for "/api/{version}/..." index 1. - */ - public ApiVersionConfigurer usePathSegment(int index) { - this.versionResolvers.add(new PathApiVersionResolver(index)); - return this; - } - /** * Add resolver to extract the version from a media type parameter found in * the Accept or Content-Type headers. @@ -101,6 +91,18 @@ public ApiVersionConfigurer useMediaTypeParameter(MediaType compatibleMediaType, return this; } + /** + * Add a resolver that extracts the API version from a path segment. + *

    Note that this resolver never returns {@code null}, and therefore + * cannot yield to other resolvers, see {@link org.springframework.web.accept.PathApiVersionResolver}. + * @param index the index of the path segment to check; e.g. for URL's like + * "/{version}/..." use index 0, for "/api/{version}/..." index 1. + */ + public ApiVersionConfigurer usePathSegment(int index) { + this.versionResolvers.add(new PathApiVersionResolver(index)); + return this; + } + /** * Add custom resolvers to resolve the API version. * @param resolvers the resolvers to use diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/accept/PathApiVersionResolverTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/accept/PathApiVersionResolverTests.java index 3feb8266f3cd..3e3ec3076fa5 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/accept/PathApiVersionResolverTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/accept/PathApiVersionResolverTests.java @@ -18,11 +18,13 @@ import org.junit.jupiter.api.Test; +import org.springframework.web.accept.InvalidApiVersionException; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest; import org.springframework.web.testfixture.server.MockServerWebExchange; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * Unit tests for {@link org.springframework.web.accept.PathApiVersionResolver}. @@ -36,6 +38,11 @@ void resolve() { testResolve(1, "/app/1.1/path", "1.1"); } + @Test + void insufficientPathSegments() { + assertThatThrownBy(() -> testResolve(0, "/", "1.0")).isInstanceOf(InvalidApiVersionException.class); + } + private static void testResolve(int index, String requestUri, String expected) { ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get(requestUri)); String actual = new PathApiVersionResolver(index).resolveVersion(exchange); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java index 5042170e6a74..e3e3b1c60a1e 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java @@ -80,16 +80,6 @@ public ApiVersionConfigurer useQueryParam(String paramName) { return this; } - /** - * Add resolver to extract the version from a path segment. - * @param index the index of the path segment to check; e.g. for URL's like - * "/{version}/..." use index 0, for "/api/{version}/..." index 1. - */ - public ApiVersionConfigurer usePathSegment(int index) { - this.versionResolvers.add(new PathApiVersionResolver(index)); - return this; - } - /** * Add resolver to extract the version from a media type parameter found in * the Accept or Content-Type headers. @@ -102,6 +92,18 @@ public ApiVersionConfigurer useMediaTypeParameter(MediaType compatibleMediaType, return this; } + /** + * Add resolver to extract the version from a path segment. + *

    Note that this resolver never returns {@code null}, and therefore + * cannot yield to other resolvers, see {@link PathApiVersionResolver}. + * @param index the index of the path segment to check; e.g. for URL's like + * "/{version}/..." use index 0, for "/api/{version}/..." index 1. + */ + public ApiVersionConfigurer usePathSegment(int index) { + this.versionResolvers.add(new PathApiVersionResolver(index)); + return this; + } + /** * Add custom resolvers to resolve the API version. * @param resolvers the resolvers to use From 2b1a815167484e7be9be0843eb6b1f4ddb871e2a Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Fri, 1 Aug 2025 12:35:18 +0100 Subject: [PATCH 043/591] Add supportedVersionPredicate to ApiVersionConfigurer Closes gh-35267 --- .../client/samples/ApiVersionTests.java | 2 +- .../samples/standalone/ApiVersionTests.java | 2 +- .../web/accept/DefaultApiVersionStrategy.java | 20 ++++++---- .../DefaultApiVersionStrategiesTests.java | 39 +++++++++++++------ .../accept/DefaultApiVersionStrategy.java | 22 +++++++---- .../reactive/config/ApiVersionConfigurer.java | 18 ++++++++- .../DefaultApiVersionStrategiesTests.java | 37 ++++++++++++------ .../server/RequestPredicatesTests.java | 2 +- .../VersionRequestConditionTests.java | 2 +- .../annotation/ApiVersionConfigurer.java | 17 +++++++- .../function/RequestPredicatesTests.java | 2 +- .../VersionRequestConditionTests.java | 2 +- 12 files changed, 117 insertions(+), 48 deletions(-) diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ApiVersionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ApiVersionTests.java index c23d60b8c8b1..70af17d22e09 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ApiVersionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ApiVersionTests.java @@ -76,7 +76,7 @@ private Map performRequest( DefaultApiVersionStrategy versionStrategy = new DefaultApiVersionStrategy( List.of(versionResolver), new SemanticApiVersionParser(), - true, null, true, null); + true, null, true, null, null); RestTestClient client = RestTestClient.bindToController(new TestController()) .configureServer(mockMvcBuilder -> mockMvcBuilder.setApiVersionStrategy(versionStrategy)) diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/ApiVersionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/ApiVersionTests.java index 25637ed8b008..be52b6634df5 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/ApiVersionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/ApiVersionTests.java @@ -50,7 +50,7 @@ public void queryParameter() throws Exception { DefaultApiVersionStrategy versionStrategy = new DefaultApiVersionStrategy( List.of(request -> request.getHeader(header)), new SemanticApiVersionParser(), - true, null, true, null); + true, null, true, null, null); MockMvc mockMvc = standaloneSetup(new PersonController()) .setApiVersionStrategy(versionStrategy) diff --git a/spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java b/spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java index a85dff6ed3c0..b9c41fb31bb5 100644 --- a/spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java +++ b/spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java @@ -20,6 +20,7 @@ import java.util.List; import java.util.Set; import java.util.TreeSet; +import java.util.function.Predicate; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -50,6 +51,8 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy { private final Set> detectedVersions = new TreeSet<>(); + private final Predicate> supportedVersionPredicate; + private final @Nullable ApiVersionDeprecationHandler deprecationHandler; @@ -71,7 +74,8 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy { */ public DefaultApiVersionStrategy( List versionResolvers, ApiVersionParser versionParser, - boolean versionRequired, @Nullable String defaultVersion, boolean detectSupportedVersions, + boolean versionRequired, @Nullable String defaultVersion, + boolean detectSupportedVersions, @Nullable Predicate> supportedVersionPredicate, @Nullable ApiVersionDeprecationHandler deprecationHandler) { Assert.notEmpty(versionResolvers, "At least one ApiVersionResolver is required"); @@ -82,9 +86,16 @@ public DefaultApiVersionStrategy( this.versionRequired = (versionRequired && defaultVersion == null); this.defaultVersion = (defaultVersion != null ? versionParser.parseVersion(defaultVersion) : null); this.detectSupportedVersions = detectSupportedVersions; + this.supportedVersionPredicate = initSupportedVersionPredicate(supportedVersionPredicate); this.deprecationHandler = deprecationHandler; } + private Predicate> initSupportedVersionPredicate(@Nullable Predicate> predicate) { + return (predicate != null ? predicate : + (version -> (this.supportedVersions.contains(version) || + this.detectSupportedVersions && this.detectedVersions.contains(version)))); + } + @Override public @Nullable Comparable getDefaultVersion() { @@ -160,16 +171,11 @@ public void validateVersion(@Nullable Comparable requestVersion, HttpServletR return; } - if (!isSupportedVersion(requestVersion)) { + if (!this.supportedVersionPredicate.test(requestVersion)) { throw new InvalidApiVersionException(requestVersion.toString()); } } - private boolean isSupportedVersion(Comparable requestVersion) { - return (this.supportedVersions.contains(requestVersion) || - this.detectSupportedVersions && this.detectedVersions.contains(requestVersion)); - } - @Override public void handleDeprecations(Comparable version, HttpServletRequest request, HttpServletResponse response) { if (this.deprecationHandler != null) { diff --git a/spring-web/src/test/java/org/springframework/web/accept/DefaultApiVersionStrategiesTests.java b/spring-web/src/test/java/org/springframework/web/accept/DefaultApiVersionStrategiesTests.java index b35282bee922..222798a3bc88 100644 --- a/spring-web/src/test/java/org/springframework/web/accept/DefaultApiVersionStrategiesTests.java +++ b/spring-web/src/test/java/org/springframework/web/accept/DefaultApiVersionStrategiesTests.java @@ -17,6 +17,7 @@ package org.springframework.web.accept; import java.util.List; +import java.util.function.Predicate; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; @@ -44,6 +45,13 @@ void defaultVersionIsParsed() { assertThat(strategy.getDefaultVersion()).isEqualTo(parser.parseVersion(version)); } + @Test + void missingRequiredVersion() { + assertThatThrownBy(() -> validateVersion(null, apiVersionStrategy())) + .isInstanceOf(MissingApiVersionException.class) + .hasMessage("400 BAD_REQUEST \"API version is required.\""); + } + @Test void validateSupportedVersion() { String version = "1.2"; @@ -53,7 +61,7 @@ void validateSupportedVersion() { } @Test - void rejectUnsupportedVersion() { + void validateUnsupportedVersion() { assertThatThrownBy(() -> validateVersion("1.2", apiVersionStrategy())) .isInstanceOf(InvalidApiVersionException.class) .hasMessage("400 BAD_REQUEST \"Invalid API version: '1.2.0'.\""); @@ -62,7 +70,7 @@ void rejectUnsupportedVersion() { @Test void validateDetectedVersion() { String version = "1.2"; - DefaultApiVersionStrategy strategy = apiVersionStrategy(null, true); + DefaultApiVersionStrategy strategy = apiVersionStrategy(null, true, null); strategy.addMappedVersion(version); validateVersion(version, strategy); } @@ -76,30 +84,37 @@ void validateWhenDetectedVersionOff() { } @Test - void missingRequiredVersion() { - assertThatThrownBy(() -> validateVersion(null, apiVersionStrategy())) - .isInstanceOf(MissingApiVersionException.class) - .hasMessage("400 BAD_REQUEST \"API version is required.\""); + void validateSupportedWithPredicate() { + SemanticApiVersionParser.Version parsedVersion = parser.parseVersion("1.2"); + validateVersion("1.2", apiVersionStrategy(null, false, version -> version.equals(parsedVersion))); + } + + @Test + void validateUnsupportedWithPredicate() { + DefaultApiVersionStrategy strategy = apiVersionStrategy(null, false, version -> version.equals("1.2")); + assertThatThrownBy(() -> validateVersion("1.2", strategy)).isInstanceOf(InvalidApiVersionException.class); } private static DefaultApiVersionStrategy apiVersionStrategy() { - return apiVersionStrategy(null, false); + return apiVersionStrategy(null, false, null); } private static DefaultApiVersionStrategy apiVersionStrategy(@Nullable String defaultVersion) { - return apiVersionStrategy(defaultVersion, false); + return apiVersionStrategy(defaultVersion, false, null); } private static DefaultApiVersionStrategy apiVersionStrategy( - @Nullable String defaultVersion, boolean detectSupportedVersions) { + @Nullable String defaultVersion, boolean detectSupportedVersions, + @Nullable Predicate> supportedVersionPredicate) { return new DefaultApiVersionStrategy( - List.of(request -> request.getParameter("api-version")), - new SemanticApiVersionParser(), true, defaultVersion, detectSupportedVersions, null); + List.of(request -> request.getParameter("api-version")), new SemanticApiVersionParser(), + true, defaultVersion, detectSupportedVersions, supportedVersionPredicate, null); } private void validateVersion(@Nullable String version, DefaultApiVersionStrategy strategy) { - strategy.validateVersion(version != null ? parser.parseVersion(version) : null, request); + Comparable parsedVersion = (version != null ? parser.parseVersion(version) : null); + strategy.validateVersion(parsedVersion, request); } } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java index 08816c003294..114f0c95dd57 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java @@ -20,6 +20,7 @@ import java.util.List; import java.util.Set; import java.util.TreeSet; +import java.util.function.Predicate; import org.jspecify.annotations.Nullable; @@ -52,6 +53,8 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy { private final Set> detectedVersions = new TreeSet<>(); + private final Predicate> supportedVersionPredicate; + private final @Nullable ApiVersionDeprecationHandler deprecationHandler; @@ -73,7 +76,8 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy { */ public DefaultApiVersionStrategy( List versionResolvers, ApiVersionParser versionParser, - boolean versionRequired, @Nullable String defaultVersion, boolean detectSupportedVersions, + boolean versionRequired, @Nullable String defaultVersion, + boolean detectSupportedVersions, @Nullable Predicate> supportedVersionPredicate, @Nullable ApiVersionDeprecationHandler deprecationHandler) { Assert.notEmpty(versionResolvers, "At least one ApiVersionResolver is required"); @@ -84,9 +88,16 @@ public DefaultApiVersionStrategy( this.versionRequired = (versionRequired && defaultVersion == null); this.defaultVersion = (defaultVersion != null ? versionParser.parseVersion(defaultVersion) : null); this.detectSupportedVersions = detectSupportedVersions; + this.supportedVersionPredicate = initSupportedVersionPredicate(supportedVersionPredicate); this.deprecationHandler = deprecationHandler; } + private Predicate> initSupportedVersionPredicate(@Nullable Predicate> predicate) { + return (predicate != null ? predicate : + (version -> (this.supportedVersions.contains(version) || + this.detectSupportedVersions && this.detectedVersions.contains(version)))); + } + @Override public @Nullable Comparable getDefaultVersion() { @@ -111,7 +122,7 @@ public boolean detectSupportedVersions() { * considered supported, and use of this method is optional. However, if you * prefer to use only explicitly configured, supported versions, then set * {@code detectSupportedVersions} flag to {@code false}. - * @param versions the supported versions to add + * @param versions the supported versions to add * @see #addMappedVersion(String...) */ public void addSupportedVersion(String... versions) { @@ -161,16 +172,11 @@ public void validateVersion(@Nullable Comparable requestVersion, ServerWebExc return; } - if (!isSupportedVersion(requestVersion)) { + if (!this.supportedVersionPredicate.test(requestVersion)) { throw new InvalidApiVersionException(requestVersion.toString()); } } - private boolean isSupportedVersion(Comparable requestVersion) { - return (this.supportedVersions.contains(requestVersion) || - this.detectSupportedVersions && this.detectedVersions.contains(requestVersion)); - } - @Override public void handleDeprecations(Comparable version, ServerWebExchange exchange) { if (this.deprecationHandler != null) { diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java b/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java index 0392f4dd8784..2fe21fc5393d 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java @@ -22,6 +22,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import java.util.function.Predicate; import org.jspecify.annotations.Nullable; @@ -58,6 +59,8 @@ public class ApiVersionConfigurer { private boolean detectSupportedVersions = true; + private @Nullable Predicate> supportedVersionPredicate; + private @Nullable ApiVersionDeprecationHandler deprecationHandler; @@ -178,6 +181,16 @@ public ApiVersionConfigurer detectSupportedVersions(boolean detect) { return this; } + /** + * Provide a {@link Predicate} to perform supported version checks with, in + * effect taking over the supported version check and superseding the + * {@link #addSupportedVersions} and {@link #detectSupportedVersions}. + * @param predicate the predicate to use + */ + public void setSupportedVersionPredicate(@Nullable Predicate> predicate) { + this.supportedVersionPredicate = predicate; + } + /** * Configure a handler to add handling for requests with a deprecated API * version. Typically, this involves sending hints and information about @@ -199,8 +212,9 @@ public ApiVersionConfigurer setDeprecationHandler(ApiVersionDeprecationHandler h DefaultApiVersionStrategy strategy = new DefaultApiVersionStrategy(this.versionResolvers, (this.versionParser != null ? this.versionParser : new SemanticApiVersionParser()), - (this.versionRequired != null ? this.versionRequired : true), - this.defaultVersion, this.detectSupportedVersions, this.deprecationHandler); + (this.versionRequired != null ? this.versionRequired : true), this.defaultVersion, + this.detectSupportedVersions, this.supportedVersionPredicate, + this.deprecationHandler); this.supportedVersions.forEach(strategy::addSupportedVersion); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java index b9e21028c9d0..367587dd50cd 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java @@ -17,6 +17,7 @@ package org.springframework.web.reactive.accept; import java.util.List; +import java.util.function.Predicate; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; @@ -45,10 +46,17 @@ public class DefaultApiVersionStrategiesTests { @Test void defaultVersionIsParsed() { String version = "1.2.3"; - ApiVersionStrategy strategy = apiVersionStrategy(version, false); + ApiVersionStrategy strategy = apiVersionStrategy(version, false, null); assertThat(strategy.getDefaultVersion()).isEqualTo(parser.parseVersion(version)); } + @Test + void missingRequiredVersion() { + assertThatThrownBy(() -> validateVersion(null, apiVersionStrategy())) + .isInstanceOf(MissingApiVersionException.class) + .hasMessage("400 BAD_REQUEST \"API version is required.\""); + } + @Test void validateSupportedVersion() { String version = "1.2"; @@ -58,7 +66,7 @@ void validateSupportedVersion() { } @Test - void rejectUnsupportedVersion() { + void validateUnsupportedVersion() { assertThatThrownBy(() -> validateVersion("1.2", apiVersionStrategy())) .isInstanceOf(InvalidApiVersionException.class) .hasMessage("400 BAD_REQUEST \"Invalid API version: '1.2.0'.\""); @@ -67,7 +75,7 @@ void rejectUnsupportedVersion() { @Test void validateDetectedVersion() { String version = "1.2"; - DefaultApiVersionStrategy strategy = apiVersionStrategy(null, true); + DefaultApiVersionStrategy strategy = apiVersionStrategy(null, true, null); strategy.addMappedVersion(version); validateVersion(version, strategy); } @@ -81,26 +89,33 @@ void validateWhenDetectedVersionOff() { } @Test - void missingRequiredVersion() { - assertThatThrownBy(() -> validateVersion(null, apiVersionStrategy())) - .isInstanceOf(MissingApiVersionException.class) - .hasMessage("400 BAD_REQUEST \"API version is required.\""); + void validateSupportedWithPredicate() { + SemanticApiVersionParser.Version parsedVersion = parser.parseVersion("1.2"); + validateVersion("1.2", apiVersionStrategy(null, false, version -> version.equals(parsedVersion))); + } + + @Test + void validateUnsupportedWithPredicate() { + DefaultApiVersionStrategy strategy = apiVersionStrategy(null, false, version -> version.equals("1.2")); + assertThatThrownBy(() -> validateVersion("1.2", strategy)).isInstanceOf(InvalidApiVersionException.class); } private static DefaultApiVersionStrategy apiVersionStrategy() { - return apiVersionStrategy(null, false); + return apiVersionStrategy(null, false, null); } private static DefaultApiVersionStrategy apiVersionStrategy( - @Nullable String defaultVersion, boolean detectSupportedVersions) { + @Nullable String defaultVersion, boolean detectSupportedVersions, + @Nullable Predicate> supportedVersionPredicate) { return new DefaultApiVersionStrategy( List.of(exchange -> exchange.getRequest().getQueryParams().getFirst("api-version")), - parser, true, defaultVersion, detectSupportedVersions, null); + parser, true, defaultVersion, detectSupportedVersions, supportedVersionPredicate, null); } private void validateVersion(@Nullable String version, DefaultApiVersionStrategy strategy) { - strategy.validateVersion(version != null ? parser.parseVersion(version) : null, exchange); + Comparable parsedVersion = (version != null ? parser.parseVersion(version) : null); + strategy.validateVersion(parsedVersion, exchange); } } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/RequestPredicatesTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/RequestPredicatesTests.java index 7f73b5e121ae..ec779c637f9c 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/RequestPredicatesTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/RequestPredicatesTests.java @@ -380,7 +380,7 @@ private static DefaultServerRequest serverRequest(String version) { private static DefaultApiVersionStrategy apiVersionStrategy() { return new DefaultApiVersionStrategy( - List.of(exchange -> null), new SemanticApiVersionParser(), true, null, false, null); + List.of(exchange -> null), new SemanticApiVersionParser(), true, null, false, null, null); } } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java index 06940692aa1d..b96de727c5a4 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java @@ -52,7 +52,7 @@ void setUp() { private static DefaultApiVersionStrategy initVersionStrategy(@Nullable String defaultVersion) { return new DefaultApiVersionStrategy( List.of(exchange -> exchange.getRequest().getQueryParams().getFirst("api-version")), - new SemanticApiVersionParser(), true, defaultVersion, false, null); + new SemanticApiVersionParser(), true, defaultVersion, false, null, null); } @Test diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java index e3e3b1c60a1e..e08e17081637 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java @@ -22,6 +22,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import java.util.function.Predicate; import org.jspecify.annotations.Nullable; @@ -59,6 +60,8 @@ public class ApiVersionConfigurer { private boolean detectSupportedVersions = true; + private @Nullable Predicate> supportedVersionPredicate; + private @Nullable ApiVersionDeprecationHandler deprecationHandler; @@ -179,6 +182,16 @@ public ApiVersionConfigurer detectSupportedVersions(boolean detect) { return this; } + /** + * Provide a {@link Predicate} to perform supported version checks with, in + * effect taking over the supported version check and superseding the + * {@link #addSupportedVersions} and {@link #detectSupportedVersions}. + * @param predicate the predicate to use + */ + public void setSupportedVersionPredicate(@Nullable Predicate> predicate) { + this.supportedVersionPredicate = predicate; + } + /** * Configure a handler to add handling for requests with a deprecated API * version. Typically, this involves sending hints and information about @@ -200,8 +213,8 @@ public ApiVersionConfigurer setDeprecationHandler(ApiVersionDeprecationHandler h DefaultApiVersionStrategy strategy = new DefaultApiVersionStrategy(this.versionResolvers, (this.versionParser != null ? this.versionParser : new SemanticApiVersionParser()), - (this.versionRequired != null ? this.versionRequired : true), - this.defaultVersion, this.detectSupportedVersions, + (this.versionRequired != null ? this.versionRequired : true), this.defaultVersion, + this.detectSupportedVersions, this.supportedVersionPredicate, this.deprecationHandler); this.supportedVersions.forEach(strategy::addSupportedVersion); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/RequestPredicatesTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/RequestPredicatesTests.java index 508ca3b46ab5..55f9eb0d22d1 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/RequestPredicatesTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/RequestPredicatesTests.java @@ -280,7 +280,7 @@ void version() { private static ServerRequest serverRequest(String version) { ApiVersionStrategy strategy = new DefaultApiVersionStrategy( - List.of(exchange -> null), new SemanticApiVersionParser(), true, null, false, null); + List.of(exchange -> null), new SemanticApiVersionParser(), true, null, false, null, null); MockHttpServletRequest servletRequest = PathPatternsTestUtils.initRequest("GET", null, "/path", true, diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java index 5ee4ddff5c35..47b41ba4563a 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java @@ -50,7 +50,7 @@ void setUp() { private static DefaultApiVersionStrategy initVersionStrategy(@Nullable String defaultVersion) { return new DefaultApiVersionStrategy( List.of(request -> request.getParameter("api-version")), - new SemanticApiVersionParser(), true, defaultVersion, false, null); + new SemanticApiVersionParser(), true, defaultVersion, false, null, null); } @Test From 96bc1f50c705272690934fc834b671f3fde60acc Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Fri, 1 Aug 2025 13:31:37 +0100 Subject: [PATCH 044/591] Add interceptors and converters to RestTestClient.Builder Closes gh-35268 --- .../server/DefaultWebTestClientBuilder.java | 5 -- .../web/reactive/server/WebTestClient.java | 56 +++++++++---------- .../servlet/client/DefaultRestTestClient.java | 36 +++++++++--- .../client/DefaultRestTestClientBuilder.java | 41 +++++++++++++- .../web/servlet/client/RestTestClient.java | 51 +++++++++++++++++ 5 files changed, 146 insertions(+), 43 deletions(-) diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClientBuilder.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClientBuilder.java index 49141b609894..d9693ca61fec 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClientBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClientBuilder.java @@ -115,11 +115,6 @@ class DefaultWebTestClientBuilder implements WebTestClient.Builder { this(httpHandlerBuilder, null, sslInfo); } - /** Use given connector. */ - DefaultWebTestClientBuilder(ClientHttpConnector connector) { - this(null, connector, null); - } - private DefaultWebTestClientBuilder(@Nullable WebHttpHandlerBuilder httpHandlerBuilder, @Nullable ClientHttpConnector connector, @Nullable SslInfo sslInfo) { diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java index 2ffaa16168fd..9fe4a75edde4 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java @@ -242,7 +242,7 @@ static Builder bindToServer() { * @since 5.0.2 */ static Builder bindToServer(ClientHttpConnector connector) { - return new DefaultWebTestClientBuilder(connector); + return new DefaultWebTestClientBuilder().clientConnector(connector); } @@ -467,33 +467,6 @@ interface Builder { */ Builder filters(Consumer> filtersConsumer); - /** - * Configure an {@code EntityExchangeResult} callback that is invoked - * every time after a response is fully decoded to a single entity, to a - * List of entities, or to a byte[]. In effect, equivalent to each and - * all of the below but registered once, globally: - *

    -		 * client.get().uri("/accounts/1")
    -		 *         .exchange()
    -		 *         .expectBody(Person.class).consumeWith(exchangeResult -> ... ));
    -		 *
    -		 * client.get().uri("/accounts")
    -		 *         .exchange()
    -		 *         .expectBodyList(Person.class).consumeWith(exchangeResult -> ... ));
    -		 *
    -		 * client.get().uri("/accounts/1")
    -		 *         .exchange()
    -		 *         .expectBody().consumeWith(exchangeResult -> ... ));
    -		 * 
    - *

    Note that the configured consumer does not apply to responses - * decoded to {@code Flux} which can be consumed outside the workflow - * of the test client, for example via {@code reactor.test.StepVerifier}. - * @param consumer the consumer to apply to entity responses - * @return the builder - * @since 5.3.5 - */ - Builder entityExchangeResultConsumer(Consumer> consumer); - /** * Configure the codecs for the {@code WebClient} in the * {@link #exchangeStrategies(ExchangeStrategies) underlying} @@ -533,6 +506,33 @@ interface Builder { */ Builder clientConnector(ClientHttpConnector connector); + /** + * Configure an {@code EntityExchangeResult} callback that is invoked + * every time after a response is fully decoded to a single entity, to a + * List of entities, or to a byte[]. In effect, equivalent to each and + * all of the below but registered once, globally: + *

    +		 * client.get().uri("/accounts/1")
    +		 *         .exchange()
    +		 *         .expectBody(Person.class).consumeWith(exchangeResult -> ... ));
    +		 *
    +		 * client.get().uri("/accounts")
    +		 *         .exchange()
    +		 *         .expectBodyList(Person.class).consumeWith(exchangeResult -> ... ));
    +		 *
    +		 * client.get().uri("/accounts/1")
    +		 *         .exchange()
    +		 *         .expectBody().consumeWith(exchangeResult -> ... ));
    +		 * 
    + *

    Note that the configured consumer does not apply to responses + * decoded to {@code Flux} which can be consumed outside the workflow + * of the test client, for example via {@code reactor.test.StepVerifier}. + * @param consumer the consumer to apply to entity responses + * @return the builder + * @since 5.3.5 + */ + Builder entityExchangeResultConsumer(Consumer> consumer); + /** * Apply the given configurer to this builder instance. *

    This can be useful for applying pre-packaged customizations. diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java index 45177fb058cc..c46bdc39b5d0 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java @@ -56,11 +56,20 @@ class DefaultRestTestClient implements RestTestClient { private final RestClient restClient; + private final Consumer> entityResultConsumer; + + private final DefaultRestTestClientBuilder restTestClientBuilder; + private final AtomicLong requestIndex = new AtomicLong(); - DefaultRestTestClient(RestClient.Builder builder) { + DefaultRestTestClient( + RestClient.Builder builder, Consumer> entityResultConsumer, + DefaultRestTestClientBuilder restTestClientBuilder) { + this.restClient = builder.build(); + this.entityResultConsumer = entityResultConsumer; + this.restTestClientBuilder = restTestClientBuilder; } @@ -108,9 +117,10 @@ private RequestBodyUriSpec methodInternal(HttpMethod httpMethod) { return new DefaultRequestBodyUriSpec(this.restClient.method(httpMethod)); } + @SuppressWarnings("unchecked") @Override public > Builder mutate() { - return new DefaultRestTestClientBuilder<>(this.restClient.mutate()); + return (Builder) this.restTestClientBuilder; } @@ -242,7 +252,8 @@ public RequestHeadersSpec body(Object body) { public ResponseSpec exchange() { return new DefaultResponseSpec( this.requestHeadersUriSpec.exchangeForRequiredValue( - (request, response) -> new ExchangeResult(request, response, this.uriTemplate), false)); + (request, response) -> new ExchangeResult(request, response, this.uriTemplate), false), + DefaultRestTestClient.this.entityResultConsumer); } } @@ -251,8 +262,11 @@ private static class DefaultResponseSpec implements ResponseSpec { private final ExchangeResult exchangeResult; - DefaultResponseSpec(ExchangeResult result) { + private final Consumer> entityResultConsumer; + + DefaultResponseSpec(ExchangeResult result, Consumer> entityResultConsumer) { this.exchangeResult = result; + this.entityResultConsumer = entityResultConsumer; } @Override @@ -280,25 +294,31 @@ public CookieAssertions expectCookie() { @Override public BodySpec expectBody(ParameterizedTypeReference bodyType) { B body = this.exchangeResult.getBody(bodyType); - EntityExchangeResult result = new EntityExchangeResult<>(this.exchangeResult, body); + EntityExchangeResult result = initExchangeResult(body); return new DefaultBodySpec<>(result); } @Override public BodyContentSpec expectBody() { byte[] body = this.exchangeResult.getBody(byte[].class); - EntityExchangeResult result = new EntityExchangeResult<>(this.exchangeResult, body); + EntityExchangeResult result = initExchangeResult(body); return new DefaultBodyContentSpec(result); } @Override public EntityExchangeResult returnResult(Class elementClass) { - return new EntityExchangeResult<>(this.exchangeResult, this.exchangeResult.getBody(elementClass)); + return initExchangeResult(this.exchangeResult.getBody(elementClass)); } @Override public EntityExchangeResult returnResult(ParameterizedTypeReference elementTypeRef) { - return new EntityExchangeResult<>(this.exchangeResult, this.exchangeResult.getBody(elementTypeRef)); + return initExchangeResult(this.exchangeResult.getBody(elementTypeRef)); + } + + private EntityExchangeResult initExchangeResult(@Nullable B body) { + EntityExchangeResult result = new EntityExchangeResult<>(this.exchangeResult, body); + result.assertWithDiagnostics(() -> this.entityResultConsumer.accept(result)); + return result; } @Override diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java index 14b37ff04420..53168bf60d34 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java @@ -16,10 +16,13 @@ package org.springframework.test.web.servlet.client; +import java.util.List; import java.util.function.Consumer; import org.springframework.http.HttpHeaders; import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.converter.HttpMessageConverters; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvcBuilder; import org.springframework.test.web.servlet.client.RestTestClient.MockMvcSetupBuilder; @@ -30,6 +33,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.web.servlet.setup.RouterFunctionMockMvcBuilder; import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder; +import org.springframework.util.Assert; import org.springframework.util.MultiValueMap; import org.springframework.web.client.ApiVersionInserter; import org.springframework.web.client.RestClient; @@ -49,15 +53,22 @@ class DefaultRestTestClientBuilder> implemen private final RestClient.Builder restClientBuilder; + private Consumer> entityResultConsumer = result -> {}; + DefaultRestTestClientBuilder() { - this.restClientBuilder = RestClient.builder(); + this(RestClient.builder()); } DefaultRestTestClientBuilder(RestClient.Builder restClientBuilder) { this.restClientBuilder = restClientBuilder; } + DefaultRestTestClientBuilder(DefaultRestTestClientBuilder other) { + this.restClientBuilder = other.restClientBuilder.clone(); + this.entityResultConsumer = other.entityResultConsumer; + } + @Override public T baseUrl(String baseUrl) { @@ -107,6 +118,31 @@ public T apiVersionInserter(ApiVersionInserter apiVersionInserter) return self(); } + @Override + public T requestInterceptor(ClientHttpRequestInterceptor interceptor) { + this.restClientBuilder.requestInterceptor(interceptor); + return self(); + } + + @Override + public T requestInterceptors(Consumer> interceptorsConsumer) { + this.restClientBuilder.requestInterceptors(interceptorsConsumer); + return self(); + } + + @Override + public T configureMessageConverters(Consumer configurer) { + this.restClientBuilder.configureMessageConverters(configurer); + return self(); + } + + @Override + public T entityExchangeResultConsumer(Consumer> entityResultConsumer) { + Assert.notNull(entityResultConsumer, "'entityResultConsumer' is required"); + this.entityResultConsumer = this.entityResultConsumer.andThen(entityResultConsumer); + return self(); + } + @SuppressWarnings("unchecked") protected T self() { return (T) this; @@ -118,7 +154,8 @@ protected void setClientHttpRequestFactory(ClientHttpRequestFactory requestFacto @Override public RestTestClient build() { - return new DefaultRestTestClient(this.restClientBuilder); + return new DefaultRestTestClient( + this.restClientBuilder, this.entityResultConsumer, new DefaultRestTestClientBuilder<>(this)); } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java index 426b8e5d8f55..f7f7bd69c6c2 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java @@ -19,6 +19,7 @@ import java.net.URI; import java.nio.charset.Charset; import java.time.ZonedDateTime; +import java.util.List; import java.util.Map; import java.util.function.Consumer; import java.util.function.Function; @@ -31,6 +32,8 @@ import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.converter.HttpMessageConverters; import org.springframework.test.json.JsonComparator; import org.springframework.test.json.JsonCompareMode; import org.springframework.test.json.JsonComparison; @@ -261,6 +264,54 @@ interface Builder> { */ T apiVersionInserter(ApiVersionInserter apiVersionInserter); + /** + * Add the given request interceptor to the end of the interceptor chain. + * @param interceptor the interceptor to be added to the chain + */ + T requestInterceptor(ClientHttpRequestInterceptor interceptor); + + /** + * Manipulate the interceptors with the given consumer. The list provided to + * the consumer is "live", so that the consumer can be used to remove + * interceptors, change ordering, etc. + * @param interceptorsConsumer a function that consumes the interceptors list + * @return this builder + */ + T requestInterceptors(Consumer> interceptorsConsumer); + + /** + * Configure the message converters to use for the request and response body. + * @param configurer the configurer to apply on an empty {@link HttpMessageConverters.ClientBuilder}. + * @return this builder + */ + T configureMessageConverters(Consumer configurer); + + /** + * Configure an {@code EntityExchangeResult} callback that is invoked + * every time after a response is fully decoded to a single entity, to a + * List of entities, or to a byte[]. In effect, equivalent to each and + * all of the below but registered once, globally: + *

    +		 * client.get().uri("/accounts/1")
    +		 *         .exchange()
    +		 *         .expectBody(Person.class).consumeWith(exchangeResult -> ... ));
    +		 *
    +		 * client.get().uri("/accounts")
    +		 *         .exchange()
    +		 *         .expectBodyList(Person.class).consumeWith(exchangeResult -> ... ));
    +		 *
    +		 * client.get().uri("/accounts/1")
    +		 *         .exchange()
    +		 *         .expectBody().consumeWith(exchangeResult -> ... ));
    +		 * 
    + *

    Note that the configured consumer does not apply to responses + * decoded to {@code Flux} which can be consumed outside the workflow + * of the test client, for example via {@code reactor.test.StepVerifier}. + * @param consumer the consumer to apply to entity responses + * @return the builder + */ + T entityExchangeResultConsumer(Consumer> consumer); + /** * Build the {@link RestTestClient} instance. */ From 149d468ce49c61b8261711bbe0a217270bc50e9c Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Fri, 1 Aug 2025 15:08:15 +0200 Subject: [PATCH 045/591] Introduce ConfigurableApplicationContext.pause() and SmartLifecycle.isPauseable() Closes gh-35269 --- .../scheduling/quartz/QuartzSupportTests.java | 1 - .../ConfigurableApplicationContext.java | 15 ++++- .../context/LifecycleProcessor.java | 9 +++ .../context/SmartLifecycle.java | 25 ++++++- .../context/event/ContextPausedEvent.java | 46 +++++++++++++ .../context/event/ContextRestartedEvent.java | 4 +- .../support/AbstractApplicationContext.java | 7 ++ .../support/DefaultLifecycleProcessor.java | 65 ++++++++++++------- .../DefaultLifecycleProcessorTests.java | 26 +++++++- .../test/context/cache/ContextCache.java | 6 +- .../context/cache/DefaultContextCache.java | 2 +- .../cache/UnusedContextsIntegrationTests.java | 38 +++++------ 12 files changed, 191 insertions(+), 53 deletions(-) create mode 100644 spring-context/src/main/java/org/springframework/context/event/ContextPausedEvent.java diff --git a/spring-context-support/src/test/java/org/springframework/scheduling/quartz/QuartzSupportTests.java b/spring-context-support/src/test/java/org/springframework/scheduling/quartz/QuartzSupportTests.java index 87adeaed3e5c..7ab4a8bd9487 100644 --- a/spring-context-support/src/test/java/org/springframework/scheduling/quartz/QuartzSupportTests.java +++ b/spring-context-support/src/test/java/org/springframework/scheduling/quartz/QuartzSupportTests.java @@ -391,7 +391,6 @@ void schedulerWithHsqlDataSource() { try (ClassPathXmlApplicationContext ctx = context("databasePersistence.xml")) { JdbcTemplate jdbcTemplate = new JdbcTemplate(ctx.getBean(DataSource.class)); assertThat(jdbcTemplate.queryForList("SELECT * FROM qrtz_triggers").isEmpty()).as("No triggers were persisted").isFalse(); - ctx.stop(); ctx.restart(); } } diff --git a/spring-context/src/main/java/org/springframework/context/ConfigurableApplicationContext.java b/spring-context/src/main/java/org/springframework/context/ConfigurableApplicationContext.java index 6f56d8a6e519..8dffb08bd691 100644 --- a/spring-context/src/main/java/org/springframework/context/ConfigurableApplicationContext.java +++ b/spring-context/src/main/java/org/springframework/context/ConfigurableApplicationContext.java @@ -221,16 +221,27 @@ public interface ConfigurableApplicationContext extends ApplicationContext, Life void refresh() throws BeansException, IllegalStateException; /** - * Stop all beans in this application context if necessary, and subsequently + * Pause all beans in this application context if necessary, and subsequently * restart all auto-startup beans, effectively restoring the lifecycle state * after {@link #refresh()} (typically after a preceding {@link #stop()} call * when a full {@link #start()} of even lazy-starting beans is to be avoided). * @since 7.0 - * @see #stop() + * @see #pause() + * @see #start() * @see SmartLifecycle#isAutoStartup() */ void restart(); + /** + * Stop all beans in this application context unless they explicitly opt out of + * pausing through {@link SmartLifecycle#isPauseable()} returning {@code false}. + * @since 7.0 + * @see #restart() + * @see #stop() + * @see SmartLifecycle#isPauseable() + */ + void pause(); + /** * Register a shutdown hook with the JVM runtime, closing this context * on JVM shutdown unless it has already been closed at that time. diff --git a/spring-context/src/main/java/org/springframework/context/LifecycleProcessor.java b/spring-context/src/main/java/org/springframework/context/LifecycleProcessor.java index d9d4ea446fb6..c2e39a84d3da 100644 --- a/spring-context/src/main/java/org/springframework/context/LifecycleProcessor.java +++ b/spring-context/src/main/java/org/springframework/context/LifecycleProcessor.java @@ -44,6 +44,15 @@ default void onRestart() { start(); } + /** + * Notification of context pause for auto-stopping components. + * @since 7.0 + * @see ConfigurableApplicationContext#pause() + */ + default void onPause() { + stop(); + } + /** * Notification of context close phase for auto-stopping components * before destruction. diff --git a/spring-context/src/main/java/org/springframework/context/SmartLifecycle.java b/spring-context/src/main/java/org/springframework/context/SmartLifecycle.java index ac2039cae684..cf4c43bd2643 100644 --- a/spring-context/src/main/java/org/springframework/context/SmartLifecycle.java +++ b/spring-context/src/main/java/org/springframework/context/SmartLifecycle.java @@ -85,7 +85,7 @@ public interface SmartLifecycle extends Lifecycle, Phased { /** * Returns {@code true} if this {@code Lifecycle} component should get * started automatically by the container at the time that the containing - * {@link ApplicationContext} gets refreshed. + * {@link ApplicationContext} gets refreshed or restarted. *

    A value of {@code false} indicates that the component is intended to * be started through an explicit {@link #start()} call instead, analogous * to a plain {@link Lifecycle} implementation. @@ -93,12 +93,35 @@ public interface SmartLifecycle extends Lifecycle, Phased { * @see #start() * @see #getPhase() * @see LifecycleProcessor#onRefresh() + * @see LifecycleProcessor#onRestart() * @see ConfigurableApplicationContext#refresh() + * @see ConfigurableApplicationContext#restart() */ default boolean isAutoStartup() { return true; } + /** + * Returns {@code true} if this {@code Lifecycle} component is able to + * participate in a restart sequence, receiving corresponding {@link #stop()} + * and {@link #start()} calls with a potential pause in-between. + *

    A value of {@code false} indicates that the component prefers to + * be skipped in a pause scenario, neither receiving a {@link #stop()} + * call nor a subsequent {@link #start()} call, analogous to a plain + * {@link Lifecycle} implementation. It will only receive a {@link #stop()} + * call on close and on explicit context-wide stopping but not on pause. + *

    The default implementation returns {@code true}. + * @since 7.0 + * @see #stop() + * @see LifecycleProcessor#onPause() + * @see LifecycleProcessor#onClose() + * @see ConfigurableApplicationContext#pause() + * @see ConfigurableApplicationContext#close() + */ + default boolean isPauseable() { + return true; + } + /** * Indicates that a Lifecycle component must stop if it is currently running. *

    The provided callback is used by the {@link LifecycleProcessor} to support diff --git a/spring-context/src/main/java/org/springframework/context/event/ContextPausedEvent.java b/spring-context/src/main/java/org/springframework/context/event/ContextPausedEvent.java new file mode 100644 index 000000000000..759783bc0b2a --- /dev/null +++ b/spring-context/src/main/java/org/springframework/context/event/ContextPausedEvent.java @@ -0,0 +1,46 @@ +/* + * Copyright 2002-present 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.context.event; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; + +/** + * Event raised when an {@code ApplicationContext} gets paused. + * + *

    Note that {@code ContextPausedEvent} is a specialization of + * {@link ContextStoppedEvent}. + * + * @author Juergen Hoeller + * @since 7.0 + * @see ConfigurableApplicationContext#pause() + * @see ContextRestartedEvent + * @see ContextStoppedEvent + */ +@SuppressWarnings("serial") +public class ContextPausedEvent extends ContextStoppedEvent { + + /** + * Create a new {@code ContextRestartedEvent}. + * @param source the {@code ContextPausedEvent} that has been restarted + * (must not be {@code null}) + */ + public ContextPausedEvent(ApplicationContext source) { + super(source); + } + +} diff --git a/spring-context/src/main/java/org/springframework/context/event/ContextRestartedEvent.java b/spring-context/src/main/java/org/springframework/context/event/ContextRestartedEvent.java index e8165be8d08b..8ac44108917f 100644 --- a/spring-context/src/main/java/org/springframework/context/event/ContextRestartedEvent.java +++ b/spring-context/src/main/java/org/springframework/context/event/ContextRestartedEvent.java @@ -17,6 +17,7 @@ package org.springframework.context.event; import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; /** * Event raised when an {@code ApplicationContext} gets restarted. @@ -26,8 +27,9 @@ * * @author Sam Brannen * @since 7.0 + * @see ConfigurableApplicationContext#restart() + * @see ContextPausedEvent * @see ContextStartedEvent - * @see ContextStoppedEvent */ @SuppressWarnings("serial") public class ContextRestartedEvent extends ContextStartedEvent { diff --git a/spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java b/spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java index cc846e3e5f3b..d741a1f25c2c 100644 --- a/spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java +++ b/spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java @@ -66,6 +66,7 @@ import org.springframework.context.ResourceLoaderAware; import org.springframework.context.event.ApplicationEventMulticaster; import org.springframework.context.event.ContextClosedEvent; +import org.springframework.context.event.ContextPausedEvent; import org.springframework.context.event.ContextRefreshedEvent; import org.springframework.context.event.ContextRestartedEvent; import org.springframework.context.event.ContextStartedEvent; @@ -1555,6 +1556,12 @@ public void restart() { publishEvent(new ContextRestartedEvent(this)); } + @Override + public void pause() { + getLifecycleProcessor().onPause(); + publishEvent(new ContextPausedEvent(this)); + } + @Override public boolean isRunning() { return (this.lifecycleProcessor != null && this.lifecycleProcessor.isRunning()); diff --git a/spring-context/src/main/java/org/springframework/context/support/DefaultLifecycleProcessor.java b/spring-context/src/main/java/org/springframework/context/support/DefaultLifecycleProcessor.java index f5760107f59e..02bf8c08ab5e 100644 --- a/spring-context/src/main/java/org/springframework/context/support/DefaultLifecycleProcessor.java +++ b/spring-context/src/main/java/org/springframework/context/support/DefaultLifecycleProcessor.java @@ -287,7 +287,7 @@ public void start() { */ @Override public void stop() { - stopBeans(); + stopBeans(false); this.running = false; } @@ -308,7 +308,7 @@ public void onRefresh() { catch (ApplicationContextException ex) { // Some bean failed to auto-start within context refresh: // stop already started beans on context refresh failure. - stopBeans(); + stopBeans(false); throw ex; } this.running = true; @@ -318,15 +318,23 @@ public void onRefresh() { public void onRestart() { this.stoppedBeans = null; if (this.running) { - stopBeans(); + stopBeans(true); } startBeans(true); this.running = true; } + @Override + public void onPause() { + if (this.running) { + stopBeans(true); + this.running = false; + } + } + @Override public void onClose() { - stopBeans(); + stopBeans(false); this.running = false; } @@ -341,7 +349,7 @@ public boolean isRunning() { void stopForRestart() { if (this.running) { this.stoppedBeans = ConcurrentHashMap.newKeySet(); - stopBeans(); + stopBeans(false); this.running = false; } } @@ -361,7 +369,8 @@ private void startBeans(boolean autoStartupOnly) { lifecycleBeans.forEach((beanName, bean) -> { if (!autoStartupOnly || isAutoStartupCandidate(beanName, bean)) { int startupPhase = getPhase(bean); - phases.computeIfAbsent(startupPhase, phase -> new LifecycleGroup(phase, lifecycleBeans, autoStartupOnly)) + phases.computeIfAbsent( + startupPhase, phase -> new LifecycleGroup(phase, lifecycleBeans, autoStartupOnly, false)) .add(beanName, bean); } }); @@ -424,13 +433,14 @@ private boolean toBeStarted(String beanName, Lifecycle bean) { (!(bean instanceof SmartLifecycle smartLifecycle) || smartLifecycle.isAutoStartup())); } - private void stopBeans() { + private void stopBeans(boolean pauseableOnly) { Map lifecycleBeans = getLifecycleBeans(); Map phases = new TreeMap<>(Comparator.reverseOrder()); lifecycleBeans.forEach((beanName, bean) -> { int shutdownPhase = getPhase(bean); - phases.computeIfAbsent(shutdownPhase, phase -> new LifecycleGroup(phase, lifecycleBeans, false)) + phases.computeIfAbsent( + shutdownPhase, phase -> new LifecycleGroup(phase, lifecycleBeans, false, pauseableOnly)) .add(beanName, bean); }); @@ -446,13 +456,13 @@ private void stopBeans() { * @param beanName the name of the bean to stop */ private void doStop(Map lifecycleBeans, final String beanName, - final CountDownLatch latch, final Set countDownBeanNames) { + boolean pauseableOnly, final CountDownLatch latch, final Set countDownBeanNames) { Lifecycle bean = lifecycleBeans.remove(beanName); if (bean != null) { String[] dependentBeans = getBeanFactory().getDependentBeans(beanName); for (String dependentBean : dependentBeans) { - doStop(lifecycleBeans, dependentBean, latch, countDownBeanNames); + doStop(lifecycleBeans, dependentBean, pauseableOnly, latch, countDownBeanNames); } try { if (bean.isRunning()) { @@ -461,20 +471,22 @@ private void doStop(Map lifecycleBeans, final Strin stoppedBeans.add(beanName); } if (bean instanceof SmartLifecycle smartLifecycle) { - if (logger.isTraceEnabled()) { - logger.trace("Asking bean '" + beanName + "' of type [" + - bean.getClass().getName() + "] to stop"); - } - countDownBeanNames.add(beanName); - smartLifecycle.stop(() -> { - latch.countDown(); - countDownBeanNames.remove(beanName); - if (logger.isDebugEnabled()) { - logger.debug("Bean '" + beanName + "' completed its stop procedure"); + if (!pauseableOnly || smartLifecycle.isPauseable()) { + if (logger.isTraceEnabled()) { + logger.trace("Asking bean '" + beanName + "' of type [" + + bean.getClass().getName() + "] to stop"); } - }); + countDownBeanNames.add(beanName); + smartLifecycle.stop(() -> { + latch.countDown(); + countDownBeanNames.remove(beanName); + if (logger.isDebugEnabled()) { + logger.debug("Bean '" + beanName + "' completed its stop procedure"); + } + }); + } } - else { + else if (!pauseableOnly) { if (logger.isTraceEnabled()) { logger.trace("Stopping bean '" + beanName + "' of type [" + bean.getClass().getName() + "]"); @@ -562,14 +574,19 @@ private class LifecycleGroup { private final boolean autoStartupOnly; + private final boolean pauseableOnly; + private final List members = new ArrayList<>(); private int smartMemberCount; - public LifecycleGroup(int phase, Map lifecycleBeans, boolean autoStartupOnly) { + public LifecycleGroup(int phase, Map lifecycleBeans, + boolean autoStartupOnly, boolean pauseableOnly) { + this.phase = phase; this.lifecycleBeans = lifecycleBeans; this.autoStartupOnly = autoStartupOnly; + this.pauseableOnly = pauseableOnly; } public void add(String name, Lifecycle bean) { @@ -621,7 +638,7 @@ public void stop() { Set lifecycleBeanNames = new HashSet<>(this.lifecycleBeans.keySet()); for (LifecycleGroupMember member : this.members) { if (lifecycleBeanNames.contains(member.name)) { - doStop(this.lifecycleBeans, member.name, latch, countDownBeanNames); + doStop(this.lifecycleBeans, member.name, this.pauseableOnly, latch, countDownBeanNames); } else if (member.bean instanceof SmartLifecycle) { // Already removed: must have been a dependent bean from another phase diff --git a/spring-context/src/test/java/org/springframework/context/support/DefaultLifecycleProcessorTests.java b/spring-context/src/test/java/org/springframework/context/support/DefaultLifecycleProcessorTests.java index cb968cf9c120..7904d88138b5 100644 --- a/spring-context/src/test/java/org/springframework/context/support/DefaultLifecycleProcessorTests.java +++ b/spring-context/src/test/java/org/springframework/context/support/DefaultLifecycleProcessorTests.java @@ -355,6 +355,7 @@ void contextRefreshThenRestartWithMixedBeans() { TestSmartLifecycleBean smartBean1 = TestSmartLifecycleBean.forShutdownTests(5, 0, stoppedBeans); TestSmartLifecycleBean smartBean2 = TestSmartLifecycleBean.forShutdownTests(-3, 0, stoppedBeans); smartBean2.setAutoStartup(false); + smartBean2.setPauseable(false); context.getBeanFactory().registerSingleton("smartBean1", smartBean1); context.getBeanFactory().registerSingleton("smartBean2", smartBean2); @@ -375,11 +376,23 @@ void contextRefreshThenRestartWithMixedBeans() { assertThat(stoppedBeans).containsExactly(smartBean1, smartBean1); assertThat(smartBean1.isRunning()).isTrue(); assertThat(smartBean2.isRunning()).isFalse(); + context.pause(); + assertThat(stoppedBeans).containsExactly(smartBean1, smartBean1, smartBean1); + assertThat(smartBean1.isRunning()).isFalse(); + assertThat(smartBean2.isRunning()).isFalse(); + context.restart(); + assertThat(stoppedBeans).containsExactly(smartBean1, smartBean1, smartBean1); + assertThat(smartBean1.isRunning()).isTrue(); + assertThat(smartBean2.isRunning()).isFalse(); context.start(); assertThat(smartBean1.isRunning()).isTrue(); assertThat(smartBean2.isRunning()).isTrue(); + context.pause(); + assertThat(stoppedBeans).containsExactly(smartBean1, smartBean1, smartBean1, smartBean1); + assertThat(smartBean1.isRunning()).isFalse(); + assertThat(smartBean2.isRunning()).isTrue(); context.close(); - assertThat(stoppedBeans).containsExactly(smartBean1, smartBean1, smartBean1, smartBean2); + assertThat(stoppedBeans).containsExactly(smartBean1, smartBean1, smartBean1, smartBean1, smartBean2); } @Test @@ -740,6 +753,8 @@ private static class TestSmartLifecycleBean extends TestLifecycleBean implements private volatile boolean autoStartup = true; + private volatile boolean pauseable = true; + static TestSmartLifecycleBean forStartupTests(int phase, CopyOnWriteArrayList startedBeans) { return new TestSmartLifecycleBean(phase, 0, startedBeans, null); } @@ -769,6 +784,15 @@ public void setAutoStartup(boolean autoStartup) { this.autoStartup = autoStartup; } + @Override + public boolean isPauseable() { + return this.pauseable; + } + + public void setPauseable(boolean pauseable) { + this.pauseable = pauseable; + } + @Override public void stop(final Runnable callback) { // calling stop() before the delay to preserve diff --git a/spring-test/src/main/java/org/springframework/test/context/cache/ContextCache.java b/spring-test/src/main/java/org/springframework/test/context/cache/ContextCache.java index 8840a4655a60..2a319d78b752 100644 --- a/spring-test/src/main/java/org/springframework/test/context/cache/ContextCache.java +++ b/spring-test/src/main/java/org/springframework/test/context/cache/ContextCache.java @@ -93,8 +93,8 @@ public interface ContextCache { /** * Obtain a cached {@link ApplicationContext} for the given key. *

    If the cached application context was previously - * {@linkplain org.springframework.context.Lifecycle#stop() stopped}, it - * must be + * {@linkplain org.springframework.context.ConfigurableApplicationContext#pause() paused}, + * it must be * {@linkplain org.springframework.context.support.AbstractApplicationContext#restart() * restarted}. This applies to parent contexts as well. *

    In addition, the {@linkplain #getHitCount() hit} and @@ -187,7 +187,7 @@ default void registerContextUsage(MergedContextConfiguration key, Class testC * {@link MergedContextConfiguration} and any of its parents. *

    If no other test classes are actively using the same application * context(s), the application context(s) should be - * {@linkplain org.springframework.context.Lifecycle#stop() stopped}. + * {@linkplain org.springframework.context.ConfigurableApplicationContext#pause() paused}. *

    The default implementation of this method does nothing. Concrete * implementations are therefore highly encouraged to override this * method, {@link #registerContextUsage(MergedContextConfiguration, Class)}, diff --git a/spring-test/src/main/java/org/springframework/test/context/cache/DefaultContextCache.java b/spring-test/src/main/java/org/springframework/test/context/cache/DefaultContextCache.java index ad52643bd599..5451fb5854ab 100644 --- a/spring-test/src/main/java/org/springframework/test/context/cache/DefaultContextCache.java +++ b/spring-test/src/main/java/org/springframework/test/context/cache/DefaultContextCache.java @@ -187,7 +187,7 @@ public void unregisterContextUsage(MergedContextConfiguration mergedConfig, Clas activeTestClasses.remove(testClass); if (activeTestClasses.isEmpty()) { if (context instanceof ConfigurableApplicationContext cac && cac.isRunning()) { - cac.stop(); + cac.pause(); } this.contextUsageMap.remove(mergedConfig); } diff --git a/spring-test/src/test/java/org/springframework/test/context/cache/UnusedContextsIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/cache/UnusedContextsIntegrationTests.java index 9e861a1aee01..49dac4c34a1c 100644 --- a/spring-test/src/test/java/org/springframework/test/context/cache/UnusedContextsIntegrationTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/cache/UnusedContextsIntegrationTests.java @@ -69,13 +69,13 @@ void topLevelTestClassesWithSharedApplicationContext() { // No BeforeTestClass, since EventPublishingTestExecutionListener // only publishes events for a context that has already been loaded. "AfterTestClass:TestCase1", - "ContextStopped:TestCase1", + "ContextPaused:TestCase1", // --- TestCase2 ----------------------------------------------- "ContextRestarted:TestCase1", "BeforeTestClass:TestCase2", "AfterTestClass:TestCase2", - "ContextStopped:TestCase1", + "ContextPaused:TestCase1", // --- TestCase3 ----------------------------------------------- "ContextRestarted:TestCase1", @@ -90,13 +90,13 @@ void topLevelTestClassesWithSharedApplicationContext() { // No BeforeTestClass, since EventPublishingTestExecutionListener // only publishes events for a context that has already been loaded. "AfterTestClass:TestCase4", - "ContextStopped:TestCase4", + "ContextPaused:TestCase4", // --- TestCase5 ----------------------------------------------- "ContextRestarted:TestCase4", "BeforeTestClass:TestCase5", "AfterTestClass:TestCase5", - "ContextStopped:TestCase4" + "ContextPaused:TestCase4" ); } @@ -130,19 +130,19 @@ void testClassesInNestedTestHierarchy() { // using the context "AfterTestClass:OverridingNestedTestCase1", - "ContextStopped:OverridingNestedTestCase1", + "ContextPaused:OverridingNestedTestCase1", // --- OverridingNestedTestCase2 --------------------------- "ContextRestarted:OverridingNestedTestCase1", "BeforeTestClass:OverridingNestedTestCase2", "AfterTestClass:OverridingNestedTestCase2", - "ContextStopped:OverridingNestedTestCase1", + "ContextPaused:OverridingNestedTestCase1", "AfterTestClass:NestedTestCase", // No Stopped event, since EnclosingTestCase is still using the context "AfterTestClass:EnclosingTestCase", - "ContextStopped:EnclosingTestCase" + "ContextPaused:EnclosingTestCase" ); } @@ -161,23 +161,23 @@ void testClassesWithContextHierarchies() { // --- ContextHierarchyLevel1TestCase ------------------------------ "ContextRefreshed:ContextHierarchyLevel1TestCase", "AfterTestClass:ContextHierarchyLevel1TestCase", - "ContextStopped:ContextHierarchyLevel1TestCase", + "ContextPaused:ContextHierarchyLevel1TestCase", // --- ContextHierarchyLevel2TestCase ------------------------------ "ContextRestarted:ContextHierarchyLevel1TestCase", "ContextRefreshed:ContextHierarchyLevel2TestCase", "AfterTestClass:ContextHierarchyLevel2TestCase", - "ContextStopped:ContextHierarchyLevel2TestCase", - "ContextStopped:ContextHierarchyLevel1TestCase", + "ContextPaused:ContextHierarchyLevel2TestCase", + "ContextPaused:ContextHierarchyLevel1TestCase", // --- ContextHierarchyLevel3a1TestCase ----------------------------- "ContextRestarted:ContextHierarchyLevel1TestCase", "ContextRestarted:ContextHierarchyLevel2TestCase", "ContextRefreshed:ContextHierarchyLevel3a1TestCase", "AfterTestClass:ContextHierarchyLevel3a1TestCase", - "ContextStopped:ContextHierarchyLevel3a1TestCase", - "ContextStopped:ContextHierarchyLevel2TestCase", - "ContextStopped:ContextHierarchyLevel1TestCase", + "ContextPaused:ContextHierarchyLevel3a1TestCase", + "ContextPaused:ContextHierarchyLevel2TestCase", + "ContextPaused:ContextHierarchyLevel1TestCase", // --- ContextHierarchyLevel3a2TestCase ----------------------------- "ContextRestarted:ContextHierarchyLevel1TestCase", @@ -185,18 +185,18 @@ void testClassesWithContextHierarchies() { "ContextRestarted:ContextHierarchyLevel3a1TestCase", "BeforeTestClass:ContextHierarchyLevel3a2TestCase", "AfterTestClass:ContextHierarchyLevel3a2TestCase", - "ContextStopped:ContextHierarchyLevel3a1TestCase", - "ContextStopped:ContextHierarchyLevel2TestCase", - "ContextStopped:ContextHierarchyLevel1TestCase", + "ContextPaused:ContextHierarchyLevel3a1TestCase", + "ContextPaused:ContextHierarchyLevel2TestCase", + "ContextPaused:ContextHierarchyLevel1TestCase", // --- ContextHierarchyLevel3bTestCase ----------------------------- "ContextRestarted:ContextHierarchyLevel1TestCase", "ContextRestarted:ContextHierarchyLevel2TestCase", "ContextRefreshed:ContextHierarchyLevel3bTestCase", "AfterTestClass:ContextHierarchyLevel3bTestCase", - "ContextStopped:ContextHierarchyLevel3bTestCase", - "ContextStopped:ContextHierarchyLevel2TestCase", - "ContextStopped:ContextHierarchyLevel1TestCase" + "ContextPaused:ContextHierarchyLevel3bTestCase", + "ContextPaused:ContextHierarchyLevel2TestCase", + "ContextPaused:ContextHierarchyLevel1TestCase" ); } From ec87d90c9b08a9df94930233a245884098d8b879 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Fri, 1 Aug 2025 15:40:32 +0200 Subject: [PATCH 046/591] Post process outgoing messages in JMS clients Prior to this commit, the `JmsTemplate` would use `MessagePostProcessor` for mutating JMS messages before they are being sent, but only if the method takes a post processor as an argument. The main use case so far is to mutate messages after they've been created by a `MessageConverter` from a payload. This commit updates the `JmsClient` to use `MessagePostProcessor` more broadly, for all outgoing messages (converted or not). This brings an interception-like mechanism for clients to enrich the message before being sent. This change also updates the `JmsClient` static factories and introduces a Builder, allowing for more configuration options: multiple message converters and message post processors. Closes gh-35271 --- .../ROOT/pages/integration/jms/sending.adoc | 94 ++++--------------- .../jms/jmssending/JmsQueueSender.java | 48 ++++++++++ .../JmsSenderWithConversion.java | 45 +++++++++ .../jmssendingjmsclient/JmsClientSample.java | 46 +++++++++ .../JmsClientWithPostProcessor.java | 58 ++++++++++++ .../jms/core/DefaultJmsClient.java | 40 ++++++-- .../jms/core/DefaultJmsClientBuilder.java | 88 +++++++++++++++++ .../springframework/jms/core/JmsClient.java | 60 +++++++++--- .../jms/core/MessagePostProcessor.java | 13 +-- .../jms/core/JmsClientTests.java | 61 +++++++++++- .../core/CompositeMessagePostProcessor.java | 48 ++++++++++ 11 files changed, 495 insertions(+), 106 deletions(-) create mode 100644 framework-docs/src/main/java/org/springframework/docs/integration/jms/jmssending/JmsQueueSender.java create mode 100644 framework-docs/src/main/java/org/springframework/docs/integration/jms/jmssendingconversion/JmsSenderWithConversion.java create mode 100644 framework-docs/src/main/java/org/springframework/docs/integration/jms/jmssendingjmsclient/JmsClientSample.java create mode 100644 framework-docs/src/main/java/org/springframework/docs/integration/jms/jmssendingpostprocessor/JmsClientWithPostProcessor.java create mode 100644 spring-jms/src/main/java/org/springframework/jms/core/DefaultJmsClientBuilder.java create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/core/CompositeMessagePostProcessor.java diff --git a/framework-docs/modules/ROOT/pages/integration/jms/sending.adoc b/framework-docs/modules/ROOT/pages/integration/jms/sending.adoc index 5ad13d40b189..27beb6225796 100644 --- a/framework-docs/modules/ROOT/pages/integration/jms/sending.adoc +++ b/framework-docs/modules/ROOT/pages/integration/jms/sending.adoc @@ -9,39 +9,7 @@ that takes no destination argument uses the default destination. The following example uses the `MessageCreator` callback to create a text message from the supplied `Session` object: -[source,java,indent=0,subs="verbatim,quotes"] ----- - import jakarta.jms.ConnectionFactory; - import jakarta.jms.JMSException; - import jakarta.jms.Message; - import jakarta.jms.Queue; - import jakarta.jms.Session; - - import org.springframework.jms.core.MessageCreator; - import org.springframework.jms.core.JmsTemplate; - - public class JmsQueueSender { - - private JmsTemplate jmsTemplate; - private Queue queue; - - public void setConnectionFactory(ConnectionFactory cf) { - this.jmsTemplate = new JmsTemplate(cf); - } - - public void setQueue(Queue queue) { - this.queue = queue; - } - - public void simpleSend() { - this.jmsTemplate.send(this.queue, new MessageCreator() { - public Message createMessage(Session session) throws JMSException { - return session.createTextMessage("hello queue world"); - } - }); - } - } ----- +include-code::./JmsQueueSender[] In the preceding example, the `JmsTemplate` is constructed by passing a reference to a `ConnectionFactory`. As an alternative, a zero-argument constructor and @@ -84,21 +52,7 @@ gives you access to the message after it has been converted but before it is sen following example shows how to modify a message header and a property after a `java.util.Map` is converted to a message: -[source,java,indent=0,subs="verbatim,quotes"] ----- - public void sendWithConversion() { - Map map = new HashMap<>(); - map.put("Name", "Mark"); - map.put("Age", new Integer(47)); - jmsTemplate.convertAndSend("testQueue", map, new MessagePostProcessor() { - public Message postProcessMessage(Message message) throws JMSException { - message.setIntProperty("AccountID", 1234); - message.setJMSCorrelationID("123-00001"); - return message; - } - }); - } ----- +include-code::./JmsSenderWithConversion[] This results in a message of the following form: @@ -126,32 +80,6 @@ to `jakarta.jms.TextMessage`, `jakarta.jms.BytesMessage`, etc. For a contract su generic message payloads, use `org.springframework.messaging.converter.MessageConverter` with `JmsMessagingTemplate` or preferably `JmsClient` as your central delegate instead. - -[[jms-sending-jmsclient]] -== Sending a Message with `JmsClient` - -[source,java,indent=0,subs="verbatim,quotes"] ----- -// Reusable handle, typically created through JmsClient.create(ConnectionFactory) -// For custom conversion, use JmsClient.create(ConnectionFactory, MessageConverter) -private JmsClient jmsClient; - -public void sendWithConversion() { - this.jmsClient.destination("myQueue") - .withTimeToLive(1000) - .send("myPayload"); // optionally with a headers Map next to the payload -} - -public void sendCustomMessage() { - Message message = - MessageBuilder.withPayload("myPayload").build(); // optionally with headers - this.jmsClient.destination("myQueue") - .withTimeToLive(1000) - .send(message); -} ----- - - [[jms-sending-callbacks]] == Using `SessionCallback` and `ProducerCallback` on `JmsTemplate` @@ -160,3 +88,21 @@ want to perform multiple operations on a JMS `Session` or `MessageProducer`. The `SessionCallback` and `ProducerCallback` expose the JMS `Session` and `Session` / `MessageProducer` pair, respectively. The `execute()` methods on `JmsTemplate` run these callback methods. + + +[[jms-sending-jmsclient]] +== Sending a Message with `JmsClient` + +include-code::./JmsClientSample[] + + +[[jms-sending-postprocessor]] +== Post-processing outgoing messages + +Applications often need to intercept messages before they are sent out, for example to add message properties to all outgoing messages. +The `org.springframework.messaging.core.MessagePostProcessor` based on the spring-messaging `Message` can do that, +when configured on the `JmsClient`. It will be used for all outgoing messages sent with the `send` and `sendAndReceive` methods. + +Here is an example of an interceptor adding a "tenantId" property to all outgoing messages. + +include-code::./JmsClientWithPostProcessor[] diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmssending/JmsQueueSender.java b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmssending/JmsQueueSender.java new file mode 100644 index 000000000000..9b8aede1b8b9 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmssending/JmsQueueSender.java @@ -0,0 +1,48 @@ +/* + * 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.integration.jms.jmssending; + +import jakarta.jms.ConnectionFactory; +import jakarta.jms.JMSException; +import jakarta.jms.Message; +import jakarta.jms.Queue; +import jakarta.jms.Session; + +import org.springframework.jms.core.MessageCreator; +import org.springframework.jms.core.JmsTemplate; + +public class JmsQueueSender { + + private JmsTemplate jmsTemplate; + private Queue queue; + + public void setConnectionFactory(ConnectionFactory cf) { + this.jmsTemplate = new JmsTemplate(cf); + } + + public void setQueue(Queue queue) { + this.queue = queue; + } + + public void simpleSend() { + this.jmsTemplate.send(this.queue, new MessageCreator() { + public Message createMessage(Session session) throws JMSException { + return session.createTextMessage("hello queue world"); + } + }); + } +} diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmssendingconversion/JmsSenderWithConversion.java b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmssendingconversion/JmsSenderWithConversion.java new file mode 100644 index 000000000000..a9f43e2c5f08 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmssendingconversion/JmsSenderWithConversion.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.integration.jms.jmssendingconversion; + +import java.util.HashMap; +import java.util.Map; + +import jakarta.jms.JMSException; +import jakarta.jms.Message; + +import org.springframework.jms.core.JmsTemplate; +import org.springframework.jms.core.MessagePostProcessor; + +public class JmsSenderWithConversion { + + private JmsTemplate jmsTemplate; + + public void sendWithConversion() { + Map map = new HashMap<>(); + map.put("Name", "Mark"); + map.put("Age", 47); + jmsTemplate.convertAndSend("testQueue", map, new MessagePostProcessor() { + public Message postProcessMessage(Message message) throws JMSException { + message.setIntProperty("AccountID", 1234); + message.setJMSCorrelationID("123-00001"); + return message; + } + }); + } + +} diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmssendingjmsclient/JmsClientSample.java b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmssendingjmsclient/JmsClientSample.java new file mode 100644 index 000000000000..3f7468c839c3 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmssendingjmsclient/JmsClientSample.java @@ -0,0 +1,46 @@ +/* + * Copyright 2002-present 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.integration.jms.jmssendingjmsclient; + +import jakarta.jms.ConnectionFactory; + +import org.springframework.jms.core.JmsClient; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; + +public class JmsClientSample { + + private final JmsClient jmsClient; + + public JmsClientSample(ConnectionFactory connectionFactory) { + // For custom options, use JmsClient.builder(ConnectionFactory) + this.jmsClient = JmsClient.create(connectionFactory); + } + + public void sendWithConversion() { + this.jmsClient.destination("myQueue") + .withTimeToLive(1000) + .send("myPayload"); // optionally with a headers Map next to the payload + } + + public void sendCustomMessage() { + Message message = MessageBuilder.withPayload("myPayload").build(); // optionally with headers + this.jmsClient.destination("myQueue") + .withTimeToLive(1000) + .send(message); + } +} diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmssendingpostprocessor/JmsClientWithPostProcessor.java b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmssendingpostprocessor/JmsClientWithPostProcessor.java new file mode 100644 index 000000000000..2e5a4a4a634a --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/jms/jmssendingpostprocessor/JmsClientWithPostProcessor.java @@ -0,0 +1,58 @@ +/* + * 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.integration.jms.jmssendingpostprocessor; + + +import jakarta.jms.ConnectionFactory; + +import org.springframework.jms.core.JmsClient; +import org.springframework.messaging.Message; +import org.springframework.messaging.core.MessagePostProcessor; +import org.springframework.messaging.support.MessageBuilder; + +public class JmsClientWithPostProcessor { + + private final JmsClient jmsClient; + + public JmsClientWithPostProcessor(ConnectionFactory connectionFactory) { + this.jmsClient = JmsClient.builder(connectionFactory) + .messagePostProcessor(new TenantIdMessageInterceptor("42")) + .build(); + } + + public void sendWithPostProcessor() { + this.jmsClient.destination("myQueue") + .withTimeToLive(1000) + .send("myPayload"); + } + + static class TenantIdMessageInterceptor implements MessagePostProcessor { + + private final String tenantId; + + public TenantIdMessageInterceptor(String tenantId) { + this.tenantId = tenantId; + } + + @Override + public Message postProcessMessage(Message message) { + return MessageBuilder.fromMessage(message) + .setHeader("tenantId", this.tenantId) + .build(); + } + } +} diff --git a/spring-jms/src/main/java/org/springframework/jms/core/DefaultJmsClient.java b/spring-jms/src/main/java/org/springframework/jms/core/DefaultJmsClient.java index fb9b2d89a65d..ac8d13af8016 100644 --- a/spring-jms/src/main/java/org/springframework/jms/core/DefaultJmsClient.java +++ b/spring-jms/src/main/java/org/springframework/jms/core/DefaultJmsClient.java @@ -27,6 +27,7 @@ import org.springframework.messaging.Message; import org.springframework.messaging.MessagingException; import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.core.MessagePostProcessor; import org.springframework.util.Assert; /** @@ -34,30 +35,40 @@ * as created by the static factory methods. * * @author Juergen Hoeller + * @author Brian Clozel * @since 7.0 * @see JmsClient#create(ConnectionFactory) - * @see JmsClient#create(ConnectionFactory, MessageConverter) * @see JmsClient#create(JmsOperations) - * @see JmsClient#create(JmsOperations, MessageConverter) */ class DefaultJmsClient implements JmsClient { private final JmsOperations jmsTemplate; - private final @Nullable MessageConverter messageConverter; + private @Nullable MessageConverter messageConverter; + private @Nullable MessagePostProcessor messagePostProcessor; - public DefaultJmsClient(ConnectionFactory connectionFactory, @Nullable MessageConverter messageConverter) { + + public DefaultJmsClient(ConnectionFactory connectionFactory) { + Assert.notNull(connectionFactory, "ConnectionFactory must not be null"); this.jmsTemplate = new JmsTemplate(connectionFactory); - this.messageConverter = messageConverter; } - public DefaultJmsClient(JmsOperations jmsTemplate, @Nullable MessageConverter messageConverter) { + public DefaultJmsClient(JmsOperations jmsTemplate) { Assert.notNull(jmsTemplate, "JmsTemplate must not be null"); this.jmsTemplate = jmsTemplate; + } + + void setMessageConverter(MessageConverter messageConverter) { + Assert.notNull(messageConverter, "MessageConverter must not be null"); this.messageConverter = messageConverter; } + void setMessagePostProcessor(MessagePostProcessor messagePostProcessor) { + Assert.notNull(messagePostProcessor, "MessagePostProcessor must not be null"); + this.messagePostProcessor = messagePostProcessor; + } + public OperationSpec destination(Destination destination) { return new DefaultOperationSpec(destination); @@ -141,17 +152,18 @@ public OperationSpec withTimeToLive(long timeToLive) { @Override public void send(Message message) throws MessagingException { + message = postProcessMessage(message); this.delegate.send(message); } @Override public void send(Object payload) throws MessagingException { - this.delegate.convertAndSend(payload); + this.delegate.convertAndSend(payload, DefaultJmsClient.this.messagePostProcessor); } @Override public void send(Object payload, Map headers) throws MessagingException { - this.delegate.convertAndSend(payload, headers); + this.delegate.convertAndSend(payload, headers, DefaultJmsClient.this.messagePostProcessor); } @Override @@ -176,19 +188,27 @@ public Optional receive(String messageSelector, Class targetClass) thr @Override public Optional> sendAndReceive(Message requestMessage) throws MessagingException { + requestMessage = postProcessMessage(requestMessage); return Optional.ofNullable(this.delegate.sendAndReceive(requestMessage)); } @Override public Optional sendAndReceive(Object request, Class targetClass) throws MessagingException { - return Optional.ofNullable(this.delegate.convertSendAndReceive(request, targetClass)); + return Optional.ofNullable(this.delegate.convertSendAndReceive(request, targetClass, DefaultJmsClient.this.messagePostProcessor)); } @Override public Optional sendAndReceive(Object request, Map headers, Class targetClass) throws MessagingException { - return Optional.ofNullable(this.delegate.convertSendAndReceive(request, headers, targetClass)); + return Optional.ofNullable(this.delegate.convertSendAndReceive(request, headers, targetClass, DefaultJmsClient.this.messagePostProcessor)); + } + + private Message postProcessMessage(Message message) { + if (DefaultJmsClient.this.messagePostProcessor != null) { + return DefaultJmsClient.this.messagePostProcessor.postProcessMessage(message); + } + return message; } } diff --git a/spring-jms/src/main/java/org/springframework/jms/core/DefaultJmsClientBuilder.java b/spring-jms/src/main/java/org/springframework/jms/core/DefaultJmsClientBuilder.java new file mode 100644 index 000000000000..da314c23969d --- /dev/null +++ b/spring-jms/src/main/java/org/springframework/jms/core/DefaultJmsClientBuilder.java @@ -0,0 +1,88 @@ +/* + * Copyright 2002-present 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.jms.core; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.jms.ConnectionFactory; +import org.jspecify.annotations.Nullable; + +import org.springframework.messaging.converter.CompositeMessageConverter; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.core.CompositeMessagePostProcessor; +import org.springframework.messaging.core.MessagePostProcessor; +import org.springframework.util.Assert; + +/** + * Default implementation of {@link JmsClient.Builder}. + * @author Brian Clozel + * @since 7.0 + * @see JmsClient#builder(ConnectionFactory) + * @see JmsClient#builder(JmsOperations) + */ +class DefaultJmsClientBuilder implements JmsClient.Builder { + + private final DefaultJmsClient jmsClient; + + private @Nullable List messageConverters; + + private @Nullable List messagePostProcessors; + + + DefaultJmsClientBuilder(ConnectionFactory connectionFactory) { + Assert.notNull(connectionFactory, "ConnectionFactory must not be null"); + this.jmsClient = new DefaultJmsClient(connectionFactory); + } + + DefaultJmsClientBuilder(JmsOperations jmsTemplate) { + Assert.notNull(jmsTemplate, "JmsOperations must not be null"); + this.jmsClient = new DefaultJmsClient(jmsTemplate); + } + + @Override + public JmsClient.Builder messageConverter(MessageConverter messageConverter) { + Assert.notNull(messageConverter, "MessageConverter must not be null"); + if (this.messageConverters == null) { + this.messageConverters = new ArrayList<>(); + } + this.messageConverters.add(messageConverter); + return this; + } + + @Override + public JmsClient.Builder messagePostProcessor(MessagePostProcessor messagePostProcessor) { + Assert.notNull(messagePostProcessor, "MessagePostProcessor must not be null"); + if (this.messagePostProcessors == null) { + this.messagePostProcessors = new ArrayList<>(); + } + this.messagePostProcessors.add(messagePostProcessor); + return this; + } + + @Override + public JmsClient build() { + if (this.messageConverters != null) { + this.jmsClient.setMessageConverter(new CompositeMessageConverter(this.messageConverters)); + } + if (this.messagePostProcessors != null) { + this.jmsClient.setMessagePostProcessor(new CompositeMessagePostProcessor(this.messagePostProcessors)); + } + return this.jmsClient; + } + +} diff --git a/spring-jms/src/main/java/org/springframework/jms/core/JmsClient.java b/spring-jms/src/main/java/org/springframework/jms/core/JmsClient.java index bbbbc89b434a..fb200c5d4e0b 100644 --- a/spring-jms/src/main/java/org/springframework/jms/core/JmsClient.java +++ b/spring-jms/src/main/java/org/springframework/jms/core/JmsClient.java @@ -25,6 +25,7 @@ import org.springframework.messaging.Message; import org.springframework.messaging.MessagingException; import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.core.MessagePostProcessor; /** * A fluent {@code JmsClient} with common send and receive operations against a JMS @@ -73,6 +74,7 @@ *

    * * @author Juergen Hoeller + * @author Brian Clozel * @since 7.0 * @see JmsTemplate * @see JmsMessagingTemplate @@ -103,35 +105,65 @@ public interface JmsClient { * @param connectionFactory the factory to obtain JMS connections from */ static JmsClient create(ConnectionFactory connectionFactory) { - return new DefaultJmsClient(connectionFactory, null); + return new DefaultJmsClient(connectionFactory); } /** - * Create a new {@code JmsClient} for the given {@link ConnectionFactory}. + * Create a new {@code JmsClient} for the given {@link JmsOperations}. + * @param jmsTemplate the {@link JmsTemplate} to use for performing operations + * (can be a custom {@link JmsOperations} implementation as well) + */ + static JmsClient create(JmsOperations jmsTemplate) { + return new DefaultJmsClient(jmsTemplate); + } + + /** + * Obtain a {@code JmsClient} builder that will use the given connection + * factory for JMS connections. * @param connectionFactory the factory to obtain JMS connections from - * @param messageConverter the message converter for payload objects + * @return a {@code JmsClient} builder that uses the given connection factory. */ - static JmsClient create(ConnectionFactory connectionFactory, MessageConverter messageConverter) { - return new DefaultJmsClient(connectionFactory, messageConverter); + static Builder builder(ConnectionFactory connectionFactory) { + return new DefaultJmsClientBuilder(connectionFactory); } /** - * Create a new {@code JmsClient} for the given {@link JmsOperations}. + * Obtain a {@code JmsClient} builder based on the configuration of the + * given {@code JmsTemplate}. * @param jmsTemplate the {@link JmsTemplate} to use for performing operations * (can be a custom {@link JmsOperations} implementation as well) + * @return a {@code JmsClient} builder that uses the given JMS template. */ - static JmsClient create(JmsOperations jmsTemplate) { - return new DefaultJmsClient(jmsTemplate, null); + static Builder builder(JmsOperations jmsTemplate) { + return new DefaultJmsClientBuilder(jmsTemplate); } /** - * Create a new {@code JmsClient} for the given {@link JmsOperations}. - * @param jmsTemplate the {@link JmsTemplate} to use for performing operations - * (can be a custom {@link JmsOperations} implementation as well) - * @param messageConverter the message converter for payload objects + * A mutable builder for creating a {@link JmsClient}. */ - static JmsClient create(JmsOperations jmsTemplate, MessageConverter messageConverter) { - return new DefaultJmsClient(jmsTemplate, messageConverter); + interface Builder { + + /** + * Add a {@code MessageConverter} to use for converting payload objects to/from messages. + * Message converters will be considered in order of registration. + * @param messageConverter the message converter for payload objects + * @return this builder + */ + Builder messageConverter(MessageConverter messageConverter); + + /** + * Add a {@link MessagePostProcessor} to use for modifying {@code Message} instances before sending. + * Post-processors will be executed in order of registration. + * @param messagePostProcessor the post-processor to use for outgoing messages + * @return this builder + */ + Builder messagePostProcessor(MessagePostProcessor messagePostProcessor); + + /** + * Build the {@code JmsClient} instance. + */ + JmsClient build(); + } diff --git a/spring-jms/src/main/java/org/springframework/jms/core/MessagePostProcessor.java b/spring-jms/src/main/java/org/springframework/jms/core/MessagePostProcessor.java index aa3d9d9ce70c..00b0c9434964 100644 --- a/spring-jms/src/main/java/org/springframework/jms/core/MessagePostProcessor.java +++ b/spring-jms/src/main/java/org/springframework/jms/core/MessagePostProcessor.java @@ -20,18 +20,19 @@ import jakarta.jms.Message; /** - * To be used with JmsTemplate's send method that converts an object to a message. + * Post-processes a {@link Message}. This is the JMS equivalent of the spring-messaging + * {@link org.springframework.messaging.core.MessagePostProcessor}. * - *

    This allows for further modification of the message after it has been processed - * by the converter and is useful for setting JMS headers and properties. - * - *

    Often implemented as a lambda expression or as an anonymous inner class. + *

    This is involved right before a {@link JmsClient} sends a message over the wire, for setting additional + * JMS properties and headers. With {@link JmsTemplate}, the message post processor is only involved + * in methods accepting it as an argument, to customize the outgoing message produced + * by a {@link org.springframework.jms.support.converter.MessageConverter}. * * @author Mark Pollack * @since 1.1 + * @see JmsClient.OperationSpec#send(org.springframework.messaging.Message) * @see JmsTemplate#convertAndSend(String, Object, MessagePostProcessor) * @see JmsTemplate#convertAndSend(jakarta.jms.Destination, Object, MessagePostProcessor) - * @see org.springframework.jms.support.converter.MessageConverter */ @FunctionalInterface public interface MessagePostProcessor { diff --git a/spring-jms/src/test/java/org/springframework/jms/core/JmsClientTests.java b/spring-jms/src/test/java/org/springframework/jms/core/JmsClientTests.java index a7731d2798ab..49b71187a4a3 100644 --- a/spring-jms/src/test/java/org/springframework/jms/core/JmsClientTests.java +++ b/spring-jms/src/test/java/org/springframework/jms/core/JmsClientTests.java @@ -141,6 +141,21 @@ void convertAndSendPayloadAndHeadersName() { assertTextMessage(this.messageCreator.getValue()); // see createTextMessage } + @Test + void convertAndSendPayloadAndHeadersWithPostProcessor() throws JMSException { + Destination destination = new Destination() {}; + Map headers = new HashMap<>(); + headers.put("foo", "bar"); + + this.jmsClient = JmsClient.builder(this.jmsTemplate) + .messagePostProcessor(msg -> MessageBuilder.fromMessage(msg).setHeader("spring", "framework").build()) + .build(); + this.jmsClient.destination(destination).send("Hello", headers); + verify(this.jmsTemplate).send(eq(destination), this.messageCreator.capture()); + TextMessage jmsMessage = createTextMessage(this.messageCreator.getValue()); + assertThat(jmsMessage.getObjectProperty("spring")).isEqualTo("framework"); + } + @Test void receive() { Destination destination = new Destination() {}; @@ -209,7 +224,7 @@ void receiveAndConvertWithConversion() { jakarta.jms.Message jmsMessage = createJmsTextMessage("123"); given(this.jmsTemplate.receive("myQueue")).willReturn(jmsMessage); - this.jmsClient = JmsClient.create(this.jmsTemplate, new GenericMessageConverter()); + this.jmsClient = JmsClient.builder(this.jmsTemplate).messageConverter(new GenericMessageConverter()).build(); Integer payload = this.jmsClient.destination("myQueue").receive(Integer.class).get(); assertThat(payload).isEqualTo(Integer.valueOf(123)); @@ -258,7 +273,7 @@ void receiveSelectedAndConvertWithConversion() { jakarta.jms.Message jmsMessage = createJmsTextMessage("123"); given(this.jmsTemplate.receiveSelected("myQueue", "selector")).willReturn(jmsMessage); - this.jmsClient = JmsClient.create(this.jmsTemplate, new GenericMessageConverter()); + this.jmsClient = JmsClient.builder(this.jmsTemplate).messageConverter(new GenericMessageConverter()).build(); Integer payload = this.jmsClient.destination("myQueue").receive("selector", Integer.class).get(); assertThat(payload).isEqualTo(Integer.valueOf(123)); @@ -315,6 +330,22 @@ void convertSendAndReceivePayload() { assertThat(reply).isEqualTo("My reply"); } + @Test + void convertSendAndReceivePayloadWithPostProcessor() throws JMSException { + Destination destination = new Destination() {}; + jakarta.jms.Message replyJmsMessage = createJmsTextMessage("My reply"); + given(this.jmsTemplate.sendAndReceive(eq(destination), any())).willReturn(replyJmsMessage); + + this.jmsClient = JmsClient.builder(this.jmsTemplate) + .messagePostProcessor(msg -> MessageBuilder.fromMessage(msg).setHeader("spring", "framework").build()) + .build(); + this.jmsClient.destination(destination).sendAndReceive("my Payload", String.class); + verify(this.jmsTemplate).sendAndReceive(eq(destination), this.messageCreator.capture()); + TextMessage jmsMessage = createTextMessage(this.messageCreator.getValue()); + assertThat(jmsMessage.getObjectProperty("spring")).isEqualTo("framework"); + verify(this.jmsTemplate, times(1)).sendAndReceive(eq(destination), any()); + } + @Test void convertSendAndReceivePayloadName() { jakarta.jms.Message replyJmsMessage = createJmsTextMessage("My reply"); @@ -391,6 +422,32 @@ void sendWithDefaults() throws Exception { verify(connection).close(); } + @Test + void sendWithPostProcessor() throws Exception { + ConnectionFactory connectionFactory = mock(); + Connection connection = mock(); + Session session = mock(); + Queue queue = mock(); + MessageProducer messageProducer = mock(); + TextMessage textMessage = mock(); + + given(connectionFactory.createConnection()).willReturn(connection); + given(connection.createSession(false, Session.AUTO_ACKNOWLEDGE)).willReturn(session); + given(session.createProducer(queue)).willReturn(messageProducer); + given(session.createTextMessage("just testing")).willReturn(textMessage); + + JmsClient.builder(connectionFactory) + .messagePostProcessor(msg -> MessageBuilder.fromMessage(msg).setHeader("spring", "framework").build()) + .build() + .destination(queue).send("just testing"); + + verify(textMessage).setObjectProperty("spring", "framework"); + verify(messageProducer).send(textMessage); + verify(messageProducer).close(); + verify(session).close(); + verify(connection).close(); + } + @Test void sendWithCustomSettings() throws Exception { ConnectionFactory connectionFactory = mock(); diff --git a/spring-messaging/src/main/java/org/springframework/messaging/core/CompositeMessagePostProcessor.java b/spring-messaging/src/main/java/org/springframework/messaging/core/CompositeMessagePostProcessor.java new file mode 100644 index 000000000000..9d2dd046b2fd --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/core/CompositeMessagePostProcessor.java @@ -0,0 +1,48 @@ +/* + * Copyright 2002-present 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.messaging.core; + +import java.util.List; + +import org.springframework.messaging.Message; + +/** + * Composite {@link MessagePostProcessor} implementation that iterates over + * a given collection of delegate {@link MessagePostProcessor} instances. + * @author Brian Clozel + * @since 7.0 + */ +public class CompositeMessagePostProcessor implements MessagePostProcessor { + + private final List messagePostProcessors; + + /** + * Construct a CompositeMessagePostProcessor from the given delegate MessagePostProcessors. + * @param messagePostProcessors the MessagePostProcessors to delegate to + */ + public CompositeMessagePostProcessor(List messagePostProcessors) { + this.messagePostProcessors = messagePostProcessors; + } + + @Override + public Message postProcessMessage(Message message) { + for (MessagePostProcessor messagePostProcessor : this.messagePostProcessors) { + message = messagePostProcessor.postProcessMessage(message); + } + return message; + } +} From 03a8933f58b1290fc2a430b5a122a222dab4df38 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Fri, 1 Aug 2025 17:18:43 +0200 Subject: [PATCH 047/591] Add transactional support for StatelessSession (next to regular Session) Exposes JPA-style shared proxy instances through LocalSessionFactoryBean. Closes gh-7184 --- .../orm/jpa/EntityManagerHolder.java | 6 +- .../orm/jpa/JpaTransactionManager.java | 7 +- .../HibernateTransactionManager.java | 39 +---- .../hibernate/LocalSessionFactoryBean.java | 30 +++- .../orm/jpa/hibernate/SessionHolder.java | 38 +++++ .../jpa/hibernate/SharedSessionCreator.java | 158 ++++++++++++++++++ .../jpa/hibernate/SpringSessionContext.java | 143 +++++++++++++--- .../SpringSessionSynchronization.java | 9 +- ...eEntityManagerFactoryIntegrationTests.java | 67 ++++++++ 9 files changed, 424 insertions(+), 73 deletions(-) create mode 100644 spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/SharedSessionCreator.java diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/EntityManagerHolder.java b/spring-orm/src/main/java/org/springframework/orm/jpa/EntityManagerHolder.java index f29b2c479f59..17ebebade89b 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/EntityManagerHolder.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/EntityManagerHolder.java @@ -37,7 +37,7 @@ */ public class EntityManagerHolder extends ResourceHolderSupport { - private final @Nullable EntityManager entityManager; + protected @Nullable EntityManager entityManager; private boolean transactionActive; @@ -78,4 +78,8 @@ public void clear() { this.savepointManager = null; } + protected void closeAll() { + EntityManagerFactoryUtils.closeEntityManager(this.entityManager); + } + } diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/JpaTransactionManager.java b/spring-orm/src/main/java/org/springframework/orm/jpa/JpaTransactionManager.java index a452992632c0..d02950e98bd0 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/JpaTransactionManager.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/JpaTransactionManager.java @@ -640,11 +640,8 @@ protected void doCleanupAfterCompletion(Object transaction) { // Remove the entity manager holder from the thread. if (txObject.isNewEntityManagerHolder()) { - EntityManager em = txObject.getEntityManagerHolder().getEntityManager(); - if (logger.isDebugEnabled()) { - logger.debug("Closing JPA EntityManager [" + em + "] after transaction"); - } - EntityManagerFactoryUtils.closeEntityManager(em); + logger.debug("Closing JPA EntityManager after transaction"); + txObject.getEntityManagerHolder().closeAll(); } else { logger.debug("Not closing pre-bound JPA EntityManager after transaction"); diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/HibernateTransactionManager.java b/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/HibernateTransactionManager.java index 8ad680da3efe..aa1568ab410b 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/HibernateTransactionManager.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/HibernateTransactionManager.java @@ -17,7 +17,6 @@ package org.springframework.orm.jpa.hibernate; import java.sql.Connection; -import java.util.Map; import java.util.function.Consumer; import javax.sql.DataSource; @@ -29,12 +28,8 @@ import org.hibernate.Session; import org.hibernate.SessionFactory; import org.hibernate.Transaction; -import org.hibernate.cfg.Environment; -import org.hibernate.engine.jdbc.connections.spi.ConnectionProvider; -import org.hibernate.engine.spi.SessionFactoryImplementor; import org.hibernate.engine.spi.SessionImplementor; import org.hibernate.resource.transaction.spi.TransactionStatus; -import org.hibernate.service.UnknownServiceException; import org.jspecify.annotations.Nullable; import org.springframework.beans.BeansException; @@ -372,7 +367,7 @@ public void afterPropertiesSet() { // Check for SessionFactory's DataSource. if (this.autodetectDataSource && getDataSource() == null) { - DataSource sfds = determineDataSource(); + DataSource sfds = SpringSessionContext.determineDataSource(obtainSessionFactory()); if (sfds != null) { // Use the SessionFactory's DataSource for exposing transactions to JDBC code. if (logger.isDebugEnabled()) { @@ -384,36 +379,6 @@ public void afterPropertiesSet() { } } - /** - * Determine the DataSource of the given SessionFactory. - * @return the DataSource, or {@code null} if none found - * @see ConnectionProvider - */ - protected @Nullable DataSource determineDataSource() { - SessionFactory sessionFactory = obtainSessionFactory(); - Map props = sessionFactory.getProperties(); - if (props != null) { - Object dataSourceValue = props.get(Environment.JAKARTA_NON_JTA_DATASOURCE); - if (dataSourceValue instanceof DataSource dataSourceToUse) { - return dataSourceToUse; - } - } - if (sessionFactory instanceof SessionFactoryImplementor sfi) { - try { - ConnectionProvider cp = sfi.getServiceRegistry().getService(ConnectionProvider.class); - if (cp != null) { - return cp.unwrap(DataSource.class); - } - } - catch (UnknownServiceException ex) { - if (logger.isDebugEnabled()) { - logger.debug("No ConnectionProvider found - cannot determine DataSource for SessionFactory: " + ex); - } - } - } - return null; - } - @Override public Object getResourceFactory() { @@ -735,7 +700,7 @@ protected void doCleanupAfterCompletion(Object transaction) { if (logger.isDebugEnabled()) { logger.debug("Closing Hibernate Session [" + session + "] after transaction"); } - EntityManagerFactoryUtils.closeEntityManager(session); + txObject.getSessionHolder().closeAll(); } else { if (logger.isDebugEnabled()) { diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/LocalSessionFactoryBean.java b/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/LocalSessionFactoryBean.java index ed939e66518a..722d14ce3754 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/LocalSessionFactoryBean.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/LocalSessionFactoryBean.java @@ -23,7 +23,9 @@ import javax.sql.DataSource; import org.hibernate.Interceptor; +import org.hibernate.Session; import org.hibernate.SessionFactory; +import org.hibernate.StatelessSession; import org.hibernate.boot.MetadataSources; import org.hibernate.boot.model.naming.ImplicitNamingStrategy; import org.hibernate.boot.model.naming.PhysicalNamingStrategy; @@ -41,6 +43,7 @@ import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.FactoryBean; import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.SmartFactoryBean; import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.context.ResourceLoaderAware; @@ -77,7 +80,7 @@ * @see org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean */ public class LocalSessionFactoryBean extends HibernateExceptionTranslator - implements FactoryBean, ResourceLoaderAware, BeanFactoryAware, + implements SmartFactoryBean, ResourceLoaderAware, BeanFactoryAware, InitializingBean, SmartInitializingSingleton, DisposableBean { private @Nullable DataSource dataSource; @@ -134,6 +137,10 @@ public class LocalSessionFactoryBean extends HibernateExceptionTranslator private @Nullable SessionFactory sessionFactory; + private @Nullable Session sharedSession; + + private @Nullable StatelessSession sharedStatelessSession; + /** * Set the DataSource to be used by the SessionFactory. @@ -565,6 +572,8 @@ public void afterPropertiesSet() throws IOException { // Build SessionFactory instance. this.configuration = sfb; this.sessionFactory = buildSessionFactory(sfb); + this.sharedSession = SharedSessionCreator.createSharedSession(this.sessionFactory); + this.sharedStatelessSession = SharedSessionCreator.createSharedStatelessSession(this.sessionFactory); } @Override @@ -614,9 +623,24 @@ public Class getObjectType() { return (this.sessionFactory != null ? this.sessionFactory.getClass() : SessionFactory.class); } + /** + * Return either the singleton SessionFactory or a shared (Stateless)Session proxy. + */ + @Override + public @Nullable S getObject(Class type) throws Exception { + if (Session.class.isAssignableFrom(type)) { + return type.cast(this.sharedSession); + } + if (StatelessSession.class.isAssignableFrom(type)) { + return type.cast(this.sharedStatelessSession); + } + return SmartFactoryBean.super.getObject(type); + } + @Override - public boolean isSingleton() { - return true; + public boolean supportsType(Class type) { + return (type == Session.class || type == StatelessSession.class || + SmartFactoryBean.super.supportsType(type)); } diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/SessionHolder.java b/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/SessionHolder.java index 405ef8e49a23..f54ac43614a4 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/SessionHolder.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/SessionHolder.java @@ -18,10 +18,12 @@ import org.hibernate.FlushMode; import org.hibernate.Session; +import org.hibernate.StatelessSession; import org.hibernate.Transaction; import org.jspecify.annotations.Nullable; import org.springframework.orm.jpa.EntityManagerHolder; +import org.springframework.util.Assert; /** * Resource holder wrapping a Hibernate {@link Session} (plus an optional {@link Transaction}). @@ -37,6 +39,8 @@ */ class SessionHolder extends EntityManagerHolder { + private @Nullable StatelessSession statelessSession; + private @Nullable Transaction transaction; private @Nullable FlushMode previousFlushMode; @@ -46,11 +50,37 @@ public SessionHolder(Session session) { super(session); } + public SessionHolder(StatelessSession session) { + super(null); + this.statelessSession = session; + } + + + public void setSession(Session session) { + this.entityManager = session; + } public Session getSession() { return (Session) getEntityManager(); } + public boolean hasSession() { + return (this.entityManager != null); + } + + public void setStatelessSession(StatelessSession statelessSession) { + this.statelessSession = statelessSession; + } + + public StatelessSession getStatelessSession() { + Assert.state(this.statelessSession != null, "No StatelessSession available"); + return this.statelessSession; + } + + public boolean hasStatelessSession() { + return (this.statelessSession != null); + } + public void setTransaction(@Nullable Transaction transaction) { this.transaction = transaction; setTransactionActive(transaction != null); @@ -76,4 +106,12 @@ public void clear() { this.previousFlushMode = null; } + @Override + protected void closeAll() { + super.closeAll(); + if (this.statelessSession != null && this.statelessSession.isOpen()) { + this.statelessSession.close(); + } + } + } diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/SharedSessionCreator.java b/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/SharedSessionCreator.java new file mode 100644 index 000000000000..c9d7e7e124e8 --- /dev/null +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/SharedSessionCreator.java @@ -0,0 +1,158 @@ +/* + * Copyright 2002-present 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.orm.jpa.hibernate; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.function.Supplier; + +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.StatelessSession; +import org.jspecify.annotations.Nullable; + +/** + * Delegate for creating shareable {@link Session}/{@link StatelessSession} + * references for a given {@link SessionFactory}. + * + *

    Typically used next to {@link LocalSessionFactoryBuilder}. Note that + * {@link LocalSessionFactoryBean} exposes shared {@link Session} as well + * as {@link StatelessSession} references for dependency injection already, + * avoiding the need to define separate beans for the shared sessions. + * + * @author Juergen Hoeller + * @since 7.0 + * @see LocalSessionFactoryBuilder + * @see LocalSessionFactoryBean + * @see org.springframework.orm.jpa.SharedEntityManagerCreator + */ +public abstract class SharedSessionCreator { + + /** + * Create a shared {@link Session} proxy for the given {@link SessionFactory}. + *

    The returned instance behaves like {@link SessionFactory#getCurrentSession()} + * but without the manual get call, automatically delegating every {@link Session} + * method invocation to the current thread-bound transactional session instance. + * Designed to work with {@link HibernateTransactionManager} as well as JTA. + *

    Alternatively, use {@link SessionFactory#getCurrentSession()} directly. + * @param sessionFactory the SessionFactory to build the Session proxy for + * @see SessionFactory#getCurrentSession() + */ + public static Session createSharedSession(SessionFactory sessionFactory) { + return (Session) Proxy.newProxyInstance(SharedSessionCreator.class.getClassLoader(), + new Class[] {Session.class}, + new SharedSessionInvocationHandler(sessionFactory, sessionFactory::getCurrentSession)); + } + + /** + * Create a shared {@link StatelessSession} proxy for the given {@link SessionFactory}. + *

    The returned instance automatically delegates every {@link StatelessSession} + * method invocation to the current thread-bound transactional session instance. + * On the first invocation within a new transaction, a {@link StatelessSession} + * will be opened for the current transactional JDBC Connection. + *

    Works with {@link HibernateTransactionManager} (side by side with a + * thread-bound regular Session that drives the transaction) as well as + * {@link org.springframework.jdbc.support.JdbcTransactionManager} or + * {@link org.springframework.transaction.jta.JtaTransactionManager} + * (with a plain StatelessSession on top of a transactional JDBC Connection). + *

    Alternatively, call {@link SpringSessionContext#currentStatelessSession} + * for every operation, avoiding the need for a proxy. + * @param sessionFactory the SessionFactory to build the StatelessSession proxy for + * @see SpringSessionContext#currentStatelessSession(SessionFactory) + */ + public static StatelessSession createSharedStatelessSession(SessionFactory sessionFactory) { + return (StatelessSession) Proxy.newProxyInstance(SharedSessionCreator.class.getClassLoader(), + new Class[] {StatelessSession.class}, + new SharedSessionInvocationHandler(sessionFactory, + () -> SpringSessionContext.currentStatelessSession(sessionFactory))); + } + + + private static class SharedSessionInvocationHandler implements InvocationHandler { + + private final SessionFactory sessionFactory; + + private final Supplier currentSessionSupplier; + + public SharedSessionInvocationHandler(SessionFactory sessionFactory, Supplier currentSessionSupplier) { + this.sessionFactory = sessionFactory; + this.currentSessionSupplier = currentSessionSupplier; + } + + @Override + public @Nullable Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + switch (method.getName()) { + case "equals" -> { + // Only consider equal when proxies are identical. + return (proxy == args[0]); + } + case "hashCode" -> { + // Use hashCode of EntityManager proxy. + return hashCode(); + } + case "toString" -> { + // Deliver toString without touching a target EntityManager. + return "Shared Session proxy for target factory [" + this.sessionFactory + "]"; + } + case "getSessionFactory", "getEntityManagerFactory" -> { + // JPA 2.0: return EntityManagerFactory without creating an EntityManager. + return this.sessionFactory; + } + case "getCriteriaBuilder", "getMetamodel" -> { + // JPA 2.0: return EntityManagerFactory's CriteriaBuilder/Metamodel (avoid creation of EntityManager) + try { + return SessionFactory.class.getMethod(method.getName()).invoke(this.sessionFactory); + } + catch (InvocationTargetException ex) { + throw ex.getTargetException(); + } + } + case "unwrap" -> { + // JPA 2.0: handle unwrap method - could be a proxy match. + Class targetClass = (Class) args[0]; + if (targetClass != null && targetClass.isInstance(proxy)) { + return proxy; + } + } + case "isOpen" -> { + // Handle isOpen method: always return true. + return true; + } + case "close" -> { + // Handle close method: suppress, not valid. + return null; + } + case "getTransaction" -> { + throw new IllegalStateException( + "Not allowed to create transaction on shared EntityManager - " + + "use Spring transactions or EJB CMT instead"); + } + } + + Object target = this.currentSessionSupplier.get(); + try { + return method.invoke(target, args); + } + catch (InvocationTargetException ex) { + throw ex.getTargetException(); + } + } + } + +} diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/SpringSessionContext.java b/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/SpringSessionContext.java index 7d63ae339259..10e238a897a6 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/SpringSessionContext.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/SpringSessionContext.java @@ -16,6 +16,11 @@ package org.springframework.orm.jpa.hibernate; +import java.sql.Connection; +import java.util.Map; + +import javax.sql.DataSource; + import jakarta.transaction.Status; import jakarta.transaction.SystemException; import jakarta.transaction.TransactionManager; @@ -23,11 +28,18 @@ import org.hibernate.FlushMode; import org.hibernate.HibernateException; import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.StatelessSession; +import org.hibernate.cfg.Environment; import org.hibernate.context.spi.CurrentSessionContext; +import org.hibernate.engine.jdbc.connections.spi.ConnectionProvider; import org.hibernate.engine.spi.SessionFactoryImplementor; +import org.hibernate.engine.spi.SessionImplementor; import org.hibernate.engine.transaction.jta.platform.spi.JtaPlatform; +import org.hibernate.service.UnknownServiceException; import org.jspecify.annotations.Nullable; +import org.springframework.jdbc.datasource.DataSourceUtils; import org.springframework.orm.jpa.EntityManagerHolder; import org.springframework.transaction.support.TransactionSynchronizationManager; @@ -78,27 +90,31 @@ public SpringSessionContext(SessionFactoryImplementor sessionFactory) { @Override public Session currentSession() throws HibernateException { Object value = TransactionSynchronizationManager.getResource(this.sessionFactory); + SessionHolder holder = null; if (value instanceof Session session) { return session; } else if (value instanceof SessionHolder sessionHolder) { // HibernateTransactionManager - Session session = sessionHolder.getSession(); - if (!sessionHolder.isSynchronizedWithTransaction() && - TransactionSynchronizationManager.isSynchronizationActive()) { - TransactionSynchronizationManager.registerSynchronization( - new SpringSessionSynchronization(sessionHolder, this.sessionFactory, false)); - sessionHolder.setSynchronizedWithTransaction(true); - // Switch to FlushMode.AUTO, as we have to assume a thread-bound Session - // with FlushMode.MANUAL, which needs to allow flushing within the transaction. - FlushMode flushMode = session.getHibernateFlushMode(); - if (flushMode.equals(FlushMode.MANUAL) && - !TransactionSynchronizationManager.isCurrentTransactionReadOnly()) { - session.setHibernateFlushMode(FlushMode.AUTO); - sessionHolder.setPreviousFlushMode(flushMode); + if (sessionHolder.hasSession()) { + Session session = sessionHolder.getSession(); + if (!sessionHolder.isSynchronizedWithTransaction() && + TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization( + new SpringSessionSynchronization(sessionHolder, this.sessionFactory, false)); + sessionHolder.setSynchronizedWithTransaction(true); + // Switch to FlushMode.AUTO, as we have to assume a thread-bound Session + // with FlushMode.MANUAL, which needs to allow flushing within the transaction. + FlushMode flushMode = session.getHibernateFlushMode(); + if (flushMode.equals(FlushMode.MANUAL) && + !TransactionSynchronizationManager.isCurrentTransactionReadOnly()) { + session.setHibernateFlushMode(FlushMode.AUTO); + sessionHolder.setPreviousFlushMode(flushMode); + } } + return session; } - return session; + holder = sessionHolder; } else if (value instanceof EntityManagerHolder entityManagerHolder) { // JpaTransactionManager @@ -122,15 +138,25 @@ else if (value instanceof EntityManagerHolder entityManagerHolder) { } if (TransactionSynchronizationManager.isSynchronizationActive()) { - Session session = this.sessionFactory.openSession(); + Session session; + DataSource dataSource = determineDataSource(this.sessionFactory); + if (dataSource != null) { + session = this.sessionFactory.withOptions() + .connection(DataSourceUtils.getConnection(dataSource)) + .openSession(); + } + else { + session = this.sessionFactory.openSession(); + } if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) { session.setHibernateFlushMode(FlushMode.MANUAL); } - SessionHolder sessionHolder = new SessionHolder(session); - TransactionSynchronizationManager.registerSynchronization( - new SpringSessionSynchronization(sessionHolder, this.sessionFactory, true)); - TransactionSynchronizationManager.bindResource(this.sessionFactory, sessionHolder); - sessionHolder.setSynchronizedWithTransaction(true); + if (holder != null) { + holder.setSession(session); + } + else { + bindSessionHolder(this.sessionFactory, new SessionHolder(session)); + } return session; } else { @@ -138,4 +164,81 @@ else if (value instanceof EntityManagerHolder entityManagerHolder) { } } + + /** + * Obtain a {@link StatelessSession} for the current transaction. + * @param sessionFactory the target SessionFactory + * @return the current StatelessSession + */ + public static StatelessSession currentStatelessSession(SessionFactory sessionFactory) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + throw new HibernateException("Could not obtain transaction-synchronized Session for current thread"); + } + Object value = TransactionSynchronizationManager.getResource(sessionFactory); + if (value instanceof StatelessSession statelessSession) { + return statelessSession; + } + SessionHolder holder = null; + if (value instanceof SessionHolder sessionHolder) { + if (sessionHolder.hasStatelessSession()) { + return sessionHolder.getStatelessSession(); + } + holder = sessionHolder; + } + StatelessSession session = sessionFactory.openStatelessSession(determineConnection(sessionFactory, holder)); + if (holder != null) { + holder.setStatelessSession(session); + } + else { + bindSessionHolder(sessionFactory, new SessionHolder(session)); + } + return session; + } + + private static void bindSessionHolder(SessionFactory sessionFactory, SessionHolder holder) { + TransactionSynchronizationManager.registerSynchronization( + new SpringSessionSynchronization(holder, sessionFactory, true)); + TransactionSynchronizationManager.bindResource(sessionFactory, holder); + holder.setSynchronizedWithTransaction(true); + } + + private static Connection determineConnection(SessionFactory sessionFactory, @Nullable SessionHolder holder) { + if (holder != null && holder.getSession() instanceof SessionImplementor session) { + return session.getJdbcCoordinator().getLogicalConnection().getPhysicalConnection(); + } + DataSource dataSource = determineDataSource(sessionFactory); + if (dataSource != null) { + return DataSourceUtils.getConnection(dataSource); + } + throw new IllegalStateException( + "Cannot determine JDBC DataSource for Hibernate SessionFactory: " + sessionFactory); + } + + /** + * Determine the DataSource of the given SessionFactory. + * @return the DataSource, or {@code null} if none found + * @see ConnectionProvider + */ + static @Nullable DataSource determineDataSource(SessionFactory sessionFactory) { + Map props = sessionFactory.getProperties(); + if (props != null) { + Object dataSourceValue = props.get(Environment.JAKARTA_NON_JTA_DATASOURCE); + if (dataSourceValue instanceof DataSource dataSourceToUse) { + return dataSourceToUse; + } + } + if (sessionFactory instanceof SessionFactoryImplementor sfi) { + try { + ConnectionProvider cp = sfi.getServiceRegistry().getService(ConnectionProvider.class); + if (cp != null) { + return cp.unwrap(DataSource.class); + } + } + catch (UnknownServiceException ex) { + // Ignore - cannot determine + } + } + return null; + } + } diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/SpringSessionSynchronization.java b/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/SpringSessionSynchronization.java index 61985b88256b..bebd714c9c5c 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/SpringSessionSynchronization.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/SpringSessionSynchronization.java @@ -25,7 +25,6 @@ import org.springframework.dao.DataAccessException; import org.springframework.dao.support.DataAccessUtils; import org.springframework.jdbc.datasource.DataSourceUtils; -import org.springframework.orm.jpa.EntityManagerFactoryUtils; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; @@ -44,7 +43,7 @@ class SpringSessionSynchronization implements TransactionSynchronization, Ordere * to execute Session cleanup before JDBC Connection cleanup, if any. * @see DataSourceUtils#CONNECTION_SYNCHRONIZATION_ORDER */ - private static final int SESSION_SYNCHRONIZATION_ORDER = + static final int SESSION_SYNCHRONIZATION_ORDER = DataSourceUtils.CONNECTION_SYNCHRONIZATION_ORDER - 100; private final SessionHolder sessionHolder; @@ -56,10 +55,6 @@ class SpringSessionSynchronization implements TransactionSynchronization, Ordere private boolean holderActive = true; - public SpringSessionSynchronization(SessionHolder sessionHolder, SessionFactory sessionFactory) { - this(sessionHolder, sessionFactory, false); - } - public SpringSessionSynchronization(SessionHolder sessionHolder, SessionFactory sessionFactory, boolean newSession) { this.sessionHolder = sessionHolder; this.sessionFactory = sessionFactory; @@ -162,7 +157,7 @@ public void afterCompletion(int status) { this.sessionHolder.setSynchronizedWithTransaction(false); // Call close() at this point if it's a new Session... if (this.newSession) { - EntityManagerFactoryUtils.closeEntityManager(this.sessionHolder.getSession()); + this.sessionHolder.closeAll(); } } } diff --git a/spring-orm/src/test/java/org/springframework/orm/jpa/hibernate/HibernateNativeEntityManagerFactoryIntegrationTests.java b/spring-orm/src/test/java/org/springframework/orm/jpa/hibernate/HibernateNativeEntityManagerFactoryIntegrationTests.java index 94d21bd55292..6e45ac277d6e 100644 --- a/spring-orm/src/test/java/org/springframework/orm/jpa/hibernate/HibernateNativeEntityManagerFactoryIntegrationTests.java +++ b/spring-orm/src/test/java/org/springframework/orm/jpa/hibernate/HibernateNativeEntityManagerFactoryIntegrationTests.java @@ -18,16 +18,22 @@ import java.util.List; +import javax.sql.DataSource; + import org.hibernate.FlushMode; +import org.hibernate.Session; import org.hibernate.SessionFactory; +import org.hibernate.StatelessSession; import org.hibernate.query.Query; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationContext; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.orm.jpa.AbstractContainerEntityManagerFactoryIntegrationTests; import org.springframework.orm.jpa.EntityManagerFactoryInfo; import org.springframework.orm.jpa.domain.Person; +import org.springframework.transaction.support.TransactionTemplate; import static org.assertj.core.api.Assertions.assertThat; @@ -42,6 +48,15 @@ class HibernateNativeEntityManagerFactoryIntegrationTests extends AbstractContai @Autowired private SessionFactory sessionFactory; + @Autowired + private Session sharedSession; + + @Autowired + private StatelessSession statelessSession; + + @Autowired + private DataSource dataSource; + @Autowired private ApplicationContext applicationContext; @@ -83,6 +98,58 @@ public void testCurrentSession() { assertThat(q.getResultList().get(0).postLoaded).isSameAs(applicationContext); } + @Test + public void testSharedSession() { + String firstName = "Tony"; + insertPerson(firstName); + + Query q = sharedSession.createQuery("select p from Person as p", Person.class); + assertThat(q.getResultList()).hasSize(1); + assertThat(q.getResultList().get(0).getFirstName()).isEqualTo(firstName); + assertThat(q.getResultList().get(0).postLoaded).isSameAs(applicationContext); + + endTransaction(); + + DataSourceTransactionManager dstm = new DataSourceTransactionManager(dataSource); + new TransactionTemplate(dstm).execute(status -> { + insertPerson(firstName); + Query q2 = sharedSession.createQuery("select p from Person as p", Person.class); + assertThat(q2.getResultList()).hasSize(1); + assertThat(q2.getResultList().get(0).getFirstName()).isEqualTo(firstName); + assertThat(q2.getResultList().get(0).postLoaded).isSameAs(applicationContext); + Query q3 = statelessSession.createQuery("select p from Person as p", Person.class); + assertThat(q3.getResultList()).hasSize(1); + assertThat(q3.getResultList().get(0).getFirstName()).isEqualTo(firstName); + status.setRollbackOnly(); + return null; + }); + } + + @Test + public void testStatelessSession() { + String firstName = "Tony"; + insertPerson(firstName); + + Query q = statelessSession.createQuery("select p from Person as p", Person.class); + assertThat(q.getResultList()).hasSize(1); + assertThat(q.getResultList().get(0).getFirstName()).isEqualTo(firstName); + + endTransaction(); + + DataSourceTransactionManager dstm = new DataSourceTransactionManager(dataSource); + new TransactionTemplate(dstm).execute(status -> { + insertPerson(firstName); + Query q2 = statelessSession.createQuery("select p from Person as p", Person.class); + assertThat(q2.getResultList()).hasSize(1); + assertThat(q2.getResultList().get(0).getFirstName()).isEqualTo(firstName); + Query q3 = sharedSession.createQuery("select p from Person as p", Person.class); + assertThat(q3.getResultList()).hasSize(1); + assertThat(q3.getResultList().get(0).getFirstName()).isEqualTo(firstName); + status.setRollbackOnly(); + return null; + }); + } + @Test // SPR-16956 public void testReadOnly() { assertThat(sessionFactory.getCurrentSession().getHibernateFlushMode()).isSameAs(FlushMode.AUTO); From 67e88f3c2023e6b5ec42995b412ccf1a8247c911 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Fri, 1 Aug 2025 21:15:25 +0200 Subject: [PATCH 048/591] Align task execution tracking and thread interruption on shutdown Closes gh-35254 --- .../scheduling/concurrent/SimpleAsyncTaskScheduler.java | 2 +- .../org/springframework/core/task/SimpleAsyncTaskExecutor.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spring-context/src/main/java/org/springframework/scheduling/concurrent/SimpleAsyncTaskScheduler.java b/spring-context/src/main/java/org/springframework/scheduling/concurrent/SimpleAsyncTaskScheduler.java index 0f3cf3d6b534..9d7d61d52c41 100644 --- a/spring-context/src/main/java/org/springframework/scheduling/concurrent/SimpleAsyncTaskScheduler.java +++ b/spring-context/src/main/java/org/springframework/scheduling/concurrent/SimpleAsyncTaskScheduler.java @@ -376,7 +376,7 @@ public void stop(Runnable callback) { @Override public boolean isRunning() { - return this.triggerLifecycle.isRunning(); + return (this.triggerLifecycle.isRunning() || this.fixedDelayLifecycle.isRunning()); } @Override diff --git a/spring-core/src/main/java/org/springframework/core/task/SimpleAsyncTaskExecutor.java b/spring-core/src/main/java/org/springframework/core/task/SimpleAsyncTaskExecutor.java index adb9eae4aa58..33b35c4b37d3 100644 --- a/spring-core/src/main/java/org/springframework/core/task/SimpleAsyncTaskExecutor.java +++ b/spring-core/src/main/java/org/springframework/core/task/SimpleAsyncTaskExecutor.java @@ -364,7 +364,6 @@ public void close() { this.active = false; Set threads = this.activeThreads; if (threads != null) { - threads.forEach(Thread::interrupt); synchronized (threads) { try { if (!threads.isEmpty()) { @@ -375,6 +374,7 @@ public void close() { Thread.currentThread().interrupt(); } } + threads.forEach(Thread::interrupt); } } } From da13a246040602249db6e509114173f326e160ae Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Fri, 1 Aug 2025 21:15:56 +0200 Subject: [PATCH 049/591] Allow any @Transactional propagation for listener with BEFORE_COMMIT phase Closes gh-35150 --- ...ctedTransactionalEventListenerFactory.java | 28 +++++++++++-------- ...ApplicationListenerMethodAdapterTests.java | 12 ++++++++ 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/spring-tx/src/main/java/org/springframework/transaction/annotation/RestrictedTransactionalEventListenerFactory.java b/spring-tx/src/main/java/org/springframework/transaction/annotation/RestrictedTransactionalEventListenerFactory.java index d480170357f9..bcf368462541 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/annotation/RestrictedTransactionalEventListenerFactory.java +++ b/spring-tx/src/main/java/org/springframework/transaction/annotation/RestrictedTransactionalEventListenerFactory.java @@ -20,6 +20,8 @@ import org.springframework.context.ApplicationListener; import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalApplicationListenerMethodAdapter; import org.springframework.transaction.event.TransactionalEventListenerFactory; /** @@ -37,20 +39,22 @@ public class RestrictedTransactionalEventListenerFactory extends TransactionalEv @Override public ApplicationListener createApplicationListener(String beanName, Class type, Method method) { - Transactional txAnn = AnnotatedElementUtils.findMergedAnnotation(method, Transactional.class); - - if (txAnn == null) { - txAnn = AnnotatedElementUtils.findMergedAnnotation(type, Transactional.class); - } - - if (txAnn != null) { - Propagation propagation = txAnn.propagation(); - if (propagation != Propagation.REQUIRES_NEW && propagation != Propagation.NOT_SUPPORTED) { - throw new IllegalStateException("@TransactionalEventListener method must not be annotated with " + - "@Transactional unless when declared as REQUIRES_NEW or NOT_SUPPORTED: " + method); + TransactionalApplicationListenerMethodAdapter adapter = + new TransactionalApplicationListenerMethodAdapter(beanName, type, method); + if (adapter.getTransactionPhase() != TransactionPhase.BEFORE_COMMIT) { + Transactional txAnn = AnnotatedElementUtils.findMergedAnnotation(method, Transactional.class); + if (txAnn == null) { + txAnn = AnnotatedElementUtils.findMergedAnnotation(type, Transactional.class); + } + if (txAnn != null) { + Propagation propagation = txAnn.propagation(); + if (propagation != Propagation.REQUIRES_NEW && propagation != Propagation.NOT_SUPPORTED) { + throw new IllegalStateException("@TransactionalEventListener method must not be annotated with " + + "@Transactional unless when declared as REQUIRES_NEW or NOT_SUPPORTED: " + method); + } } } - return super.createApplicationListener(beanName, type, method); + return adapter; } } diff --git a/spring-tx/src/test/java/org/springframework/transaction/event/TransactionalApplicationListenerMethodAdapterTests.java b/spring-tx/src/test/java/org/springframework/transaction/event/TransactionalApplicationListenerMethodAdapterTests.java index 54283661ccf7..aa32b9f37a72 100644 --- a/spring-tx/src/test/java/org/springframework/transaction/event/TransactionalApplicationListenerMethodAdapterTests.java +++ b/spring-tx/src/test/java/org/springframework/transaction/event/TransactionalApplicationListenerMethodAdapterTests.java @@ -157,6 +157,13 @@ void withAsyncTransactionalAnnotation() { assertThatNoException().isThrownBy(() -> factory.createApplicationListener("test", SampleEvents.class, m)); } + @Test + void withTransactionalAnnotationBeforeCommit() { + RestrictedTransactionalEventListenerFactory factory = new RestrictedTransactionalEventListenerFactory(); + Method m = ReflectionUtils.findMethod(SampleEvents.class, "withTransactionalAnnotationBeforeCommit", String.class); + assertThatNoException().isThrownBy(() -> factory.createApplicationListener("test", SampleEvents.class, m)); + } + @Test void withTransactionalAnnotationOnEnclosingClass() { RestrictedTransactionalEventListenerFactory factory = new RestrictedTransactionalEventListenerFactory(); @@ -277,6 +284,11 @@ public void withTransactionalNotSupportedAnnotation(String data) { public void withAsyncTransactionalAnnotation(String data) { } + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + @Transactional + public void withTransactionalAnnotationBeforeCommit(String data) { + } + @Transactional static class SampleEventsWithTransactionalAnnotation { From e590341ca78c3c6cd48230b45912beba9f4f4d3c Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Mon, 4 Aug 2025 11:50:47 +0300 Subject: [PATCH 050/591] Revise Javadoc regarding new ApplicationContext pause() support See gh-35269 --- .../context/ConfigurableApplicationContext.java | 2 +- .../springframework/context/event/ContextPausedEvent.java | 4 ++-- .../test/context/CacheAwareContextLoaderDelegate.java | 4 ++-- .../java/org/springframework/test/context/TestContext.java | 4 ++-- .../test/context/support/DefaultTestContext.java | 4 ++-- .../springframework/test/context/cache/EventTracker.java | 6 +++--- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/spring-context/src/main/java/org/springframework/context/ConfigurableApplicationContext.java b/spring-context/src/main/java/org/springframework/context/ConfigurableApplicationContext.java index 8dffb08bd691..fe471a2d252c 100644 --- a/spring-context/src/main/java/org/springframework/context/ConfigurableApplicationContext.java +++ b/spring-context/src/main/java/org/springframework/context/ConfigurableApplicationContext.java @@ -223,7 +223,7 @@ public interface ConfigurableApplicationContext extends ApplicationContext, Life /** * Pause all beans in this application context if necessary, and subsequently * restart all auto-startup beans, effectively restoring the lifecycle state - * after {@link #refresh()} (typically after a preceding {@link #stop()} call + * after {@link #refresh()} (typically after a preceding {@link #pause()} call * when a full {@link #start()} of even lazy-starting beans is to be avoided). * @since 7.0 * @see #pause() diff --git a/spring-context/src/main/java/org/springframework/context/event/ContextPausedEvent.java b/spring-context/src/main/java/org/springframework/context/event/ContextPausedEvent.java index 759783bc0b2a..6fee1579918d 100644 --- a/spring-context/src/main/java/org/springframework/context/event/ContextPausedEvent.java +++ b/spring-context/src/main/java/org/springframework/context/event/ContextPausedEvent.java @@ -35,8 +35,8 @@ public class ContextPausedEvent extends ContextStoppedEvent { /** - * Create a new {@code ContextRestartedEvent}. - * @param source the {@code ContextPausedEvent} that has been restarted + * Create a new {@code ContextPausedEvent}. + * @param source the {@code ApplicationContext} that has been paused * (must not be {@code null}) */ public ContextPausedEvent(ApplicationContext source) { diff --git a/spring-test/src/main/java/org/springframework/test/context/CacheAwareContextLoaderDelegate.java b/spring-test/src/main/java/org/springframework/test/context/CacheAwareContextLoaderDelegate.java index 0bb844431c6b..d9b56d64b80f 100644 --- a/spring-test/src/main/java/org/springframework/test/context/CacheAwareContextLoaderDelegate.java +++ b/spring-test/src/main/java/org/springframework/test/context/CacheAwareContextLoaderDelegate.java @@ -169,8 +169,8 @@ default void registerContextUsage(MergedContextConfiguration key, Class testC * for the supplied {@link MergedContextConfiguration} as well as usage of the * application context for its {@linkplain MergedContextConfiguration#getParent() * parent}, recursively. - *

    This informs the {@code ContextCache} that the application context(s) can - * be safely {@linkplain org.springframework.context.Lifecycle#stop() stopped} + *

    This informs the {@code ContextCache} that the application context(s) can be safely + * {@linkplain org.springframework.context.ConfigurableApplicationContext#pause() paused} * if no other test classes are actively using the same application context(s). * @param key the context key; never {@code null} * @param testClass the test class that is no longer using the application context(s) diff --git a/spring-test/src/main/java/org/springframework/test/context/TestContext.java b/spring-test/src/main/java/org/springframework/test/context/TestContext.java index 9e5ce628843b..e345a74c7e2a 100644 --- a/spring-test/src/main/java/org/springframework/test/context/TestContext.java +++ b/spring-test/src/main/java/org/springframework/test/context/TestContext.java @@ -135,8 +135,8 @@ default void publishEvent(Function even * Call this method to signal that the {@linkplain #getTestClass() test class} * is no longer using the {@linkplain ApplicationContext application context} * associated with this test context. - *

    This informs the context cache that the application context can be - * safely {@linkplain org.springframework.context.Lifecycle#stop() stopped} + *

    This informs the context cache that the application context can be safely + * {@linkplain org.springframework.context.ConfigurableApplicationContext#pause() paused} * if no other test classes are actively using the same application context. *

    This method is intended to be invoked after execution of the test class * has ended and should not be invoked unless the application context for this diff --git a/spring-test/src/main/java/org/springframework/test/context/support/DefaultTestContext.java b/spring-test/src/main/java/org/springframework/test/context/support/DefaultTestContext.java index 7d9a06045f0f..850c752dac27 100644 --- a/spring-test/src/main/java/org/springframework/test/context/support/DefaultTestContext.java +++ b/spring-test/src/main/java/org/springframework/test/context/support/DefaultTestContext.java @@ -143,8 +143,8 @@ public ApplicationContext getApplicationContext() { /** * Mark the {@linkplain ApplicationContext application context} associated * with this test context as unused so that it can be safely - * {@linkplain org.springframework.context.Lifecycle#stop() stopped} if no - * other test classes are actively using the same application context. + * {@linkplain org.springframework.context.ConfigurableApplicationContext#pause() paused} + * if no other test classes are actively using the same application context. *

    The default implementation delegates to the {@link CacheAwareContextLoaderDelegate} * that was supplied when this {@code TestContext} was constructed. * @since 7.0 diff --git a/spring-test/src/test/java/org/springframework/test/context/cache/EventTracker.java b/spring-test/src/test/java/org/springframework/test/context/cache/EventTracker.java index a606d70ddf06..ed997cd35c74 100644 --- a/spring-test/src/test/java/org/springframework/test/context/cache/EventTracker.java +++ b/spring-test/src/test/java/org/springframework/test/context/cache/EventTracker.java @@ -22,9 +22,9 @@ import org.springframework.context.ApplicationEvent; import org.springframework.context.event.ApplicationContextEvent; import org.springframework.context.event.ContextClosedEvent; +import org.springframework.context.event.ContextPausedEvent; import org.springframework.context.event.ContextRefreshedEvent; import org.springframework.context.event.ContextRestartedEvent; -import org.springframework.context.event.ContextStoppedEvent; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import org.springframework.test.context.event.TestContextEvent; @@ -51,8 +51,8 @@ void contextRestarted(ContextRestartedEvent event) { trackApplicationContextEvent(event); } - @EventListener(ContextStoppedEvent.class) - void contextStopped(ContextStoppedEvent event) { + @EventListener(ContextPausedEvent.class) + void contextPaused(ContextPausedEvent event) { trackApplicationContextEvent(event); } From 61df497785f2b063240641e5b6946dfc792a35bb Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Mon, 4 Aug 2025 12:05:26 +0300 Subject: [PATCH 051/591] Revise reference docs regarding new ApplicationContext pause() support See gh-35269 --- .../testcontext-framework/ctx-management/caching.adoc | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/caching.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/caching.adoc index b4beba546d8f..be0eda85172b 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/caching.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/ctx-management/caching.adoc @@ -63,13 +63,15 @@ alternative, you can set the same property via the xref:appendix.adoc#appendix-spring-properties[`SpringProperties`] mechanism. As of Spring Framework 7.0, an application context stored in the context cache will be -stopped when it is no longer actively in use and automatically restarted the next time +_paused_ when it is no longer actively in use and automatically _restarted_ the next time the context is retrieved from the cache. Specifically, the latter will restart all auto-startup beans in the application context, effectively restoring the lifecycle state. This ensures that background processes within the context are not actively running while the context is not used by tests. For example, JMS listener containers, scheduled tasks, and any other components in the context that implement `Lifecycle` or `SmartLifecycle` -will be in a "stopped" state until the context is used again by a test. +will be in a "stopped" state until the context is used again by a test. Note, however, +that `SmartLifecycle` components can opt out of pausing by returning `false` from +`SmartLifecycle#isPauseable()`. Since having a large number of application contexts loaded within a given test suite can cause the suite to take an unnecessarily long time to run, it is often beneficial to From 4ad9396b15b18be7ef05a2fe4f0523de126e67cc Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Mon, 4 Aug 2025 15:27:56 +0200 Subject: [PATCH 052/591] Update CountDownLatch for non-pauseable beans See gh-35269 --- .../context/support/DefaultLifecycleProcessor.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/spring-context/src/main/java/org/springframework/context/support/DefaultLifecycleProcessor.java b/spring-context/src/main/java/org/springframework/context/support/DefaultLifecycleProcessor.java index 02bf8c08ab5e..7d646d88604e 100644 --- a/spring-context/src/main/java/org/springframework/context/support/DefaultLifecycleProcessor.java +++ b/spring-context/src/main/java/org/springframework/context/support/DefaultLifecycleProcessor.java @@ -485,6 +485,10 @@ private void doStop(Map lifecycleBeans, final Strin } }); } + else { + // Don't wait for beans that aren't pauseable... + latch.countDown(); + } } else if (!pauseableOnly) { if (logger.isTraceEnabled()) { From 9edb96ae57eaa8e7c2bf88f3cbfb88960f5c36ba Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Wed, 6 Aug 2025 14:51:13 +0200 Subject: [PATCH 053/591] Introduce default ProxyConfig bean and exposed interfaces attribute Taken into account by all proxy processors, this enables consistent proxy type defaulting in Spring Boot as well as consistent opting out for specific bean definitions. Closes gh-35286 Closes gh-35293 --- .../aop/config/AopConfigUtils.java | 31 +++--- .../AbstractAdvisingBeanPostProcessor.java | 5 +- .../aop/framework/CglibAopProxy.java | 2 +- .../aop/framework/JdkDynamicAopProxy.java | 4 +- .../aop/framework/ProxyConfig.java | 49 +++++++--- .../autoproxy/AbstractAutoProxyCreator.java | 52 ++++------ ...BeanFactoryAwareAdvisingPostProcessor.java | 23 +++-- .../framework/autoproxy/AutoProxyUtils.java | 72 ++++++++++++++ .../resilience/RetryInterceptorTests.java | 95 +++++++++++++++++++ 9 files changed, 268 insertions(+), 65 deletions(-) diff --git a/spring-aop/src/main/java/org/springframework/aop/config/AopConfigUtils.java b/spring-aop/src/main/java/org/springframework/aop/config/AopConfigUtils.java index a7a9e033d1f7..3247fa213d39 100644 --- a/spring-aop/src/main/java/org/springframework/aop/config/AopConfigUtils.java +++ b/spring-aop/src/main/java/org/springframework/aop/config/AopConfigUtils.java @@ -23,6 +23,8 @@ import org.springframework.aop.aspectj.annotation.AnnotationAwareAspectJAutoProxyCreator; import org.springframework.aop.aspectj.autoproxy.AspectJAwareAdvisorAutoProxyCreator; +import org.springframework.aop.framework.ProxyConfig; +import org.springframework.aop.framework.autoproxy.AutoProxyUtils; import org.springframework.aop.framework.autoproxy.InfrastructureAdvisorAutoProxyCreator; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.beans.factory.support.BeanDefinitionRegistry; @@ -96,17 +98,22 @@ public abstract class AopConfigUtils { } public static void forceAutoProxyCreatorToUseClassProxying(BeanDefinitionRegistry registry) { - if (registry.containsBeanDefinition(AUTO_PROXY_CREATOR_BEAN_NAME)) { - BeanDefinition definition = registry.getBeanDefinition(AUTO_PROXY_CREATOR_BEAN_NAME); - definition.getPropertyValues().add("proxyTargetClass", Boolean.TRUE); - } + defaultProxyConfig(registry).getPropertyValues().add("proxyTargetClass", Boolean.TRUE); } public static void forceAutoProxyCreatorToExposeProxy(BeanDefinitionRegistry registry) { - if (registry.containsBeanDefinition(AUTO_PROXY_CREATOR_BEAN_NAME)) { - BeanDefinition definition = registry.getBeanDefinition(AUTO_PROXY_CREATOR_BEAN_NAME); - definition.getPropertyValues().add("exposeProxy", Boolean.TRUE); + defaultProxyConfig(registry).getPropertyValues().add("exposeProxy", Boolean.TRUE); + } + + private static BeanDefinition defaultProxyConfig(BeanDefinitionRegistry registry) { + if (registry.containsBeanDefinition(AutoProxyUtils.DEFAULT_PROXY_CONFIG_BEAN_NAME)) { + return registry.getBeanDefinition(AutoProxyUtils.DEFAULT_PROXY_CONFIG_BEAN_NAME); } + RootBeanDefinition beanDefinition = new RootBeanDefinition(ProxyConfig.class); + beanDefinition.setSource(AopConfigUtils.class); + beanDefinition.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); + registry.registerBeanDefinition(AutoProxyUtils.DEFAULT_PROXY_CONFIG_BEAN_NAME, beanDefinition); + return beanDefinition; } private static @Nullable BeanDefinition registerOrEscalateApcAsRequired( @@ -115,12 +122,12 @@ public static void forceAutoProxyCreatorToExposeProxy(BeanDefinitionRegistry reg Assert.notNull(registry, "BeanDefinitionRegistry must not be null"); if (registry.containsBeanDefinition(AUTO_PROXY_CREATOR_BEAN_NAME)) { - BeanDefinition apcDefinition = registry.getBeanDefinition(AUTO_PROXY_CREATOR_BEAN_NAME); - if (!cls.getName().equals(apcDefinition.getBeanClassName())) { - int currentPriority = findPriorityForClass(apcDefinition.getBeanClassName()); + BeanDefinition beanDefinition = registry.getBeanDefinition(AUTO_PROXY_CREATOR_BEAN_NAME); + if (!cls.getName().equals(beanDefinition.getBeanClassName())) { + int currentPriority = findPriorityForClass(beanDefinition.getBeanClassName()); int requiredPriority = findPriorityForClass(cls); if (currentPriority < requiredPriority) { - apcDefinition.setBeanClassName(cls.getName()); + beanDefinition.setBeanClassName(cls.getName()); } } return null; @@ -128,8 +135,8 @@ public static void forceAutoProxyCreatorToExposeProxy(BeanDefinitionRegistry reg RootBeanDefinition beanDefinition = new RootBeanDefinition(cls); beanDefinition.setSource(source); - beanDefinition.getPropertyValues().add("order", Ordered.HIGHEST_PRECEDENCE); beanDefinition.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); + beanDefinition.getPropertyValues().add("order", Ordered.HIGHEST_PRECEDENCE); registry.registerBeanDefinition(AUTO_PROXY_CREATOR_BEAN_NAME, beanDefinition); return beanDefinition; } diff --git a/spring-aop/src/main/java/org/springframework/aop/framework/AbstractAdvisingBeanPostProcessor.java b/spring-aop/src/main/java/org/springframework/aop/framework/AbstractAdvisingBeanPostProcessor.java index 3b7b17052437..70f0c63122e6 100644 --- a/spring-aop/src/main/java/org/springframework/aop/framework/AbstractAdvisingBeanPostProcessor.java +++ b/spring-aop/src/main/java/org/springframework/aop/framework/AbstractAdvisingBeanPostProcessor.java @@ -112,11 +112,13 @@ else if (advised.getTargetSource() == AdvisedSupport.EMPTY_TARGET_SOURCE && if (isEligible(bean, beanName)) { ProxyFactory proxyFactory = prepareProxyFactory(bean, beanName); - if (!proxyFactory.isProxyTargetClass()) { + if (!proxyFactory.isProxyTargetClass() && !proxyFactory.hasUserSuppliedInterfaces()) { evaluateProxyInterfaces(bean.getClass(), proxyFactory); } proxyFactory.addAdvisor(this.advisor); customizeProxyFactory(proxyFactory); + proxyFactory.setFrozen(isFrozen()); + proxyFactory.setPreFiltered(true); // Use original ClassLoader if bean class not locally loaded in overriding class loader ClassLoader classLoader = getProxyClassLoader(); @@ -187,6 +189,7 @@ protected boolean isEligible(Class targetClass) { protected ProxyFactory prepareProxyFactory(Object bean, String beanName) { ProxyFactory proxyFactory = new ProxyFactory(); proxyFactory.copyFrom(this); + proxyFactory.setFrozen(false); proxyFactory.setTarget(bean); return proxyFactory; } diff --git a/spring-aop/src/main/java/org/springframework/aop/framework/CglibAopProxy.java b/spring-aop/src/main/java/org/springframework/aop/framework/CglibAopProxy.java index be021f99f58b..8ed496c3dade 100644 --- a/spring-aop/src/main/java/org/springframework/aop/framework/CglibAopProxy.java +++ b/spring-aop/src/main/java/org/springframework/aop/framework/CglibAopProxy.java @@ -694,7 +694,7 @@ public DynamicAdvisedInterceptor(AdvisedSupport advised) { Object target = null; TargetSource targetSource = this.advised.getTargetSource(); try { - if (this.advised.exposeProxy) { + if (this.advised.isExposeProxy()) { // Make invocation available if necessary. oldProxy = AopContext.setCurrentProxy(proxy); setProxyContext = true; diff --git a/spring-aop/src/main/java/org/springframework/aop/framework/JdkDynamicAopProxy.java b/spring-aop/src/main/java/org/springframework/aop/framework/JdkDynamicAopProxy.java index a2b105839d27..b6601801d8ab 100644 --- a/spring-aop/src/main/java/org/springframework/aop/framework/JdkDynamicAopProxy.java +++ b/spring-aop/src/main/java/org/springframework/aop/framework/JdkDynamicAopProxy.java @@ -183,7 +183,7 @@ else if (method.getDeclaringClass() == DecoratingProxy.class) { // There is only getDecoratedClass() declared -> dispatch to proxy config. return AopProxyUtils.ultimateTargetClass(this.advised); } - else if (!this.advised.opaque && method.getDeclaringClass().isInterface() && + else if (!this.advised.isOpaque() && method.getDeclaringClass().isInterface() && method.getDeclaringClass().isAssignableFrom(Advised.class)) { // Service invocations on ProxyConfig with the proxy config... return AopUtils.invokeJoinpointUsingReflection(this.advised, method, args); @@ -191,7 +191,7 @@ else if (!this.advised.opaque && method.getDeclaringClass().isInterface() && Object retVal; - if (this.advised.exposeProxy) { + if (this.advised.isExposeProxy()) { // Make invocation available if necessary. oldProxy = AopContext.setCurrentProxy(proxy); setProxyContext = true; diff --git a/spring-aop/src/main/java/org/springframework/aop/framework/ProxyConfig.java b/spring-aop/src/main/java/org/springframework/aop/framework/ProxyConfig.java index 3c4ee97be346..ca21266ba0ec 100644 --- a/spring-aop/src/main/java/org/springframework/aop/framework/ProxyConfig.java +++ b/spring-aop/src/main/java/org/springframework/aop/framework/ProxyConfig.java @@ -18,6 +18,8 @@ import java.io.Serializable; +import org.jspecify.annotations.Nullable; + import org.springframework.util.Assert; /** @@ -34,15 +36,15 @@ public class ProxyConfig implements Serializable { private static final long serialVersionUID = -8409359707199703185L; - private boolean proxyTargetClass = false; + private @Nullable Boolean proxyTargetClass; - private boolean optimize = false; + private @Nullable Boolean optimize; - boolean opaque = false; + private @Nullable Boolean opaque; - boolean exposeProxy = false; + private @Nullable Boolean exposeProxy; - private boolean frozen = false; + private @Nullable Boolean frozen; /** @@ -65,7 +67,7 @@ public void setProxyTargetClass(boolean proxyTargetClass) { * Return whether to proxy the target class directly as well as any interfaces. */ public boolean isProxyTargetClass() { - return this.proxyTargetClass; + return (this.proxyTargetClass != null && this.proxyTargetClass); } /** @@ -85,7 +87,7 @@ public void setOptimize(boolean optimize) { * Return whether proxies should perform aggressive optimizations. */ public boolean isOptimize() { - return this.optimize; + return (this.optimize != null && this.optimize); } /** @@ -103,7 +105,7 @@ public void setOpaque(boolean opaque) { * prevented from being cast to {@link Advised}. */ public boolean isOpaque() { - return this.opaque; + return (this.opaque != null && this.opaque); } /** @@ -124,7 +126,7 @@ public void setExposeProxy(boolean exposeProxy) { * each invocation. */ public boolean isExposeProxy() { - return this.exposeProxy; + return (this.exposeProxy != null && this.exposeProxy); } /** @@ -141,7 +143,7 @@ public void setFrozen(boolean frozen) { * Return whether the config is frozen, and no advice changes can be made. */ public boolean isFrozen() { - return this.frozen; + return (this.frozen != null && this.frozen); } @@ -153,9 +155,34 @@ public void copyFrom(ProxyConfig other) { Assert.notNull(other, "Other ProxyConfig object must not be null"); this.proxyTargetClass = other.proxyTargetClass; this.optimize = other.optimize; + this.opaque = other.opaque; this.exposeProxy = other.exposeProxy; this.frozen = other.frozen; - this.opaque = other.opaque; + } + + /** + * Copy default settings from the other config object, + * for settings that have not been locally set. + * @param other object to copy configuration from + * @since 7.0 + */ + public void copyDefault(ProxyConfig other) { + Assert.notNull(other, "Other ProxyConfig object must not be null"); + if (this.proxyTargetClass == null) { + this.proxyTargetClass = other.proxyTargetClass; + } + if (this.optimize == null) { + this.optimize = other.optimize; + } + if (this.opaque == null) { + this.opaque = other.opaque; + } + if (this.exposeProxy == null) { + this.exposeProxy = other.exposeProxy; + } + if (this.frozen == null) { + this.frozen = other.frozen; + } } @Override diff --git a/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractAutoProxyCreator.java b/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractAutoProxyCreator.java index c8c2f56f2bd6..8e621fd2139a 100644 --- a/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractAutoProxyCreator.java +++ b/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractAutoProxyCreator.java @@ -117,12 +117,6 @@ public abstract class AbstractAutoProxyCreator extends ProxyProcessorSupport /** Default is global AdvisorAdapterRegistry. */ private AdvisorAdapterRegistry advisorAdapterRegistry = GlobalAdvisorAdapterRegistry.getInstance(); - /** - * Indicates whether the proxy should be frozen. Overridden from super - * to prevent the configuration from becoming frozen too early. - */ - private boolean freezeProxy = false; - /** Default is no common interceptors. */ private String[] interceptorNames = new String[0]; @@ -141,22 +135,6 @@ public abstract class AbstractAutoProxyCreator extends ProxyProcessorSupport private final Map advisedBeans = new ConcurrentHashMap<>(256); - /** - * Set whether the proxy should be frozen, preventing advice - * from being added to it once it is created. - *

    Overridden from the superclass to prevent the proxy configuration - * from being frozen before the proxy is created. - */ - @Override - public void setFrozen(boolean frozen) { - this.freezeProxy = frozen; - } - - @Override - public boolean isFrozen() { - return this.freezeProxy; - } - /** * Specify the {@link AdvisorAdapterRegistry} to use. *

    Default is the global {@link AdvisorAdapterRegistry}. @@ -206,6 +184,7 @@ public void setApplyCommonInterceptorsFirst(boolean applyCommonInterceptorsFirst @Override public void setBeanFactory(BeanFactory beanFactory) { this.beanFactory = beanFactory; + AutoProxyUtils.applyDefaultProxyConfig(this, beanFactory); } /** @@ -471,6 +450,24 @@ private Object buildProxy(Class beanClass, @Nullable String beanName, ProxyFactory proxyFactory = new ProxyFactory(); proxyFactory.copyFrom(this); + proxyFactory.setFrozen(false); + + if (shouldProxyTargetClass(beanClass, beanName)) { + proxyFactory.setProxyTargetClass(true); + } + else { + Class[] ifcs = (this.beanFactory instanceof ConfigurableListableBeanFactory clbf ? + AutoProxyUtils.determineExposedInterfaces(clbf, beanName) : null); + if (ifcs != null) { + proxyFactory.setProxyTargetClass(false); + for (Class ifc : ifcs) { + proxyFactory.addInterface(ifc); + } + } + else if (!proxyFactory.isProxyTargetClass()) { + evaluateProxyInterfaces(beanClass, proxyFactory); + } + } if (proxyFactory.isProxyTargetClass()) { // Explicit handling of JDK proxy targets and lambdas (for introduction advice scenarios) @@ -481,22 +478,13 @@ private Object buildProxy(Class beanClass, @Nullable String beanName, } } } - else { - // No proxyTargetClass flag enforced, let's apply our default checks... - if (shouldProxyTargetClass(beanClass, beanName)) { - proxyFactory.setProxyTargetClass(true); - } - else { - evaluateProxyInterfaces(beanClass, proxyFactory); - } - } Advisor[] advisors = buildAdvisors(beanName, specificInterceptors); proxyFactory.addAdvisors(advisors); proxyFactory.setTargetSource(targetSource); customizeProxyFactory(proxyFactory); - proxyFactory.setFrozen(this.freezeProxy); + proxyFactory.setFrozen(isFrozen()); if (advisorsPreFiltered()) { proxyFactory.setPreFiltered(true); } diff --git a/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractBeanFactoryAwareAdvisingPostProcessor.java b/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractBeanFactoryAwareAdvisingPostProcessor.java index c9d07a1fdf8d..256bdd5c9d56 100644 --- a/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractBeanFactoryAwareAdvisingPostProcessor.java +++ b/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractBeanFactoryAwareAdvisingPostProcessor.java @@ -25,9 +25,9 @@ import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; /** - * Extension of {@link AbstractAutoProxyCreator} which implements {@link BeanFactoryAware}, - * adds exposure of the original target class for each proxied bean - * ({@link AutoProxyUtils#ORIGINAL_TARGET_CLASS_ATTRIBUTE}), + * Extension of {@link AbstractAdvisingBeanPostProcessor} which implements + * {@link BeanFactoryAware}, adds exposure of the original target class for each + * proxied bean ({@link AutoProxyUtils#ORIGINAL_TARGET_CLASS_ATTRIBUTE}), * and participates in an externally enforced target-class mode for any given bean * ({@link AutoProxyUtils#PRESERVE_TARGET_CLASS_ATTRIBUTE}). * This post-processor is therefore aligned with {@link AbstractAutoProxyCreator}. @@ -47,6 +47,7 @@ public abstract class AbstractBeanFactoryAwareAdvisingPostProcessor extends Abst @Override public void setBeanFactory(BeanFactory beanFactory) { this.beanFactory = (beanFactory instanceof ConfigurableListableBeanFactory clbf ? clbf : null); + AutoProxyUtils.applyDefaultProxyConfig(this, beanFactory); } @Override @@ -56,9 +57,19 @@ protected ProxyFactory prepareProxyFactory(Object bean, String beanName) { } ProxyFactory proxyFactory = super.prepareProxyFactory(bean, beanName); - if (!proxyFactory.isProxyTargetClass() && this.beanFactory != null && - AutoProxyUtils.shouldProxyTargetClass(this.beanFactory, beanName)) { - proxyFactory.setProxyTargetClass(true); + if (this.beanFactory != null) { + if (AutoProxyUtils.shouldProxyTargetClass(this.beanFactory, beanName)) { + proxyFactory.setProxyTargetClass(true); + } + else { + Class[] ifcs = AutoProxyUtils.determineExposedInterfaces(this.beanFactory, beanName); + if (ifcs != null) { + proxyFactory.setProxyTargetClass(false); + for (Class ifc : ifcs) { + proxyFactory.addInterface(ifc); + } + } + } } return proxyFactory; } diff --git a/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AutoProxyUtils.java b/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AutoProxyUtils.java index b73b9abd5bbf..3522bfd8b668 100644 --- a/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AutoProxyUtils.java +++ b/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AutoProxyUtils.java @@ -18,6 +18,8 @@ import org.jspecify.annotations.Nullable; +import org.springframework.aop.framework.ProxyConfig; +import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.config.AutowireCapableBeanFactory; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; @@ -31,9 +33,37 @@ * @author Juergen Hoeller * @since 2.0.3 * @see AbstractAutoProxyCreator + * @see AbstractBeanFactoryAwareAdvisingPostProcessor */ public abstract class AutoProxyUtils { + /** + * The bean name of the internally managed auto-proxy creator. + * @since 7.0 + */ + public static final String DEFAULT_PROXY_CONFIG_BEAN_NAME = + "org.springframework.aop.framework.autoproxy.defaultProxyConfig"; + + /** + * Bean definition attribute that may indicate the interfaces to be proxied + * (in case of it getting proxied in the first place). The value is either + * a single interface {@code Class} or an array of {@code Class}, with an + * empty array specifically signalling that all implemented interfaces need + * to be proxied. + * @since 7.0 + * @see #determineExposedInterfaces + */ + public static final String EXPOSED_INTERFACES_ATTRIBUTE = + Conventions.getQualifiedAttributeName(AutoProxyUtils.class, "exposedInterfaces"); + + /** + * Attribute value for specifically signalling that all implemented interfaces + * need to be proxied (through an empty {@code Class} array). + * @since 7.0 + * @see #EXPOSED_INTERFACES_ATTRIBUTE + */ + public static final Object ALL_INTERFACES_ATTRIBUTE_VALUE = new Class[0]; + /** * Bean definition attribute that may indicate whether a given bean is supposed * to be proxied with its target class (in case of it getting proxied in the first @@ -57,6 +87,47 @@ public abstract class AutoProxyUtils { Conventions.getQualifiedAttributeName(AutoProxyUtils.class, "originalTargetClass"); + /** + * Apply default ProxyConfig settings to the given ProxyConfig instance, if necessary. + * @param proxyConfig the current ProxyConfig instance + * @param beanFactory the BeanFactory to take the default ProxyConfig from + * @since 7.0 + * @see #DEFAULT_PROXY_CONFIG_BEAN_NAME + * @see ProxyConfig#copyDefault + */ + static void applyDefaultProxyConfig(ProxyConfig proxyConfig, BeanFactory beanFactory) { + if (beanFactory.containsBean(DEFAULT_PROXY_CONFIG_BEAN_NAME)) { + ProxyConfig defaultProxyConfig = beanFactory.getBean(DEFAULT_PROXY_CONFIG_BEAN_NAME, ProxyConfig.class); + proxyConfig.copyDefault(defaultProxyConfig); + } + } + + /** + * Determine the specific interfaces for proxying the given bean, if any. + * Checks the {@link #EXPOSED_INTERFACES_ATTRIBUTE "exposedInterfaces" attribute} + * of the corresponding bean definition. + * @param beanFactory the containing ConfigurableListableBeanFactory + * @param beanName the name of the bean + * @return whether the given bean should be proxied with its target class + * @since 7.0 + * @see #EXPOSED_INTERFACES_ATTRIBUTE + */ + static Class @Nullable [] determineExposedInterfaces( + ConfigurableListableBeanFactory beanFactory, @Nullable String beanName) { + + if (beanName != null && beanFactory.containsBeanDefinition(beanName)) { + BeanDefinition bd = beanFactory.getBeanDefinition(beanName); + Object interfaces = bd.getAttribute(EXPOSED_INTERFACES_ATTRIBUTE); + if (interfaces instanceof Class[] ifcs) { + return ifcs; + } + else if (interfaces instanceof Class ifc) { + return new Class[] {ifc}; + } + } + return null; + } + /** * Determine whether the given bean should be proxied with its target * class rather than its interfaces. Checks the @@ -65,6 +136,7 @@ public abstract class AutoProxyUtils { * @param beanFactory the containing ConfigurableListableBeanFactory * @param beanName the name of the bean * @return whether the given bean should be proxied with its target class + * @see #PRESERVE_TARGET_CLASS_ATTRIBUTE */ public static boolean shouldProxyTargetClass( ConfigurableListableBeanFactory beanFactory, @Nullable String beanName) { diff --git a/spring-context/src/test/java/org/springframework/resilience/RetryInterceptorTests.java b/spring-context/src/test/java/org/springframework/resilience/RetryInterceptorTests.java index 21577a7edcb2..ab0a4feea7ee 100644 --- a/spring-context/src/test/java/org/springframework/resilience/RetryInterceptorTests.java +++ b/spring-context/src/test/java/org/springframework/resilience/RetryInterceptorTests.java @@ -26,7 +26,10 @@ import org.junit.jupiter.api.Test; import org.springframework.aop.framework.AopProxyUtils; +import org.springframework.aop.framework.ProxyConfig; import org.springframework.aop.framework.ProxyFactory; +import org.springframework.aop.framework.autoproxy.AutoProxyUtils; +import org.springframework.aop.support.AopUtils; import org.springframework.beans.factory.support.DefaultListableBeanFactory; import org.springframework.beans.factory.support.RootBeanDefinition; import org.springframework.context.annotation.AnnotationConfigApplicationContext; @@ -76,6 +79,78 @@ void withPostProcessorForMethod() { assertThat(target.counter).isEqualTo(6); } + @Test + void withPostProcessorForMethodWithInterface() { + DefaultListableBeanFactory bf = new DefaultListableBeanFactory(); + bf.registerBeanDefinition("bean", new RootBeanDefinition(AnnotatedMethodBeanWithInterface.class)); + RetryAnnotationBeanPostProcessor bpp = new RetryAnnotationBeanPostProcessor(); + bpp.setBeanFactory(bf); + bf.addBeanPostProcessor(bpp); + AnnotatedInterface proxy = bf.getBean(AnnotatedInterface.class); + AnnotatedMethodBeanWithInterface target = (AnnotatedMethodBeanWithInterface) AopProxyUtils.getSingletonTarget(proxy); + + assertThat(AopUtils.isJdkDynamicProxy(proxy)).isTrue(); + assertThatIOException().isThrownBy(proxy::retryOperation).withMessage("6"); + assertThat(target.counter).isEqualTo(6); + } + + @Test + void withPostProcessorForMethodWithInterfaceAndDefaultTargetClass() { + ProxyConfig defaultProxyConfig = new ProxyConfig(); + defaultProxyConfig.setProxyTargetClass(true); + + DefaultListableBeanFactory bf = new DefaultListableBeanFactory(); + bf.registerSingleton(AutoProxyUtils.DEFAULT_PROXY_CONFIG_BEAN_NAME, defaultProxyConfig); + bf.registerBeanDefinition("bean", new RootBeanDefinition(AnnotatedMethodBeanWithInterface.class)); + RetryAnnotationBeanPostProcessor bpp = new RetryAnnotationBeanPostProcessor(); + bpp.setBeanFactory(bf); + bf.addBeanPostProcessor(bpp); + AnnotatedInterface proxy = bf.getBean(AnnotatedInterface.class); + AnnotatedMethodBeanWithInterface target = (AnnotatedMethodBeanWithInterface) AopProxyUtils.getSingletonTarget(proxy); + + assertThat(AopUtils.isCglibProxy(proxy)).isTrue(); + assertThatIOException().isThrownBy(proxy::retryOperation).withMessage("6"); + assertThat(target.counter).isEqualTo(6); + } + + @Test + void withPostProcessorForMethodWithInterfaceAndPreserveTargetClass() { + DefaultListableBeanFactory bf = new DefaultListableBeanFactory(); + RootBeanDefinition bd = new RootBeanDefinition(AnnotatedMethodBeanWithInterface.class); + bd.setAttribute(AutoProxyUtils.PRESERVE_TARGET_CLASS_ATTRIBUTE, Boolean.TRUE); + bf.registerBeanDefinition("bean", bd); + RetryAnnotationBeanPostProcessor bpp = new RetryAnnotationBeanPostProcessor(); + bpp.setBeanFactory(bf); + bf.addBeanPostProcessor(bpp); + AnnotatedInterface proxy = bf.getBean(AnnotatedInterface.class); + AnnotatedMethodBeanWithInterface target = (AnnotatedMethodBeanWithInterface) AopProxyUtils.getSingletonTarget(proxy); + + assertThat(AopUtils.isCglibProxy(proxy)).isTrue(); + assertThatIOException().isThrownBy(proxy::retryOperation).withMessage("6"); + assertThat(target.counter).isEqualTo(6); + } + + @Test + void withPostProcessorForMethodWithInterfaceAndExposeInterfaces() { + ProxyConfig defaultProxyConfig = new ProxyConfig(); + defaultProxyConfig.setProxyTargetClass(true); + + DefaultListableBeanFactory bf = new DefaultListableBeanFactory(); + bf.registerSingleton(AutoProxyUtils.DEFAULT_PROXY_CONFIG_BEAN_NAME, defaultProxyConfig); + RootBeanDefinition bd = new RootBeanDefinition(AnnotatedMethodBeanWithInterface.class); + bd.setAttribute(AutoProxyUtils.EXPOSED_INTERFACES_ATTRIBUTE, AutoProxyUtils.ALL_INTERFACES_ATTRIBUTE_VALUE); + bf.registerBeanDefinition("bean", bd); + RetryAnnotationBeanPostProcessor bpp = new RetryAnnotationBeanPostProcessor(); + bpp.setBeanFactory(bf); + bf.addBeanPostProcessor(bpp); + AnnotatedInterface proxy = bf.getBean(AnnotatedInterface.class); + AnnotatedMethodBeanWithInterface target = (AnnotatedMethodBeanWithInterface) AopProxyUtils.getSingletonTarget(proxy); + + assertThat(AopUtils.isJdkDynamicProxy(proxy)).isTrue(); + assertThatIOException().isThrownBy(proxy::retryOperation).withMessage("6"); + assertThat(target.counter).isEqualTo(6); + } + @Test void withPostProcessorForClass() { DefaultListableBeanFactory bf = new DefaultListableBeanFactory(); @@ -160,6 +235,26 @@ public void retryOperation() throws IOException { } + static class AnnotatedMethodBeanWithInterface implements AnnotatedInterface { + + int counter = 0; + + @Retryable(maxAttempts = 5, delay = 10) + @Override + public void retryOperation() throws IOException { + counter++; + throw new IOException(Integer.toString(counter)); + } + } + + + interface AnnotatedInterface { + + @Retryable(maxAttempts = 5, delay = 10) + void retryOperation() throws IOException; + } + + @Retryable(delay = 10, jitter = 5, multiplier = 2.0, maxDelay = 40, includes = IOException.class, excludes = AccessDeniedException.class, predicate = CustomPredicate.class) From df86a9973db6e88c12771003c4a4c667ad5b47ad Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Wed, 6 Aug 2025 18:26:40 +0200 Subject: [PATCH 054/591] Introduce @Proxyable annotation for bean-specific proxy type Closes gh-35296 See gh-35293 --- .../autoproxy/AbstractAutoProxyCreator.java | 2 +- .../annotation/AnnotationConfigUtils.java | 15 ++++ .../example/scannable/FooServiceImpl.java | 3 +- .../example/scannable/OtherFooService.java | 46 ++++++++++ ...anningCandidateComponentProviderTests.java | 31 ++++--- .../EnableAspectJAutoProxyTests.java | 4 +- ...figurationClassAspectIntegrationTests.java | 89 ++++++++++++++++++- .../example/scannable/spring.components | 7 +- 8 files changed, 175 insertions(+), 22 deletions(-) create mode 100644 spring-context/src/test/java/example/scannable/OtherFooService.java diff --git a/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractAutoProxyCreator.java b/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractAutoProxyCreator.java index 8e621fd2139a..ccdf91877228 100644 --- a/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractAutoProxyCreator.java +++ b/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractAutoProxyCreator.java @@ -464,7 +464,7 @@ private Object buildProxy(Class beanClass, @Nullable String beanName, proxyFactory.addInterface(ifc); } } - else if (!proxyFactory.isProxyTargetClass()) { + if (ifcs != null ? ifcs.length == 0 : !proxyFactory.isProxyTargetClass()) { evaluateProxyInterfaces(beanClass, proxyFactory); } } diff --git a/spring-context/src/main/java/org/springframework/context/annotation/AnnotationConfigUtils.java b/spring-context/src/main/java/org/springframework/context/annotation/AnnotationConfigUtils.java index bc83fecad7ba..7c116824bd61 100644 --- a/spring-context/src/main/java/org/springframework/context/annotation/AnnotationConfigUtils.java +++ b/spring-context/src/main/java/org/springframework/context/annotation/AnnotationConfigUtils.java @@ -22,6 +22,7 @@ import org.jspecify.annotations.Nullable; +import org.springframework.aop.framework.autoproxy.AutoProxyUtils; import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition; import org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor; import org.springframework.beans.factory.config.BeanDefinition; @@ -258,6 +259,20 @@ else if (abd.getMetadata() != metadata) { if (description != null) { abd.setDescription(description.getString("value")); } + + AnnotationAttributes proxyable = attributesFor(metadata, Proxyable.class); + if (proxyable != null) { + ProxyType mode = proxyable.getEnum("value"); + if (mode == ProxyType.TARGET_CLASS) { + abd.setAttribute(AutoProxyUtils.PRESERVE_TARGET_CLASS_ATTRIBUTE, Boolean.TRUE); + } + else { + Class[] ifcs = proxyable.getClassArray("interfaces"); + if (ifcs.length > 0 || mode == ProxyType.INTERFACES) { + abd.setAttribute(AutoProxyUtils.EXPOSED_INTERFACES_ATTRIBUTE, ifcs); + } + } + } } static BeanDefinitionHolder applyScopedProxyMode( diff --git a/spring-context/src/test/java/example/scannable/FooServiceImpl.java b/spring-context/src/test/java/example/scannable/FooServiceImpl.java index 8e8c3b09d815..441d5282cb30 100644 --- a/spring-context/src/test/java/example/scannable/FooServiceImpl.java +++ b/spring-context/src/test/java/example/scannable/FooServiceImpl.java @@ -33,6 +33,7 @@ import org.springframework.context.MessageSource; import org.springframework.context.annotation.DependsOn; import org.springframework.context.annotation.Lazy; +import org.springframework.context.annotation.Primary; import org.springframework.context.support.AbstractApplicationContext; import org.springframework.core.io.ResourceLoader; import org.springframework.core.io.support.ResourcePatternResolver; @@ -43,7 +44,7 @@ * @author Mark Fisher * @author Juergen Hoeller */ -@Service @Lazy @DependsOn("myNamedComponent") +@Service @Primary @Lazy @DependsOn("myNamedComponent") public abstract class FooServiceImpl implements FooService { // Just to test ASM5's bytecode parsing of INVOKESPECIAL/STATIC on interfaces diff --git a/spring-context/src/test/java/example/scannable/OtherFooService.java b/spring-context/src/test/java/example/scannable/OtherFooService.java new file mode 100644 index 000000000000..23fe99961b94 --- /dev/null +++ b/spring-context/src/test/java/example/scannable/OtherFooService.java @@ -0,0 +1,46 @@ +/* + * Copyright 2002-present 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 example.scannable; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; + +import org.springframework.context.annotation.Proxyable; +import org.springframework.stereotype.Service; + +/** + * @author Juergen Hoeller + */ +@Service @Proxyable(interfaces = FooService.class) +public class OtherFooService implements FooService { + + @Override + public String foo(int id) { + return "" + id; + } + + @Override + public Future asyncFoo(int id) { + return CompletableFuture.completedFuture("" + id); + } + + @Override + public boolean isInitCalled() { + return false; + } + +} diff --git a/spring-context/src/test/java/org/springframework/context/annotation/ClassPathScanningCandidateComponentProviderTests.java b/spring-context/src/test/java/org/springframework/context/annotation/ClassPathScanningCandidateComponentProviderTests.java index 144834bd8957..c6ef88e0a8ab 100644 --- a/spring-context/src/test/java/org/springframework/context/annotation/ClassPathScanningCandidateComponentProviderTests.java +++ b/spring-context/src/test/java/org/springframework/context/annotation/ClassPathScanningCandidateComponentProviderTests.java @@ -41,6 +41,7 @@ import example.scannable.MessageBean; import example.scannable.NamedComponent; import example.scannable.NamedStubDao; +import example.scannable.OtherFooService; import example.scannable.ScopedProxyTestBean; import example.scannable.ServiceInvocationCounter; import example.scannable.StubFooDao; @@ -85,13 +86,13 @@ class ClassPathScanningCandidateComponentProviderTests { private static final Set> springComponents = Set.of( DefaultNamedComponent.class, - NamedComponent.class, FooServiceImpl.class, - StubFooDao.class, + NamedComponent.class, NamedStubDao.class, + OtherFooService.class, ServiceInvocationCounter.class, - BarComponent.class - ); + StubFooDao.class, + BarComponent.class); @Test @@ -213,7 +214,8 @@ private void testCustomAssignableTypeIncludeFilter(ClassPathScanningCandidateCom Set candidates = provider.findCandidateComponents(TEST_BASE_PACKAGE); assertScannedBeanDefinitions(candidates); // Interfaces/Abstract class are filtered out automatically. - assertBeanTypes(candidates, AutowiredQualifierFooService.class, FooServiceImpl.class, ScopedProxyTestBean.class); + assertBeanTypes(candidates, + AutowiredQualifierFooService.class, FooServiceImpl.class, OtherFooService.class, ScopedProxyTestBean.class); } @Test @@ -237,7 +239,8 @@ private void testCustomSupportedIncludeAndExcludeFilter(ClassPathScanningCandida provider.addExcludeFilter(new AnnotationTypeFilter(Repository.class)); Set candidates = provider.findCandidateComponents(TEST_BASE_PACKAGE); assertScannedBeanDefinitions(candidates); - assertBeanTypes(candidates, NamedComponent.class, ServiceInvocationCounter.class, BarComponent.class); + assertBeanTypes(candidates, + NamedComponent.class, ServiceInvocationCounter.class, BarComponent.class); } @Test @@ -282,7 +285,8 @@ void excludeFilterWithIndex() { private void testExclude(ClassPathScanningCandidateComponentProvider provider) { Set candidates = provider.findCandidateComponents(TEST_BASE_PACKAGE); assertScannedBeanDefinitions(candidates); - assertBeanTypes(candidates, FooServiceImpl.class, StubFooDao.class, ServiceInvocationCounter.class, + assertBeanTypes(candidates, + FooServiceImpl.class, OtherFooService.class, ServiceInvocationCounter.class, StubFooDao.class, BarComponent.class); } @@ -301,7 +305,8 @@ void withComponentAnnotationOnly() { provider.addExcludeFilter(new AnnotationTypeFilter(Service.class)); provider.addExcludeFilter(new AnnotationTypeFilter(Controller.class)); Set candidates = provider.findCandidateComponents(TEST_BASE_PACKAGE); - assertBeanTypes(candidates, NamedComponent.class, ServiceInvocationCounter.class, BarComponent.class); + assertBeanTypes(candidates, + NamedComponent.class, ServiceInvocationCounter.class, BarComponent.class); } @Test @@ -334,8 +339,9 @@ void withMultipleMatchingFilters() { provider.addIncludeFilter(new AnnotationTypeFilter(Component.class)); provider.addIncludeFilter(new AssignableTypeFilter(FooServiceImpl.class)); Set candidates = provider.findCandidateComponents(TEST_BASE_PACKAGE); - assertBeanTypes(candidates, NamedComponent.class, ServiceInvocationCounter.class, FooServiceImpl.class, - BarComponent.class, DefaultNamedComponent.class, NamedStubDao.class, StubFooDao.class); + assertBeanTypes(candidates, + DefaultNamedComponent.class, FooServiceImpl.class, NamedComponent.class, NamedStubDao.class, + OtherFooService.class, ServiceInvocationCounter.class, StubFooDao.class, BarComponent.class); } @Test @@ -345,8 +351,9 @@ void excludeTakesPrecedence() { provider.addIncludeFilter(new AssignableTypeFilter(FooServiceImpl.class)); provider.addExcludeFilter(new AssignableTypeFilter(FooService.class)); Set candidates = provider.findCandidateComponents(TEST_BASE_PACKAGE); - assertBeanTypes(candidates, NamedComponent.class, ServiceInvocationCounter.class, BarComponent.class, - DefaultNamedComponent.class, NamedStubDao.class, StubFooDao.class); + assertBeanTypes(candidates, + DefaultNamedComponent.class, NamedComponent.class, NamedStubDao.class, + ServiceInvocationCounter.class, StubFooDao.class, BarComponent.class); } @Test diff --git a/spring-context/src/test/java/org/springframework/context/annotation/EnableAspectJAutoProxyTests.java b/spring-context/src/test/java/org/springframework/context/annotation/EnableAspectJAutoProxyTests.java index 556d0b91fa52..1f758bfff609 100644 --- a/spring-context/src/test/java/org/springframework/context/annotation/EnableAspectJAutoProxyTests.java +++ b/spring-context/src/test/java/org/springframework/context/annotation/EnableAspectJAutoProxyTests.java @@ -47,6 +47,7 @@ void withJdkProxy() { aspectIsApplied(ctx); assertThat(AopUtils.isJdkDynamicProxy(ctx.getBean(FooService.class))).isTrue(); + assertThat(AopUtils.isJdkDynamicProxy(ctx.getBean("otherFooService"))).isTrue(); ctx.close(); } @@ -56,6 +57,7 @@ void withCglibProxy() { aspectIsApplied(ctx); assertThat(AopUtils.isCglibProxy(ctx.getBean(FooService.class))).isTrue(); + assertThat(AopUtils.isJdkDynamicProxy(ctx.getBean("otherFooService"))).isTrue(); ctx.close(); } @@ -124,7 +126,7 @@ static class ConfigWithCglibProxy { } - @Import({ ServiceInvocationCounter.class, StubFooDao.class }) + @Import({ServiceInvocationCounter.class, StubFooDao.class}) @EnableAspectJAutoProxy(exposeProxy = true) static class ConfigWithExposedProxy { diff --git a/spring-context/src/test/java/org/springframework/context/annotation/configuration/ConfigurationClassAspectIntegrationTests.java b/spring-context/src/test/java/org/springframework/context/annotation/configuration/ConfigurationClassAspectIntegrationTests.java index 884b830695f5..867a48479323 100644 --- a/spring-context/src/test/java/org/springframework/context/annotation/configuration/ConfigurationClassAspectIntegrationTests.java +++ b/spring-context/src/test/java/org/springframework/context/annotation/configuration/ConfigurationClassAspectIntegrationTests.java @@ -22,9 +22,13 @@ import org.aspectj.lang.annotation.Before; import org.junit.jupiter.api.Test; +import org.springframework.aop.framework.autoproxy.AbstractAutoProxyCreator; +import org.springframework.beans.factory.support.AbstractBeanDefinition; import org.springframework.beans.factory.support.DefaultListableBeanFactory; import org.springframework.beans.factory.support.RootBeanDefinition; import org.springframework.beans.factory.xml.XmlBeanDefinitionReader; +import org.springframework.beans.testfixture.beans.IOther; +import org.springframework.beans.testfixture.beans.ITestBean; import org.springframework.beans.testfixture.beans.TestBean; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.AnnotationConfigApplicationContext; @@ -32,10 +36,13 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.ConfigurationClassPostProcessor; import org.springframework.context.annotation.EnableAspectJAutoProxy; +import org.springframework.context.annotation.Proxyable; import org.springframework.context.support.GenericApplicationContext; import org.springframework.core.io.ClassPathResource; import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.context.annotation.ProxyType.INTERFACES; +import static org.springframework.context.annotation.ProxyType.TARGET_CLASS; /** * System tests covering use of AspectJ {@link Aspect}s in conjunction with {@link Configuration} classes. @@ -62,18 +69,40 @@ void configurationIncludesAspect() { assertAdviceWasApplied(ConfigurationWithAspect.class); } - private void assertAdviceWasApplied(Class configClass) { + @Test + void configurationIncludesAspectAndProxyable() { + assertAdviceWasApplied(ConfigurationWithAspectAndProxyable.class, TestBean.class); + } + + @Test + void configurationIncludesAspectAndProxyableInterfaces() { + assertAdviceWasApplied(ConfigurationWithAspectAndProxyableInterfaces.class, TestBean.class, Comparable.class); + } + + @Test + void configurationIncludesAspectAndProxyableTargetClass() { + assertAdviceWasApplied(ConfigurationWithAspectAndProxyableTargetClass.class); + } + + private void assertAdviceWasApplied(Class configClass, Class... notImplemented) { DefaultListableBeanFactory factory = new DefaultListableBeanFactory(); new XmlBeanDefinitionReader(factory).loadBeanDefinitions( new ClassPathResource("aspectj-autoproxy-config.xml", ConfigurationClassAspectIntegrationTests.class)); GenericApplicationContext ctx = new GenericApplicationContext(factory); ctx.addBeanFactoryPostProcessor(new ConfigurationClassPostProcessor()); - ctx.registerBeanDefinition("config", new RootBeanDefinition(configClass)); + ctx.registerBeanDefinition("config", + new RootBeanDefinition(configClass, AbstractBeanDefinition.AUTOWIRE_CONSTRUCTOR, false)); ctx.refresh(); - TestBean testBean = ctx.getBean("testBean", TestBean.class); + ITestBean testBean = ctx.getBean("testBean", ITestBean.class); + if (notImplemented.length > 0) { + assertThat(testBean).isNotInstanceOfAny(notImplemented); + } + else { + assertThat(testBean).isInstanceOf(TestBean.class); + } assertThat(testBean.getName()).isEqualTo("name"); - testBean.absquatulate(); + ((IOther) testBean).absquatulate(); assertThat(testBean.getName()).isEqualTo("advisedName"); ctx.close(); } @@ -120,6 +149,58 @@ public NameChangingAspect nameChangingAspect() { } + @Configuration + static class ConfigurationWithAspectAndProxyable { + + @Bean + @Proxyable(INTERFACES) + public TestBean testBean() { + return new TestBean("name"); + } + + @Bean + public NameChangingAspect nameChangingAspect() { + return new NameChangingAspect(); + } + } + + + @Configuration() + static class ConfigurationWithAspectAndProxyableInterfaces { + + @Bean + @Proxyable(interfaces = {ITestBean.class, IOther.class}) + public TestBean testBean() { + return new TestBean("name"); + } + + @Bean + public NameChangingAspect nameChangingAspect() { + return new NameChangingAspect(); + } + } + + + @Configuration + static class ConfigurationWithAspectAndProxyableTargetClass { + + public ConfigurationWithAspectAndProxyableTargetClass(AbstractAutoProxyCreator autoProxyCreator) { + autoProxyCreator.setProxyTargetClass(false); + } + + @Bean + @Proxyable(TARGET_CLASS) + public TestBean testBean() { + return new TestBean("name"); + } + + @Bean + public NameChangingAspect nameChangingAspect() { + return new NameChangingAspect(); + } + } + + @Aspect static class NameChangingAspect { diff --git a/spring-context/src/test/resources/example/scannable/spring.components b/spring-context/src/test/resources/example/scannable/spring.components index 8c298cd44d96..c56e0854cb83 100644 --- a/spring-context/src/test/resources/example/scannable/spring.components +++ b/spring-context/src/test/resources/example/scannable/spring.components @@ -1,12 +1,13 @@ example.scannable.AutowiredQualifierFooService=example.scannable.FooService example.scannable.DefaultNamedComponent=org.springframework.stereotype.Component -example.scannable.NamedComponent=org.springframework.stereotype.Component example.scannable.FooService=example.scannable.FooService example.scannable.FooServiceImpl=org.springframework.stereotype.Component,example.scannable.FooService -example.scannable.ScopedProxyTestBean=example.scannable.FooService -example.scannable.StubFooDao=org.springframework.stereotype.Component +example.scannable.NamedComponent=org.springframework.stereotype.Component example.scannable.NamedStubDao=org.springframework.stereotype.Component +example.scannable.OtherFooService=org.springframework.stereotype.Component,example.scannable.FooService +example.scannable.ScopedProxyTestBean=example.scannable.FooService example.scannable.ServiceInvocationCounter=org.springframework.stereotype.Component +example.scannable.StubFooDao=org.springframework.stereotype.Component example.scannable.sub.BarComponent=org.springframework.stereotype.Component example.scannable.JakartaManagedBeanComponent=jakarta.annotation.ManagedBean example.scannable.JakartaNamedComponent=jakarta.inject.Named From d5408c047dad533fcbee31d20a168cdba5a85850 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Wed, 6 Aug 2025 18:32:01 +0200 Subject: [PATCH 055/591] Introduce @Proxyable annotation for bean-specific proxy type Closes gh-35296 See gh-35293 --- .../context/annotation/ProxyType.java | 45 ++++++++++++++ .../context/annotation/Proxyable.java | 58 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 spring-context/src/main/java/org/springframework/context/annotation/ProxyType.java create mode 100644 spring-context/src/main/java/org/springframework/context/annotation/Proxyable.java diff --git a/spring-context/src/main/java/org/springframework/context/annotation/ProxyType.java b/spring-context/src/main/java/org/springframework/context/annotation/ProxyType.java new file mode 100644 index 000000000000..e733b8b27efc --- /dev/null +++ b/spring-context/src/main/java/org/springframework/context/annotation/ProxyType.java @@ -0,0 +1,45 @@ +/* + * Copyright 2002-present 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.context.annotation; + +/** + * Common enum for indicating a desired proxy type. + * + * @author Juergen Hoeller + * @since 7.0 + * @see Proxyable#value() + */ +public enum ProxyType { + + /** + * Default is a JDK dynamic proxy, or potentially a class-based CGLIB proxy + * when globally configured. + */ + DEFAULT, + + /** + * Suggest a JDK dynamic proxy implementing all interfaces exposed by + * the class of the target object. Overrides a globally configured default. + */ + INTERFACES, + + /** + * Suggest a class-based CGLIB proxy. Overrides a globally configured default. + */ + TARGET_CLASS + +} diff --git a/spring-context/src/main/java/org/springframework/context/annotation/Proxyable.java b/spring-context/src/main/java/org/springframework/context/annotation/Proxyable.java new file mode 100644 index 000000000000..623364ea2d3a --- /dev/null +++ b/spring-context/src/main/java/org/springframework/context/annotation/Proxyable.java @@ -0,0 +1,58 @@ +/* + * Copyright 2002-present 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.context.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Common annotation for suggesting a specific proxy type for a {@link Bean @Bean} + * method or {@link org.springframework.stereotype.Component @Component} class, + * overriding a globally configured default. + * + *

    Only actually applying in case of a bean actually getting auto-proxied in + * the first place. Actual auto-proxying is dependent on external configuration. + * + * @author Juergen Hoeller + * @since 7.0 + * @see org.springframework.aop.framework.autoproxy.AutoProxyUtils#PRESERVE_TARGET_CLASS_ATTRIBUTE + * @see org.springframework.aop.framework.autoproxy.AutoProxyUtils#EXPOSED_INTERFACES_ATTRIBUTE + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Proxyable { + + /** + * Suggest a specific proxy type, either {@link ProxyType#INTERFACES} for + * a JDK dynamic proxy or {@link ProxyType#TARGET_CLASS} for a CGLIB proxy, + * overriding a globally configured default. + */ + ProxyType value() default ProxyType.DEFAULT; + + /** + * Suggest a JDK dynamic proxy with specific interfaces to expose, overriding + * a globally configured default. + *

    Only taken into account if {@link #value()} is not {@link ProxyType#TARGET_CLASS}. + */ + Class[] interfaces() default {}; + + +} From 5df9fd4eff5e0c4534b889b70f80414813807c50 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Wed, 6 Aug 2025 19:01:18 +0200 Subject: [PATCH 056/591] Polishing (aligned with main) --- .../aop/config/AopConfigUtils.java | 10 +++---- .../AbstractAdvisingBeanPostProcessor.java | 1 + ...BeanFactoryAwareAdvisingPostProcessor.java | 6 ++--- .../context/annotation/ReflectiveScan.java | 2 +- .../web/util/RfcUriParser.java | 26 +++++++++++++++---- 5 files changed, 31 insertions(+), 14 deletions(-) diff --git a/spring-aop/src/main/java/org/springframework/aop/config/AopConfigUtils.java b/spring-aop/src/main/java/org/springframework/aop/config/AopConfigUtils.java index 8e4b99c293b5..325a6e532870 100644 --- a/spring-aop/src/main/java/org/springframework/aop/config/AopConfigUtils.java +++ b/spring-aop/src/main/java/org/springframework/aop/config/AopConfigUtils.java @@ -121,12 +121,12 @@ private static BeanDefinition registerOrEscalateApcAsRequired( Assert.notNull(registry, "BeanDefinitionRegistry must not be null"); if (registry.containsBeanDefinition(AUTO_PROXY_CREATOR_BEAN_NAME)) { - BeanDefinition apcDefinition = registry.getBeanDefinition(AUTO_PROXY_CREATOR_BEAN_NAME); - if (!cls.getName().equals(apcDefinition.getBeanClassName())) { - int currentPriority = findPriorityForClass(apcDefinition.getBeanClassName()); + BeanDefinition beanDefinition = registry.getBeanDefinition(AUTO_PROXY_CREATOR_BEAN_NAME); + if (!cls.getName().equals(beanDefinition.getBeanClassName())) { + int currentPriority = findPriorityForClass(beanDefinition.getBeanClassName()); int requiredPriority = findPriorityForClass(cls); if (currentPriority < requiredPriority) { - apcDefinition.setBeanClassName(cls.getName()); + beanDefinition.setBeanClassName(cls.getName()); } } return null; @@ -134,8 +134,8 @@ private static BeanDefinition registerOrEscalateApcAsRequired( RootBeanDefinition beanDefinition = new RootBeanDefinition(cls); beanDefinition.setSource(source); - beanDefinition.getPropertyValues().add("order", Ordered.HIGHEST_PRECEDENCE); beanDefinition.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); + beanDefinition.getPropertyValues().add("order", Ordered.HIGHEST_PRECEDENCE); registry.registerBeanDefinition(AUTO_PROXY_CREATOR_BEAN_NAME, beanDefinition); return beanDefinition; } diff --git a/spring-aop/src/main/java/org/springframework/aop/framework/AbstractAdvisingBeanPostProcessor.java b/spring-aop/src/main/java/org/springframework/aop/framework/AbstractAdvisingBeanPostProcessor.java index 196db14cfa9f..c2bef3c19d86 100644 --- a/spring-aop/src/main/java/org/springframework/aop/framework/AbstractAdvisingBeanPostProcessor.java +++ b/spring-aop/src/main/java/org/springframework/aop/framework/AbstractAdvisingBeanPostProcessor.java @@ -117,6 +117,7 @@ else if (advised.getTargetSource() == AdvisedSupport.EMPTY_TARGET_SOURCE && } proxyFactory.addAdvisor(this.advisor); customizeProxyFactory(proxyFactory); + proxyFactory.setPreFiltered(true); // Use original ClassLoader if bean class not locally loaded in overriding class loader ClassLoader classLoader = getProxyClassLoader(); diff --git a/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractBeanFactoryAwareAdvisingPostProcessor.java b/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractBeanFactoryAwareAdvisingPostProcessor.java index fd5f98aa1be8..f456ed39241d 100644 --- a/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractBeanFactoryAwareAdvisingPostProcessor.java +++ b/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractBeanFactoryAwareAdvisingPostProcessor.java @@ -24,9 +24,9 @@ import org.springframework.lang.Nullable; /** - * Extension of {@link AbstractAutoProxyCreator} which implements {@link BeanFactoryAware}, - * adds exposure of the original target class for each proxied bean - * ({@link AutoProxyUtils#ORIGINAL_TARGET_CLASS_ATTRIBUTE}), + * Extension of {@link AbstractAdvisingBeanPostProcessor} which implements + * {@link BeanFactoryAware}, adds exposure of the original target class for each + * proxied bean ({@link AutoProxyUtils#ORIGINAL_TARGET_CLASS_ATTRIBUTE}), * and participates in an externally enforced target-class mode for any given bean * ({@link AutoProxyUtils#PRESERVE_TARGET_CLASS_ATTRIBUTE}). * This post-processor is therefore aligned with {@link AbstractAutoProxyCreator}. diff --git a/spring-context/src/main/java/org/springframework/context/annotation/ReflectiveScan.java b/spring-context/src/main/java/org/springframework/context/annotation/ReflectiveScan.java index c71da8589e77..b83bff6ef9a5 100644 --- a/spring-context/src/main/java/org/springframework/context/annotation/ReflectiveScan.java +++ b/spring-context/src/main/java/org/springframework/context/annotation/ReflectiveScan.java @@ -51,9 +51,9 @@ * ignored. * * @author Stephane Nicoll + * @since 6.2 * @see Reflective @Reflective * @see RegisterReflection @RegisterReflection - * @since 6.2 */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) diff --git a/spring-web/src/main/java/org/springframework/web/util/RfcUriParser.java b/spring-web/src/main/java/org/springframework/web/util/RfcUriParser.java index b1c71154d850..ec0b7f629b9f 100644 --- a/spring-web/src/main/java/org/springframework/web/util/RfcUriParser.java +++ b/spring-web/src/main/java/org/springframework/web/util/RfcUriParser.java @@ -30,7 +30,6 @@ * * @author Rossen Stoyanchev * @since 6.2 - * * @see RFC 3986 */ abstract class RfcUriParser { @@ -78,10 +77,10 @@ private static void fail(InternalParser parser, String message) { * @param query the query, if present * @param fragment the fragment, if present */ - record UriRecord(@Nullable String scheme, boolean isOpaque, - @Nullable String user, @Nullable String host, @Nullable String port, - @Nullable String path, @Nullable String query, @Nullable String fragment) { - + record UriRecord( + @Nullable String scheme, boolean isOpaque, + @Nullable String user, @Nullable String host, @Nullable String port, + @Nullable String path, @Nullable String query, @Nullable String fragment) { } @@ -130,6 +129,7 @@ public void handleEnd(InternalParser parser) { } }, + HOST_OR_PATH { @Override @@ -158,6 +158,7 @@ public void handleEnd(InternalParser parser) { } }, + SCHEME_OR_PATH { @Override @@ -188,6 +189,7 @@ public void handleEnd(InternalParser parser) { } }, + HOST { @Override @@ -229,6 +231,7 @@ public void handleEnd(InternalParser parser) { } }, + IPV6 { @Override @@ -259,6 +262,7 @@ public void handleEnd(InternalParser parser) { } }, + PORT { @Override @@ -291,6 +295,7 @@ public void handleEnd(InternalParser parser) { } }, + PATH { @Override @@ -319,6 +324,7 @@ public void handleEnd(InternalParser parser) { } }, + QUERY { @Override @@ -334,7 +340,9 @@ public void handleEnd(InternalParser parser) { } }, + FRAGMENT { + @Override public void handleNext(InternalParser parser, char c, int i) { } @@ -345,6 +353,7 @@ public void handleEnd(InternalParser parser) { } }, + WILDCARD { @Override @@ -358,6 +367,7 @@ public void handleEnd(InternalParser parser) { } }; + /** * Method to handle each character from the input string. * @param parser provides access to parsing state, and helper methods @@ -429,6 +439,7 @@ public InternalParser(String uri) { this.uri = uri; } + // Check internal state public boolean hasScheme() { @@ -451,6 +462,7 @@ public boolean isAtStartOfComponent() { return (this.index == this.componentIndex); } + // Top-level parse loop, iterate over chars and delegate to states public UriRecord parse() { @@ -475,6 +487,7 @@ public char charAtIndex() { return this.uri.charAt(this.index); } + // Transitions and index updates public void advanceTo(State state) { @@ -500,6 +513,7 @@ public void index(int index) { this.index = index; } + // Component capture public InternalParser resolveIfOpaque() { @@ -593,6 +607,7 @@ public InternalParser markPercentEncoding() { return this; } + // Encoding and curly bracket handling /** @@ -643,6 +658,7 @@ else if (c == '}') { return (this.openCurlyBracketCount > 0); } + @Override public String toString() { return "[State=" + this.state + ", index=" + this.index + ", componentIndex=" + this.componentIndex + From 2f262afc5151b7c77028123784b5a0e98bf2056c Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Wed, 6 Aug 2025 21:11:07 +0200 Subject: [PATCH 057/591] Add documentation section on proxy type defaults and @Proxyable See gh-35286 See gh-35296 --- .../modules/ROOT/pages/core/aop/proxying.adoc | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/framework-docs/modules/ROOT/pages/core/aop/proxying.adoc b/framework-docs/modules/ROOT/pages/core/aop/proxying.adoc index 58d150a4c8d2..429e5d6e7ef1 100644 --- a/framework-docs/modules/ROOT/pages/core/aop/proxying.adoc +++ b/framework-docs/modules/ROOT/pages/core/aop/proxying.adoc @@ -28,6 +28,10 @@ you can do so. However, you should consider the following issues: deploying on the module path. Such cases require a JVM bootstrap flag `--add-opens=java.base/java.lang=ALL-UNNAMED` which is not available for modules. + +[[aop-forcing-proxy-types]] +== Forcing Specific AOP Proxy Types + To force the use of CGLIB proxies, set the value of the `proxy-target-class` attribute of the `` element to true, as follows: @@ -60,6 +64,24 @@ To be clear, using `proxy-target-class="true"` on ``, proxies _for all three of them_. ==== +`@EnableAspectJAutoProxy`, `@EnableTransactionManagement` and related configuration +annotations offer a corresponding `proxyTargetClass` attribute. These are collapsed +into a single unified auto-proxy creator too, effectively applying the _strongest_ +proxy settings at runtime. As of 7.0, this applies to individual proxy processors +as well, for example `@EnableAsync`, consistently participating in unified global +default settings for all auto-proxying attempts in a given application. + +The global default proxy type may differ between setups. While the core framework +suggests interface-based proxies by default, Spring Boot may - depending on +configuration properties - enable class-based proxies by default. + +As of 7.0, forcing a specific proxy type for individual beans is possible through +the `@Proxyable` annotation on a given `@Bean` method or `@Component` class, with +`@Proxyable(INTERFACES)` or `@Proxyable(TARGET_CLASS)` overriding any globally +configured default. For very specific purposes, you may even specify the proxy +interface(s) to use through `@Proxyable(interfaces=...)`, limiting the exposure +to selected interfaces rather than all interfaces that the target bean implements. + [[aop-understanding-aop-proxies]] == Understanding AOP Proxies From 7a55ce48a93f004c7acc0b51c04bb39be2c6b733 Mon Sep 17 00:00:00 2001 From: giampaolo Date: Sat, 5 Apr 2025 14:19:06 +0200 Subject: [PATCH 058/591] Handle CancellationException in JdkClientHttpRequest Handle CancellationException in order to throw an HttpTimeoutException when the timeout handler caused the cancellation. See gh-34721 Signed-off-by: giampaolo fix: use timeoutHandler with a flag isTimeout Closes gh-33973 Signed-off-by: giampaolo --- .../http/client/JdkClientHttpRequest.java | 21 +++- .../http/client/JdkClientHttpRequestTest.java | 117 ++++++++++++++++++ 2 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTest.java diff --git a/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java b/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java index e2955266ab36..9f5f7740ecdd 100644 --- a/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java @@ -37,6 +37,7 @@ import java.util.concurrent.Executor; import java.util.concurrent.Flow; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -97,12 +98,13 @@ public URI getURI() { @SuppressWarnings("NullAway") protected ClientHttpResponse executeInternal(HttpHeaders headers, @Nullable Body body) throws IOException { CompletableFuture> responseFuture = null; + TimeoutHandler timeoutHandler = null; try { HttpRequest request = buildRequest(headers, body); responseFuture = this.httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()); if (this.timeout != null) { - TimeoutHandler timeoutHandler = new TimeoutHandler(responseFuture, this.timeout); + timeoutHandler = new TimeoutHandler(responseFuture, this.timeout); HttpResponse response = responseFuture.get(); InputStream inputStream = timeoutHandler.wrapInputStream(response); return new JdkClientHttpResponse(response, inputStream); @@ -121,7 +123,10 @@ protected ClientHttpResponse executeInternal(HttpHeaders headers, @Nullable Body Throwable cause = ex.getCause(); if (cause instanceof CancellationException) { - throw new HttpTimeoutException("Request timed out"); + if (timeoutHandler != null && timeoutHandler.isTimeout()) { + throw new HttpTimeoutException("Request timed out"); + } + throw new IOException("Request was cancelled"); } if (cause instanceof UncheckedIOException uioEx) { throw uioEx.getCause(); @@ -136,6 +141,12 @@ else if (cause instanceof IOException ioEx) { throw new IOException(cause.getMessage(), cause); } } + catch (CancellationException ex) { + if (timeoutHandler != null && timeoutHandler.isTimeout()) { + throw new HttpTimeoutException("Request timed out"); + } + throw new IOException("Request was cancelled"); + } } private HttpRequest buildRequest(HttpHeaders headers, @Nullable Body body) { @@ -233,6 +244,7 @@ public ByteBuffer map(byte[] b, int off, int len) { private static final class TimeoutHandler { private final CompletableFuture timeoutFuture; + private final AtomicBoolean isTimeout = new AtomicBoolean(false); private TimeoutHandler(CompletableFuture> future, Duration timeout) { @@ -241,6 +253,7 @@ private TimeoutHandler(CompletableFuture> future, Dura this.timeoutFuture.thenRun(() -> { if (future.cancel(true) || future.isCompletedExceptionally() || !future.isDone()) { + isTimeout.set(true); return; } try { @@ -268,6 +281,10 @@ public void close() throws IOException { } }; } + + public boolean isTimeout() { + return isTimeout.get(); + } } } diff --git a/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTest.java b/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTest.java new file mode 100644 index 000000000000..86630e1fd37c --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTest.java @@ -0,0 +1,117 @@ +package org.springframework.http.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.time.Duration; +import java.util.concurrent.*; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; + +class JdkClientHttpRequestTest { + + private HttpClient mockHttpClient; + private URI uri = URI.create("http://example.com"); + private HttpMethod method = HttpMethod.GET; + + private ExecutorService executor; + + @BeforeEach + void setup() { + mockHttpClient = mock(HttpClient.class); + executor = Executors.newSingleThreadExecutor(); + } + + @AfterEach + void tearDown() { + executor.shutdownNow(); + } + + @Test + void executeInternal_withTimeout_shouldThrowHttpTimeoutException() throws Exception { + Duration timeout = Duration.ofMillis(10); + + JdkClientHttpRequest request = new JdkClientHttpRequest(mockHttpClient, uri, method, executor, timeout); + + CompletableFuture> future = new CompletableFuture<>(); + + when(mockHttpClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(future); + + HttpHeaders headers = new HttpHeaders(); + + CountDownLatch startLatch = new CountDownLatch(1); + + // Cancellation thread waits for startLatch, then cancels the future after a delay + Thread canceller = new Thread(() -> { + try { + startLatch.await(); + Thread.sleep(500); + future.cancel(true); + } catch (InterruptedException ignored) { + } + }); + canceller.start(); + + IOException ex = assertThrows(IOException.class, () -> { + startLatch.countDown(); + request.executeInternal(headers, null); + }); + + assertThat(ex) + .isInstanceOf(HttpTimeoutException.class) + .hasMessage("Request timed out"); + + canceller.join(); + } + + @Test + void executeInternal_withTimeout_shouldThrowIOException() throws Exception { + Duration timeout = Duration.ofMillis(500); + + JdkClientHttpRequest request = new JdkClientHttpRequest(mockHttpClient, uri, method, executor, timeout); + + CompletableFuture> future = new CompletableFuture<>(); + + when(mockHttpClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(future); + + HttpHeaders headers = new HttpHeaders(); + + CountDownLatch startLatch = new CountDownLatch(1); + + Thread canceller = new Thread(() -> { + try { + startLatch.await(); + Thread.sleep(10); + future.cancel(true); + } catch (InterruptedException ignored) { + } + }); + canceller.start(); + + IOException ex = assertThrows(IOException.class, () -> { + startLatch.countDown(); + request.executeInternal(headers, null); + }); + + assertThat(ex) + .isInstanceOf(IOException.class) + .hasMessage("Request was cancelled"); + + canceller.join(); + } + +} From 600d6c6fc0c345d2226aa16c3329ffb077fc9189 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Fri, 8 Aug 2025 11:30:57 +0100 Subject: [PATCH 059/591] Update contribution Closes gh-34721 --- .../http/client/JdkClientHttpRequest.java | 26 ++-- .../http/client/JdkClientHttpRequestTest.java | 117 ------------------ .../client/JdkClientHttpRequestTests.java | 87 +++++++++++++ 3 files changed, 101 insertions(+), 129 deletions(-) delete mode 100644 spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTest.java create mode 100644 spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTests.java diff --git a/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java b/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java index 9f5f7740ecdd..0d14667f83a5 100644 --- a/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java @@ -122,11 +122,11 @@ protected ClientHttpResponse executeInternal(HttpHeaders headers, @Nullable Body catch (ExecutionException ex) { Throwable cause = ex.getCause(); - if (cause instanceof CancellationException) { - if (timeoutHandler != null && timeoutHandler.isTimeout()) { - throw new HttpTimeoutException("Request timed out"); + if (cause instanceof CancellationException ce) { + if (timeoutHandler != null) { + timeoutHandler.handleCancellationException(ce); } - throw new IOException("Request was cancelled"); + throw new IOException("Request cancelled", cause); } if (cause instanceof UncheckedIOException uioEx) { throw uioEx.getCause(); @@ -142,10 +142,10 @@ else if (cause instanceof IOException ioEx) { } } catch (CancellationException ex) { - if (timeoutHandler != null && timeoutHandler.isTimeout()) { - throw new HttpTimeoutException("Request timed out"); + if (timeoutHandler != null) { + timeoutHandler.handleCancellationException(ex); } - throw new IOException("Request was cancelled"); + throw new IOException("Request cancelled", ex); } } @@ -244,7 +244,8 @@ public ByteBuffer map(byte[] b, int off, int len) { private static final class TimeoutHandler { private final CompletableFuture timeoutFuture; - private final AtomicBoolean isTimeout = new AtomicBoolean(false); + + private final AtomicBoolean timeout = new AtomicBoolean(false); private TimeoutHandler(CompletableFuture> future, Duration timeout) { @@ -252,8 +253,8 @@ private TimeoutHandler(CompletableFuture> future, Dura .completeOnTimeout(null, timeout.toMillis(), TimeUnit.MILLISECONDS); this.timeoutFuture.thenRun(() -> { + this.timeout.set(true); if (future.cancel(true) || future.isCompletedExceptionally() || !future.isDone()) { - isTimeout.set(true); return; } try { @@ -263,7 +264,6 @@ private TimeoutHandler(CompletableFuture> future, Dura // ignore } }); - } @Nullable @@ -282,8 +282,10 @@ public void close() throws IOException { }; } - public boolean isTimeout() { - return isTimeout.get(); + public void handleCancellationException(CancellationException ex) throws HttpTimeoutException { + if (this.timeout.get()) { + throw new HttpTimeoutException(ex.getMessage()); + } } } diff --git a/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTest.java b/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTest.java deleted file mode 100644 index 86630e1fd37c..000000000000 --- a/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTest.java +++ /dev/null @@ -1,117 +0,0 @@ -package org.springframework.http.client; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -import java.io.IOException; -import java.io.InputStream; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.net.http.HttpTimeoutException; -import java.time.Duration; -import java.util.concurrent.*; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; - -class JdkClientHttpRequestTest { - - private HttpClient mockHttpClient; - private URI uri = URI.create("http://example.com"); - private HttpMethod method = HttpMethod.GET; - - private ExecutorService executor; - - @BeforeEach - void setup() { - mockHttpClient = mock(HttpClient.class); - executor = Executors.newSingleThreadExecutor(); - } - - @AfterEach - void tearDown() { - executor.shutdownNow(); - } - - @Test - void executeInternal_withTimeout_shouldThrowHttpTimeoutException() throws Exception { - Duration timeout = Duration.ofMillis(10); - - JdkClientHttpRequest request = new JdkClientHttpRequest(mockHttpClient, uri, method, executor, timeout); - - CompletableFuture> future = new CompletableFuture<>(); - - when(mockHttpClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenReturn(future); - - HttpHeaders headers = new HttpHeaders(); - - CountDownLatch startLatch = new CountDownLatch(1); - - // Cancellation thread waits for startLatch, then cancels the future after a delay - Thread canceller = new Thread(() -> { - try { - startLatch.await(); - Thread.sleep(500); - future.cancel(true); - } catch (InterruptedException ignored) { - } - }); - canceller.start(); - - IOException ex = assertThrows(IOException.class, () -> { - startLatch.countDown(); - request.executeInternal(headers, null); - }); - - assertThat(ex) - .isInstanceOf(HttpTimeoutException.class) - .hasMessage("Request timed out"); - - canceller.join(); - } - - @Test - void executeInternal_withTimeout_shouldThrowIOException() throws Exception { - Duration timeout = Duration.ofMillis(500); - - JdkClientHttpRequest request = new JdkClientHttpRequest(mockHttpClient, uri, method, executor, timeout); - - CompletableFuture> future = new CompletableFuture<>(); - - when(mockHttpClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenReturn(future); - - HttpHeaders headers = new HttpHeaders(); - - CountDownLatch startLatch = new CountDownLatch(1); - - Thread canceller = new Thread(() -> { - try { - startLatch.await(); - Thread.sleep(10); - future.cancel(true); - } catch (InterruptedException ignored) { - } - }); - canceller.start(); - - IOException ex = assertThrows(IOException.class, () -> { - startLatch.countDown(); - request.executeInternal(headers, null); - }); - - assertThat(ex) - .isInstanceOf(IOException.class) - .hasMessage("Request was cancelled"); - - canceller.join(); - } - -} diff --git a/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTests.java b/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTests.java new file mode 100644 index 000000000000..1cf59ca59380 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTests.java @@ -0,0 +1,87 @@ +/* + * Copyright 2002-present 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.http.client; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link JdkClientHttpRequest}. + */ +class JdkClientHttpRequestTests { + + private final HttpClient client = mock(HttpClient.class); + + private ExecutorService executor; + + + @BeforeEach + void setup() { + executor = Executors.newSingleThreadExecutor(); + } + + @AfterEach + void tearDown() { + executor.shutdownNow(); + } + + + @Test + void futureCancelledAfterTimeout() { + CompletableFuture> future = new CompletableFuture<>(); + when(client.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))).thenReturn(future); + + assertThatThrownBy(() -> createRequest(Duration.ofMillis(10)).executeInternal(new HttpHeaders(), null)) + .isExactlyInstanceOf(HttpTimeoutException.class); + } + + @Test + void futureCancelled() { + CompletableFuture> future = new CompletableFuture<>(); + future.cancel(true); + when(client.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))).thenReturn(future); + + assertThatThrownBy(() -> createRequest(null).executeInternal(new HttpHeaders(), null)) + .isExactlyInstanceOf(IOException.class); + } + + private JdkClientHttpRequest createRequest(Duration timeout) { + return new JdkClientHttpRequest(client, URI.create("http://abc.com"), HttpMethod.GET, executor, timeout); + } + +} From f0a9f649c16218b5fe90969d33472e6a89689516 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Fri, 8 Aug 2025 11:36:53 +0100 Subject: [PATCH 060/591] Allow null in ProblemDetail#type See gh-35294 --- .../src/main/java/org/springframework/http/ProblemDetail.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/http/ProblemDetail.java b/spring-web/src/main/java/org/springframework/http/ProblemDetail.java index f24351ca09f7..65968b53a9fa 100644 --- a/spring-web/src/main/java/org/springframework/http/ProblemDetail.java +++ b/spring-web/src/main/java/org/springframework/http/ProblemDetail.java @@ -110,7 +110,6 @@ protected ProblemDetail() { * @param type the problem type */ public void setType(URI type) { - Assert.notNull(type, "'type' is required"); this.type = type; } @@ -251,7 +250,7 @@ public Map getProperties() { @Override public boolean equals(@Nullable Object other) { return (this == other || (other instanceof ProblemDetail that && - getType().equals(that.getType()) && + ObjectUtils.nullSafeEquals(getType(), that.getType()) && ObjectUtils.nullSafeEquals(getTitle(), that.getTitle()) && this.status == that.status && ObjectUtils.nullSafeEquals(this.detail, that.detail) && From 968e037503319511e06403cbbd28efe2d394cfd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=88=A9=E6=96=8C?= <68638598+Allan-QLB@users.noreply.github.com> Date: Tue, 22 Jul 2025 23:34:14 +0800 Subject: [PATCH 061/591] Add documentation of RequestMapping about SpEL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 秦利斌 <68638598+Allan-QLB@users.noreply.github.com> --- .../ROOT/pages/web/webflux/controller/ann-requestmapping.adoc | 2 +- .../pages/web/webmvc/mvc-controller/ann-requestmapping.adoc | 2 +- .../springframework/web/bind/annotation/RequestMapping.java | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc index 8230b453bbe4..532e6250fe9b 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc @@ -237,7 +237,7 @@ Kotlin:: URI path patterns can also have embedded `${...}` placeholders that are resolved on startup by using `PropertySourcesPlaceholderConfigurer` against local, system, environment, and other property sources. You can use this, for example, to parameterize a base URL based on -some external configuration. +some external configuration. SpEL expression `#{...}` is also supported in URI path pattern by default. NOTE: Spring WebFlux uses `PathPattern` and the `PathPatternParser` for URI path matching support. Both classes are located in `spring-web` and are expressly designed for use with HTTP URL diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc index 902a56ac7f4a..1d582f4f49a2 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc @@ -220,7 +220,7 @@ Kotlin:: URI path patterns can also have embedded `${...}` placeholders that are resolved on startup by using `PropertySourcesPlaceholderConfigurer` against local, system, environment, and other property sources. You can use this, for example, to parameterize a base URL based on -some external configuration. +some external configuration. SpEL expression `#{...}` is also supported in URI path pattern by default. [[mvc-ann-requestmapping-pattern-comparison]] diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java index a2f330b74b66..2f0b035eba27 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java @@ -109,12 +109,16 @@ * At the method level, relative paths (for example, {@code "edit"}) are supported * within the primary mapping expressed at the type level. * Path mapping URIs may contain placeholders (for example, "/${profile_path}"). + * By default, SpEL expression is also supported (for example {@code "/profile/#{@bean.property}"}). *

    Supported at the type level as well as at the method level! * When used at the type level, all method-level mappings inherit * this primary mapping, narrowing it for a specific handler method. *

    NOTE: A handler method that is not mapped to any path * explicitly is effectively mapped to an empty path. * @since 4.2 + * @see org.springframework.beans.factory.config.EmbeddedValueResolver + * @see org.springframework.context.expression.StandardBeanExpressionResolver + * @see org.springframework.context.support.AbstractApplicationContext */ @AliasFor("value") String[] path() default {}; From 6e2fbfe10813e671283ccacbd60096e2f90bdaad Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Fri, 8 Aug 2025 11:50:07 +0100 Subject: [PATCH 062/591] Polishing contribution Closes gh-35232 --- .../web/webflux/controller/ann-requestmapping.adoc | 11 +++++++---- .../web/webmvc/mvc-controller/ann-requestmapping.adoc | 11 +++++++---- .../web/bind/annotation/RequestMapping.java | 8 ++++---- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc index 532e6250fe9b..f15948bea144 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc @@ -234,10 +234,13 @@ Kotlin:: ====== -- -URI path patterns can also have embedded `${...}` placeholders that are resolved on startup -by using `PropertySourcesPlaceholderConfigurer` against local, system, environment, and -other property sources. You can use this, for example, to parameterize a base URL based on -some external configuration. SpEL expression `#{...}` is also supported in URI path pattern by default. +URI path patterns can also have: + +- Embedded `${...}` placeholders that are resolved on startup via +`PropertySourcesPlaceholderConfigurer` against local, system, environment, and +other property sources. This is useful, for example, to parameterize a base URL based on +external configuration. +- SpEL expressions `#{...}`. NOTE: Spring WebFlux uses `PathPattern` and the `PathPatternParser` for URI path matching support. Both classes are located in `spring-web` and are expressly designed for use with HTTP URL diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc index 1d582f4f49a2..64f5abec6c49 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc @@ -217,10 +217,13 @@ Kotlin:: ---- ====== -URI path patterns can also have embedded `${...}` placeholders that are resolved on startup -by using `PropertySourcesPlaceholderConfigurer` against local, system, environment, and -other property sources. You can use this, for example, to parameterize a base URL based on -some external configuration. SpEL expression `#{...}` is also supported in URI path pattern by default. +URI path patterns can also have: + +- Embedded `${...}` placeholders that are resolved on startup via +`PropertySourcesPlaceholderConfigurer` against local, system, environment, and +other property sources. This is useful, for example, to parameterize a base URL based on +external configuration. +- SpEL expression `#{...}`. [[mvc-ann-requestmapping-pattern-comparison]] diff --git a/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java b/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java index 2f0b035eba27..3e3dba15a02b 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java +++ b/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java @@ -105,11 +105,11 @@ /** * The path mapping URIs — for example, {@code "/profile"}. - *

    Ant-style path patterns are also supported (for example, {@code "/profile/**"}). - * At the method level, relative paths (for example, {@code "edit"}) are supported + *

    Ant-style path patterns are also supported, e.g. {@code "/profile/**"}. + * At the method level, relative paths, e.g., {@code "edit"} are supported * within the primary mapping expressed at the type level. - * Path mapping URIs may contain placeholders (for example, "/${profile_path}"). - * By default, SpEL expression is also supported (for example {@code "/profile/#{@bean.property}"}). + * Path mapping URIs may contain property placeholders, e.g. "/${profile_path}", + * and SpEL expressions, e.g. {@code "/profile/#{@bean.property}"}. *

    Supported at the type level as well as at the method level! * When used at the type level, all method-level mappings inherit * this primary mapping, narrowing it for a specific handler method. From 96deb272110a28ef48d585a84aea0ecda34fce8d Mon Sep 17 00:00:00 2001 From: Christoph Date: Wed, 6 Aug 2025 14:08:49 +0200 Subject: [PATCH 063/591] Make `type` in `ProblemDetail` nullable See gh-35294 Signed-off-by: Christoph Wagner Signed-off-by: Christoph --- .../java/org/springframework/http/ProblemDetail.java | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/http/ProblemDetail.java b/spring-web/src/main/java/org/springframework/http/ProblemDetail.java index ed094ebbb86e..3c58ba2a593d 100644 --- a/spring-web/src/main/java/org/springframework/http/ProblemDetail.java +++ b/spring-web/src/main/java/org/springframework/http/ProblemDetail.java @@ -55,10 +55,8 @@ public class ProblemDetail implements Serializable { private static final long serialVersionUID = 3307761915842206538L; - private static final URI BLANK_TYPE = URI.create("about:blank"); - - private URI type = BLANK_TYPE; + private @Nullable URI type; private @Nullable String title; @@ -104,17 +102,17 @@ protected ProblemDetail() { /** * Setter for the {@link #getType() problem type}. - *

    By default, this is {@link #BLANK_TYPE}. + *

    By default, this is not set. According to the spec, when not present, its value is assumed to be "about:blank" * @param type the problem type */ - public void setType(URI type) { + public void setType(@Nullable URI type) { this.type = type; } /** * Return the configured {@link #setType(URI) problem type}. */ - public URI getType() { + public @Nullable URI getType() { return this.type; } From 89ba0fd6df33bdf185020c52991334fc0269f624 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Fri, 8 Aug 2025 12:01:23 +0100 Subject: [PATCH 064/591] Polishing contribution Closes gh-35294 --- .../java/org/springframework/http/ProblemDetail.java | 3 ++- .../web/DefaultErrorResponseBuilder.java | 2 +- .../springframework/web/ErrorResponseException.java | 2 +- .../json/ProblemDetailJacksonMixinTests.java | 12 +++++------- .../web/reactive/DispatcherHandlerErrorTests.java | 3 +-- ...uestMappingExceptionHandlingIntegrationTests.java | 6 ++---- .../annotation/ResponseBodyResultHandlerTests.java | 3 +-- .../annotation/ResponseEntityResultHandlerTests.java | 6 ++---- .../RequestResponseBodyMethodProcessorTests.java | 2 -- .../ResourceHttpRequestHandlerIntegrationTests.java | 3 +-- 10 files changed, 16 insertions(+), 26 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/http/ProblemDetail.java b/spring-web/src/main/java/org/springframework/http/ProblemDetail.java index 3c58ba2a593d..ffdfd89ad060 100644 --- a/spring-web/src/main/java/org/springframework/http/ProblemDetail.java +++ b/spring-web/src/main/java/org/springframework/http/ProblemDetail.java @@ -102,7 +102,8 @@ protected ProblemDetail() { /** * Setter for the {@link #getType() problem type}. - *

    By default, this is not set. According to the spec, when not present, its value is assumed to be "about:blank" + *

    By default, this is not set. According to the spec, when not present, + * the type is assumed to be "about:blank" * @param type the problem type */ public void setType(@Nullable URI type) { diff --git a/spring-web/src/main/java/org/springframework/web/DefaultErrorResponseBuilder.java b/spring-web/src/main/java/org/springframework/web/DefaultErrorResponseBuilder.java index 9bf4a0ddd1fa..93f25b543b42 100644 --- a/spring-web/src/main/java/org/springframework/web/DefaultErrorResponseBuilder.java +++ b/spring-web/src/main/java/org/springframework/web/DefaultErrorResponseBuilder.java @@ -85,7 +85,7 @@ private HttpHeaders getHeaders() { } @Override - public ErrorResponse.Builder type(URI type) { + public ErrorResponse.Builder type(@Nullable URI type) { this.problemDetail.setType(type); return this; } diff --git a/spring-web/src/main/java/org/springframework/web/ErrorResponseException.java b/spring-web/src/main/java/org/springframework/web/ErrorResponseException.java index f4b29db0a669..5fc792a3980d 100644 --- a/spring-web/src/main/java/org/springframework/web/ErrorResponseException.java +++ b/spring-web/src/main/java/org/springframework/web/ErrorResponseException.java @@ -111,7 +111,7 @@ public HttpHeaders getHeaders() { * Set the {@link ProblemDetail#setType(URI) type} field of the response body. * @param type the problem type */ - public void setType(URI type) { + public void setType(@Nullable URI type) { this.body.setType(type); } diff --git a/spring-web/src/test/java/org/springframework/http/converter/json/ProblemDetailJacksonMixinTests.java b/spring-web/src/test/java/org/springframework/http/converter/json/ProblemDetailJacksonMixinTests.java index 7ab2faaa5521..4096c051d816 100644 --- a/spring-web/src/test/java/org/springframework/http/converter/json/ProblemDetailJacksonMixinTests.java +++ b/spring-web/src/test/java/org/springframework/http/converter/json/ProblemDetailJacksonMixinTests.java @@ -42,12 +42,11 @@ class ProblemDetailJacksonMixinTests { @Test - void writeStatusAndHeaders() throws Exception { + void writeStatusAndHeaders() { ProblemDetail detail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Missing header"); testWrite(detail, """ { - "type": "about:blank", "title": "Bad Request", "status": 400, "detail": "Missing header" @@ -55,14 +54,13 @@ void writeStatusAndHeaders() throws Exception { } @Test - void writeCustomProperty() throws Exception { + void writeCustomProperty() { ProblemDetail detail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Missing header"); detail.setProperty("host", "abc.org"); detail.setProperty("user", null); testWrite(detail, """ { - "type": "about:blank", "title": "Bad Request", "status": 400, "detail": "Missing header", @@ -72,7 +70,7 @@ void writeCustomProperty() throws Exception { } @Test - void readCustomProperty() throws Exception { + void readCustomProperty() { ProblemDetail detail = this.mapper.readValue(""" { "type": "about:blank", @@ -93,7 +91,7 @@ void readCustomProperty() throws Exception { } @Test - void readCustomPropertyFromXml() throws Exception { + void readCustomPropertyFromXml() { ObjectMapper xmlMapper = XmlMapper.builder().addMixIn(ProblemDetail.class, ProblemDetailJacksonMixin.class).build(); ProblemDetail detail = xmlMapper.readValue(""" @@ -111,7 +109,7 @@ void readCustomPropertyFromXml() throws Exception { assertThat(detail.getProperties()).containsEntry("host", "abc.org"); } - private void testWrite(ProblemDetail problemDetail, String expected) throws Exception { + private void testWrite(ProblemDetail problemDetail, String expected) { String output = this.mapper.writeValueAsString(problemDetail); JSONAssert.assertEquals(expected, output, false); } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java index 85747f9a5daa..aab6f80d9b4b 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java @@ -125,8 +125,7 @@ void noStaticResource() { "detail":"No static resource non-existing.",\ "instance":"\\/resources\\/non-existing",\ "status":404,\ - "title":"Not Found",\ - "type":"about:blank"}\ + "title":"Not Found"}\ """); } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingExceptionHandlingIntegrationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingExceptionHandlingIntegrationTests.java index 477a2caf37cc..1ed443e7e96f 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingExceptionHandlingIntegrationTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingExceptionHandlingIntegrationTests.java @@ -125,8 +125,7 @@ void globalExceptionHandlerWithHandlerNotFound() throws Exception { assertThat(ex.getResponseBodyAsString()).isEqualTo("{" + "\"instance\":\"\\/no-such-handler\"," + "\"status\":404," + - "\"title\":\"Not Found\"," + - "\"type\":\"about:blank\"}"); + "\"title\":\"Not Found\"}"); }); } @@ -142,8 +141,7 @@ void globalExceptionHandlerWithMissingRequestParameter() throws Exception { "\"detail\":\"Required query parameter 'q' is not present.\"," + "\"instance\":\"\\/missing-request-parameter\"," + "\"status\":400," + - "\"title\":\"Bad Request\"," + - "\"type\":\"about:blank\"}"); + "\"title\":\"Bad Request\"}"); }); } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandlerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandlerTests.java index 46c3613ec534..714c5cdbdc35 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandlerTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandlerTests.java @@ -152,8 +152,7 @@ private void testProblemDetailMediaType(MockServerWebExchange exchange, MediaTyp {\ "status":400,\ "instance":"\\/path",\ - "title":"Bad Request",\ - "type":"about:blank"\ + "title":"Bad Request"\ }"""); } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java index bc6b8f5cff32..9c3932fbb6de 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java @@ -244,8 +244,7 @@ void handleErrorResponse() { {\ "instance":"\\/path",\ "status":400,\ - "title":"Bad Request",\ - "type":"about:blank"\ + "title":"Bad Request"\ }"""); } @@ -265,8 +264,7 @@ void handleProblemDetail() { {\ "instance":"\\/path",\ "status":400,\ - "title":"Bad Request",\ - "type":"about:blank"\ + "title":"Bad Request"\ }"""); } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorTests.java index 2c1554ec023d..4f1eecd9a839 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorTests.java @@ -395,7 +395,6 @@ private void testProblemDetailMediaType(String expectedContentType) throws Excep 400 /path Bad Request - about:blank """) .ignoreWhitespace() .areIdentical(); @@ -403,7 +402,6 @@ private void testProblemDetailMediaType(String expectedContentType) throws Excep else { JSONAssert.assertEquals(""" { - "type": "about:blank", "title": "Bad Request", "status": 400, "instance": "/path" diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerIntegrationTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerIntegrationTests.java index 887658fd893c..39cc11763f79 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerIntegrationTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerIntegrationTests.java @@ -144,8 +144,7 @@ void testNoResourceFoundException() throws Exception { "detail":"No static resource non-existing.",\ "instance":"\\/cp\\/non-existing",\ "status":404,\ - "title":"Not Found",\ - "type":"about:blank"\ + "title":"Not Found"\ }\ """); } From ffc785471bbe579aaef282720baef00a44d46435 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Fri, 8 Aug 2025 12:31:13 +0100 Subject: [PATCH 065/591] Fix checkstyle error --- .../springframework/http/client/JdkClientHttpRequestTests.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTests.java b/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTests.java index 1cf59ca59380..b48a4d79f25f 100644 --- a/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTests.java +++ b/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTests.java @@ -81,7 +81,7 @@ void futureCancelled() { } private JdkClientHttpRequest createRequest(Duration timeout) { - return new JdkClientHttpRequest(client, URI.create("http://abc.com"), HttpMethod.GET, executor, timeout); + return new JdkClientHttpRequest(client, URI.create("https://abc.com"), HttpMethod.GET, executor, timeout); } } From f11a1e6f827ed7c1bfb18b8318c1f60f2634fc83 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Mon, 11 Aug 2025 11:27:10 +0300 Subject: [PATCH 066/591] Polish tests --- .../client/JdkClientHttpRequestTests.java | 21 ++++++------------- .../RequestMappingHandlerAdapterTests.java | 2 +- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTests.java b/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTests.java index b48a4d79f25f..300af1ea221c 100644 --- a/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTests.java +++ b/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTests.java @@ -28,15 +28,14 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AutoClose; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.any; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -47,21 +46,12 @@ class JdkClientHttpRequestTests { private final HttpClient client = mock(HttpClient.class); - private ExecutorService executor; - - - @BeforeEach - void setup() { - executor = Executors.newSingleThreadExecutor(); - } - - @AfterEach - void tearDown() { - executor.shutdownNow(); - } + @AutoClose("shutdownNow") + private final ExecutorService executor = Executors.newSingleThreadExecutor(); @Test + @SuppressWarnings("unchecked") void futureCancelledAfterTimeout() { CompletableFuture> future = new CompletableFuture<>(); when(client.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))).thenReturn(future); @@ -71,6 +61,7 @@ void futureCancelledAfterTimeout() { } @Test + @SuppressWarnings("unchecked") void futureCancelled() { CompletableFuture> future = new CompletableFuture<>(); future.cancel(true); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapterTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapterTests.java index f768d641e4ca..3e2940181cc4 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapterTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapterTests.java @@ -393,7 +393,7 @@ public String handle(Model model) { } - private static class SseController { + static class SseController { public ResponseEntity handle(@RequestParam String q) throws IOException { if (q.equals("sse")) { From 3781ba223ed76823b99e9c699e0957b391e22bf9 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Mon, 11 Aug 2025 14:32:39 +0200 Subject: [PATCH 067/591] Optimize NIO path resolution in PathEditor Closes gh-35304 --- .../org/springframework/beans/propertyeditors/PathEditor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spring-beans/src/main/java/org/springframework/beans/propertyeditors/PathEditor.java b/spring-beans/src/main/java/org/springframework/beans/propertyeditors/PathEditor.java index 3fc5065b5991..7b53e70ff3a0 100644 --- a/spring-beans/src/main/java/org/springframework/beans/propertyeditors/PathEditor.java +++ b/spring-beans/src/main/java/org/springframework/beans/propertyeditors/PathEditor.java @@ -103,7 +103,7 @@ public void setAsText(String text) throws IllegalArgumentException { if (resource == null) { setValue(null); } - else if (nioPathCandidate && !resource.exists()) { + else if (nioPathCandidate && (!resource.isFile() || !resource.exists())) { setValue(Paths.get(text).normalize()); } else { From a9453a59594dfb547a3411135dce210f6f9b1588 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Mon, 11 Aug 2025 14:32:45 +0200 Subject: [PATCH 068/591] Polishing --- .../pages/core/beans/classpath-scanning.adoc | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/core/beans/classpath-scanning.adoc b/framework-docs/modules/ROOT/pages/core/beans/classpath-scanning.adoc index 25a6570b1321..1ab948e144cd 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/classpath-scanning.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/classpath-scanning.adoc @@ -1,24 +1,26 @@ [[beans-classpath-scanning]] = Classpath Scanning and Managed Components -Most examples in this chapter use XML to specify the configuration metadata that produces -each `BeanDefinition` within the Spring container. The previous section -(xref:core/beans/annotation-config.adoc[Annotation-based Container Configuration]) demonstrates how to provide a lot of the configuration -metadata through source-level annotations. Even in those examples, however, the "base" -bean definitions are explicitly defined in the XML file, while the annotations drive only -the dependency injection. This section describes an option for implicitly detecting the -candidate components by scanning the classpath. Candidate components are classes that -match against a filter criteria and have a corresponding bean definition registered with -the container. This removes the need to use XML to perform bean registration. Instead, you -can use annotations (for example, `@Component`), AspectJ type expressions, or your own +Most examples in this chapter use XML to specify the configuration metadata that +produces each `BeanDefinition` within the Spring container. The previous section +(xref:core/beans/annotation-config.adoc[Annotation-based Container Configuration]) +demonstrates how to provide a lot of the configuration metadata through source-level +annotations. Even in those examples, however, the "base" bean definitions are explicitly +defined in the XML file, while the annotations drive only the dependency injection. + +This section describes an option for implicitly detecting the candidate components by +scanning the classpath. Candidate components are classes that match against a filter +criteria and have a corresponding bean definition registered with the container. +This removes the need to use XML to perform bean registration. Instead, you can use +annotations (for example, `@Component`), AspectJ type expressions, or your own custom filter criteria to select which classes have bean definitions registered with the container. [NOTE] ==== You can define beans using Java rather than using XML files. Take a look at the -`@Configuration`, `@Bean`, `@Import`, and `@DependsOn` annotations for examples of how to -use these features. +`@Configuration`, `@Bean`, `@Import`, and `@DependsOn` annotations for examples +of how to use these features. ==== @@ -830,10 +832,10 @@ definitions, there is no notion of bean definition inheritance, and inheritance hierarchies at the class level are irrelevant for metadata purposes. For details on web-specific scopes such as "`request`" or "`session`" in a Spring context, -see xref:core/beans/factory-scopes.adoc#beans-factory-scopes-other[Request, Session, Application, and WebSocket Scopes]. As with the pre-built annotations for those scopes, -you may also compose your own scoping annotations by using Spring's meta-annotation -approach: for example, a custom annotation meta-annotated with `@Scope("prototype")`, -possibly also declaring a custom scoped-proxy mode. +see xref:core/beans/factory-scopes.adoc#beans-factory-scopes-other[Request, Session, Application, and WebSocket Scopes]. +As with the pre-built annotations for those scopes, you may also compose your own scoping +annotations by using Spring's meta-annotation approach: for example, a custom annotation +meta-annotated with `@Scope("prototype")`, possibly also declaring a custom scoped-proxy mode. NOTE: To provide a custom strategy for scope resolution rather than relying on the annotation-based approach, you can implement the @@ -875,7 +877,8 @@ Kotlin:: ---- When using certain non-singleton scopes, it may be necessary to generate proxies for the -scoped objects. The reasoning is described in xref:core/beans/factory-scopes.adoc#beans-factory-scopes-other-injection[Scoped Beans as Dependencies]. +scoped objects. The reasoning is described in +xref:core/beans/factory-scopes.adoc#beans-factory-scopes-other-injection[Scoped Beans as Dependencies]. For this purpose, a scoped-proxy attribute is available on the component-scan element. The three possible values are: `no`, `interfaces`, and `targetClass`. For example, the following configuration results in standard JDK dynamic proxies: From 37b076be5121edbe0412f6b8ef190d595692b0e0 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Mon, 11 Aug 2025 15:22:48 +0300 Subject: [PATCH 069/591] Support multiple result sets in ScriptUtils.executeSqlScript() Prior to this commit, ScriptUtils.executeSqlScript() treated every statement within the script as if it were a single insert/update/delete statement. This disregarded the fact that the execution of a JDBC Statement can result in multiple individual statements, some of which result in a ResultSet and others that result in an update count. For example, when executing a stored procedure on Sybase, ScriptUtils did not execute all statements within the stored procedure. To address that, this commit revises the implementation of ScriptUtils.executeSqlScript() so that it handles multiple results and differentiates between result sets and update counts. Closes gh-35248 --- .../jdbc/datasource/init/ScriptUtils.java | 41 ++++++++--- .../simple/JdbcClientIntegrationTests.java | 10 +-- .../SimpleJdbcInsertIntegrationTests.java | 2 +- .../init/ScriptUtilsIntegrationTests.java | 69 ++++++++++++++++++- .../init/users-schema-with-custom-schema.sql | 2 +- .../jdbc/datasource/init/users-schema.sql | 2 +- 6 files changed, 106 insertions(+), 20 deletions(-) diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/init/ScriptUtils.java b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/init/ScriptUtils.java index b93ab0b6a92b..ec40441a7aaa 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/init/ScriptUtils.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/datasource/init/ScriptUtils.java @@ -258,18 +258,29 @@ public static void executeSqlScript(Connection connection, EncodedResource resou for (String statement : statements) { stmtNumber++; try { - stmt.execute(statement); - int rowsAffected = stmt.getUpdateCount(); + boolean hasResultSet = stmt.execute(statement); + int updateCount = -1; if (logger.isDebugEnabled()) { - logger.debug(rowsAffected + " returned as update count for SQL: " + statement); - SQLWarning warningToLog = stmt.getWarnings(); - while (warningToLog != null) { - logger.debug("SQLWarning ignored: SQL state '" + warningToLog.getSQLState() + - "', error code '" + warningToLog.getErrorCode() + - "', message [" + warningToLog.getMessage() + "]"); - warningToLog = warningToLog.getNextWarning(); - } + logSqlWarnings(stmt); } + do { + if (hasResultSet) { + // We invoke getResultSet() to ensure the JDBC driver processes + // it, but we intentionally ignore the returned ResultSet since + // we cannot do anything meaningful with it here. + stmt.getResultSet(); + if (logger.isDebugEnabled()) { + logger.debug("ResultSet returned for SQL: " + statement); + } + } + else { + updateCount = stmt.getUpdateCount(); + if (updateCount >= 0 && logger.isDebugEnabled()) { + logger.debug(updateCount + " returned as update count for SQL: " + statement); + } + } + hasResultSet = stmt.getMoreResults(); + } while (hasResultSet || updateCount != -1); } catch (SQLException ex) { boolean dropStatement = StringUtils.startsWithIgnoreCase(statement.trim(), "drop"); @@ -307,6 +318,16 @@ public static void executeSqlScript(Connection connection, EncodedResource resou } } + private static void logSqlWarnings(Statement stmt) throws SQLException { + SQLWarning warningToLog = stmt.getWarnings(); + while (warningToLog != null) { + logger.debug("SQLWarning ignored: SQL state '" + warningToLog.getSQLState() + + "', error code '" + warningToLog.getErrorCode() + + "', message [" + warningToLog.getMessage() + "]"); + warningToLog = warningToLog.getNextWarning(); + } + } + /** * Read a script from the provided resource, using the supplied comment prefixes * and statement separator, and build a {@code String} containing the lines. diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/JdbcClientIntegrationTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/JdbcClientIntegrationTests.java index fd4daf2064df..55130eb1ec9b 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/JdbcClientIntegrationTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/JdbcClientIntegrationTests.java @@ -74,7 +74,7 @@ void shutdownDatabase() { @Test void updateWithGeneratedKeys() { - int expectedId = 2; + int expectedId = 1; String firstName = "Jane"; String lastName = "Smith"; @@ -92,7 +92,7 @@ void updateWithGeneratedKeys() { @Test void updateWithGeneratedKeysAndKeyColumnNames() { - int expectedId = 2; + int expectedId = 1; String firstName = "Jane"; String lastName = "Smith"; @@ -110,7 +110,7 @@ void updateWithGeneratedKeysAndKeyColumnNames() { @Test void updateWithGeneratedKeysUsingNamedParameters() { - int expectedId = 2; + int expectedId = 1; String firstName = "Jane"; String lastName = "Smith"; @@ -129,7 +129,7 @@ void updateWithGeneratedKeysUsingNamedParameters() { @Test void updateWithGeneratedKeysAndKeyColumnNamesUsingNamedParameters() { - int expectedId = 2; + int expectedId = 1; String firstName = "Jane"; String lastName = "Smith"; @@ -217,7 +217,7 @@ void selectWithReusedNamedParameterListFromBeanProperties() { private static void assertResults(List users) { - assertThat(users).containsExactly(new User(2, "John", "John"), new User(3, "John", "Smith")); + assertThat(users).containsExactly(new User(1, "John", "John"), new User(2, "John", "Smith")); } record Name(String name) {} diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/SimpleJdbcInsertIntegrationTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/SimpleJdbcInsertIntegrationTests.java index 02928652e530..720fbb330a42 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/SimpleJdbcInsertIntegrationTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/SimpleJdbcInsertIntegrationTests.java @@ -323,7 +323,7 @@ protected void assertNumRows(long count) { protected void insertJaneSmith(SimpleJdbcInsert insert) { Number id = insert.executeAndReturnKey(Map.of("first_name", "Jane", "last_name", "Smith")); - assertThat(id.intValue()).isEqualTo(2); + assertThat(id.intValue()).isEqualTo(1); assertNumRows(2); } diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/init/ScriptUtilsIntegrationTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/init/ScriptUtilsIntegrationTests.java index 8eb91b296893..f59db0e6a46b 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/init/ScriptUtilsIntegrationTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/datasource/init/ScriptUtilsIntegrationTests.java @@ -16,13 +16,24 @@ package org.springframework.jdbc.datasource.init; +import java.nio.charset.StandardCharsets; import java.sql.SQLException; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.Parameter; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.EncodedResource; +import org.springframework.jdbc.core.DataClassRowMapper; import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; import static org.springframework.jdbc.datasource.init.ScriptUtils.executeSqlScript; /** @@ -32,16 +43,22 @@ * @since 4.0.3 * @see ScriptUtilsTests */ +@ParameterizedClass +@EnumSource(EmbeddedDatabaseType.class) class ScriptUtilsIntegrationTests extends AbstractDatabaseInitializationTests { + @Parameter + EmbeddedDatabaseType databaseType; + + @Override protected EmbeddedDatabaseType getEmbeddedDatabaseType() { - return EmbeddedDatabaseType.HSQL; + return this.databaseType; } @BeforeEach void setUpSchema() throws SQLException { - executeSqlScript(db.getConnection(), usersSchema()); + executeSqlScript(db.getConnection(), encodedResource(usersSchema()), false, true, "--", null, "/*", "*/"); } @Test @@ -59,4 +76,52 @@ void executeSqlScriptContainingSingleQuotesNestedInsideDoubleQuotes() throws SQL assertUsersDatabaseCreated("Hoeller", "Brannen"); } + @Test + @SuppressWarnings("unchecked") + void statementWithMultipleResultSets() throws SQLException { + // Derby does not support multiple statements/ResultSets within a single Statement. + assumeThat(this.databaseType).isNotSameAs(EmbeddedDatabaseType.DERBY); + + EncodedResource resource = encodedResource(resource("users-data.sql")); + executeSqlScript(db.getConnection(), resource, false, true, "--", null, "/*", "*/"); + + assertUsersInDatabase(user("Sam", "Brannen")); + + resource = encodedResource(inlineResource(""" + SELECT last_name FROM users WHERE id = 0; + UPDATE users SET first_name = 'Jane' WHERE id = 0; + UPDATE users SET last_name = 'Smith' WHERE id = 0; + SELECT last_name FROM users WHERE id = 0; + GO + """)); + + String separator = "GO\n"; + executeSqlScript(db.getConnection(), resource, false, true, "--", separator, "/*", "*/"); + + assertUsersInDatabase(user("Jane", "Smith")); + } + + private void assertUsersInDatabase(User... expectedUsers) { + List users = jdbcTemplate.query("SELECT * FROM users WHERE id = 0", + new DataClassRowMapper<>(User.class)); + assertThat(users).containsExactly(expectedUsers); + } + + + private static EncodedResource encodedResource(Resource resource) { + return new EncodedResource(resource); + } + + private static Resource inlineResource(String sql) { + byte[] bytes = sql.getBytes(StandardCharsets.UTF_8); + return new ByteArrayResource(bytes, "inline SQL"); + } + + private static User user(String firstName, String lastName) { + return new User(0, firstName, lastName); + } + + record User(int id, String firstName, String lastName) { + } + } diff --git a/spring-jdbc/src/test/resources/org/springframework/jdbc/datasource/init/users-schema-with-custom-schema.sql b/spring-jdbc/src/test/resources/org/springframework/jdbc/datasource/init/users-schema-with-custom-schema.sql index 6da1c2978205..0959c7e6955b 100644 --- a/spring-jdbc/src/test/resources/org/springframework/jdbc/datasource/init/users-schema-with-custom-schema.sql +++ b/spring-jdbc/src/test/resources/org/springframework/jdbc/datasource/init/users-schema-with-custom-schema.sql @@ -5,7 +5,7 @@ SET SCHEMA my_schema; DROP TABLE users IF EXISTS; CREATE TABLE users ( - id INTEGER GENERATED BY DEFAULT AS IDENTITY, + id INTEGER GENERATED BY DEFAULT AS IDENTITY(START WITH 0) PRIMARY KEY, first_name VARCHAR(50) NOT NULL, last_name VARCHAR(50) NOT NULL ); diff --git a/spring-jdbc/src/test/resources/org/springframework/jdbc/datasource/init/users-schema.sql b/spring-jdbc/src/test/resources/org/springframework/jdbc/datasource/init/users-schema.sql index 523c4a7c2b19..d9cb2918b1c6 100644 --- a/spring-jdbc/src/test/resources/org/springframework/jdbc/datasource/init/users-schema.sql +++ b/spring-jdbc/src/test/resources/org/springframework/jdbc/datasource/init/users-schema.sql @@ -1,7 +1,7 @@ DROP TABLE users IF EXISTS; CREATE TABLE users ( - id INTEGER GENERATED BY DEFAULT AS IDENTITY, + id INTEGER GENERATED BY DEFAULT AS IDENTITY(START WITH 0) PRIMARY KEY, first_name VARCHAR(50) NOT NULL, last_name VARCHAR(50) NOT NULL ); From 15d369266925ba453e9286d82e7e4d1dc52269a9 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Mon, 11 Aug 2025 17:53:05 +0300 Subject: [PATCH 070/591] Update assertion in JdbcClientIntegrationTests --- .../jdbc/core/simple/JdbcClientIntegrationTests.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/JdbcClientIntegrationTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/JdbcClientIntegrationTests.java index a7f776e8e31a..743803f960f4 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/JdbcClientIntegrationTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/core/simple/JdbcClientIntegrationTests.java @@ -246,7 +246,7 @@ private static void assertResults(List users) { } private static void assertSingleResult(List users) { - assertThat(users).containsExactly(new User(2, "John", "John")); + assertThat(users).containsExactly(new User(1, "John", "John")); } From 4d6a921df51283aa1c996eddb05af9a51181efab Mon Sep 17 00:00:00 2001 From: SRIRAM9487 Date: Fri, 8 Aug 2025 14:35:35 +0530 Subject: [PATCH 071/591] Add HTTP method support to MappedInterceptor This enhancement enables finer control over interceptor application based on HTTP methods, aligning with modern Spring 7.x practices. - Extend MappedInterceptor with include/exclude HTTP methods - Add constructors for interceptor implementations - Update InterceptorRegistration with fluent methods - Keep existing constructors and methods for compatibility - Update matches() to check HTTP method conditions See gh-35273 Signed-off-by: SRIRAM9487 --- .../annotation/InterceptorRegistration.java | 66 ++++++++- .../servlet/handler/MappedInterceptor.java | 126 +++++++++++++++--- .../handler/MappedInterceptorTests.java | 108 +++++++++++++-- 3 files changed, 271 insertions(+), 29 deletions(-) diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/InterceptorRegistration.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/InterceptorRegistration.java index 1ce334a9be1e..d4804f4cc149 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/InterceptorRegistration.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/InterceptorRegistration.java @@ -22,6 +22,7 @@ import org.jspecify.annotations.Nullable; +import org.springframework.http.HttpMethod; import org.springframework.util.AntPathMatcher; import org.springframework.util.Assert; import org.springframework.util.PathMatcher; @@ -48,6 +49,10 @@ public class InterceptorRegistration { private @Nullable List excludePatterns; + private @Nullable List includeHttpMethods; + + private @Nullable List excludeHttpMethods; + private @Nullable PathMatcher pathMatcher; private int order = 0; @@ -106,6 +111,46 @@ public InterceptorRegistration excludePathPatterns(List patterns) { return this; } + /** + * Add HTTP methods the interceptor should be included for. + *

    Only requests with these HTTP methods will be intercepted. + * @since 7.0.x + */ + public InterceptorRegistration includeHttpMethods(HttpMethod... httpMethods) { + return includeHttpMethods(Arrays.asList(httpMethods)); + } + + /** + * List-based variant of {@link #includeHttpMethods(HttpMethod...)}. + * @since 7.0.x + */ + public InterceptorRegistration includeHttpMethods(List httpMethods) { + this.includeHttpMethods = (this.includeHttpMethods != null ? + this.includeHttpMethods : new ArrayList<>(httpMethods.size())); + this.includeHttpMethods.addAll(httpMethods); + return this; + } + + /** + * Add HTTP methods the interceptor should be excluded from. + *

    Requests with these HTTP methods will be ignored by the interceptor. + * @since 7.0.x + */ + public InterceptorRegistration excludeHttpMethods(HttpMethod... httpMethods){ + return this.excludeHttpMethods(Arrays.asList(httpMethods)); + } + + /** + * List-based variant of {@link #excludeHttpMethods(HttpMethod...)}. + * @since 7.0.x + */ + public InterceptorRegistration excludeHttpMethods(List httpMethods){ + this.excludeHttpMethods = (this.excludeHttpMethods != null ? + this.excludeHttpMethods : new ArrayList<>(httpMethods.size())); + this.excludeHttpMethods.addAll(httpMethods); + return this; + } + /** * Configure the PathMatcher to use to match URL paths with against include * and exclude patterns. @@ -143,19 +188,32 @@ protected int getOrder() { } /** - * Build the underlying interceptor. If URL patterns are provided, the returned + * Build the underlying interceptor. If URL patterns or HTTP methods are provided, the returned * type is {@link MappedInterceptor}; otherwise {@link HandlerInterceptor}. */ @SuppressWarnings("removal") protected Object getInterceptor() { - if (this.includePatterns == null && this.excludePatterns == null) { + if (this.includePatterns == null && this.excludePatterns == null && this.includeHttpMethods == null && this.excludeHttpMethods == null) { return this.interceptor; } + HttpMethod[] includeMethodsArray = (this.includeHttpMethods != null) ? + this.includeHttpMethods.toArray(new HttpMethod[0]) : null; + + HttpMethod[] excludeMethodsArray = (this.excludeHttpMethods != null) ? + this.excludeHttpMethods.toArray(new HttpMethod[0]) : null; + + String[] includePattersArray = StringUtils.toStringArray(this.includePatterns); + + String[] excludePattersArray = StringUtils.toStringArray(this.excludePatterns); + + MappedInterceptor mappedInterceptor = new MappedInterceptor( - StringUtils.toStringArray(this.includePatterns), - StringUtils.toStringArray(this.excludePatterns), + includePattersArray, + excludePattersArray, + includeMethodsArray, + excludeMethodsArray, this.interceptor); if (this.pathMatcher != null) { diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/MappedInterceptor.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/MappedInterceptor.java index 5071a495383a..31a05bdfc4ee 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/MappedInterceptor.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/MappedInterceptor.java @@ -22,6 +22,7 @@ import jakarta.servlet.http.HttpServletResponse; import org.jspecify.annotations.Nullable; +import org.springframework.http.HttpMethod; import org.springframework.http.server.PathContainer; import org.springframework.util.AntPathMatcher; import org.springframework.util.ObjectUtils; @@ -67,6 +68,10 @@ public final class MappedInterceptor implements HandlerInterceptor { private final PatternAdapter @Nullable [] excludePatterns; + private final MethodAdapter @Nullable [] includeHttpMethods; + + private final MethodAdapter @Nullable [] excludeHttpMethods; + private PathMatcher pathMatcher = defaultPathMatcher; private final HandlerInterceptor interceptor; @@ -78,58 +83,79 @@ public final class MappedInterceptor implements HandlerInterceptor { * @param includePatterns patterns to which requests must match, or null to * match all paths * @param excludePatterns patterns to which requests must not match + * @param includeHttpMethods http methods to which request must match, or null to match all paths + * @param excludeHttpMethods http methods to which request must not match * @param interceptor the target interceptor * @param parser a parser to use to pre-parse patterns into {@link PathPattern}; * when not provided, {@link PathPatternParser#defaultInstance} is used. * @since 5.3 */ - public MappedInterceptor(String @Nullable [] includePatterns, String @Nullable [] excludePatterns, + public MappedInterceptor(String @Nullable [] includePatterns, String @Nullable [] excludePatterns, HttpMethod @Nullable [] includeHttpMethods, HttpMethod @Nullable [] excludeHttpMethods, HandlerInterceptor interceptor, @Nullable PathPatternParser parser) { this.includePatterns = PatternAdapter.initPatterns(includePatterns, parser); this.excludePatterns = PatternAdapter.initPatterns(excludePatterns, parser); + this.includeHttpMethods = MethodAdapter.initHttpMethods(includeHttpMethods); + this.excludeHttpMethods = MethodAdapter.initHttpMethods(excludeHttpMethods); this.interceptor = interceptor; } /** * Variant of - * {@link #MappedInterceptor(String[], String[], HandlerInterceptor, PathPatternParser)} + * {@link #MappedInterceptor(String[], String[], HttpMethod[], HttpMethod[], HandlerInterceptor, PathPatternParser)} * with include patterns only. */ public MappedInterceptor(String @Nullable [] includePatterns, HandlerInterceptor interceptor) { - this(includePatterns, null, interceptor); + this(includePatterns, null, null, null, interceptor); } /** * Variant of - * {@link #MappedInterceptor(String[], String[], HandlerInterceptor, PathPatternParser)} + * {@link #MappedInterceptor(String[], String[], HttpMethod[], HttpMethod[], HandlerInterceptor, PathPatternParser)} + * with include methods only. + */ + public MappedInterceptor(HttpMethod @Nullable [] includeHttpMethods, HandlerInterceptor interceptor) { + this(null, null, includeHttpMethods, null, interceptor); + } + + /** + * Variant of + * {@link #MappedInterceptor(String[], String[], HttpMethod[], HttpMethod[], HandlerInterceptor, PathPatternParser)} * without a provided parser. */ - public MappedInterceptor(String @Nullable [] includePatterns, String @Nullable [] excludePatterns, + public MappedInterceptor(String @Nullable [] includePatterns, String @Nullable [] excludePatterns, HttpMethod @Nullable [] includeHttpMethods, HttpMethod @Nullable [] excludeHttpMethods, HandlerInterceptor interceptor) { - this(includePatterns, excludePatterns, interceptor, null); + this(includePatterns, excludePatterns,includeHttpMethods,excludeHttpMethods, interceptor, null); } /** * Variant of - * {@link #MappedInterceptor(String[], String[], HandlerInterceptor, PathPatternParser)} + * {@link #MappedInterceptor(String[], String[], HttpMethod[], HttpMethod[], HandlerInterceptor, PathPatternParser)} * with a {@link WebRequestInterceptor} as the target. */ public MappedInterceptor(String @Nullable [] includePatterns, WebRequestInterceptor interceptor) { - this(includePatterns, null, interceptor); + this(includePatterns, null,null,null, interceptor); + } + /** + * Variant of + * {@link #MappedInterceptor(String[], String[], HttpMethod[], HttpMethod[], HandlerInterceptor, PathPatternParser)} + * with a {@link WebRequestInterceptor} as the target. + */ + public MappedInterceptor(HttpMethod @Nullable [] includeHttpMethods, WebRequestInterceptor interceptor) { + this(null, null,includeHttpMethods ,null, interceptor); } /** * Variant of - * {@link #MappedInterceptor(String[], String[], HandlerInterceptor, PathPatternParser)} + * {@link #MappedInterceptor(String[], String[], HttpMethod[], HttpMethod[] , HandlerInterceptor, PathPatternParser)} * with a {@link WebRequestInterceptor} as the target. */ - public MappedInterceptor(String @Nullable [] includePatterns, String @Nullable [] excludePatterns, + public MappedInterceptor(String @Nullable [] includePatterns, String @Nullable [] excludePatterns, HttpMethod @Nullable [] includeHttpMethods, HttpMethod @Nullable [] excludeHttpMethods, WebRequestInterceptor interceptor) { - this(includePatterns, excludePatterns, new WebRequestHandlerInterceptorAdapter(interceptor)); + this(includePatterns, excludePatterns,includeHttpMethods,excludeHttpMethods, new WebRequestHandlerInterceptorAdapter(interceptor)); } @@ -202,6 +228,7 @@ public PathMatcher getPathMatcher() { */ public boolean matches(HttpServletRequest request) { Object path = ServletRequestPathUtils.getCachedPath(request); + HttpMethod method = HttpMethod.valueOf(request.getMethod()); if (this.pathMatcher != defaultPathMatcher) { path = path.toString(); } @@ -213,12 +240,45 @@ public boolean matches(HttpServletRequest request) { } } } - if (ObjectUtils.isEmpty(this.includePatterns)) { + if (!ObjectUtils.isEmpty(this.excludeHttpMethods)) { + for (MethodAdapter adapter : this.excludeHttpMethods) { + if (adapter.match(method)){ + return false; + } + } + } + if (ObjectUtils.isEmpty(this.includePatterns) && ObjectUtils.isEmpty(this.includeHttpMethods)) { return true; } - for (PatternAdapter adapter : this.includePatterns) { - if (adapter.match(path, isPathContainer, this.pathMatcher)) { - return true; + if (!ObjectUtils.isEmpty(this.includePatterns) && ObjectUtils.isEmpty(this.includeHttpMethods)) { + for (PatternAdapter adapter : this.includePatterns) { + if (adapter.match(path, isPathContainer, this.pathMatcher)) { + return true; + } + } + } + if (!ObjectUtils.isEmpty(this.includeHttpMethods) && ObjectUtils.isEmpty(this.includePatterns)) { + for (MethodAdapter adapter : this.includeHttpMethods) { + if (adapter.match(method)) { + return true; + } + } + } + if (!ObjectUtils.isEmpty(this.includePatterns) && !ObjectUtils.isEmpty(this.includeHttpMethods)) { + boolean match = false; + for (MethodAdapter methodAdapter : this.includeHttpMethods) { + if (methodAdapter.match(method)) { + match = true; + break; + } + } + if (!match) { + return false; + } + for (PatternAdapter pathAdapter : this.includePatterns) { + if (pathAdapter.match(path, isPathContainer, pathMatcher)) { + return true; + } } } return false; @@ -305,4 +365,40 @@ public boolean match(Object path, boolean isPathContainer, PathMatcher pathMatch } } + /** + * Adapts {@link HttpMethod} instances for internal matching purposes. + * + *

    Encapsulates an {@link HttpMethod} and provides matching functionality. + * Also provides a utility method to initialize arrays of {@code MethodAdapter} + * instances from arrays of {@link HttpMethod}.

    + * + * @since 7.0.x + */ + private static class MethodAdapter { + + private final @Nullable HttpMethod httpMethod; + + public MethodAdapter(@Nullable HttpMethod httpMethod) { + this.httpMethod = httpMethod; + } + + public boolean match(HttpMethod method) { + return this.httpMethod == method; + } + + public @Nullable HttpMethod getHttpMethod() { + return this.httpMethod; + } + + private static MethodAdapter @Nullable [] initHttpMethods(HttpMethod @Nullable [] methods) { + if (ObjectUtils.isEmpty(methods)) { + return null; + } + return Arrays.stream(methods) + .map(MethodAdapter::new) + .toArray(MethodAdapter[]::new); + } + + } + } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/MappedInterceptorTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/MappedInterceptorTests.java index f3c2955ff7e3..25c3cb7e0fac 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/MappedInterceptorTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/MappedInterceptorTests.java @@ -26,10 +26,12 @@ import org.junit.jupiter.api.Named; import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; import org.springframework.util.PathMatcher; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.i18n.LocaleChangeInterceptor; import org.springframework.web.testfixture.servlet.MockHttpServletRequest; +import org.springframework.web.util.ServletRequestPathUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -50,16 +52,23 @@ private static Stream>> pathPatte return PathPatternsTestUtils.requestArguments(); } + private MockHttpServletRequest requestWithMethod(String method) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/some/path"); + request.setMethod(method); + ServletRequestPathUtils.parseAndCache(request); + return request; + } @PathPatternsParameterizedTest void noPatterns(Function requestFactory) { - MappedInterceptor interceptor = new MappedInterceptor(null, null, delegate); + MappedInterceptor interceptor = new MappedInterceptor(null, null,null,null, delegate); assertThat(interceptor.matches(requestFactory.apply("/foo"))).isTrue(); } @PathPatternsParameterizedTest void includePattern(Function requestFactory) { - MappedInterceptor interceptor = new MappedInterceptor(new String[] { "/foo/*" }, null, delegate); + MappedInterceptor interceptor = new MappedInterceptor(new String[] { "/foo/*" }, null,null,null, delegate); assertThat(interceptor.matches(requestFactory.apply("/foo/bar"))).isTrue(); assertThat(interceptor.matches(requestFactory.apply("/bar/foo"))).isFalse(); @@ -67,13 +76,13 @@ void includePattern(Function requestFactory) { @PathPatternsParameterizedTest void includePatternWithMatrixVariables(Function requestFactory) { - MappedInterceptor interceptor = new MappedInterceptor(new String[] { "/foo*/*" }, null, delegate); + MappedInterceptor interceptor = new MappedInterceptor(new String[] { "/foo*/*" },null,null, null, delegate); assertThat(interceptor.matches(requestFactory.apply("/foo;q=1/bar;s=2"))).isTrue(); } @PathPatternsParameterizedTest void excludePattern(Function requestFactory) { - MappedInterceptor interceptor = new MappedInterceptor(null, new String[] { "/admin/**" }, delegate); + MappedInterceptor interceptor = new MappedInterceptor(null, new String[] { "/admin/**" },null,null, delegate); assertThat(interceptor.matches(requestFactory.apply("/foo"))).isTrue(); assertThat(interceptor.matches(requestFactory.apply("/admin/foo"))).isFalse(); @@ -82,7 +91,7 @@ void excludePattern(Function requestFactory) { @PathPatternsParameterizedTest void includeAndExcludePatterns(Function requestFactory) { MappedInterceptor interceptor = - new MappedInterceptor(new String[] { "/**" }, new String[] { "/admin/**" }, delegate); + new MappedInterceptor(new String[] { "/**" }, new String[] { "/admin/**" },null,null, delegate); assertThat(interceptor.matches(requestFactory.apply("/foo"))).isTrue(); assertThat(interceptor.matches(requestFactory.apply("/admin/foo"))).isFalse(); @@ -90,7 +99,7 @@ void includeAndExcludePatterns(Function requestF @PathPatternsParameterizedTest // gh-26690 void includePatternWithFallbackOnPathMatcher(Function requestFactory) { - MappedInterceptor interceptor = new MappedInterceptor(new String[] { "/path1/**/path2" }, null, delegate); + MappedInterceptor interceptor = new MappedInterceptor(new String[] { "/path1/**/path2" },null,null, null, delegate); assertThat(interceptor.matches(requestFactory.apply("/path1/foo/bar/path2"))).isTrue(); assertThat(interceptor.matches(requestFactory.apply("/path1/foo/bar/path3"))).isFalse(); @@ -100,18 +109,97 @@ void includePatternWithFallbackOnPathMatcher(Function requestFactory) { - MappedInterceptor interceptor = new MappedInterceptor(new String[] { "/foo/[0-9]*" }, null, delegate); + MappedInterceptor interceptor = new MappedInterceptor(new String[] { "/foo/[0-9]*" },null,null, null, delegate); interceptor.setPathMatcher(new TestPathMatcher()); assertThat(interceptor.matches(requestFactory.apply("/foo/123"))).isTrue(); assertThat(interceptor.matches(requestFactory.apply("/foo/bar"))).isFalse(); } + @Test + void includeMethods(){ + MappedInterceptor interceptor = new MappedInterceptor(null, null,new HttpMethod[]{HttpMethod.GET},null, delegate); + assertThat(interceptor.matches(requestWithMethod("GET"))).isTrue(); + assertThat(interceptor.matches(requestWithMethod("HEAD"))).isFalse(); + assertThat(interceptor.matches(requestWithMethod("POST"))).isFalse(); + assertThat(interceptor.matches(requestWithMethod("PUT"))).isFalse(); + assertThat(interceptor.matches(requestWithMethod("DELETE"))).isFalse(); + assertThat(interceptor.matches(requestWithMethod("CONNECT"))).isFalse(); + assertThat(interceptor.matches(requestWithMethod("OPTIONS"))).isFalse(); + assertThat(interceptor.matches(requestWithMethod("TRACE"))).isFalse(); + assertThat(interceptor.matches(requestWithMethod("PATCH"))).isFalse(); + } + + @Test + void includeMultipleMethods(){ + MappedInterceptor interceptor = new MappedInterceptor(null, null,new HttpMethod[]{HttpMethod.GET,HttpMethod.POST},null, delegate); + assertThat(interceptor.matches(requestWithMethod("GET"))).isTrue(); + assertThat(interceptor.matches(requestWithMethod("HEAD"))).isFalse(); + assertThat(interceptor.matches(requestWithMethod("POST"))).isTrue(); + assertThat(interceptor.matches(requestWithMethod("PUT"))).isFalse(); + assertThat(interceptor.matches(requestWithMethod("DELETE"))).isFalse(); + assertThat(interceptor.matches(requestWithMethod("CONNECT"))).isFalse(); + assertThat(interceptor.matches(requestWithMethod("OPTIONS"))).isFalse(); + assertThat(interceptor.matches(requestWithMethod("TRACE"))).isFalse(); + assertThat(interceptor.matches(requestWithMethod("PATCH"))).isFalse(); + } + + @Test + void excludeMethods(){ + MappedInterceptor interceptor = new MappedInterceptor(null, null,null,new HttpMethod[]{HttpMethod.GET}, delegate); + assertThat(interceptor.matches(requestWithMethod("GET"))).isFalse(); + assertThat(interceptor.matches(requestWithMethod("HEAD"))).isTrue(); + assertThat(interceptor.matches(requestWithMethod("POST"))).isTrue(); + assertThat(interceptor.matches(requestWithMethod("PUT"))).isTrue(); + assertThat(interceptor.matches(requestWithMethod("DELETE"))).isTrue(); + assertThat(interceptor.matches(requestWithMethod("CONNECT"))).isTrue(); + assertThat(interceptor.matches(requestWithMethod("OPTIONS"))).isTrue(); + assertThat(interceptor.matches(requestWithMethod("TRACE"))).isTrue(); + assertThat(interceptor.matches(requestWithMethod("PATCH"))).isTrue(); + } + + @Test + void excludeMultipleMethods(){ + MappedInterceptor interceptor = new MappedInterceptor(null, null,null,new HttpMethod[]{HttpMethod.GET,HttpMethod.POST,HttpMethod.OPTIONS}, delegate); + assertThat(interceptor.matches(requestWithMethod("GET"))).isFalse(); + assertThat(interceptor.matches(requestWithMethod("HEAD"))).isTrue(); + assertThat(interceptor.matches(requestWithMethod("POST"))).isFalse(); + assertThat(interceptor.matches(requestWithMethod("PUT"))).isTrue(); + assertThat(interceptor.matches(requestWithMethod("DELETE"))).isTrue(); + assertThat(interceptor.matches(requestWithMethod("CONNECT"))).isTrue(); + assertThat(interceptor.matches(requestWithMethod("OPTIONS"))).isFalse(); + assertThat(interceptor.matches(requestWithMethod("TRACE"))).isTrue(); + assertThat(interceptor.matches(requestWithMethod("PATCH"))).isTrue(); + } + + @Test + void includeMethodsAndExcludeMethods(){ + MappedInterceptor interceptor = new MappedInterceptor(null, null,new HttpMethod[]{HttpMethod.GET,HttpMethod.POST},new HttpMethod[]{HttpMethod.OPTIONS}, delegate); + assertThat(interceptor.matches(requestWithMethod("GET"))).isTrue(); + assertThat(interceptor.matches(requestWithMethod("HEAD"))).isFalse(); + assertThat(interceptor.matches(requestWithMethod("POST"))).isTrue(); + assertThat(interceptor.matches(requestWithMethod("PUT"))).isFalse(); + assertThat(interceptor.matches(requestWithMethod("DELETE"))).isFalse(); + assertThat(interceptor.matches(requestWithMethod("CONNECT"))).isFalse(); + assertThat(interceptor.matches(requestWithMethod("OPTIONS"))).isFalse(); + assertThat(interceptor.matches(requestWithMethod("TRACE"))).isFalse(); + assertThat(interceptor.matches(requestWithMethod("PATCH"))).isFalse(); + } + + @PathPatternsParameterizedTest + void includePatternAndIncludeMethods(Function requestFactory) { + MappedInterceptor interceptor = new MappedInterceptor(new String[] { "/foo/*" }, null,new HttpMethod[]{HttpMethod.GET},null, delegate); + + assertThat(interceptor.matches(requestFactory.apply("/foo/bar"))).isTrue(); + assertThat(interceptor.matches(requestFactory.apply("/bar/foo"))).isFalse(); + } + + @Test void preHandle() throws Exception { HandlerInterceptor delegate = mock(); - new MappedInterceptor(null, delegate).preHandle(mock(), mock(), null); + new MappedInterceptor(null,null,null,null, delegate).preHandle(mock(), mock(), null); then(delegate).should().preHandle(any(HttpServletRequest.class), any(HttpServletResponse.class), any()); } @@ -120,7 +208,7 @@ void preHandle() throws Exception { void postHandle() throws Exception { HandlerInterceptor delegate = mock(); - new MappedInterceptor(null, delegate).postHandle(mock(), mock(), null, mock()); + new MappedInterceptor(null,null,null,null, delegate).postHandle(mock(), mock(), null, mock()); then(delegate).should().postHandle(any(), any(), any(), any()); } @@ -129,7 +217,7 @@ void postHandle() throws Exception { void afterCompletion() throws Exception { HandlerInterceptor delegate = mock(); - new MappedInterceptor(null, delegate).afterCompletion(mock(), mock(), null, mock()); + new MappedInterceptor(null,null,null,null, delegate).afterCompletion(mock(), mock(), null, mock()); then(delegate).should().afterCompletion(any(), any(), any(), any()); } From 8f1ade55d9e2cfaf84005fd11cb4a884ff09aec0 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Mon, 11 Aug 2025 15:50:52 +0100 Subject: [PATCH 072/591] Update contribution Closes gh-35273 --- .../annotation/InterceptorRegistration.java | 66 ++++----- .../servlet/handler/MappedInterceptor.java | 137 ++++++------------ .../handler/MappedInterceptorTests.java | 128 ++++++---------- 3 files changed, 118 insertions(+), 213 deletions(-) diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/InterceptorRegistration.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/InterceptorRegistration.java index d4804f4cc149..c86c0063875b 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/InterceptorRegistration.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/InterceptorRegistration.java @@ -83,8 +83,9 @@ public InterceptorRegistration addPathPatterns(String... patterns) { * @since 5.0.3 */ public InterceptorRegistration addPathPatterns(List patterns) { - this.includePatterns = (this.includePatterns != null ? - this.includePatterns : new ArrayList<>(patterns.size())); + if (this.includePatterns == null) { + this.includePatterns = new ArrayList<>(patterns.size()); + } this.includePatterns.addAll(patterns); return this; } @@ -105,16 +106,16 @@ public InterceptorRegistration excludePathPatterns(String... patterns) { * @since 5.0.3 */ public InterceptorRegistration excludePathPatterns(List patterns) { - this.excludePatterns = (this.excludePatterns != null ? - this.excludePatterns : new ArrayList<>(patterns.size())); + if (this.excludePatterns == null) { + this.excludePatterns = new ArrayList<>(patterns.size()); + } this.excludePatterns.addAll(patterns); return this; } /** - * Add HTTP methods the interceptor should be included for. - *

    Only requests with these HTTP methods will be intercepted. - * @since 7.0.x + * Add HTTP methods for requests the interceptor should be included in. + * @since 7.0 */ public InterceptorRegistration includeHttpMethods(HttpMethod... httpMethods) { return includeHttpMethods(Arrays.asList(httpMethods)); @@ -122,31 +123,33 @@ public InterceptorRegistration includeHttpMethods(HttpMethod... httpMethods) { /** * List-based variant of {@link #includeHttpMethods(HttpMethod...)}. - * @since 7.0.x + * @since 7.0 */ public InterceptorRegistration includeHttpMethods(List httpMethods) { - this.includeHttpMethods = (this.includeHttpMethods != null ? - this.includeHttpMethods : new ArrayList<>(httpMethods.size())); + if (this.includeHttpMethods == null) { + this.includeHttpMethods = new ArrayList<>(httpMethods.size()); + } this.includeHttpMethods.addAll(httpMethods); return this; } /** - * Add HTTP methods the interceptor should be excluded from. + * Add HTTP methods for requests the interceptor should be excluded from. *

    Requests with these HTTP methods will be ignored by the interceptor. - * @since 7.0.x + * @since 7.0 */ - public InterceptorRegistration excludeHttpMethods(HttpMethod... httpMethods){ - return this.excludeHttpMethods(Arrays.asList(httpMethods)); + public InterceptorRegistration excludeHttpMethods(HttpMethod... httpMethods) { + return excludeHttpMethods(Arrays.asList(httpMethods)); } /** * List-based variant of {@link #excludeHttpMethods(HttpMethod...)}. - * @since 7.0.x + * @since 7.0 */ - public InterceptorRegistration excludeHttpMethods(List httpMethods){ - this.excludeHttpMethods = (this.excludeHttpMethods != null ? - this.excludeHttpMethods : new ArrayList<>(httpMethods.size())); + public InterceptorRegistration excludeHttpMethods(List httpMethods) { + if (this.excludeHttpMethods == null) { + this.excludeHttpMethods = new ArrayList<>(httpMethods.size()); + } this.excludeHttpMethods.addAll(httpMethods); return this; } @@ -175,7 +178,7 @@ public InterceptorRegistration pathMatcher(PathMatcher pathMatcher) { * Specify an order position to be used. Default is 0. * @since 4.3.23 */ - public InterceptorRegistration order(int order){ + public InterceptorRegistration order(int order) { this.order = order; return this; } @@ -194,27 +197,18 @@ protected int getOrder() { @SuppressWarnings("removal") protected Object getInterceptor() { - if (this.includePatterns == null && this.excludePatterns == null && this.includeHttpMethods == null && this.excludeHttpMethods == null) { + if (this.includePatterns == null && this.excludePatterns == null && + this.includeHttpMethods == null && this.excludeHttpMethods == null) { + return this.interceptor; } - HttpMethod[] includeMethodsArray = (this.includeHttpMethods != null) ? - this.includeHttpMethods.toArray(new HttpMethod[0]) : null; - - HttpMethod[] excludeMethodsArray = (this.excludeHttpMethods != null) ? - this.excludeHttpMethods.toArray(new HttpMethod[0]) : null; - - String[] includePattersArray = StringUtils.toStringArray(this.includePatterns); - - String[] excludePattersArray = StringUtils.toStringArray(this.excludePatterns); - - MappedInterceptor mappedInterceptor = new MappedInterceptor( - includePattersArray, - excludePattersArray, - includeMethodsArray, - excludeMethodsArray, - this.interceptor); + StringUtils.toStringArray(this.includePatterns), + StringUtils.toStringArray(this.excludePatterns), + (this.includeHttpMethods != null) ? this.includeHttpMethods.toArray(new HttpMethod[0]) : null, + (this.excludeHttpMethods != null) ? this.excludeHttpMethods.toArray(new HttpMethod[0]) : null, + this.interceptor, null); if (this.pathMatcher != null) { mappedInterceptor.setPathMatcher(this.pathMatcher); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/MappedInterceptor.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/MappedInterceptor.java index 31a05bdfc4ee..7071d32e010a 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/MappedInterceptor.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/MappedInterceptor.java @@ -68,9 +68,9 @@ public final class MappedInterceptor implements HandlerInterceptor { private final PatternAdapter @Nullable [] excludePatterns; - private final MethodAdapter @Nullable [] includeHttpMethods; + private final HttpMethod @Nullable [] includeHttpMethods; - private final MethodAdapter @Nullable [] excludeHttpMethods; + private final HttpMethod @Nullable [] excludeHttpMethods; private PathMatcher pathMatcher = defaultPathMatcher; @@ -78,45 +78,48 @@ public final class MappedInterceptor implements HandlerInterceptor { /** - * Create an instance with the given include and exclude patterns along with - * the target interceptor for the mappings. - * @param includePatterns patterns to which requests must match, or null to - * match all paths - * @param excludePatterns patterns to which requests must not match - * @param includeHttpMethods http methods to which request must match, or null to match all paths - * @param excludeHttpMethods http methods to which request must not match + * Create an instance with the given include and exclude patterns and HTTP methods. + * @param includePatterns patterns to match, or null to match all paths + * @param excludePatterns patterns for which requests must not match + * @param includeHttpMethods the HTTP methods to match, or null for all methods + * @param excludeHttpMethods the ßHTTP methods to which requests must not match * @param interceptor the target interceptor * @param parser a parser to use to pre-parse patterns into {@link PathPattern}; * when not provided, {@link PathPatternParser#defaultInstance} is used. - * @since 5.3 + * @since 7.0 */ - public MappedInterceptor(String @Nullable [] includePatterns, String @Nullable [] excludePatterns, HttpMethod @Nullable [] includeHttpMethods, HttpMethod @Nullable [] excludeHttpMethods, + public MappedInterceptor(String @Nullable [] includePatterns, String @Nullable [] excludePatterns, + HttpMethod @Nullable [] includeHttpMethods, HttpMethod @Nullable [] excludeHttpMethods, HandlerInterceptor interceptor, @Nullable PathPatternParser parser) { this.includePatterns = PatternAdapter.initPatterns(includePatterns, parser); this.excludePatterns = PatternAdapter.initPatterns(excludePatterns, parser); - this.includeHttpMethods = MethodAdapter.initHttpMethods(includeHttpMethods); - this.excludeHttpMethods = MethodAdapter.initHttpMethods(excludeHttpMethods); + this.includeHttpMethods = includeHttpMethods; + this.excludeHttpMethods = excludeHttpMethods; this.interceptor = interceptor; } - /** - * Variant of + * Variation of * {@link #MappedInterceptor(String[], String[], HttpMethod[], HttpMethod[], HandlerInterceptor, PathPatternParser)} - * with include patterns only. + * without HTTP methods. + * @since 5.3 + * @deprecated in favor of the constructor variant with HTTP methods */ - public MappedInterceptor(String @Nullable [] includePatterns, HandlerInterceptor interceptor) { - this(includePatterns, null, null, null, interceptor); + @Deprecated(since = "7.0", forRemoval = true) + public MappedInterceptor(String @Nullable [] includePatterns, String @Nullable [] excludePatterns, + HandlerInterceptor interceptor, @Nullable PathPatternParser parser) { + + this(includePatterns, excludePatterns, null, null, interceptor, parser); } /** * Variant of * {@link #MappedInterceptor(String[], String[], HttpMethod[], HttpMethod[], HandlerInterceptor, PathPatternParser)} - * with include methods only. + * with include patterns only. */ - public MappedInterceptor(HttpMethod @Nullable [] includeHttpMethods, HandlerInterceptor interceptor) { - this(null, null, includeHttpMethods, null, interceptor); + public MappedInterceptor(String @Nullable [] includePatterns, HandlerInterceptor interceptor) { + this(includePatterns, null, null, null, interceptor, null); } /** @@ -124,10 +127,10 @@ public MappedInterceptor(HttpMethod @Nullable [] includeHttpMethods, HandlerInte * {@link #MappedInterceptor(String[], String[], HttpMethod[], HttpMethod[], HandlerInterceptor, PathPatternParser)} * without a provided parser. */ - public MappedInterceptor(String @Nullable [] includePatterns, String @Nullable [] excludePatterns, HttpMethod @Nullable [] includeHttpMethods, HttpMethod @Nullable [] excludeHttpMethods, + public MappedInterceptor(String @Nullable [] includePatterns, String @Nullable [] excludePatterns, HandlerInterceptor interceptor) { - this(includePatterns, excludePatterns,includeHttpMethods,excludeHttpMethods, interceptor, null); + this(includePatterns, excludePatterns, null, null, interceptor, null); } /** @@ -136,26 +139,18 @@ public MappedInterceptor(String @Nullable [] includePatterns, String @Nullable [ * with a {@link WebRequestInterceptor} as the target. */ public MappedInterceptor(String @Nullable [] includePatterns, WebRequestInterceptor interceptor) { - this(includePatterns, null,null,null, interceptor); - } - /** - * Variant of - * {@link #MappedInterceptor(String[], String[], HttpMethod[], HttpMethod[], HandlerInterceptor, PathPatternParser)} - * with a {@link WebRequestInterceptor} as the target. - */ - public MappedInterceptor(HttpMethod @Nullable [] includeHttpMethods, WebRequestInterceptor interceptor) { - this(null, null,includeHttpMethods ,null, interceptor); + this(includePatterns, null, interceptor); } /** * Variant of - * {@link #MappedInterceptor(String[], String[], HttpMethod[], HttpMethod[] , HandlerInterceptor, PathPatternParser)} + * {@link #MappedInterceptor(String[], String[], HttpMethod[], HttpMethod[], HandlerInterceptor, PathPatternParser)} * with a {@link WebRequestInterceptor} as the target. */ - public MappedInterceptor(String @Nullable [] includePatterns, String @Nullable [] excludePatterns, HttpMethod @Nullable [] includeHttpMethods, HttpMethod @Nullable [] excludeHttpMethods, + public MappedInterceptor(String @Nullable [] includePatterns, String @Nullable [] excludePatterns, WebRequestInterceptor interceptor) { - this(includePatterns, excludePatterns,includeHttpMethods,excludeHttpMethods, new WebRequestHandlerInterceptorAdapter(interceptor)); + this(includePatterns, excludePatterns, null, null, new WebRequestHandlerInterceptorAdapter(interceptor), null); } @@ -228,7 +223,7 @@ public PathMatcher getPathMatcher() { */ public boolean matches(HttpServletRequest request) { Object path = ServletRequestPathUtils.getCachedPath(request); - HttpMethod method = HttpMethod.valueOf(request.getMethod()); + HttpMethod httpMethod = HttpMethod.valueOf(request.getMethod()); if (this.pathMatcher != defaultPathMatcher) { path = path.toString(); } @@ -241,33 +236,28 @@ public boolean matches(HttpServletRequest request) { } } if (!ObjectUtils.isEmpty(this.excludeHttpMethods)) { - for (MethodAdapter adapter : this.excludeHttpMethods) { - if (adapter.match(method)){ + for (HttpMethod excluded : this.excludeHttpMethods) { + if (excluded == httpMethod) { return false; } } } - if (ObjectUtils.isEmpty(this.includePatterns) && ObjectUtils.isEmpty(this.includeHttpMethods)) { - return true; - } - if (!ObjectUtils.isEmpty(this.includePatterns) && ObjectUtils.isEmpty(this.includeHttpMethods)) { + if (!ObjectUtils.isEmpty(this.includePatterns)) { + boolean match = false; for (PatternAdapter adapter : this.includePatterns) { if (adapter.match(path, isPathContainer, this.pathMatcher)) { - return true; + match = true; + break; } } - } - if (!ObjectUtils.isEmpty(this.includeHttpMethods) && ObjectUtils.isEmpty(this.includePatterns)) { - for (MethodAdapter adapter : this.includeHttpMethods) { - if (adapter.match(method)) { - return true; - } + if (!match) { + return false; } } - if (!ObjectUtils.isEmpty(this.includePatterns) && !ObjectUtils.isEmpty(this.includeHttpMethods)) { + if (!ObjectUtils.isEmpty(this.includeHttpMethods)) { boolean match = false; - for (MethodAdapter methodAdapter : this.includeHttpMethods) { - if (methodAdapter.match(method)) { + for (HttpMethod included : this.includeHttpMethods) { + if (included == httpMethod) { match = true; break; } @@ -275,13 +265,8 @@ public boolean matches(HttpServletRequest request) { if (!match) { return false; } - for (PatternAdapter pathAdapter : this.includePatterns) { - if (pathAdapter.match(path, isPathContainer, pathMatcher)) { - return true; - } - } } - return false; + return true; } @@ -365,40 +350,4 @@ public boolean match(Object path, boolean isPathContainer, PathMatcher pathMatch } } - /** - * Adapts {@link HttpMethod} instances for internal matching purposes. - * - *

    Encapsulates an {@link HttpMethod} and provides matching functionality. - * Also provides a utility method to initialize arrays of {@code MethodAdapter} - * instances from arrays of {@link HttpMethod}.

    - * - * @since 7.0.x - */ - private static class MethodAdapter { - - private final @Nullable HttpMethod httpMethod; - - public MethodAdapter(@Nullable HttpMethod httpMethod) { - this.httpMethod = httpMethod; - } - - public boolean match(HttpMethod method) { - return this.httpMethod == method; - } - - public @Nullable HttpMethod getHttpMethod() { - return this.httpMethod; - } - - private static MethodAdapter @Nullable [] initHttpMethods(HttpMethod @Nullable [] methods) { - if (ObjectUtils.isEmpty(methods)) { - return null; - } - return Arrays.stream(methods) - .map(MethodAdapter::new) - .toArray(MethodAdapter[]::new); - } - - } - } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/MappedInterceptorTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/MappedInterceptorTests.java index 25c3cb7e0fac..80d72452312e 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/MappedInterceptorTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/MappedInterceptorTests.java @@ -27,6 +27,7 @@ import org.junit.jupiter.api.Test; import org.springframework.http.HttpMethod; +import org.springframework.util.ObjectUtils; import org.springframework.util.PathMatcher; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.i18n.LocaleChangeInterceptor; @@ -52,23 +53,16 @@ private static Stream>> pathPatte return PathPatternsTestUtils.requestArguments(); } - private MockHttpServletRequest requestWithMethod(String method) { - MockHttpServletRequest request = new MockHttpServletRequest(); - request.setRequestURI("/some/path"); - request.setMethod(method); - ServletRequestPathUtils.parseAndCache(request); - return request; - } @PathPatternsParameterizedTest void noPatterns(Function requestFactory) { - MappedInterceptor interceptor = new MappedInterceptor(null, null,null,null, delegate); + MappedInterceptor interceptor = new MappedInterceptor(null, null, delegate); assertThat(interceptor.matches(requestFactory.apply("/foo"))).isTrue(); } @PathPatternsParameterizedTest void includePattern(Function requestFactory) { - MappedInterceptor interceptor = new MappedInterceptor(new String[] { "/foo/*" }, null,null,null, delegate); + MappedInterceptor interceptor = new MappedInterceptor(new String[] { "/foo/*" }, null, delegate); assertThat(interceptor.matches(requestFactory.apply("/foo/bar"))).isTrue(); assertThat(interceptor.matches(requestFactory.apply("/bar/foo"))).isFalse(); @@ -76,13 +70,13 @@ void includePattern(Function requestFactory) { @PathPatternsParameterizedTest void includePatternWithMatrixVariables(Function requestFactory) { - MappedInterceptor interceptor = new MappedInterceptor(new String[] { "/foo*/*" },null,null, null, delegate); + MappedInterceptor interceptor = new MappedInterceptor(new String[] { "/foo*/*" }, null, delegate); assertThat(interceptor.matches(requestFactory.apply("/foo;q=1/bar;s=2"))).isTrue(); } @PathPatternsParameterizedTest void excludePattern(Function requestFactory) { - MappedInterceptor interceptor = new MappedInterceptor(null, new String[] { "/admin/**" },null,null, delegate); + MappedInterceptor interceptor = new MappedInterceptor(null, new String[] { "/admin/**" }, delegate); assertThat(interceptor.matches(requestFactory.apply("/foo"))).isTrue(); assertThat(interceptor.matches(requestFactory.apply("/admin/foo"))).isFalse(); @@ -91,7 +85,7 @@ void excludePattern(Function requestFactory) { @PathPatternsParameterizedTest void includeAndExcludePatterns(Function requestFactory) { MappedInterceptor interceptor = - new MappedInterceptor(new String[] { "/**" }, new String[] { "/admin/**" },null,null, delegate); + new MappedInterceptor(new String[] { "/**" }, new String[] { "/admin/**" }, delegate); assertThat(interceptor.matches(requestFactory.apply("/foo"))).isTrue(); assertThat(interceptor.matches(requestFactory.apply("/admin/foo"))).isFalse(); @@ -99,7 +93,7 @@ void includeAndExcludePatterns(Function requestF @PathPatternsParameterizedTest // gh-26690 void includePatternWithFallbackOnPathMatcher(Function requestFactory) { - MappedInterceptor interceptor = new MappedInterceptor(new String[] { "/path1/**/path2" },null,null, null, delegate); + MappedInterceptor interceptor = new MappedInterceptor(new String[] { "/path1/**/path2" }, null, delegate); assertThat(interceptor.matches(requestFactory.apply("/path1/foo/bar/path2"))).isTrue(); assertThat(interceptor.matches(requestFactory.apply("/path1/foo/bar/path3"))).isFalse(); @@ -109,97 +103,65 @@ void includePatternWithFallbackOnPathMatcher(Function requestFactory) { - MappedInterceptor interceptor = new MappedInterceptor(new String[] { "/foo/[0-9]*" },null,null, null, delegate); + MappedInterceptor interceptor = new MappedInterceptor(new String[] { "/foo/[0-9]*" }, null, delegate); interceptor.setPathMatcher(new TestPathMatcher()); assertThat(interceptor.matches(requestFactory.apply("/foo/123"))).isTrue(); assertThat(interceptor.matches(requestFactory.apply("/foo/bar"))).isFalse(); } - @Test - void includeMethods(){ - MappedInterceptor interceptor = new MappedInterceptor(null, null,new HttpMethod[]{HttpMethod.GET},null, delegate); - assertThat(interceptor.matches(requestWithMethod("GET"))).isTrue(); - assertThat(interceptor.matches(requestWithMethod("HEAD"))).isFalse(); - assertThat(interceptor.matches(requestWithMethod("POST"))).isFalse(); - assertThat(interceptor.matches(requestWithMethod("PUT"))).isFalse(); - assertThat(interceptor.matches(requestWithMethod("DELETE"))).isFalse(); - assertThat(interceptor.matches(requestWithMethod("CONNECT"))).isFalse(); - assertThat(interceptor.matches(requestWithMethod("OPTIONS"))).isFalse(); - assertThat(interceptor.matches(requestWithMethod("TRACE"))).isFalse(); - assertThat(interceptor.matches(requestWithMethod("PATCH"))).isFalse(); - } - @Test void includeMultipleMethods(){ - MappedInterceptor interceptor = new MappedInterceptor(null, null,new HttpMethod[]{HttpMethod.GET,HttpMethod.POST},null, delegate); - assertThat(interceptor.matches(requestWithMethod("GET"))).isTrue(); - assertThat(interceptor.matches(requestWithMethod("HEAD"))).isFalse(); - assertThat(interceptor.matches(requestWithMethod("POST"))).isTrue(); - assertThat(interceptor.matches(requestWithMethod("PUT"))).isFalse(); - assertThat(interceptor.matches(requestWithMethod("DELETE"))).isFalse(); - assertThat(interceptor.matches(requestWithMethod("CONNECT"))).isFalse(); - assertThat(interceptor.matches(requestWithMethod("OPTIONS"))).isFalse(); - assertThat(interceptor.matches(requestWithMethod("TRACE"))).isFalse(); - assertThat(interceptor.matches(requestWithMethod("PATCH"))).isFalse(); - } - - @Test - void excludeMethods(){ - MappedInterceptor interceptor = new MappedInterceptor(null, null,null,new HttpMethod[]{HttpMethod.GET}, delegate); - assertThat(interceptor.matches(requestWithMethod("GET"))).isFalse(); - assertThat(interceptor.matches(requestWithMethod("HEAD"))).isTrue(); - assertThat(interceptor.matches(requestWithMethod("POST"))).isTrue(); - assertThat(interceptor.matches(requestWithMethod("PUT"))).isTrue(); - assertThat(interceptor.matches(requestWithMethod("DELETE"))).isTrue(); - assertThat(interceptor.matches(requestWithMethod("CONNECT"))).isTrue(); - assertThat(interceptor.matches(requestWithMethod("OPTIONS"))).isTrue(); - assertThat(interceptor.matches(requestWithMethod("TRACE"))).isTrue(); - assertThat(interceptor.matches(requestWithMethod("PATCH"))).isTrue(); + testHttpMethods( + new HttpMethod[] {HttpMethod.GET, HttpMethod.POST}, + new HttpMethod[] {}, + "GET", "POST"); } @Test - void excludeMultipleMethods(){ - MappedInterceptor interceptor = new MappedInterceptor(null, null,null,new HttpMethod[]{HttpMethod.GET,HttpMethod.POST,HttpMethod.OPTIONS}, delegate); - assertThat(interceptor.matches(requestWithMethod("GET"))).isFalse(); - assertThat(interceptor.matches(requestWithMethod("HEAD"))).isTrue(); - assertThat(interceptor.matches(requestWithMethod("POST"))).isFalse(); - assertThat(interceptor.matches(requestWithMethod("PUT"))).isTrue(); - assertThat(interceptor.matches(requestWithMethod("DELETE"))).isTrue(); - assertThat(interceptor.matches(requestWithMethod("CONNECT"))).isTrue(); - assertThat(interceptor.matches(requestWithMethod("OPTIONS"))).isFalse(); - assertThat(interceptor.matches(requestWithMethod("TRACE"))).isTrue(); - assertThat(interceptor.matches(requestWithMethod("PATCH"))).isTrue(); + void excludeMultipleMethods() { + testHttpMethods( + new HttpMethod[] {}, + new HttpMethod[] {HttpMethod.GET, HttpMethod.POST, HttpMethod.OPTIONS}, + "HEAD", "PUT", "DELETE", "TRACE", "PATCH"); + } + + private void testHttpMethods(HttpMethod[] include, HttpMethod[] exclude, String... expected) { + MappedInterceptor interceptor = new MappedInterceptor(null, null, include, exclude, delegate, null); + for (HttpMethod httpMethod : HttpMethod.values()) { + boolean matches = ObjectUtils.containsElement(expected, httpMethod.name()); + assertThat(interceptor.matches(requestWithMethod(httpMethod.name()))) + .as("Expected " + httpMethod + " to " + (matches ? "" : "not ") + "match") + .isEqualTo(matches); + } } - @Test - void includeMethodsAndExcludeMethods(){ - MappedInterceptor interceptor = new MappedInterceptor(null, null,new HttpMethod[]{HttpMethod.GET,HttpMethod.POST},new HttpMethod[]{HttpMethod.OPTIONS}, delegate); - assertThat(interceptor.matches(requestWithMethod("GET"))).isTrue(); - assertThat(interceptor.matches(requestWithMethod("HEAD"))).isFalse(); - assertThat(interceptor.matches(requestWithMethod("POST"))).isTrue(); - assertThat(interceptor.matches(requestWithMethod("PUT"))).isFalse(); - assertThat(interceptor.matches(requestWithMethod("DELETE"))).isFalse(); - assertThat(interceptor.matches(requestWithMethod("CONNECT"))).isFalse(); - assertThat(interceptor.matches(requestWithMethod("OPTIONS"))).isFalse(); - assertThat(interceptor.matches(requestWithMethod("TRACE"))).isFalse(); - assertThat(interceptor.matches(requestWithMethod("PATCH"))).isFalse(); + private MockHttpServletRequest requestWithMethod(String method) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/some/path"); + request.setMethod(method); + ServletRequestPathUtils.parseAndCache(request); + return request; } @PathPatternsParameterizedTest - void includePatternAndIncludeMethods(Function requestFactory) { - MappedInterceptor interceptor = new MappedInterceptor(new String[] { "/foo/*" }, null,new HttpMethod[]{HttpMethod.GET},null, delegate); + void includePatternAndHttMethods(Function requestFactory) { - assertThat(interceptor.matches(requestFactory.apply("/foo/bar"))).isTrue(); - assertThat(interceptor.matches(requestFactory.apply("/bar/foo"))).isFalse(); - } + MappedInterceptor getInterceptor = new MappedInterceptor( + new String[] {"/foo/*"}, null, new HttpMethod[] {HttpMethod.GET}, null, delegate, null); + + MappedInterceptor putInterceptor = new MappedInterceptor( + new String[] {"/foo/*"}, null, new HttpMethod[] {HttpMethod.PUT}, null, delegate, null); + assertThat(getInterceptor.matches(requestFactory.apply("/foo/bar"))).isTrue(); + assertThat(putInterceptor.matches(requestFactory.apply("/foo/bar"))).isFalse(); + } @Test void preHandle() throws Exception { HandlerInterceptor delegate = mock(); - new MappedInterceptor(null,null,null,null, delegate).preHandle(mock(), mock(), null); + new MappedInterceptor(null,null, delegate).preHandle(mock(), mock(), null); then(delegate).should().preHandle(any(HttpServletRequest.class), any(HttpServletResponse.class), any()); } @@ -208,7 +170,7 @@ void preHandle() throws Exception { void postHandle() throws Exception { HandlerInterceptor delegate = mock(); - new MappedInterceptor(null,null,null,null, delegate).postHandle(mock(), mock(), null, mock()); + new MappedInterceptor(null,null, delegate).postHandle(mock(), mock(), null, mock()); then(delegate).should().postHandle(any(), any(), any(), any()); } @@ -217,7 +179,7 @@ void postHandle() throws Exception { void afterCompletion() throws Exception { HandlerInterceptor delegate = mock(); - new MappedInterceptor(null,null,null,null, delegate).afterCompletion(mock(), mock(), null, mock()); + new MappedInterceptor(null,null, delegate).afterCompletion(mock(), mock(), null, mock()); then(delegate).should().afterCompletion(any(), any(), any(), any()); } From 876b7d4209f6630cf472a72adb62b243fecc259a Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Mon, 11 Aug 2025 22:43:02 +0200 Subject: [PATCH 073/591] Upgrade to Hibernate ORM 7.1 Closes gh-35308 --- .../modules/ROOT/pages/data-access/orm/hibernate.adoc | 6 +++--- framework-platform/framework-platform.gradle | 2 +- .../orm/jpa/hibernate/LocalSessionFactoryBean.java | 6 +++--- .../orm/jpa/hibernate/LocalSessionFactoryBuilder.java | 4 ++-- .../org/springframework/orm/jpa/hibernate/package-info.java | 2 +- .../springframework/orm/jpa/vendor/HibernateJpaDialect.java | 2 +- .../orm/jpa/vendor/HibernateJpaVendorAdapter.java | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/data-access/orm/hibernate.adoc b/framework-docs/modules/ROOT/pages/data-access/orm/hibernate.adoc index 2a5ef2f0eab6..a5c38886a52b 100644 --- a/framework-docs/modules/ROOT/pages/data-access/orm/hibernate.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/orm/hibernate.adoc @@ -10,11 +10,11 @@ cover the other ORM technologies and show brief examples. [NOTE] ==== -As of Spring Framework 7.0, Spring requires Hibernate ORM 7.0 for Spring's -`HibernateJpaVendorAdapter` as well as for a native Hibernate `SessionFactory` setup. +As of Spring Framework 7.0, Spring requires Hibernate ORM 7.x for Spring's +`HibernateJpaVendorAdapter`. The `org.springframework.orm.jpa.hibernate` package supersedes the former `orm.hibernate5`: -now for use with Hibernate ORM 7.0, tightly integrated with `HibernateJpaVendorAdapter` +now for use with Hibernate ORM 7.1+, tightly integrated with `HibernateJpaVendorAdapter` as well as supporting Hibernate's native `SessionFactory.getCurrentSession()` style. ==== diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index a554d8325a79..6156a55bf674 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -124,7 +124,7 @@ dependencies { api("org.glassfish:jakarta.el:4.0.2") api("org.graalvm.sdk:graal-sdk:22.3.1") api("org.hamcrest:hamcrest:3.0") - api("org.hibernate.orm:hibernate-core:7.0.5.Final") + api("org.hibernate.orm:hibernate-core:7.1.0.Final") api("org.hibernate.validator:hibernate-validator:9.0.1.Final") api("org.hsqldb:hsqldb:2.7.4") api("org.htmlunit:htmlunit:4.13.0") diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/LocalSessionFactoryBean.java b/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/LocalSessionFactoryBean.java index 722d14ce3754..e4a6d556f990 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/LocalSessionFactoryBean.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/LocalSessionFactoryBean.java @@ -62,7 +62,7 @@ * way to set up a shared Hibernate SessionFactory in a Spring application context; the * SessionFactory can then be passed to data access objects via dependency injection. * - *

    Compatible with Hibernate ORM 7.0, as of Spring Framework 7.0. + *

    Compatible with Hibernate ORM 7.1, as of Spring Framework 7.0. * This Hibernate-specific {@code LocalSessionFactoryBean} can be an immediate alternative * to {@link org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean} for * common JPA purposes: The Hibernate {@code SessionFactory} will natively expose the JPA @@ -109,7 +109,7 @@ public class LocalSessionFactoryBean extends HibernateExceptionTranslator private @Nullable MultiTenantConnectionProvider multiTenantConnectionProvider; - private @Nullable CurrentTenantIdentifierResolver currentTenantIdentifierResolver; + private @Nullable CurrentTenantIdentifierResolver currentTenantIdentifierResolver; private @Nullable Properties hibernateProperties; @@ -295,7 +295,7 @@ public void setMultiTenantConnectionProvider(MultiTenantConnectionProvider mu * Set a {@link CurrentTenantIdentifierResolver} to be passed on to the SessionFactory. * @see LocalSessionFactoryBuilder#setCurrentTenantIdentifierResolver */ - public void setCurrentTenantIdentifierResolver(CurrentTenantIdentifierResolver currentTenantIdentifierResolver) { + public void setCurrentTenantIdentifierResolver(CurrentTenantIdentifierResolver currentTenantIdentifierResolver) { this.currentTenantIdentifierResolver = currentTenantIdentifierResolver; } diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/LocalSessionFactoryBuilder.java b/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/LocalSessionFactoryBuilder.java index 77d8ca3193c9..338f235752d9 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/LocalSessionFactoryBuilder.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/LocalSessionFactoryBuilder.java @@ -80,7 +80,7 @@ * Typically combined with {@link HibernateTransactionManager} for declarative * transactions against the {@code SessionFactory} and its JDBC {@code DataSource}. * - *

    Compatible with Hibernate ORM 7.0, as of Spring Framework 7.0. + *

    Compatible with Hibernate ORM 7.1, as of Spring Framework 7.0. * This Hibernate-specific factory builder can also be a convenient way to set up * a JPA {@code EntityManagerFactory} since the Hibernate {@code SessionFactory} * natively exposes the JPA {@code EntityManagerFactory} interface as well now. @@ -261,7 +261,7 @@ public LocalSessionFactoryBuilder setMultiTenantConnectionProvider(MultiTenantCo * @see AvailableSettings#MULTI_TENANT_IDENTIFIER_RESOLVER */ @Override - public LocalSessionFactoryBuilder setCurrentTenantIdentifierResolver(CurrentTenantIdentifierResolver currentTenantIdentifierResolver) { + public LocalSessionFactoryBuilder setCurrentTenantIdentifierResolver(CurrentTenantIdentifierResolver currentTenantIdentifierResolver) { getProperties().put(AvailableSettings.MULTI_TENANT_IDENTIFIER_RESOLVER, currentTenantIdentifierResolver); super.setCurrentTenantIdentifierResolver(currentTenantIdentifierResolver); return this; diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/package-info.java b/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/package-info.java index e7569de71640..a20766242b01 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/package-info.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/package-info.java @@ -6,7 +6,7 @@ * but potentially also for JPA repositories or mixed use of native Hibernate and JPA. * *

    As of Spring Framework 7.0, this package supersedes {@code orm.hibernate5} - - * now for use with Hibernate ORM 7.0, tightly integrated with JPA. + * now for use with Hibernate ORM 7.1+, tightly integrated with JPA. */ @NullMarked package org.springframework.orm.jpa.hibernate; diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/HibernateJpaDialect.java b/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/HibernateJpaDialect.java index b5c363d77137..4c21adde1bc1 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/HibernateJpaDialect.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/HibernateJpaDialect.java @@ -42,7 +42,7 @@ /** * {@link org.springframework.orm.jpa.JpaDialect} implementation for Hibernate. - * Compatible with Hibernate ORM 7.0. + * Compatible with Hibernate ORM 7.x. * * @author Juergen Hoeller * @author Costin Leau diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/HibernateJpaVendorAdapter.java b/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/HibernateJpaVendorAdapter.java index 295472300aef..bb1eb81b4d88 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/HibernateJpaVendorAdapter.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/vendor/HibernateJpaVendorAdapter.java @@ -41,7 +41,7 @@ /** * {@link org.springframework.orm.jpa.JpaVendorAdapter} implementation for Hibernate. - * Compatible with Hibernate ORM 7.0. + * Compatible with Hibernate ORM 7.x. * *

    Exposes Hibernate's persistence provider and Hibernate's Session as extended * EntityManager interface, and adapts {@link AbstractJpaVendorAdapter}'s common From b89dcb1a1aded7b0176779c03fd7d4d258660f18 Mon Sep 17 00:00:00 2001 From: Songdoeon Date: Sun, 20 Jul 2025 16:18:29 +0900 Subject: [PATCH 074/591] Subscription.unsubscribe() returns Receiptable See gh-35224 Signed-off-by: Songdoeon --- .../simp/stomp/DefaultStompSession.java | 28 +++++--- .../messaging/simp/stomp/StompSession.java | 4 +- .../simp/stomp/DefaultStompSessionTests.java | 70 +++++++++++++++++++ 3 files changed, 92 insertions(+), 10 deletions(-) diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/DefaultStompSession.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/DefaultStompSession.java index 33297dd4f122..6f72bb0f953e 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/DefaultStompSession.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/DefaultStompSession.java @@ -345,14 +345,24 @@ public Receiptable acknowledge(StompHeaders headers, boolean consumed) { return receiptable; } - private void unsubscribe(String id, @Nullable StompHeaders headers) { - StompHeaderAccessor accessor = createHeaderAccessor(StompCommand.UNSUBSCRIBE); - if (headers != null) { - accessor.addNativeHeaders(headers); + private Receiptable unsubscribe(String id, @Nullable StompHeaders headers) { + Assert.hasText(id, "Subscription id is required"); + + if (headers == null){ + headers = new StompHeaders(); } + + String receiptId = checkOrAddReceipt(headers); + Receiptable receiptable = new ReceiptHandler(receiptId); + + StompHeaderAccessor accessor = createHeaderAccessor(StompCommand.UNSUBSCRIBE); + accessor.addNativeHeaders(headers); accessor.setSubscriptionId(id); + Message message = createMessage(accessor, EMPTY_PAYLOAD); execute(message); + + return receiptable; } @Override @@ -674,17 +684,19 @@ public StompFrameHandler getHandler() { } @Override - public void unsubscribe() { - unsubscribe(null); + public Receiptable unsubscribe() { + return unsubscribe(null); } @Override - public void unsubscribe(@Nullable StompHeaders headers) { + public Receiptable unsubscribe(@Nullable StompHeaders headers) { String id = this.headers.getId(); + Receiptable receiptable = new ReceiptHandler(null); if (id != null) { DefaultStompSession.this.subscriptions.remove(id); - DefaultStompSession.this.unsubscribe(id, headers); + receiptable = DefaultStompSession.this.unsubscribe(id, headers); } + return receiptable; } @Override diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompSession.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompSession.java index d940cc74731b..bcb816c0f6a7 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompSession.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompSession.java @@ -183,7 +183,7 @@ interface Subscription extends Receiptable { /** * Remove the subscription by sending an UNSUBSCRIBE frame. */ - void unsubscribe(); + Receiptable unsubscribe(); /** * Alternative to {@link #unsubscribe()} with additional custom headers @@ -192,7 +192,7 @@ interface Subscription extends Receiptable { * @param headers the custom headers, if any * @since 5.0 */ - void unsubscribe(@Nullable StompHeaders headers); + Receiptable unsubscribe(@Nullable StompHeaders headers); } } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/simp/stomp/DefaultStompSessionTests.java b/spring-messaging/src/test/java/org/springframework/messaging/simp/stomp/DefaultStompSessionTests.java index 09b092f86be2..c310afb870f7 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/simp/stomp/DefaultStompSessionTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/simp/stomp/DefaultStompSessionTests.java @@ -22,6 +22,7 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.BeforeEach; @@ -662,6 +663,75 @@ public void receiptNotReceived() { verifyNoMoreInteractions(future); } + @Test + void unsubscribeWithReceipt() { + this.session.afterConnected(this.connection); + assertThat(this.session.isConnected()).isTrue(); + Subscription subscription = this.session.subscribe("/topic/foo", mock()); + + Receiptable receipt = subscription.unsubscribe(); + assertThat(receipt).isNotNull(); + assertThat(receipt.getReceiptId()).isNull(); + + Message message = this.messageCaptor.getValue(); + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + assertThat(accessor.getCommand()).isEqualTo(StompCommand.UNSUBSCRIBE); + + StompHeaders stompHeaders = StompHeaders.readOnlyStompHeaders(accessor.getNativeHeaders()); + assertThat(stompHeaders).hasSize(1); + assertThat(stompHeaders.getId()).isEqualTo(subscription.getSubscriptionId()); + } + + @Test + void unsubscribeWithCustomHeaderAndReceipt() { + this.session.afterConnected(this.connection); + this.session.setTaskScheduler(mock()); + this.session.setAutoReceipt(true); + + StompHeaders subHeaders = new StompHeaders(); + subHeaders.setDestination("/topic/foo"); + Subscription subscription = this.session.subscribe(subHeaders, mock()); + + StompHeaders custom = new StompHeaders(); + custom.set("x-cust", "value"); + + Receiptable receipt = subscription.unsubscribe(custom); + assertThat(receipt).isNotNull(); + assertThat(receipt.getReceiptId()).isNotNull(); + + Message message = this.messageCaptor.getValue(); + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + assertThat(accessor.getCommand()).isEqualTo(StompCommand.UNSUBSCRIBE); + + StompHeaders stompHeaders = StompHeaders.readOnlyStompHeaders(accessor.getNativeHeaders()); + assertThat(stompHeaders.getId()).isEqualTo(subscription.getSubscriptionId()); + assertThat(stompHeaders.get("x-cust")).containsExactly("value"); + assertThat(stompHeaders.getReceipt()).isEqualTo(receipt.getReceiptId()); + } + + @Test + void receiptReceivedOnUnsubscribe() { + this.session.afterConnected(this.connection); + TaskScheduler scheduler = mock(); + this.session.setTaskScheduler(scheduler); + this.session.setAutoReceipt(true); + + Subscription subscription = this.session.subscribe("/topic/foo", mock()); + Receiptable receipt = subscription.unsubscribe(); + + StompHeaderAccessor ack = StompHeaderAccessor.create(StompCommand.RECEIPT); + ack.setReceiptId(receipt.getReceiptId()); + ack.setLeaveMutable(true); + Message receiptMessage = MessageBuilder.createMessage(new byte[0], ack.getMessageHeaders()); + + AtomicBoolean called = new AtomicBoolean(false); + receipt.addReceiptTask(() -> called.set(true)); + + this.session.handleMessage(receiptMessage); + + assertThat(called.get()).isTrue(); + } + @Test void disconnect() { this.session.afterConnected(this.connection); From fbe96a81129a6111f95e68e1e6bd0e1c57a37f3c Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Tue, 12 Aug 2025 06:30:08 +0100 Subject: [PATCH 075/591] Polishing contribution Closes gh-35224 --- .../simp/stomp/DefaultStompSession.java | 1 - .../messaging/simp/stomp/StompSession.java | 6 ++- .../simp/stomp/DefaultStompSessionTests.java | 42 +++++-------------- 3 files changed, 14 insertions(+), 35 deletions(-) diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/DefaultStompSession.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/DefaultStompSession.java index 6f72bb0f953e..4dd0418e6ab5 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/DefaultStompSession.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/DefaultStompSession.java @@ -351,7 +351,6 @@ private Receiptable unsubscribe(String id, @Nullable StompHeaders headers) { if (headers == null){ headers = new StompHeaders(); } - String receiptId = checkOrAddReceipt(headers); Receiptable receiptable = new ReceiptHandler(receiptId); diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompSession.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompSession.java index bcb816c0f6a7..1bce9ff94d0a 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompSession.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/stomp/StompSession.java @@ -182,13 +182,15 @@ interface Subscription extends Receiptable { /** * Remove the subscription by sending an UNSUBSCRIBE frame. + *

    As of 7.0, this method returns {@link Receiptable}. */ Receiptable unsubscribe(); /** * Alternative to {@link #unsubscribe()} with additional custom headers - * to send to the server. - *

    Note: There is no need to set the subscription id. + * to send to the server. Note, however, there is no need to set the + * subscription id. + *

    As of 7.0, this method returns {@link Receiptable}. * @param headers the custom headers, if any * @since 5.0 */ diff --git a/spring-messaging/src/test/java/org/springframework/messaging/simp/stomp/DefaultStompSessionTests.java b/spring-messaging/src/test/java/org/springframework/messaging/simp/stomp/DefaultStompSessionTests.java index c310afb870f7..fea9dc4496ef 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/simp/stomp/DefaultStompSessionTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/simp/stomp/DefaultStompSessionTests.java @@ -664,7 +664,7 @@ public void receiptNotReceived() { } @Test - void unsubscribeWithReceipt() { + void unsubscribeWithoutReceipt() { this.session.afterConnected(this.connection); assertThat(this.session.isConnected()).isTrue(); Subscription subscription = this.session.subscribe("/topic/foo", mock()); @@ -672,62 +672,40 @@ void unsubscribeWithReceipt() { Receiptable receipt = subscription.unsubscribe(); assertThat(receipt).isNotNull(); assertThat(receipt.getReceiptId()).isNull(); - - Message message = this.messageCaptor.getValue(); - StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); - assertThat(accessor.getCommand()).isEqualTo(StompCommand.UNSUBSCRIBE); - - StompHeaders stompHeaders = StompHeaders.readOnlyStompHeaders(accessor.getNativeHeaders()); - assertThat(stompHeaders).hasSize(1); - assertThat(stompHeaders.getId()).isEqualTo(subscription.getSubscriptionId()); } @Test - void unsubscribeWithCustomHeaderAndReceipt() { + void unsubscribeWithReceipt() { this.session.afterConnected(this.connection); this.session.setTaskScheduler(mock()); this.session.setAutoReceipt(true); + Subscription subscription = this.session.subscribe("/topic/foo", mock()); - StompHeaders subHeaders = new StompHeaders(); - subHeaders.setDestination("/topic/foo"); - Subscription subscription = this.session.subscribe(subHeaders, mock()); - - StompHeaders custom = new StompHeaders(); - custom.set("x-cust", "value"); - - Receiptable receipt = subscription.unsubscribe(custom); + Receiptable receipt = subscription.unsubscribe(); assertThat(receipt).isNotNull(); assertThat(receipt.getReceiptId()).isNotNull(); Message message = this.messageCaptor.getValue(); StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); - assertThat(accessor.getCommand()).isEqualTo(StompCommand.UNSUBSCRIBE); - - StompHeaders stompHeaders = StompHeaders.readOnlyStompHeaders(accessor.getNativeHeaders()); - assertThat(stompHeaders.getId()).isEqualTo(subscription.getSubscriptionId()); - assertThat(stompHeaders.get("x-cust")).containsExactly("value"); - assertThat(stompHeaders.getReceipt()).isEqualTo(receipt.getReceiptId()); + assertThat(accessor.getReceipt()).isEqualTo(receipt.getReceiptId()); } @Test void receiptReceivedOnUnsubscribe() { this.session.afterConnected(this.connection); - TaskScheduler scheduler = mock(); - this.session.setTaskScheduler(scheduler); + this.session.setTaskScheduler(mock()); this.session.setAutoReceipt(true); Subscription subscription = this.session.subscribe("/topic/foo", mock()); Receiptable receipt = subscription.unsubscribe(); - StompHeaderAccessor ack = StompHeaderAccessor.create(StompCommand.RECEIPT); - ack.setReceiptId(receipt.getReceiptId()); - ack.setLeaveMutable(true); - Message receiptMessage = MessageBuilder.createMessage(new byte[0], ack.getMessageHeaders()); - AtomicBoolean called = new AtomicBoolean(false); receipt.addReceiptTask(() -> called.set(true)); - this.session.handleMessage(receiptMessage); + StompHeaderAccessor ack = StompHeaderAccessor.create(StompCommand.RECEIPT); + ack.setReceiptId(receipt.getReceiptId()); + ack.setLeaveMutable(true); + this.session.handleMessage(MessageBuilder.createMessage(new byte[0], ack.getMessageHeaders())); assertThat(called.get()).isTrue(); } From 169b7015d2353dbdc33aee3e0bca15afb4968839 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Mon, 11 Aug 2025 17:32:27 +0100 Subject: [PATCH 076/591] Only add `httpServiceProxyRegistry` bean when necessary Update `AbstractHttpServiceRegistrar` so that the `httpServiceProxyRegistry` bean is only added when registrations are found. --- .../AbstractHttpServiceRegistrar.java | 30 ++++++++++--------- .../web/service/registry/GroupsMetadata.java | 7 +++++ .../ClientHttpServiceRegistrarTests.java | 25 ++++++++++++++++ .../registry/HttpServiceRegistrarTests.java | 3 +- 4 files changed, 49 insertions(+), 16 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java b/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java index 9fc72368d515..1ee41b4f5d3b 100644 --- a/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java +++ b/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java @@ -147,20 +147,22 @@ public final void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefin registerHttpServices(new DefaultGroupRegistry(), metadata); - RootBeanDefinition proxyRegistryBeanDef = createOrGetRegistry(beanRegistry); - - mergeGroups(proxyRegistryBeanDef); - - this.groupsMetadata.forEachRegistration((groupName, types) -> types.forEach(type -> { - RootBeanDefinition proxyBeanDef = new RootBeanDefinition(); - proxyBeanDef.setBeanClassName(type); - proxyBeanDef.setAttribute(HTTP_SERVICE_GROUP_NAME_ATTRIBUTE, groupName); - proxyBeanDef.setInstanceSupplier(() -> getProxyInstance(groupName, type)); - String beanName = (groupName + "#" + type); - if (!beanRegistry.containsBeanDefinition(beanName)) { - beanRegistry.registerBeanDefinition(beanName, proxyBeanDef); - } - })); + if (this.groupsMetadata.hasRegistrations()) { + RootBeanDefinition proxyRegistryBeanDef = createOrGetRegistry(beanRegistry); + + mergeGroups(proxyRegistryBeanDef); + + this.groupsMetadata.forEachRegistration((groupName, types) -> types.forEach(type -> { + RootBeanDefinition proxyBeanDef = new RootBeanDefinition(); + proxyBeanDef.setBeanClassName(type); + proxyBeanDef.setAttribute(HTTP_SERVICE_GROUP_NAME_ATTRIBUTE, groupName); + proxyBeanDef.setInstanceSupplier(() -> getProxyInstance(groupName, type)); + String beanName = (groupName + "#" + type); + if (!beanRegistry.containsBeanDefinition(beanName)) { + beanRegistry.registerBeanDefinition(beanName, proxyBeanDef); + } + })); + } } /** diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/GroupsMetadata.java b/spring-web/src/main/java/org/springframework/web/service/registry/GroupsMetadata.java index 04df0e33a0e1..4aa5ec1c8bbb 100644 --- a/spring-web/src/main/java/org/springframework/web/service/registry/GroupsMetadata.java +++ b/spring-web/src/main/java/org/springframework/web/service/registry/GroupsMetadata.java @@ -97,6 +97,13 @@ Stream registrations() { return this.groupMap.values().stream(); } + /** + * Return if there are any {@link Registration registrations}. + */ + boolean hasRegistrations() { + return !this.groupMap.isEmpty(); + } + /** * Registration metadata for an {@link HttpServiceGroup}. diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/ClientHttpServiceRegistrarTests.java b/spring-web/src/test/java/org/springframework/web/service/registry/ClientHttpServiceRegistrarTests.java index 7f60b777538c..57b507fa700c 100644 --- a/spring-web/src/test/java/org/springframework/web/service/registry/ClientHttpServiceRegistrarTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/registry/ClientHttpServiceRegistrarTests.java @@ -22,6 +22,9 @@ import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; import org.springframework.core.env.StandardEnvironment; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.core.type.AnnotationMetadata; @@ -63,6 +66,13 @@ protected void registerHttpServices(GroupRegistry registry, AnnotationMetadata i TestGroup.ofListing("echo", EchoClientA.class, EchoClientB.class)); } + @Test + void registerWhenNoClientsDoesNotCreateBeans() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(NothingFoundConfiguration.class)) { + assertThat(context.getBeanNamesForType(HttpServiceProxyRegistry.class)).isEmpty(); + } + } + private void assertGroups(TestGroup... expectedGroups) { Map groupMap = this.groupRegistry.groupMap(); assertThat(groupMap.size()).isEqualTo(expectedGroups.length); @@ -75,4 +85,19 @@ private void assertGroups(TestGroup... expectedGroups) { } } + @Configuration(proxyBeanMethods = false) + @Import(NothingFoundRegistrar.class) + static class NothingFoundConfiguration { + + } + + static class NothingFoundRegistrar extends AbstractClientHttpServiceRegistrar { + + @Override + protected void registerHttpServices(GroupRegistry registry, + AnnotationMetadata importingClassMetadata) { + findAndRegisterHttpServiceClients(registry, List.of("com.example.missing.package")); + } + } + } diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceRegistrarTests.java b/spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceRegistrarTests.java index 80b34af8105e..35ef88180e42 100644 --- a/spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceRegistrarTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceRegistrarTests.java @@ -120,8 +120,7 @@ void defaultClientType() { @Test void noRegistrations() { doRegister(registry -> {}); - assertRegistryBeanDef(); - assertBeanDefinitionCount(1); + assertBeanDefinitionCount(0); } From 553f289ddbb3dabad9e1ec27fdd89b20ba6bf680 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Tue, 12 Aug 2025 06:43:10 +0100 Subject: [PATCH 077/591] Polishing contribution Closes gh-35307 --- .../AbstractHttpServiceRegistrar.java | 31 ++++++++++--------- .../web/service/registry/GroupsMetadata.java | 6 ++-- .../ClientHttpServiceRegistrarTests.java | 17 +++++----- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java b/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java index 1ee41b4f5d3b..ea8af343994b 100644 --- a/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java +++ b/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java @@ -147,22 +147,23 @@ public final void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefin registerHttpServices(new DefaultGroupRegistry(), metadata); - if (this.groupsMetadata.hasRegistrations()) { - RootBeanDefinition proxyRegistryBeanDef = createOrGetRegistry(beanRegistry); - - mergeGroups(proxyRegistryBeanDef); - - this.groupsMetadata.forEachRegistration((groupName, types) -> types.forEach(type -> { - RootBeanDefinition proxyBeanDef = new RootBeanDefinition(); - proxyBeanDef.setBeanClassName(type); - proxyBeanDef.setAttribute(HTTP_SERVICE_GROUP_NAME_ATTRIBUTE, groupName); - proxyBeanDef.setInstanceSupplier(() -> getProxyInstance(groupName, type)); - String beanName = (groupName + "#" + type); - if (!beanRegistry.containsBeanDefinition(beanName)) { - beanRegistry.registerBeanDefinition(beanName, proxyBeanDef); - } - })); + if (this.groupsMetadata.isEmpty()) { + return; } + + RootBeanDefinition proxyRegistryBeanDef = createOrGetRegistry(beanRegistry); + mergeGroups(proxyRegistryBeanDef); + + this.groupsMetadata.forEachRegistration((groupName, types) -> types.forEach(type -> { + RootBeanDefinition proxyBeanDef = new RootBeanDefinition(); + proxyBeanDef.setBeanClassName(type); + proxyBeanDef.setAttribute(HTTP_SERVICE_GROUP_NAME_ATTRIBUTE, groupName); + proxyBeanDef.setInstanceSupplier(() -> getProxyInstance(groupName, type)); + String beanName = (groupName + "#" + type); + if (!beanRegistry.containsBeanDefinition(beanName)) { + beanRegistry.registerBeanDefinition(beanName, proxyBeanDef); + } + })); } /** diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/GroupsMetadata.java b/spring-web/src/main/java/org/springframework/web/service/registry/GroupsMetadata.java index 4aa5ec1c8bbb..0c991d4fad5a 100644 --- a/spring-web/src/main/java/org/springframework/web/service/registry/GroupsMetadata.java +++ b/spring-web/src/main/java/org/springframework/web/service/registry/GroupsMetadata.java @@ -98,10 +98,10 @@ Stream registrations() { } /** - * Return if there are any {@link Registration registrations}. + * Return {@code true} if there are no {@link Registration registrations}. */ - boolean hasRegistrations() { - return !this.groupMap.isEmpty(); + boolean isEmpty() { + return this.groupMap.isEmpty(); } diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/ClientHttpServiceRegistrarTests.java b/spring-web/src/test/java/org/springframework/web/service/registry/ClientHttpServiceRegistrarTests.java index 57b507fa700c..3373945b12f9 100644 --- a/spring-web/src/test/java/org/springframework/web/service/registry/ClientHttpServiceRegistrarTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/registry/ClientHttpServiceRegistrarTests.java @@ -68,8 +68,8 @@ protected void registerHttpServices(GroupRegistry registry, AnnotationMetadata i @Test void registerWhenNoClientsDoesNotCreateBeans() { - try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(NothingFoundConfiguration.class)) { - assertThat(context.getBeanNamesForType(HttpServiceProxyRegistry.class)).isEmpty(); + try (AnnotationConfigApplicationContext cxt = new AnnotationConfigApplicationContext(NoOpImportConfig.class)) { + assertThat(cxt.getBeanNamesForType(HttpServiceProxyRegistry.class)).isEmpty(); } } @@ -85,18 +85,17 @@ private void assertGroups(TestGroup... expectedGroups) { } } - @Configuration(proxyBeanMethods = false) - @Import(NothingFoundRegistrar.class) - static class NothingFoundConfiguration { + @Configuration(proxyBeanMethods = false) + @Import(NoOpRegistrar.class) + static class NoOpImportConfig { } - static class NothingFoundRegistrar extends AbstractClientHttpServiceRegistrar { + + static class NoOpRegistrar extends AbstractClientHttpServiceRegistrar { @Override - protected void registerHttpServices(GroupRegistry registry, - AnnotationMetadata importingClassMetadata) { - findAndRegisterHttpServiceClients(registry, List.of("com.example.missing.package")); + protected void registerHttpServices(GroupRegistry registry, AnnotationMetadata metadata) { } } From bfcf4ea81868e45477a680bd244cc0c38aac0ad5 Mon Sep 17 00:00:00 2001 From: Fabrice Bibonne Date: Mon, 21 Jul 2025 09:44:59 +0200 Subject: [PATCH 078/591] Document HTTP range request constraints See gh-35227 Signed-off-by: Fabrice Bibonne --- framework-docs/modules/ROOT/pages/web/webmvc/mvc-range.adoc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-range.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-range.adoc index f47c85cb79e6..7e38dc4954e6 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-range.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-range.adoc @@ -15,6 +15,8 @@ xref:web/webmvc-functional.adoc#webmvc-fn-resources[serves a `Resource`]. `Range support is also transparently handled when serving xref:web/webmvc/mvc-config/static-resources.adoc[static resources]. +NOTE: To be handled transparently, the `Resource` object must not be a `InputStreamResource` and, in case of an annotated controller returning `ResponseEntity`, the status of the response must be 200. + The underlying support is in the `HttpRange` class, which exposes methods to parse `Range` headers and split a `Resource` into a `List` that in turn can be then written to the response via `ResourceRegionHttpMessageConverter`. \ No newline at end of file From 83b7bef572233e6bc5c21212d036c37e0bad3da0 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Tue, 12 Aug 2025 06:55:36 +0100 Subject: [PATCH 079/591] Polishing contribution Closes gh-35227 --- framework-docs/modules/ROOT/pages/web/webflux/range.adoc | 3 +++ framework-docs/modules/ROOT/pages/web/webmvc/mvc-range.adoc | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/framework-docs/modules/ROOT/pages/web/webflux/range.adoc b/framework-docs/modules/ROOT/pages/web/webflux/range.adoc index 7e2d5417ffe5..edcd170bd574 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/range.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/range.adoc @@ -15,6 +15,9 @@ xref:web/webflux-functional.adoc#webflux-fn-resources[serves a `Resource`]. `Ran support is also transparently handled when serving xref:web/webflux/config.adoc#webflux-config-static-resources[static resources]. +TIP: The `Resource` must not be an `InputStreamResource` and with `ResponseEntity`, +the status of the response must be 200. + The underlying support is in the `HttpRange` class, which exposes methods to parse `Range` headers and split a `Resource` into a `List` that in turn can be then written to the response via `ResourceRegionEncoder` and `ResourceHttpMessageWriter`. \ No newline at end of file diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-range.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-range.adoc index 7e38dc4954e6..7ba60ead5379 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-range.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-range.adoc @@ -15,7 +15,8 @@ xref:web/webmvc-functional.adoc#webmvc-fn-resources[serves a `Resource`]. `Range support is also transparently handled when serving xref:web/webmvc/mvc-config/static-resources.adoc[static resources]. -NOTE: To be handled transparently, the `Resource` object must not be a `InputStreamResource` and, in case of an annotated controller returning `ResponseEntity`, the status of the response must be 200. +TIP: The `Resource` must not be an `InputStreamResource` and with `ResponseEntity`, +the status of the response must be 200. The underlying support is in the `HttpRange` class, which exposes methods to parse `Range` headers and split a `Resource` into a `List` that in turn can be From bde806b7fc6dbca7cc71789de82a24958f21872a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Mon, 11 Aug 2025 17:06:22 +0200 Subject: [PATCH 080/591] Upgrade SDKMAN to Java 24.0.2 --- .sdkmanrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.sdkmanrc b/.sdkmanrc index b41ba343b002..b280a1b69076 100644 --- a/.sdkmanrc +++ b/.sdkmanrc @@ -1,3 +1,3 @@ # Enable auto-env through the sdkman_auto_env config # Add key=value pairs of SDKs to use below -java=24.0.1-librca +java=24.0.2-librca From d115f36400eb84ce6ec5b97830af6d69c54c14f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Tue, 12 Aug 2025 10:18:49 +0200 Subject: [PATCH 081/591] Use JsonMapper instead of ObjectMapper when relevant This commit updates Jackson 3 support to use JsonMapper instead of ObjectMapper in converter, codec and view constructors. Closes gh-35282 --- .../JacksonJsonMessageConverter.java | 33 +++++++------- .../JacksonJsonMessageConverter.java | 45 +++++++++---------- .../json/AbstractJsonContentAssertTests.java | 4 +- .../test/json/JsonPathValueAssertTests.java | 4 +- .../util/JsonPathExpectationsHelperTests.java | 16 +++---- .../EncoderDecoderMappingProviderTests.java | 6 +-- .../server/JsonEncoderDecoderTests.java | 8 ++-- .../http/codec/json/JacksonJsonDecoder.java | 9 ++-- .../http/codec/json/JacksonJsonEncoder.java | 9 ++-- .../json/JacksonJsonHttpMessageConverter.java | 7 ++- .../codec/json/JacksonJsonDecoderTests.java | 13 +++--- .../codec/json/JacksonJsonEncoderTests.java | 7 ++- .../JacksonJsonHttpMessageConverterTests.java | 4 +- .../servlet/view/json/JacksonJsonView.java | 9 ++-- .../function/SseServerResponseTests.java | 5 +-- .../view/json/JacksonJsonViewTests.java | 3 +- .../frame/JacksonJsonSockJsMessageCodec.java | 17 ++++--- 17 files changed, 94 insertions(+), 105 deletions(-) diff --git a/spring-jms/src/main/java/org/springframework/jms/support/converter/JacksonJsonMessageConverter.java b/spring-jms/src/main/java/org/springframework/jms/support/converter/JacksonJsonMessageConverter.java index eabc2f4b49d2..e1d0d9f2dfaf 100644 --- a/spring-jms/src/main/java/org/springframework/jms/support/converter/JacksonJsonMessageConverter.java +++ b/spring-jms/src/main/java/org/springframework/jms/support/converter/JacksonJsonMessageConverter.java @@ -32,7 +32,6 @@ import jakarta.jms.TextMessage; import org.jspecify.annotations.Nullable; import tools.jackson.databind.JavaType; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectWriter; import tools.jackson.databind.cfg.MapperBuilder; import tools.jackson.databind.json.JsonMapper; @@ -63,7 +62,7 @@ public class JacksonJsonMessageConverter implements SmartMessageConverter, BeanC public static final String DEFAULT_ENCODING = "UTF-8"; - private final ObjectMapper objectMapper; + private final JsonMapper jsonMapper; private MessageType targetType = MessageType.BYTES; @@ -86,17 +85,17 @@ public class JacksonJsonMessageConverter implements SmartMessageConverter, BeanC * {@link MapperBuilder#findModules(ClassLoader)}. */ public JacksonJsonMessageConverter() { - this.objectMapper = JsonMapper.builder().findAndAddModules(JacksonJsonMessageConverter.class.getClassLoader()).build(); + this.jsonMapper = JsonMapper.builder().findAndAddModules(JacksonJsonMessageConverter.class.getClassLoader()).build(); } /** - * Construct a new instance with the provided {@link ObjectMapper}. + * Construct a new instance with the provided {@link JsonMapper}. * @see JsonMapper#builder() * @see MapperBuilder#findModules(ClassLoader) */ - public JacksonJsonMessageConverter(ObjectMapper objectMapper) { - Assert.notNull(objectMapper, "ObjectMapper must not be null"); - this.objectMapper = objectMapper; + public JacksonJsonMessageConverter(JsonMapper jsonMapper) { + Assert.notNull(jsonMapper, "JsonMapper must not be null"); + this.jsonMapper = jsonMapper; } /** @@ -173,9 +172,9 @@ public Message toMessage(Object object, Session session) throws JMSException, Me Message message; try { message = switch (this.targetType) { - case TEXT -> mapToTextMessage(object, session, this.objectMapper.writer()); - case BYTES -> mapToBytesMessage(object, session, this.objectMapper.writer()); - default -> mapToMessage(object, session, this.objectMapper.writer(), this.targetType); + case TEXT -> mapToTextMessage(object, session, this.jsonMapper.writer()); + case BYTES -> mapToBytesMessage(object, session, this.jsonMapper.writer()); + default -> mapToMessage(object, session, this.jsonMapper.writer(), this.targetType); }; } catch (IOException ex) { @@ -206,10 +205,10 @@ public Message toMessage(Object object, Session session, @Nullable Class json throws JMSException, MessageConversionException { if (jsonView != null) { - return toMessage(object, session, this.objectMapper.writerWithView(jsonView)); + return toMessage(object, session, this.jsonMapper.writerWithView(jsonView)); } else { - return toMessage(object, session, this.objectMapper.writer()); + return toMessage(object, session, this.jsonMapper.writer()); } } @@ -363,7 +362,7 @@ protected Object convertFromTextMessage(TextMessage message, JavaType targetJava throws JMSException, IOException { String body = message.getText(); - return this.objectMapper.readValue(body, targetJavaType); + return this.jsonMapper.readValue(body, targetJavaType); } /** @@ -386,7 +385,7 @@ protected Object convertFromBytesMessage(BytesMessage message, JavaType targetJa if (encoding != null) { try { String body = new String(bytes, encoding); - return this.objectMapper.readValue(body, targetJavaType); + return this.jsonMapper.readValue(body, targetJavaType); } catch (UnsupportedEncodingException ex) { throw new MessageConversionException("Cannot convert bytes to String", ex); @@ -394,7 +393,7 @@ protected Object convertFromBytesMessage(BytesMessage message, JavaType targetJa } else { // Jackson internally performs encoding detection, falling back to UTF-8. - return this.objectMapper.readValue(bytes, targetJavaType); + return this.jsonMapper.readValue(bytes, targetJavaType); } } @@ -437,11 +436,11 @@ protected JavaType getJavaTypeForMessage(Message message) throws JMSException { } Class mappedClass = this.idClassMappings.get(typeId); if (mappedClass != null) { - return this.objectMapper.constructType(mappedClass); + return this.jsonMapper.constructType(mappedClass); } try { Class typeClass = ClassUtils.forName(typeId, this.beanClassLoader); - return this.objectMapper.constructType(typeClass); + return this.jsonMapper.constructType(typeClass); } catch (Throwable ex) { throw new MessageConversionException("Failed to resolve type id [" + typeId + "]", ex); diff --git a/spring-messaging/src/main/java/org/springframework/messaging/converter/JacksonJsonMessageConverter.java b/spring-messaging/src/main/java/org/springframework/messaging/converter/JacksonJsonMessageConverter.java index 804f05b411ea..a2eb535e6d96 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/converter/JacksonJsonMessageConverter.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/converter/JacksonJsonMessageConverter.java @@ -27,7 +27,6 @@ import tools.jackson.core.JsonEncoding; import tools.jackson.core.JsonGenerator; import tools.jackson.databind.JavaType; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.cfg.MapperBuilder; import tools.jackson.databind.json.JsonMapper; @@ -52,7 +51,7 @@ public class JacksonJsonMessageConverter extends AbstractMessageConverter { private static final MimeType[] DEFAULT_MIME_TYPES = new MimeType[] { new MimeType("application", "json"), new MimeType("application", "*+json")}; - private final ObjectMapper objectMapper; + private final JsonMapper jsonMapper; /** @@ -73,35 +72,35 @@ public JacksonJsonMessageConverter() { */ public JacksonJsonMessageConverter(MimeType... supportedMimeTypes) { super(supportedMimeTypes); - this.objectMapper = JsonMapper.builder().findAndAddModules(JacksonJsonMessageConverter.class.getClassLoader()).build(); + this.jsonMapper = JsonMapper.builder().findAndAddModules(JacksonJsonMessageConverter.class.getClassLoader()).build(); } /** - * Construct a new instance with the provided {@link ObjectMapper}. + * Construct a new instance with the provided {@link JsonMapper}. * @see JsonMapper#builder() * @see MapperBuilder#findModules(ClassLoader) */ - public JacksonJsonMessageConverter(ObjectMapper objectMapper) { - this(objectMapper, DEFAULT_MIME_TYPES); + public JacksonJsonMessageConverter(JsonMapper jsonMapper) { + this(jsonMapper, DEFAULT_MIME_TYPES); } /** - * Construct a new instance with the provided {@link ObjectMapper} and the + * Construct a new instance with the provided {@link JsonMapper} and the * provided {@link MimeType}s. * @see JsonMapper#builder() * @see MapperBuilder#findModules(ClassLoader) */ - public JacksonJsonMessageConverter(ObjectMapper objectMapper, MimeType... supportedMimeTypes) { + public JacksonJsonMessageConverter(JsonMapper jsonMapper, MimeType... supportedMimeTypes) { super(supportedMimeTypes); - Assert.notNull(objectMapper, "ObjectMapper must not be null"); - this.objectMapper = objectMapper; + Assert.notNull(jsonMapper, "JsonMapper must not be null"); + this.jsonMapper = jsonMapper; } /** - * Return the underlying {@code ObjectMapper} for this converter. + * Return the underlying {@code JsonMapper} for this converter. */ - protected ObjectMapper getObjectMapper() { - return this.objectMapper; + protected JsonMapper getJsonMapper() { + return this.jsonMapper; } @Override @@ -122,7 +121,7 @@ protected boolean supports(Class clazz) { @Override protected @Nullable Object convertFromInternal(Message message, Class targetClass, @Nullable Object conversionHint) { - JavaType javaType = this.objectMapper.constructType(getResolvedType(targetClass, conversionHint)); + JavaType javaType = this.jsonMapper.constructType(getResolvedType(targetClass, conversionHint)); Object payload = message.getPayload(); Class view = getSerializationView(conversionHint); try { @@ -131,19 +130,19 @@ protected boolean supports(Class clazz) { } else if (payload instanceof byte[] bytes) { if (view != null) { - return this.objectMapper.readerWithView(view).forType(javaType).readValue(bytes); + return this.jsonMapper.readerWithView(view).forType(javaType).readValue(bytes); } else { - return this.objectMapper.readValue(bytes, javaType); + return this.jsonMapper.readValue(bytes, javaType); } } else { // Assuming a text-based source payload if (view != null) { - return this.objectMapper.readerWithView(view).forType(javaType).readValue(payload.toString()); + return this.jsonMapper.readerWithView(view).forType(javaType).readValue(payload.toString()); } else { - return this.objectMapper.readValue(payload.toString(), javaType); + return this.jsonMapper.readValue(payload.toString(), javaType); } } } @@ -161,12 +160,12 @@ else if (payload instanceof byte[] bytes) { if (byte[].class == getSerializedPayloadClass()) { ByteArrayOutputStream out = new ByteArrayOutputStream(1024); JsonEncoding encoding = getJsonEncoding(getMimeType(headers)); - try (JsonGenerator generator = this.objectMapper.createGenerator(out, encoding)) { + try (JsonGenerator generator = this.jsonMapper.createGenerator(out, encoding)) { if (view != null) { - this.objectMapper.writerWithView(view).writeValue(generator, payload); + this.jsonMapper.writerWithView(view).writeValue(generator, payload); } else { - this.objectMapper.writeValue(generator, payload); + this.jsonMapper.writeValue(generator, payload); } payload = out.toByteArray(); } @@ -175,10 +174,10 @@ else if (payload instanceof byte[] bytes) { // Assuming a text-based target payload Writer writer = new StringWriter(1024); if (view != null) { - this.objectMapper.writerWithView(view).writeValue(writer, payload); + this.jsonMapper.writerWithView(view).writeValue(writer, payload); } else { - this.objectMapper.writeValue(writer, payload); + this.jsonMapper.writeValue(writer, payload); } payload = writer.toString(); } diff --git a/spring-test/src/test/java/org/springframework/test/json/AbstractJsonContentAssertTests.java b/spring-test/src/test/java/org/springframework/test/json/AbstractJsonContentAssertTests.java index 92287b4d0177..c37ea1914d5e 100644 --- a/spring-test/src/test/java/org/springframework/test/json/AbstractJsonContentAssertTests.java +++ b/spring-test/src/test/java/org/springframework/test/json/AbstractJsonContentAssertTests.java @@ -45,7 +45,7 @@ import org.skyscreamer.jsonassert.JSONCompareMode; import org.skyscreamer.jsonassert.JSONCompareResult; import org.skyscreamer.jsonassert.comparator.JSONComparator; -import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.ClassPathResource; @@ -86,7 +86,7 @@ class AbstractJsonContentAssertTests { private static final String DIFFERENT = loadJson("different.json"); private static final HttpMessageContentConverter jsonContentConverter = HttpMessageContentConverter.of( - new JacksonJsonHttpMessageConverter(new ObjectMapper())); + new JacksonJsonHttpMessageConverter(new JsonMapper())); private static final JsonComparator comparator = JsonAssert.comparator(JsonCompareMode.LENIENT); diff --git a/spring-test/src/test/java/org/springframework/test/json/JsonPathValueAssertTests.java b/spring-test/src/test/java/org/springframework/test/json/JsonPathValueAssertTests.java index c0a3b832eb5b..1c26fa1865e5 100644 --- a/spring-test/src/test/java/org/springframework/test/json/JsonPathValueAssertTests.java +++ b/spring-test/src/test/java/org/springframework/test/json/JsonPathValueAssertTests.java @@ -27,7 +27,7 @@ import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; import org.springframework.test.http.HttpMessageContentConverter; @@ -206,7 +206,7 @@ void asMapWithNullFails() { class ConvertToTests { private static final HttpMessageContentConverter jsonContentConverter = HttpMessageContentConverter.of( - new JacksonJsonHttpMessageConverter(new ObjectMapper())); + new JacksonJsonHttpMessageConverter(new JsonMapper())); @Test void convertToWithoutHttpMessageConverter() { diff --git a/spring-test/src/test/java/org/springframework/test/util/JsonPathExpectationsHelperTests.java b/spring-test/src/test/java/org/springframework/test/util/JsonPathExpectationsHelperTests.java index 17c068dae759..f1c0f00e8d71 100644 --- a/spring-test/src/test/java/org/springframework/test/util/JsonPathExpectationsHelperTests.java +++ b/spring-test/src/test/java/org/springframework/test/util/JsonPathExpectationsHelperTests.java @@ -27,7 +27,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import tools.jackson.databind.JavaType; -import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; import org.springframework.core.ParameterizedTypeReference; @@ -385,14 +385,14 @@ public record Member(String name) {} */ private static class JacksonMappingProvider implements MappingProvider { - private final ObjectMapper objectMapper; + private final JsonMapper jsonMapper; public JacksonMappingProvider() { - this(new ObjectMapper()); + this(new JsonMapper()); } - public JacksonMappingProvider(ObjectMapper objectMapper) { - this.objectMapper = objectMapper; + public JacksonMappingProvider(JsonMapper jsonMapper) { + this.jsonMapper = jsonMapper; } @@ -402,7 +402,7 @@ public T map(Object source, Class targetType, Configuration configuration return null; } try { - return objectMapper.convertValue(source, targetType); + return jsonMapper.convertValue(source, targetType); } catch (Exception ex) { throw new MappingException(ex); @@ -416,10 +416,10 @@ public T map(Object source, final TypeRef targetType, Configuration confi if (source == null){ return null; } - JavaType type = objectMapper.getTypeFactory().constructType(targetType.getType()); + JavaType type = jsonMapper.getTypeFactory().constructType(targetType.getType()); try { - return (T) objectMapper.convertValue(source, type); + return (T) jsonMapper.convertValue(source, type); } catch (Exception ex) { throw new MappingException(ex); diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/EncoderDecoderMappingProviderTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/EncoderDecoderMappingProviderTests.java index 190a72b4866a..1051834999c3 100644 --- a/spring-test/src/test/java/org/springframework/test/web/reactive/server/EncoderDecoderMappingProviderTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/EncoderDecoderMappingProviderTests.java @@ -22,7 +22,7 @@ import com.jayway.jsonpath.Configuration; import com.jayway.jsonpath.TypeRef; import org.junit.jupiter.api.Test; -import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; import org.springframework.http.codec.json.JacksonJsonDecoder; import org.springframework.http.codec.json.JacksonJsonEncoder; @@ -36,10 +36,10 @@ */ class EncoderDecoderMappingProviderTests { - private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final JsonMapper jsonMapper = new JsonMapper(); private final EncoderDecoderMappingProvider mappingProvider = new EncoderDecoderMappingProvider( - new JacksonJsonEncoder(objectMapper), new JacksonJsonDecoder(objectMapper)); + new JacksonJsonEncoder(jsonMapper), new JacksonJsonDecoder(jsonMapper)); @Test diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/JsonEncoderDecoderTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/JsonEncoderDecoderTests.java index 2d5ee9beb8ae..62432a05a035 100644 --- a/spring-test/src/test/java/org/springframework/test/web/reactive/server/JsonEncoderDecoderTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/JsonEncoderDecoderTests.java @@ -19,7 +19,7 @@ import java.util.List; import org.junit.jupiter.api.Test; -import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; import org.springframework.http.codec.DecoderHttpMessageReader; import org.springframework.http.codec.EncoderHttpMessageWriter; @@ -39,13 +39,13 @@ */ class JsonEncoderDecoderTests { - private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final JsonMapper jsonMapper = new JsonMapper(); private static final HttpMessageWriter jacksonMessageWriter = new EncoderHttpMessageWriter<>( - new JacksonJsonEncoder(objectMapper)); + new JacksonJsonEncoder(jsonMapper)); private static final HttpMessageReader jacksonMessageReader = new DecoderHttpMessageReader<>( - new JacksonJsonDecoder(objectMapper)); + new JacksonJsonDecoder(jsonMapper)); @Test void fromWithEmptyWriters() { diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonDecoder.java b/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonDecoder.java index a4202c5c1a5f..fef5ea45a7f5 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonDecoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonDecoder.java @@ -25,7 +25,6 @@ import org.jspecify.annotations.Nullable; import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.cfg.MapperBuilder; import tools.jackson.databind.json.JsonMapper; @@ -73,20 +72,20 @@ public JacksonJsonDecoder() { } /** - * Construct a new instance with the provided {@link ObjectMapper}. + * Construct a new instance with the provided {@link JsonMapper}. * @see JsonMapper#builder() * @see MapperBuilder#findModules(ClassLoader) */ - public JacksonJsonDecoder(ObjectMapper mapper) { + public JacksonJsonDecoder(JsonMapper mapper) { this(mapper, DEFAULT_JSON_MIME_TYPES); } /** - * Construct a new instance with the provided {@link ObjectMapper} and {@link MimeType}s. + * Construct a new instance with the provided {@link JsonMapper} and {@link MimeType}s. * @see JsonMapper#builder() * @see MapperBuilder#findModules(ClassLoader) */ - public JacksonJsonDecoder(ObjectMapper mapper, MimeType... mimeTypes) { + public JacksonJsonDecoder(JsonMapper mapper, MimeType... mimeTypes) { super(mapper, mimeTypes); } diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonEncoder.java b/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonEncoder.java index f883727aa582..6e331781799e 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonEncoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonEncoder.java @@ -25,7 +25,6 @@ import tools.jackson.core.PrettyPrinter; import tools.jackson.core.util.DefaultIndenter; import tools.jackson.core.util.DefaultPrettyPrinter; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectWriter; import tools.jackson.databind.SerializationFeature; import tools.jackson.databind.cfg.MapperBuilder; @@ -80,21 +79,21 @@ public JacksonJsonEncoder() { } /** - * Construct a new instance with the provided {@link ObjectMapper}. + * Construct a new instance with the provided {@link JsonMapper}. * @see JsonMapper#builder() * @see MapperBuilder#findModules(ClassLoader) */ - public JacksonJsonEncoder(ObjectMapper mapper) { + public JacksonJsonEncoder(JsonMapper mapper) { this(mapper, DEFAULT_JSON_MIME_TYPES); } /** - * Construct a new instance with the provided {@link ObjectMapper} and + * Construct a new instance with the provided {@link JsonMapper} and * {@link MimeType}s. * @see JsonMapper#builder() * @see MapperBuilder#findModules(ClassLoader) */ - public JacksonJsonEncoder(ObjectMapper mapper, MimeType... mimeTypes) { + public JacksonJsonEncoder(JsonMapper mapper, MimeType... mimeTypes) { super(mapper, mimeTypes); setStreamingMediaTypes(List.of(MediaType.APPLICATION_NDJSON)); this.ssePrettyPrinter = initSsePrettyPrinter(); diff --git a/spring-web/src/main/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverter.java index f1902a8ad618..c87304c317bb 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverter.java @@ -21,7 +21,6 @@ import org.jspecify.annotations.Nullable; import tools.jackson.core.JsonGenerator; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.cfg.MapperBuilder; import tools.jackson.databind.json.JsonMapper; @@ -32,7 +31,7 @@ /** * Implementation of {@link org.springframework.http.converter.HttpMessageConverter} * that can read and write JSON using Jackson 3.x's - * {@link ObjectMapper}. + * {@link JsonMapper}. * *

    This converter can be used to bind to typed beans, or untyped * {@code HashMap} instances. @@ -79,11 +78,11 @@ public JacksonJsonHttpMessageConverter() { } /** - * Construct a new instance with the provided {@link ObjectMapper}. + * Construct a new instance with the provided {@link JsonMapper}. * @see JsonMapper#builder() * @see MapperBuilder#findModules(ClassLoader) */ - public JacksonJsonHttpMessageConverter(ObjectMapper objectMapper) { + public JacksonJsonHttpMessageConverter(JsonMapper objectMapper) { super(objectMapper, DEFAULT_JSON_MIME_TYPES); } diff --git a/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonDecoderTests.java b/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonDecoderTests.java index 56a202d0f21a..254c157b6bdb 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonDecoderTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonDecoderTests.java @@ -31,7 +31,6 @@ import tools.jackson.core.JsonParser; import tools.jackson.databind.DeserializationContext; import tools.jackson.databind.JsonNode; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.annotation.JsonDeserialize; import tools.jackson.databind.deser.std.StdDeserializer; import tools.jackson.databind.json.JsonMapper; @@ -102,8 +101,8 @@ void canDecodeWithObjectMapperRegistrationForType() { assertThat(decoder.canDecode(ResolvableType.forClass(Map.class), MediaType.APPLICATION_JSON)).isTrue(); decoder.registerObjectMappersForType(Pojo.class, map -> { - map.put(halJsonMediaType, new ObjectMapper()); - map.put(MediaType.APPLICATION_JSON, new ObjectMapper()); + map.put(halJsonMediaType, new JsonMapper()); + map.put(MediaType.APPLICATION_JSON, new JsonMapper()); }); assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), halJsonMediaType)).isTrue(); @@ -115,7 +114,7 @@ void canDecodeWithObjectMapperRegistrationForType() { @Test // SPR-15866 void canDecodeWithProvidedMimeType() { MimeType textJavascript = new MimeType("text", "javascript", StandardCharsets.UTF_8); - JacksonJsonDecoder decoder = new JacksonJsonDecoder(new ObjectMapper(), textJavascript); + JacksonJsonDecoder decoder = new JacksonJsonDecoder(new JsonMapper(), textJavascript); assertThat(decoder.getDecodableMimeTypes()).isEqualTo(Collections.singletonList(textJavascript)); } @@ -124,7 +123,7 @@ void canDecodeWithProvidedMimeType() { @SuppressWarnings("unchecked") void decodableMimeTypesIsImmutable() { MimeType textJavascript = new MimeType("text", "javascript", StandardCharsets.UTF_8); - JacksonJsonDecoder decoder = new JacksonJsonDecoder(new ObjectMapper(), textJavascript); + JacksonJsonDecoder decoder = new JacksonJsonDecoder(new JsonMapper(), textJavascript); assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> decoder.getDecodableMimeTypes().add(new MimeType("text", "ecmascript"))); @@ -135,8 +134,8 @@ void decodableMimeTypesWithObjectMapperRegistration() { MimeType mimeType1 = MediaType.parseMediaType("application/hal+json"); MimeType mimeType2 = new MimeType("text", "javascript", StandardCharsets.UTF_8); - JacksonJsonDecoder decoder = new JacksonJsonDecoder(new ObjectMapper(), mimeType2); - decoder.registerObjectMappersForType(Pojo.class, map -> map.put(mimeType1, new ObjectMapper())); + JacksonJsonDecoder decoder = new JacksonJsonDecoder(new JsonMapper(), mimeType2); + decoder.registerObjectMappersForType(Pojo.class, map -> map.put(mimeType1, new JsonMapper())); assertThat(decoder.getDecodableMimeTypes(ResolvableType.forClass(Pojo.class))) .containsExactly(mimeType1); diff --git a/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonEncoderTests.java b/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonEncoderTests.java index d0a0ff530f43..229fb9fed27b 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonEncoderTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonEncoderTests.java @@ -28,7 +28,6 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.SerializationFeature; import tools.jackson.databind.json.JsonMapper; @@ -109,7 +108,7 @@ public void encode() throws Exception { @Test // SPR-15866 public void canEncodeWithCustomMimeType() { MimeType textJavascript = new MimeType("text", "javascript", StandardCharsets.UTF_8); - JacksonJsonEncoder encoder = new JacksonJsonEncoder(new ObjectMapper(), textJavascript); + JacksonJsonEncoder encoder = new JacksonJsonEncoder(new JsonMapper(), textJavascript); assertThat(encoder.getEncodableMimeTypes()).isEqualTo(Collections.singletonList(textJavascript)); } @@ -117,7 +116,7 @@ public void canEncodeWithCustomMimeType() { @Test void encodableMimeTypesIsImmutable() { MimeType textJavascript = new MimeType("text", "javascript", StandardCharsets.UTF_8); - JacksonJsonEncoder encoder = new JacksonJsonEncoder(new ObjectMapper(), textJavascript); + JacksonJsonEncoder encoder = new JacksonJsonEncoder(new JsonMapper(), textJavascript); assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> encoder.getEncodableMimeTypes().add(new MimeType("text", "ecmascript"))); @@ -231,7 +230,7 @@ void classLevelJsonView() { @Test // gh-22771 public void encodeWithFlushAfterWriteOff() { - ObjectMapper mapper = JsonMapper.builder().configure(SerializationFeature.FLUSH_AFTER_WRITE_VALUE, false).build(); + JsonMapper mapper = JsonMapper.builder().configure(SerializationFeature.FLUSH_AFTER_WRITE_VALUE, false).build(); JacksonJsonEncoder encoder = new JacksonJsonEncoder(mapper); Flux result = encoder.encode(Flux.just(new Pojo("foo", "bar")), this.bufferFactory, diff --git a/spring-web/src/test/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverterTests.java b/spring-web/src/test/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverterTests.java index b3f7e08f0b5e..34c5b5e8bfa1 100644 --- a/spring-web/src/test/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverterTests.java +++ b/spring-web/src/test/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverterTests.java @@ -365,7 +365,7 @@ void prettyPrint() throws Exception { PrettyPrintBean bean = new PrettyPrintBean(); bean.setName("Jason"); - ObjectMapper mapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); + JsonMapper mapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); this.converter = new JacksonJsonHttpMessageConverter(mapper); this.converter.write(bean, ResolvableType.forType(PrettyPrintBean.class), MediaType.APPLICATION_JSON, outputMessage, null); @@ -384,7 +384,7 @@ void prettyPrintWithSse() throws Exception { PrettyPrintBean bean = new PrettyPrintBean(); bean.setName("Jason"); - ObjectMapper mapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); + JsonMapper mapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); this.converter = new JacksonJsonHttpMessageConverter(mapper); this.converter.write(bean, ResolvableType.forType(PrettyPrintBean.class), MediaType.APPLICATION_JSON, outputMessage, null); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/JacksonJsonView.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/JacksonJsonView.java index 6c2abcc89412..371b66825005 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/JacksonJsonView.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/JacksonJsonView.java @@ -24,7 +24,6 @@ import jakarta.servlet.http.HttpServletRequest; import org.jspecify.annotations.Nullable; import tools.jackson.core.JsonGenerator; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.cfg.MapperBuilder; import tools.jackson.databind.json.JsonMapper; @@ -35,7 +34,7 @@ /** * Spring MVC {@link View} that renders JSON content by serializing the model for the current request - * using Jackson 3's {@link ObjectMapper}. + * using Jackson 3's {@link JsonMapper}. * *

    By default, the entire contents of the model map (with the exception of framework-specific classes) * will be encoded as JSON. If the model contains only one key, you can have it extracted encoded as JSON @@ -79,11 +78,11 @@ public JacksonJsonView() { } /** - * Construct a new instance using the provided {@link ObjectMapper} + * Construct a new instance using the provided {@link JsonMapper} * and setting the content type to {@value #DEFAULT_CONTENT_TYPE}. */ - public JacksonJsonView(ObjectMapper objectMapper) { - super(objectMapper, DEFAULT_CONTENT_TYPE); + public JacksonJsonView(JsonMapper jsonMapper) { + super(jsonMapper, DEFAULT_CONTENT_TYPE); } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/SseServerResponseTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/SseServerResponseTests.java index ef13f5626ca8..a23f0fdcc0bb 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/SseServerResponseTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/SseServerResponseTests.java @@ -24,7 +24,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.SerializationFeature; import tools.jackson.databind.json.JsonMapper; @@ -109,8 +108,8 @@ void sendObjectWithPrettyPrint() throws Exception { } }); - ObjectMapper objectMapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); - JacksonJsonHttpMessageConverter converter = new JacksonJsonHttpMessageConverter(objectMapper); + JsonMapper jsonMapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); + JacksonJsonHttpMessageConverter converter = new JacksonJsonHttpMessageConverter(jsonMapper); ServerResponse.Context context = () -> List.of(converter); ModelAndView mav = response.writeTo(this.mockRequest, this.mockResponse, context); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/view/json/JacksonJsonViewTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/view/json/JacksonJsonViewTests.java index 86a1212d718a..2186de361e75 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/view/json/JacksonJsonViewTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/view/json/JacksonJsonViewTests.java @@ -33,7 +33,6 @@ import tools.jackson.core.JsonGenerator; import tools.jackson.databind.BeanDescription; import tools.jackson.databind.JavaType; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.SerializationContext; import tools.jackson.databind.SerializationFeature; import tools.jackson.databind.ValueSerializer; @@ -181,7 +180,7 @@ void renderSimpleBeanNotPrefixed() throws Exception { @Test void renderWithCustomSerializerLocatedByFactory() throws Exception { SerializerFactory factory = new DelegatingSerializerFactory(null); - ObjectMapper mapper = JsonMapper.builder().serializerFactory(factory).build(); + JsonMapper mapper = JsonMapper.builder().serializerFactory(factory).build(); view = new JacksonJsonView(mapper); Object bean = new TestBeanSimple(); diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/frame/JacksonJsonSockJsMessageCodec.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/frame/JacksonJsonSockJsMessageCodec.java index d52820dcbc6f..35941b22e4e9 100644 --- a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/frame/JacksonJsonSockJsMessageCodec.java +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/frame/JacksonJsonSockJsMessageCodec.java @@ -20,7 +20,6 @@ import com.fasterxml.jackson.core.io.JsonStringEncoder; import org.jspecify.annotations.Nullable; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.cfg.MapperBuilder; import tools.jackson.databind.json.JsonMapper; @@ -37,7 +36,7 @@ */ public class JacksonJsonSockJsMessageCodec extends AbstractSockJsMessageCodec { - private final ObjectMapper objectMapper; + private final JsonMapper jsonMapper; /** @@ -46,28 +45,28 @@ public class JacksonJsonSockJsMessageCodec extends AbstractSockJsMessageCodec { * {@link MapperBuilder#findModules(ClassLoader)}. */ public JacksonJsonSockJsMessageCodec() { - this.objectMapper = JsonMapper.builder().findAndAddModules(JacksonJsonSockJsMessageCodec.class.getClassLoader()).build(); + this.jsonMapper = JsonMapper.builder().findAndAddModules(JacksonJsonSockJsMessageCodec.class.getClassLoader()).build(); } /** - * Construct a new instance with the provided {@link ObjectMapper}. + * Construct a new instance with the provided {@link JsonMapper}. * @see JsonMapper#builder() * @see MapperBuilder#findAndAddModules(ClassLoader) */ - public JacksonJsonSockJsMessageCodec(ObjectMapper objectMapper) { - Assert.notNull(objectMapper, "ObjectMapper must not be null"); - this.objectMapper = objectMapper; + public JacksonJsonSockJsMessageCodec(JsonMapper jsonMapper) { + Assert.notNull(jsonMapper, "JsonMapper must not be null"); + this.jsonMapper = jsonMapper; } @Override public String @Nullable [] decode(String content) { - return this.objectMapper.readValue(content, String[].class); + return this.jsonMapper.readValue(content, String[].class); } @Override public String @Nullable [] decodeInputStream(InputStream content) { - return this.objectMapper.readValue(content, String[].class); + return this.jsonMapper.readValue(content, String[].class); } @Override From 49b28be1be41477b431cfa98747cdaf6d93528be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Tue, 12 Aug 2025 10:43:07 +0200 Subject: [PATCH 082/591] Fix JacksonJsonSockJsMessageCodec imports Closes gh-35309 --- .../socket/sockjs/frame/JacksonJsonSockJsMessageCodec.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/frame/JacksonJsonSockJsMessageCodec.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/frame/JacksonJsonSockJsMessageCodec.java index 35941b22e4e9..8bbedfb14962 100644 --- a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/frame/JacksonJsonSockJsMessageCodec.java +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/frame/JacksonJsonSockJsMessageCodec.java @@ -18,8 +18,8 @@ import java.io.InputStream; -import com.fasterxml.jackson.core.io.JsonStringEncoder; import org.jspecify.annotations.Nullable; +import tools.jackson.core.io.JsonStringEncoder; import tools.jackson.databind.cfg.MapperBuilder; import tools.jackson.databind.json.JsonMapper; @@ -71,7 +71,7 @@ public JacksonJsonSockJsMessageCodec(JsonMapper jsonMapper) { @Override protected char[] applyJsonQuoting(String content) { - return JsonStringEncoder.getInstance().quoteAsString(content); + return JsonStringEncoder.getInstance().quoteAsCharArray(content); } } From 0389e3e3afb3bfb50083fa7c7288ede88bb49aa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Tue, 12 Aug 2025 15:16:03 +0200 Subject: [PATCH 083/591] Revert "Use JsonMapper instead of ObjectMapper when relevant" This reverts commit d115f36400eb84ce6ec5b97830af6d69c54c14f3. See gh-35282 --- .../JacksonJsonMessageConverter.java | 33 +++++++------- .../JacksonJsonMessageConverter.java | 45 ++++++++++--------- .../json/AbstractJsonContentAssertTests.java | 4 +- .../test/json/JsonPathValueAssertTests.java | 4 +- .../util/JsonPathExpectationsHelperTests.java | 16 +++---- .../EncoderDecoderMappingProviderTests.java | 6 +-- .../server/JsonEncoderDecoderTests.java | 8 ++-- .../http/codec/json/JacksonJsonDecoder.java | 9 ++-- .../http/codec/json/JacksonJsonEncoder.java | 9 ++-- .../json/JacksonJsonHttpMessageConverter.java | 7 +-- .../codec/json/JacksonJsonDecoderTests.java | 13 +++--- .../codec/json/JacksonJsonEncoderTests.java | 7 +-- .../JacksonJsonHttpMessageConverterTests.java | 4 +- .../servlet/view/json/JacksonJsonView.java | 9 ++-- .../function/SseServerResponseTests.java | 5 ++- .../view/json/JacksonJsonViewTests.java | 3 +- .../frame/JacksonJsonSockJsMessageCodec.java | 17 +++---- 17 files changed, 105 insertions(+), 94 deletions(-) diff --git a/spring-jms/src/main/java/org/springframework/jms/support/converter/JacksonJsonMessageConverter.java b/spring-jms/src/main/java/org/springframework/jms/support/converter/JacksonJsonMessageConverter.java index e1d0d9f2dfaf..eabc2f4b49d2 100644 --- a/spring-jms/src/main/java/org/springframework/jms/support/converter/JacksonJsonMessageConverter.java +++ b/spring-jms/src/main/java/org/springframework/jms/support/converter/JacksonJsonMessageConverter.java @@ -32,6 +32,7 @@ import jakarta.jms.TextMessage; import org.jspecify.annotations.Nullable; import tools.jackson.databind.JavaType; +import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectWriter; import tools.jackson.databind.cfg.MapperBuilder; import tools.jackson.databind.json.JsonMapper; @@ -62,7 +63,7 @@ public class JacksonJsonMessageConverter implements SmartMessageConverter, BeanC public static final String DEFAULT_ENCODING = "UTF-8"; - private final JsonMapper jsonMapper; + private final ObjectMapper objectMapper; private MessageType targetType = MessageType.BYTES; @@ -85,17 +86,17 @@ public class JacksonJsonMessageConverter implements SmartMessageConverter, BeanC * {@link MapperBuilder#findModules(ClassLoader)}. */ public JacksonJsonMessageConverter() { - this.jsonMapper = JsonMapper.builder().findAndAddModules(JacksonJsonMessageConverter.class.getClassLoader()).build(); + this.objectMapper = JsonMapper.builder().findAndAddModules(JacksonJsonMessageConverter.class.getClassLoader()).build(); } /** - * Construct a new instance with the provided {@link JsonMapper}. + * Construct a new instance with the provided {@link ObjectMapper}. * @see JsonMapper#builder() * @see MapperBuilder#findModules(ClassLoader) */ - public JacksonJsonMessageConverter(JsonMapper jsonMapper) { - Assert.notNull(jsonMapper, "JsonMapper must not be null"); - this.jsonMapper = jsonMapper; + public JacksonJsonMessageConverter(ObjectMapper objectMapper) { + Assert.notNull(objectMapper, "ObjectMapper must not be null"); + this.objectMapper = objectMapper; } /** @@ -172,9 +173,9 @@ public Message toMessage(Object object, Session session) throws JMSException, Me Message message; try { message = switch (this.targetType) { - case TEXT -> mapToTextMessage(object, session, this.jsonMapper.writer()); - case BYTES -> mapToBytesMessage(object, session, this.jsonMapper.writer()); - default -> mapToMessage(object, session, this.jsonMapper.writer(), this.targetType); + case TEXT -> mapToTextMessage(object, session, this.objectMapper.writer()); + case BYTES -> mapToBytesMessage(object, session, this.objectMapper.writer()); + default -> mapToMessage(object, session, this.objectMapper.writer(), this.targetType); }; } catch (IOException ex) { @@ -205,10 +206,10 @@ public Message toMessage(Object object, Session session, @Nullable Class json throws JMSException, MessageConversionException { if (jsonView != null) { - return toMessage(object, session, this.jsonMapper.writerWithView(jsonView)); + return toMessage(object, session, this.objectMapper.writerWithView(jsonView)); } else { - return toMessage(object, session, this.jsonMapper.writer()); + return toMessage(object, session, this.objectMapper.writer()); } } @@ -362,7 +363,7 @@ protected Object convertFromTextMessage(TextMessage message, JavaType targetJava throws JMSException, IOException { String body = message.getText(); - return this.jsonMapper.readValue(body, targetJavaType); + return this.objectMapper.readValue(body, targetJavaType); } /** @@ -385,7 +386,7 @@ protected Object convertFromBytesMessage(BytesMessage message, JavaType targetJa if (encoding != null) { try { String body = new String(bytes, encoding); - return this.jsonMapper.readValue(body, targetJavaType); + return this.objectMapper.readValue(body, targetJavaType); } catch (UnsupportedEncodingException ex) { throw new MessageConversionException("Cannot convert bytes to String", ex); @@ -393,7 +394,7 @@ protected Object convertFromBytesMessage(BytesMessage message, JavaType targetJa } else { // Jackson internally performs encoding detection, falling back to UTF-8. - return this.jsonMapper.readValue(bytes, targetJavaType); + return this.objectMapper.readValue(bytes, targetJavaType); } } @@ -436,11 +437,11 @@ protected JavaType getJavaTypeForMessage(Message message) throws JMSException { } Class mappedClass = this.idClassMappings.get(typeId); if (mappedClass != null) { - return this.jsonMapper.constructType(mappedClass); + return this.objectMapper.constructType(mappedClass); } try { Class typeClass = ClassUtils.forName(typeId, this.beanClassLoader); - return this.jsonMapper.constructType(typeClass); + return this.objectMapper.constructType(typeClass); } catch (Throwable ex) { throw new MessageConversionException("Failed to resolve type id [" + typeId + "]", ex); diff --git a/spring-messaging/src/main/java/org/springframework/messaging/converter/JacksonJsonMessageConverter.java b/spring-messaging/src/main/java/org/springframework/messaging/converter/JacksonJsonMessageConverter.java index a2eb535e6d96..804f05b411ea 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/converter/JacksonJsonMessageConverter.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/converter/JacksonJsonMessageConverter.java @@ -27,6 +27,7 @@ import tools.jackson.core.JsonEncoding; import tools.jackson.core.JsonGenerator; import tools.jackson.databind.JavaType; +import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.cfg.MapperBuilder; import tools.jackson.databind.json.JsonMapper; @@ -51,7 +52,7 @@ public class JacksonJsonMessageConverter extends AbstractMessageConverter { private static final MimeType[] DEFAULT_MIME_TYPES = new MimeType[] { new MimeType("application", "json"), new MimeType("application", "*+json")}; - private final JsonMapper jsonMapper; + private final ObjectMapper objectMapper; /** @@ -72,35 +73,35 @@ public JacksonJsonMessageConverter() { */ public JacksonJsonMessageConverter(MimeType... supportedMimeTypes) { super(supportedMimeTypes); - this.jsonMapper = JsonMapper.builder().findAndAddModules(JacksonJsonMessageConverter.class.getClassLoader()).build(); + this.objectMapper = JsonMapper.builder().findAndAddModules(JacksonJsonMessageConverter.class.getClassLoader()).build(); } /** - * Construct a new instance with the provided {@link JsonMapper}. + * Construct a new instance with the provided {@link ObjectMapper}. * @see JsonMapper#builder() * @see MapperBuilder#findModules(ClassLoader) */ - public JacksonJsonMessageConverter(JsonMapper jsonMapper) { - this(jsonMapper, DEFAULT_MIME_TYPES); + public JacksonJsonMessageConverter(ObjectMapper objectMapper) { + this(objectMapper, DEFAULT_MIME_TYPES); } /** - * Construct a new instance with the provided {@link JsonMapper} and the + * Construct a new instance with the provided {@link ObjectMapper} and the * provided {@link MimeType}s. * @see JsonMapper#builder() * @see MapperBuilder#findModules(ClassLoader) */ - public JacksonJsonMessageConverter(JsonMapper jsonMapper, MimeType... supportedMimeTypes) { + public JacksonJsonMessageConverter(ObjectMapper objectMapper, MimeType... supportedMimeTypes) { super(supportedMimeTypes); - Assert.notNull(jsonMapper, "JsonMapper must not be null"); - this.jsonMapper = jsonMapper; + Assert.notNull(objectMapper, "ObjectMapper must not be null"); + this.objectMapper = objectMapper; } /** - * Return the underlying {@code JsonMapper} for this converter. + * Return the underlying {@code ObjectMapper} for this converter. */ - protected JsonMapper getJsonMapper() { - return this.jsonMapper; + protected ObjectMapper getObjectMapper() { + return this.objectMapper; } @Override @@ -121,7 +122,7 @@ protected boolean supports(Class clazz) { @Override protected @Nullable Object convertFromInternal(Message message, Class targetClass, @Nullable Object conversionHint) { - JavaType javaType = this.jsonMapper.constructType(getResolvedType(targetClass, conversionHint)); + JavaType javaType = this.objectMapper.constructType(getResolvedType(targetClass, conversionHint)); Object payload = message.getPayload(); Class view = getSerializationView(conversionHint); try { @@ -130,19 +131,19 @@ protected boolean supports(Class clazz) { } else if (payload instanceof byte[] bytes) { if (view != null) { - return this.jsonMapper.readerWithView(view).forType(javaType).readValue(bytes); + return this.objectMapper.readerWithView(view).forType(javaType).readValue(bytes); } else { - return this.jsonMapper.readValue(bytes, javaType); + return this.objectMapper.readValue(bytes, javaType); } } else { // Assuming a text-based source payload if (view != null) { - return this.jsonMapper.readerWithView(view).forType(javaType).readValue(payload.toString()); + return this.objectMapper.readerWithView(view).forType(javaType).readValue(payload.toString()); } else { - return this.jsonMapper.readValue(payload.toString(), javaType); + return this.objectMapper.readValue(payload.toString(), javaType); } } } @@ -160,12 +161,12 @@ else if (payload instanceof byte[] bytes) { if (byte[].class == getSerializedPayloadClass()) { ByteArrayOutputStream out = new ByteArrayOutputStream(1024); JsonEncoding encoding = getJsonEncoding(getMimeType(headers)); - try (JsonGenerator generator = this.jsonMapper.createGenerator(out, encoding)) { + try (JsonGenerator generator = this.objectMapper.createGenerator(out, encoding)) { if (view != null) { - this.jsonMapper.writerWithView(view).writeValue(generator, payload); + this.objectMapper.writerWithView(view).writeValue(generator, payload); } else { - this.jsonMapper.writeValue(generator, payload); + this.objectMapper.writeValue(generator, payload); } payload = out.toByteArray(); } @@ -174,10 +175,10 @@ else if (payload instanceof byte[] bytes) { // Assuming a text-based target payload Writer writer = new StringWriter(1024); if (view != null) { - this.jsonMapper.writerWithView(view).writeValue(writer, payload); + this.objectMapper.writerWithView(view).writeValue(writer, payload); } else { - this.jsonMapper.writeValue(writer, payload); + this.objectMapper.writeValue(writer, payload); } payload = writer.toString(); } diff --git a/spring-test/src/test/java/org/springframework/test/json/AbstractJsonContentAssertTests.java b/spring-test/src/test/java/org/springframework/test/json/AbstractJsonContentAssertTests.java index c37ea1914d5e..92287b4d0177 100644 --- a/spring-test/src/test/java/org/springframework/test/json/AbstractJsonContentAssertTests.java +++ b/spring-test/src/test/java/org/springframework/test/json/AbstractJsonContentAssertTests.java @@ -45,7 +45,7 @@ import org.skyscreamer.jsonassert.JSONCompareMode; import org.skyscreamer.jsonassert.JSONCompareResult; import org.skyscreamer.jsonassert.comparator.JSONComparator; -import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.ObjectMapper; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.ClassPathResource; @@ -86,7 +86,7 @@ class AbstractJsonContentAssertTests { private static final String DIFFERENT = loadJson("different.json"); private static final HttpMessageContentConverter jsonContentConverter = HttpMessageContentConverter.of( - new JacksonJsonHttpMessageConverter(new JsonMapper())); + new JacksonJsonHttpMessageConverter(new ObjectMapper())); private static final JsonComparator comparator = JsonAssert.comparator(JsonCompareMode.LENIENT); diff --git a/spring-test/src/test/java/org/springframework/test/json/JsonPathValueAssertTests.java b/spring-test/src/test/java/org/springframework/test/json/JsonPathValueAssertTests.java index 1c26fa1865e5..c0a3b832eb5b 100644 --- a/spring-test/src/test/java/org/springframework/test/json/JsonPathValueAssertTests.java +++ b/spring-test/src/test/java/org/springframework/test/json/JsonPathValueAssertTests.java @@ -27,7 +27,7 @@ import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.ObjectMapper; import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; import org.springframework.test.http.HttpMessageContentConverter; @@ -206,7 +206,7 @@ void asMapWithNullFails() { class ConvertToTests { private static final HttpMessageContentConverter jsonContentConverter = HttpMessageContentConverter.of( - new JacksonJsonHttpMessageConverter(new JsonMapper())); + new JacksonJsonHttpMessageConverter(new ObjectMapper())); @Test void convertToWithoutHttpMessageConverter() { diff --git a/spring-test/src/test/java/org/springframework/test/util/JsonPathExpectationsHelperTests.java b/spring-test/src/test/java/org/springframework/test/util/JsonPathExpectationsHelperTests.java index f1c0f00e8d71..17c068dae759 100644 --- a/spring-test/src/test/java/org/springframework/test/util/JsonPathExpectationsHelperTests.java +++ b/spring-test/src/test/java/org/springframework/test/util/JsonPathExpectationsHelperTests.java @@ -27,7 +27,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import tools.jackson.databind.JavaType; -import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.ObjectMapper; import org.springframework.core.ParameterizedTypeReference; @@ -385,14 +385,14 @@ public record Member(String name) {} */ private static class JacksonMappingProvider implements MappingProvider { - private final JsonMapper jsonMapper; + private final ObjectMapper objectMapper; public JacksonMappingProvider() { - this(new JsonMapper()); + this(new ObjectMapper()); } - public JacksonMappingProvider(JsonMapper jsonMapper) { - this.jsonMapper = jsonMapper; + public JacksonMappingProvider(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; } @@ -402,7 +402,7 @@ public T map(Object source, Class targetType, Configuration configuration return null; } try { - return jsonMapper.convertValue(source, targetType); + return objectMapper.convertValue(source, targetType); } catch (Exception ex) { throw new MappingException(ex); @@ -416,10 +416,10 @@ public T map(Object source, final TypeRef targetType, Configuration confi if (source == null){ return null; } - JavaType type = jsonMapper.getTypeFactory().constructType(targetType.getType()); + JavaType type = objectMapper.getTypeFactory().constructType(targetType.getType()); try { - return (T) jsonMapper.convertValue(source, type); + return (T) objectMapper.convertValue(source, type); } catch (Exception ex) { throw new MappingException(ex); diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/EncoderDecoderMappingProviderTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/EncoderDecoderMappingProviderTests.java index 1051834999c3..190a72b4866a 100644 --- a/spring-test/src/test/java/org/springframework/test/web/reactive/server/EncoderDecoderMappingProviderTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/EncoderDecoderMappingProviderTests.java @@ -22,7 +22,7 @@ import com.jayway.jsonpath.Configuration; import com.jayway.jsonpath.TypeRef; import org.junit.jupiter.api.Test; -import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.ObjectMapper; import org.springframework.http.codec.json.JacksonJsonDecoder; import org.springframework.http.codec.json.JacksonJsonEncoder; @@ -36,10 +36,10 @@ */ class EncoderDecoderMappingProviderTests { - private static final JsonMapper jsonMapper = new JsonMapper(); + private static final ObjectMapper objectMapper = new ObjectMapper(); private final EncoderDecoderMappingProvider mappingProvider = new EncoderDecoderMappingProvider( - new JacksonJsonEncoder(jsonMapper), new JacksonJsonDecoder(jsonMapper)); + new JacksonJsonEncoder(objectMapper), new JacksonJsonDecoder(objectMapper)); @Test diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/JsonEncoderDecoderTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/JsonEncoderDecoderTests.java index 62432a05a035..2d5ee9beb8ae 100644 --- a/spring-test/src/test/java/org/springframework/test/web/reactive/server/JsonEncoderDecoderTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/JsonEncoderDecoderTests.java @@ -19,7 +19,7 @@ import java.util.List; import org.junit.jupiter.api.Test; -import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.ObjectMapper; import org.springframework.http.codec.DecoderHttpMessageReader; import org.springframework.http.codec.EncoderHttpMessageWriter; @@ -39,13 +39,13 @@ */ class JsonEncoderDecoderTests { - private static final JsonMapper jsonMapper = new JsonMapper(); + private static final ObjectMapper objectMapper = new ObjectMapper(); private static final HttpMessageWriter jacksonMessageWriter = new EncoderHttpMessageWriter<>( - new JacksonJsonEncoder(jsonMapper)); + new JacksonJsonEncoder(objectMapper)); private static final HttpMessageReader jacksonMessageReader = new DecoderHttpMessageReader<>( - new JacksonJsonDecoder(jsonMapper)); + new JacksonJsonDecoder(objectMapper)); @Test void fromWithEmptyWriters() { diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonDecoder.java b/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonDecoder.java index fef5ea45a7f5..a4202c5c1a5f 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonDecoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonDecoder.java @@ -25,6 +25,7 @@ import org.jspecify.annotations.Nullable; import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; +import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.cfg.MapperBuilder; import tools.jackson.databind.json.JsonMapper; @@ -72,20 +73,20 @@ public JacksonJsonDecoder() { } /** - * Construct a new instance with the provided {@link JsonMapper}. + * Construct a new instance with the provided {@link ObjectMapper}. * @see JsonMapper#builder() * @see MapperBuilder#findModules(ClassLoader) */ - public JacksonJsonDecoder(JsonMapper mapper) { + public JacksonJsonDecoder(ObjectMapper mapper) { this(mapper, DEFAULT_JSON_MIME_TYPES); } /** - * Construct a new instance with the provided {@link JsonMapper} and {@link MimeType}s. + * Construct a new instance with the provided {@link ObjectMapper} and {@link MimeType}s. * @see JsonMapper#builder() * @see MapperBuilder#findModules(ClassLoader) */ - public JacksonJsonDecoder(JsonMapper mapper, MimeType... mimeTypes) { + public JacksonJsonDecoder(ObjectMapper mapper, MimeType... mimeTypes) { super(mapper, mimeTypes); } diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonEncoder.java b/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonEncoder.java index 6e331781799e..f883727aa582 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonEncoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonEncoder.java @@ -25,6 +25,7 @@ import tools.jackson.core.PrettyPrinter; import tools.jackson.core.util.DefaultIndenter; import tools.jackson.core.util.DefaultPrettyPrinter; +import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectWriter; import tools.jackson.databind.SerializationFeature; import tools.jackson.databind.cfg.MapperBuilder; @@ -79,21 +80,21 @@ public JacksonJsonEncoder() { } /** - * Construct a new instance with the provided {@link JsonMapper}. + * Construct a new instance with the provided {@link ObjectMapper}. * @see JsonMapper#builder() * @see MapperBuilder#findModules(ClassLoader) */ - public JacksonJsonEncoder(JsonMapper mapper) { + public JacksonJsonEncoder(ObjectMapper mapper) { this(mapper, DEFAULT_JSON_MIME_TYPES); } /** - * Construct a new instance with the provided {@link JsonMapper} and + * Construct a new instance with the provided {@link ObjectMapper} and * {@link MimeType}s. * @see JsonMapper#builder() * @see MapperBuilder#findModules(ClassLoader) */ - public JacksonJsonEncoder(JsonMapper mapper, MimeType... mimeTypes) { + public JacksonJsonEncoder(ObjectMapper mapper, MimeType... mimeTypes) { super(mapper, mimeTypes); setStreamingMediaTypes(List.of(MediaType.APPLICATION_NDJSON)); this.ssePrettyPrinter = initSsePrettyPrinter(); diff --git a/spring-web/src/main/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverter.java index c87304c317bb..f1902a8ad618 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverter.java @@ -21,6 +21,7 @@ import org.jspecify.annotations.Nullable; import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.cfg.MapperBuilder; import tools.jackson.databind.json.JsonMapper; @@ -31,7 +32,7 @@ /** * Implementation of {@link org.springframework.http.converter.HttpMessageConverter} * that can read and write JSON using Jackson 3.x's - * {@link JsonMapper}. + * {@link ObjectMapper}. * *

    This converter can be used to bind to typed beans, or untyped * {@code HashMap} instances. @@ -78,11 +79,11 @@ public JacksonJsonHttpMessageConverter() { } /** - * Construct a new instance with the provided {@link JsonMapper}. + * Construct a new instance with the provided {@link ObjectMapper}. * @see JsonMapper#builder() * @see MapperBuilder#findModules(ClassLoader) */ - public JacksonJsonHttpMessageConverter(JsonMapper objectMapper) { + public JacksonJsonHttpMessageConverter(ObjectMapper objectMapper) { super(objectMapper, DEFAULT_JSON_MIME_TYPES); } diff --git a/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonDecoderTests.java b/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonDecoderTests.java index 254c157b6bdb..56a202d0f21a 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonDecoderTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonDecoderTests.java @@ -31,6 +31,7 @@ import tools.jackson.core.JsonParser; import tools.jackson.databind.DeserializationContext; import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.annotation.JsonDeserialize; import tools.jackson.databind.deser.std.StdDeserializer; import tools.jackson.databind.json.JsonMapper; @@ -101,8 +102,8 @@ void canDecodeWithObjectMapperRegistrationForType() { assertThat(decoder.canDecode(ResolvableType.forClass(Map.class), MediaType.APPLICATION_JSON)).isTrue(); decoder.registerObjectMappersForType(Pojo.class, map -> { - map.put(halJsonMediaType, new JsonMapper()); - map.put(MediaType.APPLICATION_JSON, new JsonMapper()); + map.put(halJsonMediaType, new ObjectMapper()); + map.put(MediaType.APPLICATION_JSON, new ObjectMapper()); }); assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), halJsonMediaType)).isTrue(); @@ -114,7 +115,7 @@ void canDecodeWithObjectMapperRegistrationForType() { @Test // SPR-15866 void canDecodeWithProvidedMimeType() { MimeType textJavascript = new MimeType("text", "javascript", StandardCharsets.UTF_8); - JacksonJsonDecoder decoder = new JacksonJsonDecoder(new JsonMapper(), textJavascript); + JacksonJsonDecoder decoder = new JacksonJsonDecoder(new ObjectMapper(), textJavascript); assertThat(decoder.getDecodableMimeTypes()).isEqualTo(Collections.singletonList(textJavascript)); } @@ -123,7 +124,7 @@ void canDecodeWithProvidedMimeType() { @SuppressWarnings("unchecked") void decodableMimeTypesIsImmutable() { MimeType textJavascript = new MimeType("text", "javascript", StandardCharsets.UTF_8); - JacksonJsonDecoder decoder = new JacksonJsonDecoder(new JsonMapper(), textJavascript); + JacksonJsonDecoder decoder = new JacksonJsonDecoder(new ObjectMapper(), textJavascript); assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> decoder.getDecodableMimeTypes().add(new MimeType("text", "ecmascript"))); @@ -134,8 +135,8 @@ void decodableMimeTypesWithObjectMapperRegistration() { MimeType mimeType1 = MediaType.parseMediaType("application/hal+json"); MimeType mimeType2 = new MimeType("text", "javascript", StandardCharsets.UTF_8); - JacksonJsonDecoder decoder = new JacksonJsonDecoder(new JsonMapper(), mimeType2); - decoder.registerObjectMappersForType(Pojo.class, map -> map.put(mimeType1, new JsonMapper())); + JacksonJsonDecoder decoder = new JacksonJsonDecoder(new ObjectMapper(), mimeType2); + decoder.registerObjectMappersForType(Pojo.class, map -> map.put(mimeType1, new ObjectMapper())); assertThat(decoder.getDecodableMimeTypes(ResolvableType.forClass(Pojo.class))) .containsExactly(mimeType1); diff --git a/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonEncoderTests.java b/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonEncoderTests.java index 229fb9fed27b..d0a0ff530f43 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonEncoderTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonEncoderTests.java @@ -28,6 +28,7 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.SerializationFeature; import tools.jackson.databind.json.JsonMapper; @@ -108,7 +109,7 @@ public void encode() throws Exception { @Test // SPR-15866 public void canEncodeWithCustomMimeType() { MimeType textJavascript = new MimeType("text", "javascript", StandardCharsets.UTF_8); - JacksonJsonEncoder encoder = new JacksonJsonEncoder(new JsonMapper(), textJavascript); + JacksonJsonEncoder encoder = new JacksonJsonEncoder(new ObjectMapper(), textJavascript); assertThat(encoder.getEncodableMimeTypes()).isEqualTo(Collections.singletonList(textJavascript)); } @@ -116,7 +117,7 @@ public void canEncodeWithCustomMimeType() { @Test void encodableMimeTypesIsImmutable() { MimeType textJavascript = new MimeType("text", "javascript", StandardCharsets.UTF_8); - JacksonJsonEncoder encoder = new JacksonJsonEncoder(new JsonMapper(), textJavascript); + JacksonJsonEncoder encoder = new JacksonJsonEncoder(new ObjectMapper(), textJavascript); assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> encoder.getEncodableMimeTypes().add(new MimeType("text", "ecmascript"))); @@ -230,7 +231,7 @@ void classLevelJsonView() { @Test // gh-22771 public void encodeWithFlushAfterWriteOff() { - JsonMapper mapper = JsonMapper.builder().configure(SerializationFeature.FLUSH_AFTER_WRITE_VALUE, false).build(); + ObjectMapper mapper = JsonMapper.builder().configure(SerializationFeature.FLUSH_AFTER_WRITE_VALUE, false).build(); JacksonJsonEncoder encoder = new JacksonJsonEncoder(mapper); Flux result = encoder.encode(Flux.just(new Pojo("foo", "bar")), this.bufferFactory, diff --git a/spring-web/src/test/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverterTests.java b/spring-web/src/test/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverterTests.java index 34c5b5e8bfa1..b3f7e08f0b5e 100644 --- a/spring-web/src/test/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverterTests.java +++ b/spring-web/src/test/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverterTests.java @@ -365,7 +365,7 @@ void prettyPrint() throws Exception { PrettyPrintBean bean = new PrettyPrintBean(); bean.setName("Jason"); - JsonMapper mapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); + ObjectMapper mapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); this.converter = new JacksonJsonHttpMessageConverter(mapper); this.converter.write(bean, ResolvableType.forType(PrettyPrintBean.class), MediaType.APPLICATION_JSON, outputMessage, null); @@ -384,7 +384,7 @@ void prettyPrintWithSse() throws Exception { PrettyPrintBean bean = new PrettyPrintBean(); bean.setName("Jason"); - JsonMapper mapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); + ObjectMapper mapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); this.converter = new JacksonJsonHttpMessageConverter(mapper); this.converter.write(bean, ResolvableType.forType(PrettyPrintBean.class), MediaType.APPLICATION_JSON, outputMessage, null); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/JacksonJsonView.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/JacksonJsonView.java index 371b66825005..6c2abcc89412 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/JacksonJsonView.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/JacksonJsonView.java @@ -24,6 +24,7 @@ import jakarta.servlet.http.HttpServletRequest; import org.jspecify.annotations.Nullable; import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.cfg.MapperBuilder; import tools.jackson.databind.json.JsonMapper; @@ -34,7 +35,7 @@ /** * Spring MVC {@link View} that renders JSON content by serializing the model for the current request - * using Jackson 3's {@link JsonMapper}. + * using Jackson 3's {@link ObjectMapper}. * *

    By default, the entire contents of the model map (with the exception of framework-specific classes) * will be encoded as JSON. If the model contains only one key, you can have it extracted encoded as JSON @@ -78,11 +79,11 @@ public JacksonJsonView() { } /** - * Construct a new instance using the provided {@link JsonMapper} + * Construct a new instance using the provided {@link ObjectMapper} * and setting the content type to {@value #DEFAULT_CONTENT_TYPE}. */ - public JacksonJsonView(JsonMapper jsonMapper) { - super(jsonMapper, DEFAULT_CONTENT_TYPE); + public JacksonJsonView(ObjectMapper objectMapper) { + super(objectMapper, DEFAULT_CONTENT_TYPE); } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/SseServerResponseTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/SseServerResponseTests.java index a23f0fdcc0bb..ef13f5626ca8 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/SseServerResponseTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/SseServerResponseTests.java @@ -24,6 +24,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.SerializationFeature; import tools.jackson.databind.json.JsonMapper; @@ -108,8 +109,8 @@ void sendObjectWithPrettyPrint() throws Exception { } }); - JsonMapper jsonMapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); - JacksonJsonHttpMessageConverter converter = new JacksonJsonHttpMessageConverter(jsonMapper); + ObjectMapper objectMapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); + JacksonJsonHttpMessageConverter converter = new JacksonJsonHttpMessageConverter(objectMapper); ServerResponse.Context context = () -> List.of(converter); ModelAndView mav = response.writeTo(this.mockRequest, this.mockResponse, context); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/view/json/JacksonJsonViewTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/view/json/JacksonJsonViewTests.java index 2186de361e75..86a1212d718a 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/view/json/JacksonJsonViewTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/view/json/JacksonJsonViewTests.java @@ -33,6 +33,7 @@ import tools.jackson.core.JsonGenerator; import tools.jackson.databind.BeanDescription; import tools.jackson.databind.JavaType; +import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.SerializationContext; import tools.jackson.databind.SerializationFeature; import tools.jackson.databind.ValueSerializer; @@ -180,7 +181,7 @@ void renderSimpleBeanNotPrefixed() throws Exception { @Test void renderWithCustomSerializerLocatedByFactory() throws Exception { SerializerFactory factory = new DelegatingSerializerFactory(null); - JsonMapper mapper = JsonMapper.builder().serializerFactory(factory).build(); + ObjectMapper mapper = JsonMapper.builder().serializerFactory(factory).build(); view = new JacksonJsonView(mapper); Object bean = new TestBeanSimple(); diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/frame/JacksonJsonSockJsMessageCodec.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/frame/JacksonJsonSockJsMessageCodec.java index 8bbedfb14962..70a18aa8cf52 100644 --- a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/frame/JacksonJsonSockJsMessageCodec.java +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/frame/JacksonJsonSockJsMessageCodec.java @@ -20,6 +20,7 @@ import org.jspecify.annotations.Nullable; import tools.jackson.core.io.JsonStringEncoder; +import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.cfg.MapperBuilder; import tools.jackson.databind.json.JsonMapper; @@ -36,7 +37,7 @@ */ public class JacksonJsonSockJsMessageCodec extends AbstractSockJsMessageCodec { - private final JsonMapper jsonMapper; + private final ObjectMapper objectMapper; /** @@ -45,28 +46,28 @@ public class JacksonJsonSockJsMessageCodec extends AbstractSockJsMessageCodec { * {@link MapperBuilder#findModules(ClassLoader)}. */ public JacksonJsonSockJsMessageCodec() { - this.jsonMapper = JsonMapper.builder().findAndAddModules(JacksonJsonSockJsMessageCodec.class.getClassLoader()).build(); + this.objectMapper = JsonMapper.builder().findAndAddModules(JacksonJsonSockJsMessageCodec.class.getClassLoader()).build(); } /** - * Construct a new instance with the provided {@link JsonMapper}. + * Construct a new instance with the provided {@link ObjectMapper}. * @see JsonMapper#builder() * @see MapperBuilder#findAndAddModules(ClassLoader) */ - public JacksonJsonSockJsMessageCodec(JsonMapper jsonMapper) { - Assert.notNull(jsonMapper, "JsonMapper must not be null"); - this.jsonMapper = jsonMapper; + public JacksonJsonSockJsMessageCodec(ObjectMapper objectMapper) { + Assert.notNull(objectMapper, "ObjectMapper must not be null"); + this.objectMapper = objectMapper; } @Override public String @Nullable [] decode(String content) { - return this.jsonMapper.readValue(content, String[].class); + return this.objectMapper.readValue(content, String[].class); } @Override public String @Nullable [] decodeInputStream(InputStream content) { - return this.jsonMapper.readValue(content, String[].class); + return this.objectMapper.readValue(content, String[].class); } @Override From 1d908f1847a2ee1d3e24ba2a789b881691494eeb Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Wed, 13 Aug 2025 00:04:31 +0200 Subject: [PATCH 084/591] Upgrade to Reactor 2024.0.9 and Micrometer 1.14.10 Includes Groovy 4.0.28, JRuby 9.4.13, Jetty 12.0.25, Caffeine 3.2.2, Protobuf 4.31.1, Selenium 4.35, HtmlUnit 4.14 Closes gh-35312 Closes gh-35313 --- framework-platform/framework-platform.gradle | 22 ++++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index e83712dbedd7..4e9178dfa1ae 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -8,16 +8,16 @@ javaPlatform { dependencies { api(platform("com.fasterxml.jackson:jackson-bom:2.18.4")) - api(platform("io.micrometer:micrometer-bom:1.14.9")) + api(platform("io.micrometer:micrometer-bom:1.14.10")) api(platform("io.netty:netty-bom:4.1.123.Final")) api(platform("io.netty:netty5-bom:5.0.0.Alpha5")) - api(platform("io.projectreactor:reactor-bom:2024.0.8")) + api(platform("io.projectreactor:reactor-bom:2024.0.9")) api(platform("io.rsocket:rsocket-bom:1.1.5")) - api(platform("org.apache.groovy:groovy-bom:4.0.27")) + api(platform("org.apache.groovy:groovy-bom:4.0.28")) api(platform("org.apache.logging.log4j:log4j-bom:2.21.1")) api(platform("org.assertj:assertj-bom:3.27.3")) - api(platform("org.eclipse.jetty:jetty-bom:12.0.23")) - api(platform("org.eclipse.jetty.ee10:jetty-ee10-bom:12.0.23")) + api(platform("org.eclipse.jetty:jetty-bom:12.0.25")) + api(platform("org.eclipse.jetty.ee10:jetty-ee10-bom:12.0.25")) api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.1")) api(platform("org.jetbrains.kotlinx:kotlinx-serialization-bom:1.6.3")) api(platform("org.junit:junit-bom:5.13.4")) @@ -26,12 +26,12 @@ dependencies { constraints { api("com.fasterxml:aalto-xml:1.3.2") api("com.fasterxml.woodstox:woodstox-core:6.7.0") - api("com.github.ben-manes.caffeine:caffeine:3.2.1") + api("com.github.ben-manes.caffeine:caffeine:3.2.2") api("com.github.librepdf:openpdf:1.3.43") api("com.google.code.findbugs:findbugs:3.0.1") api("com.google.code.findbugs:jsr305:3.0.2") api("com.google.code.gson:gson:2.13.1") - api("com.google.protobuf:protobuf-java-util:4.30.2") + api("com.google.protobuf:protobuf-java-util:4.31.1") api("com.h2database:h2:2.3.232") api("com.jayway.jsonpath:json-path:2.9.0") api("com.oracle.database.jdbc:ojdbc11:21.9.0.0") @@ -129,16 +129,16 @@ dependencies { api("org.hibernate:hibernate-core-jakarta:5.6.15.Final") api("org.hibernate:hibernate-validator:7.0.5.Final") api("org.hsqldb:hsqldb:2.7.4") - api("org.htmlunit:htmlunit:4.13.0") + api("org.htmlunit:htmlunit:4.14.0") api("org.javamoney:moneta:1.4.4") - api("org.jruby:jruby:9.4.12.0") + api("org.jruby:jruby:9.4.13.0") api("org.junit.support:testng-engine:1.0.5") api("org.mozilla:rhino:1.7.15") api("org.ogce:xpp3:1.1.6") api("org.python:jython-standalone:2.7.4") api("org.quartz-scheduler:quartz:2.3.2") - api("org.seleniumhq.selenium:htmlunit3-driver:4.33.0") - api("org.seleniumhq.selenium:selenium-java:4.34.0") + api("org.seleniumhq.selenium:htmlunit3-driver:4.34.0") + api("org.seleniumhq.selenium:selenium-java:4.35.0") api("org.skyscreamer:jsonassert:1.5.3") api("org.slf4j:slf4j-api:2.0.17") api("org.testng:testng:7.11.0") From 9f9b33c2ac2736a516ccf8e5d5457afbcc54f675 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Wed, 13 Aug 2025 00:48:37 +0200 Subject: [PATCH 085/591] Upgrade to Reactor 2025.0.0-M6, Micrometer 1.16.0-M2, Jetty 12.1.0.beta3 Includes Checkstyle 11.0, Groovy 5.0 RC1, JRuby 10.0.2, MockK 1.14.5 Closes gh-35310 Closes gh-35311 Closes gh-35233 --- .../build/CheckstyleConventions.java | 2 +- framework-platform/framework-platform.gradle | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/buildSrc/src/main/java/org/springframework/build/CheckstyleConventions.java b/buildSrc/src/main/java/org/springframework/build/CheckstyleConventions.java index 19a06771cdf1..c5294f452418 100644 --- a/buildSrc/src/main/java/org/springframework/build/CheckstyleConventions.java +++ b/buildSrc/src/main/java/org/springframework/build/CheckstyleConventions.java @@ -50,7 +50,7 @@ public void apply(Project project) { project.getPlugins().apply(CheckstylePlugin.class); project.getTasks().withType(Checkstyle.class).forEach(checkstyle -> checkstyle.getMaxHeapSize().set("1g")); CheckstyleExtension checkstyle = project.getExtensions().getByType(CheckstyleExtension.class); - checkstyle.setToolVersion("10.26.1"); + checkstyle.setToolVersion("11.0.0"); checkstyle.getConfigDirectory().set(project.getRootProject().file("src/checkstyle")); String version = SpringJavaFormatPlugin.class.getPackage().getImplementationVersion(); DependencySet checkstyleDependencies = project.getConfigurations().getByName("checkstyle").getDependencies(); diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index fb9abe45be51..2b8e3dab3804 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -8,15 +8,15 @@ javaPlatform { dependencies { api(platform("com.fasterxml.jackson:jackson-bom:2.19.2")) - api(platform("io.micrometer:micrometer-bom:1.16.0-M1")) + api(platform("io.micrometer:micrometer-bom:1.16.0-M2")) api(platform("io.netty:netty-bom:4.2.3.Final")) - api(platform("io.projectreactor:reactor-bom:2025.0.0-M5")) + api(platform("io.projectreactor:reactor-bom:2025.0.0-M6")) api(platform("io.rsocket:rsocket-bom:1.1.5")) - api(platform("org.apache.groovy:groovy-bom:4.0.28")) + api(platform("org.apache.groovy:groovy-bom:5.0.0-rc-1")) api(platform("org.apache.logging.log4j:log4j-bom:3.0.0-beta3")) api(platform("org.assertj:assertj-bom:3.27.3")) - api(platform("org.eclipse.jetty:jetty-bom:12.1.0.beta2")) - api(platform("org.eclipse.jetty.ee11:jetty-ee11-bom:12.1.0.beta1")) + api(platform("org.eclipse.jetty:jetty-bom:12.1.0.beta3")) + api(platform("org.eclipse.jetty.ee11:jetty-ee11-bom:12.1.0.beta3")) api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2")) api(platform("org.jetbrains.kotlinx:kotlinx-serialization-bom:1.9.0")) api(platform("org.junit:junit-bom:5.13.4")) @@ -47,7 +47,7 @@ dependencies { api("commons-io:commons-io:2.15.0") api("commons-logging:commons-logging:1.3.5") api("de.bechte.junit:junit-hierarchicalcontextrunner:4.12.2") - api("io.mockk:mockk:1.14.4") + api("io.mockk:mockk:1.14.5") api("io.projectreactor.tools:blockhound:1.0.8.RELEASE") api("io.r2dbc:r2dbc-h2:1.0.0.RELEASE") api("io.r2dbc:r2dbc-spi-test:1.0.0.RELEASE") @@ -130,7 +130,7 @@ dependencies { api("org.htmlunit:htmlunit:4.14.0") api("org.javamoney:moneta:1.4.4") api("org.jboss.logging:jboss-logging:3.6.1.Final") - api("org.jruby:jruby:9.4.13.0") + api("org.jruby:jruby:10.0.2.0") api("org.jspecify:jspecify:1.0.0") api("org.junit.support:testng-engine:1.0.5") api("org.mozilla:rhino:1.7.15") From 8ec0c21b0a0ef6c725c27d787c4e27ae5fe328f4 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Wed, 13 Aug 2025 15:23:04 +0100 Subject: [PATCH 086/591] MockMvc handles param without values Closes gh-35210 --- .../AbstractMockHttpServletRequestBuilder.java | 11 ++++++----- .../request/MockHttpServletRequestBuilderTests.java | 10 ++++++++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilder.java index d38e31c3efb7..bcc47e2b491c 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilder.java @@ -372,6 +372,10 @@ public B headers(HttpHeaders httpHeaders) { * @param values one or more values */ public B param(String name, String... values) { + if (values.length == 0) { + this.parameters.computeIfAbsent(name, k -> new ArrayList<>()); + return self(); + } addToMultiValueMap(this.parameters, name, values); return self(); } @@ -821,11 +825,8 @@ public final MockHttpServletRequest buildRequest(ServletContext servletContext) } addRequestParams(request, UriComponentsBuilder.fromUri(uri).build().getQueryParams()); - this.parameters.forEach((name, values) -> { - for (String value : values) { - request.addParameter(name, value); - } - }); + this.parameters.forEach((name, values) -> + request.setParameter(name, values.toArray(new String[0]))); if (!this.formFields.isEmpty()) { if (this.content != null && this.content.length > 0) { diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilderTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilderTests.java index a180878c4e22..f62cce7c551c 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilderTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilderTests.java @@ -263,6 +263,16 @@ void queryParameter() { assertThat(request.getQueryString()).isEqualTo("foo=bar&foo=baz"); } + @Test // gh-35210 + void queryParameterWithoutValues() { + this.builder = new MockHttpServletRequestBuilder(GET).uri("/"); + this.builder.queryParam("foo"); + MockHttpServletRequest request = this.builder.buildRequest(this.servletContext); + + assertThat(request.getQueryString()).isEqualTo("foo"); + assertThat(request.getParameterMap().get("foo")).containsExactly(); + } + @Test void queryParameterMap() { this.builder = new MockHttpServletRequestBuilder(GET).uri("/"); From 1af95a0704944629a530113939ee960b49ad05fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Tue, 12 Aug 2025 11:42:53 +0200 Subject: [PATCH 087/591] Upgrade to Jackson 3.0.0-rc8 and 2.20.0-rc1 Closes gh-35295 --- framework-platform/framework-platform.gradle | 4 ++-- .../http/converter/json/Jackson2ObjectMapperBuilderTests.java | 3 ++- .../converter/json/Jackson2ObjectMapperFactoryBeanTests.java | 3 ++- .../web/reactive/DispatcherHandlerErrorTests.java | 2 +- .../RequestMappingExceptionHandlingIntegrationTests.java | 4 ++-- .../method/annotation/ResponseBodyResultHandlerTests.java | 2 +- .../method/annotation/ResponseEntityResultHandlerTests.java | 4 ++-- .../resource/ResourceHttpRequestHandlerIntegrationTests.java | 2 +- 8 files changed, 13 insertions(+), 11 deletions(-) diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index 2b8e3dab3804..50fb2b65dc35 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -7,7 +7,7 @@ javaPlatform { } dependencies { - api(platform("com.fasterxml.jackson:jackson-bom:2.19.2")) + api(platform("com.fasterxml.jackson:jackson-bom:2.20.0-rc1")) api(platform("io.micrometer:micrometer-bom:1.16.0-M2")) api(platform("io.netty:netty-bom:4.2.3.Final")) api(platform("io.projectreactor:reactor-bom:2025.0.0-M6")) @@ -21,7 +21,7 @@ dependencies { api(platform("org.jetbrains.kotlinx:kotlinx-serialization-bom:1.9.0")) api(platform("org.junit:junit-bom:5.13.4")) api(platform("org.mockito:mockito-bom:5.18.0")) - api(platform("tools.jackson:jackson-bom:3.0.0-rc6")) + api(platform("tools.jackson:jackson-bom:3.0.0-rc8")) constraints { api("com.fasterxml:aalto-xml:1.3.2") diff --git a/spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperBuilderTests.java b/spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperBuilderTests.java index ca30c2e539a8..83f7c82a640a 100644 --- a/spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperBuilderTests.java +++ b/spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperBuilderTests.java @@ -52,6 +52,7 @@ import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.Module; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.PropertyNamingStrategy; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.SerializerProvider; @@ -375,7 +376,7 @@ void modulesWithConsumerAfterModulesToInstall() { @Test void propertyNamingStrategy() { - PropertyNamingStrategy strategy = new PropertyNamingStrategy.SnakeCaseStrategy(); + PropertyNamingStrategy strategy = new PropertyNamingStrategies.SnakeCaseStrategy(); ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json().propertyNamingStrategy(strategy).build(); assertThat(objectMapper.getSerializationConfig().getPropertyNamingStrategy()).isSameAs(strategy); assertThat(objectMapper.getDeserializationConfig().getPropertyNamingStrategy()).isSameAs(strategy); diff --git a/spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperFactoryBeanTests.java b/spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperFactoryBeanTests.java index 1255f6e02c79..3763042f841a 100644 --- a/spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperFactoryBeanTests.java +++ b/spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperFactoryBeanTests.java @@ -39,6 +39,7 @@ import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.Module; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.PropertyNamingStrategy; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.SerializerProvider; @@ -222,7 +223,7 @@ private static DeserializerFactoryConfig getDeserializerFactoryConfig(ObjectMapp @Test void propertyNamingStrategy() { - PropertyNamingStrategy strategy = new PropertyNamingStrategy.SnakeCaseStrategy(); + PropertyNamingStrategy strategy = new PropertyNamingStrategies.SnakeCaseStrategy(); this.factory.setPropertyNamingStrategy(strategy); this.factory.afterPropertiesSet(); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java index aab6f80d9b4b..d94049d42889 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java @@ -123,7 +123,7 @@ void noStaticResource() { assertThat(response.getBodyAsString().block()).isEqualTo(""" {\ "detail":"No static resource non-existing.",\ - "instance":"\\/resources\\/non-existing",\ + "instance":"/resources/non-existing",\ "status":404,\ "title":"Not Found"}\ """); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingExceptionHandlingIntegrationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingExceptionHandlingIntegrationTests.java index 1ed443e7e96f..1a7fc03ecafe 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingExceptionHandlingIntegrationTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingExceptionHandlingIntegrationTests.java @@ -123,7 +123,7 @@ void globalExceptionHandlerWithHandlerNotFound() throws Exception { .satisfies(ex -> { assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); assertThat(ex.getResponseBodyAsString()).isEqualTo("{" + - "\"instance\":\"\\/no-such-handler\"," + + "\"instance\":\"/no-such-handler\"," + "\"status\":404," + "\"title\":\"Not Found\"}"); }); @@ -139,7 +139,7 @@ void globalExceptionHandlerWithMissingRequestParameter() throws Exception { assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); assertThat(ex.getResponseBodyAsString()).isEqualTo("{" + "\"detail\":\"Required query parameter 'q' is not present.\"," + - "\"instance\":\"\\/missing-request-parameter\"," + + "\"instance\":\"/missing-request-parameter\"," + "\"status\":400," + "\"title\":\"Bad Request\"}"); }); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandlerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandlerTests.java index 714c5cdbdc35..17fa500d6f23 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandlerTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandlerTests.java @@ -151,7 +151,7 @@ private void testProblemDetailMediaType(MockServerWebExchange exchange, MediaTyp assertResponseBody(exchange,""" {\ "status":400,\ - "instance":"\\/path",\ + "instance":"/path",\ "title":"Bad Request"\ }"""); } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java index 9c3932fbb6de..98a31e66a0a7 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java @@ -242,7 +242,7 @@ void handleErrorResponse() { assertThat(exchange.getResponse().getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_PROBLEM_JSON); assertResponseBody(exchange,""" {\ - "instance":"\\/path",\ + "instance":"/path",\ "status":400,\ "title":"Bad Request"\ }"""); @@ -262,7 +262,7 @@ void handleProblemDetail() { assertThat(exchange.getResponse().getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_PROBLEM_JSON); assertResponseBody(exchange,""" {\ - "instance":"\\/path",\ + "instance":"/path",\ "status":400,\ "title":"Bad Request"\ }"""); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerIntegrationTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerIntegrationTests.java index 39cc11763f79..4fcdab8d061f 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerIntegrationTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerIntegrationTests.java @@ -142,7 +142,7 @@ void testNoResourceFoundException() throws Exception { assertThat(response.getContentAsString()).isEqualTo(""" {\ "detail":"No static resource non-existing.",\ - "instance":"\\/cp\\/non-existing",\ + "instance":"/cp/non-existing",\ "status":404,\ "title":"Not Found"\ }\ From c30427fd4e18088c02c19be66567440a5878a369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Nicoll?= Date: Thu, 14 Aug 2025 08:38:34 +0200 Subject: [PATCH 088/591] Upgrade to Netty 4.1.124.Final Closes gh-35321 --- framework-platform/framework-platform.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index 4e9178dfa1ae..5d96c54f68ef 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -9,7 +9,7 @@ javaPlatform { dependencies { api(platform("com.fasterxml.jackson:jackson-bom:2.18.4")) api(platform("io.micrometer:micrometer-bom:1.14.10")) - api(platform("io.netty:netty-bom:4.1.123.Final")) + api(platform("io.netty:netty-bom:4.1.124.Final")) api(platform("io.netty:netty5-bom:5.0.0.Alpha5")) api(platform("io.projectreactor:reactor-bom:2024.0.9")) api(platform("io.rsocket:rsocket-bom:1.1.5")) From 9fa2d7d190160bc1a4a713c40c0d3a5d229010e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Nicoll?= Date: Thu, 14 Aug 2025 08:38:58 +0200 Subject: [PATCH 089/591] Upgrade to Jackson 2.18.4.1 Closes gh-35322 --- framework-platform/framework-platform.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index 5d96c54f68ef..96b106f330ff 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -7,7 +7,7 @@ javaPlatform { } dependencies { - api(platform("com.fasterxml.jackson:jackson-bom:2.18.4")) + api(platform("com.fasterxml.jackson:jackson-bom:2.18.4.1")) api(platform("io.micrometer:micrometer-bom:1.14.10")) api(platform("io.netty:netty-bom:4.1.124.Final")) api(platform("io.netty:netty5-bom:5.0.0.Alpha5")) From edda4731e133dd785271cccd032fb4bb028d2720 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Nicoll?= Date: Thu, 14 Aug 2025 09:06:18 +0200 Subject: [PATCH 090/591] Build against Java 24 Closes gh-35326 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc9a83255f68..789f85844260 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: toolchain: false - version: 21 toolchain: true - - version: 23 + - version: 24 toolchain: true exclude: - os: From e2085063f69cb995faeab0bfd0fa2bd70e0bdb43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Nicoll?= Date: Thu, 14 Aug 2025 09:55:02 +0200 Subject: [PATCH 091/591] Next development version (v6.2.11-SNAPSHOT) --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 8f00a339715a..25e5cb4cd721 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=6.2.10-SNAPSHOT +version=6.2.11-SNAPSHOT org.gradle.caching=true org.gradle.jvmargs=-Xmx2048m From c6f1f719c3a1afca478057196fb557a5cea49d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Thu, 14 Aug 2025 15:59:21 +0200 Subject: [PATCH 092/591] Formatting issue in RestTestClient documentation Closes gh-35328 --- framework-docs/modules/ROOT/pages/testing/resttestclient.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework-docs/modules/ROOT/pages/testing/resttestclient.adoc b/framework-docs/modules/ROOT/pages/testing/resttestclient.adoc index 5fb725a43fba..f27dce89cdb3 100644 --- a/framework-docs/modules/ROOT/pages/testing/resttestclient.adoc +++ b/framework-docs/modules/ROOT/pages/testing/resttestclient.adoc @@ -306,7 +306,7 @@ Kotlin:: ====== TIP: When you need to decode to a target type with generics, look for the overloaded methods -that accept{spring-framework-api}/core/ParameterizedTypeReference.html[`ParameterizedTypeReference`] +that accept {spring-framework-api}/core/ParameterizedTypeReference.html[`ParameterizedTypeReference`] instead of `Class`. From ed28390d24bc437569acfe84a106454fd7120144 Mon Sep 17 00:00:00 2001 From: Stefano Cordio Date: Mon, 4 Aug 2025 12:03:55 +0200 Subject: [PATCH 093/591] Refine `@Contract` Javadoc This commit removes references to the `pure` attribute. Relates to gh-33820. Closes gh-35285 Signed-off-by: Stefano Cordio --- .../src/main/java/org/springframework/lang/Contract.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spring-core/src/main/java/org/springframework/lang/Contract.java b/spring-core/src/main/java/org/springframework/lang/Contract.java index 09b5e6837edd..d75e45175e7e 100644 --- a/spring-core/src/main/java/org/springframework/lang/Contract.java +++ b/spring-core/src/main/java/org/springframework/lang/Contract.java @@ -51,7 +51,8 @@ *

    The additional return values denote the following: *

      *
    • {@code fail} - the method throws an exception, if the arguments satisfy argument constraints - *
    • {@code new} - the method returns a non-null new object which is distinct from any other object existing in the heap prior to method execution. If method is also pure, then we can be sure that the new object is not stored to any field/array and will be lost if method return value is not used. + *
    • {@code new} - the method returns a non-null new object which is distinct from any other object existing in the heap prior to method execution. + * If the method has no visible side effects, then we can be sure that the new object is not stored to any field/array and will be lost if the method's return value is not used. *
    • {@code this} - the method returns its qualifier value (not applicable for static methods) *
    • {@code param1, param2, ...} - the method returns its first (second, ...) parameter value *
    From 498b0c231bf759a0d4f3f6710e9c4025ea2a2120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Thu, 14 Aug 2025 20:03:08 +0200 Subject: [PATCH 094/591] Always provision Java 24 for the CI Gradle build See gh-35007 --- .github/actions/prepare-gradle-build/action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/prepare-gradle-build/action.yml b/.github/actions/prepare-gradle-build/action.yml index c07a74c1dfef..c91ca7d9c9a9 100644 --- a/.github/actions/prepare-gradle-build/action.yml +++ b/.github/actions/prepare-gradle-build/action.yml @@ -30,6 +30,7 @@ runs: java-version: | ${{ inputs.java-early-access == 'true' && format('{0}-ea', inputs.java-version) || inputs.java-version }} ${{ inputs.java-toolchain == 'true' && '17' || '' }} + 24 - name: Set Up Gradle uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 with: From 532911eb93021c802959bb925186ab6d881b6647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Nicoll?= Date: Sat, 16 Aug 2025 09:14:59 +0200 Subject: [PATCH 095/591] Mark RetryException#getCause non null This commit simplifies RetryException to always require a root cause and mark it as not nullable. Such exception is the exception thrown by the retryable operation and should always be available as it explains why the invocation was a candidate for retrying in the first place. Closes gh-35332 --- .../retry/AbstractRetryInterceptor.java | 3 +-- .../core/retry/RetryException.java | 17 +++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/spring-context/src/main/java/org/springframework/resilience/retry/AbstractRetryInterceptor.java b/spring-context/src/main/java/org/springframework/resilience/retry/AbstractRetryInterceptor.java index 659424115466..762c5612f425 100644 --- a/spring-context/src/main/java/org/springframework/resilience/retry/AbstractRetryInterceptor.java +++ b/spring-context/src/main/java/org/springframework/resilience/retry/AbstractRetryInterceptor.java @@ -112,8 +112,7 @@ public String getName() { }); } catch (RetryException ex) { - Throwable cause = ex.getCause(); - throw (cause != null ? cause : new IllegalStateException(ex.getMessage(), ex)); + throw ex.getCause(); } } diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryException.java b/spring-core/src/main/java/org/springframework/core/retry/RetryException.java index d9c0f34e2752..f0e490a6917f 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryException.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryException.java @@ -17,6 +17,9 @@ package org.springframework.core.retry; import java.io.Serial; +import java.util.Objects; + +import org.jspecify.annotations.NonNull; /** * Exception thrown when a {@link RetryPolicy} has been exhausted. @@ -31,14 +34,6 @@ public class RetryException extends Exception { private static final long serialVersionUID = 5439915454935047936L; - /** - * Create a new {@code RetryException} for the supplied message. - * @param message the detail message - */ - public RetryException(String message) { - super(message); - } - /** * Create a new {@code RetryException} for the supplied message and cause. * @param message the detail message @@ -48,4 +43,10 @@ public RetryException(String message, Throwable cause) { super(message, cause); } + + @Override + public synchronized @NonNull Throwable getCause() { + return Objects.requireNonNull(super.getCause()); + } + } From 99823699822f48b23b0ebf28aff2a638836c88b2 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Sat, 16 Aug 2025 14:36:09 +0200 Subject: [PATCH 096/591] Revise nullability in RetryException See gh-35332 --- .../springframework/core/retry/RetryException.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryException.java b/spring-core/src/main/java/org/springframework/core/retry/RetryException.java index f0e490a6917f..dbc377726394 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryException.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryException.java @@ -19,8 +19,6 @@ import java.io.Serial; import java.util.Objects; -import org.jspecify.annotations.NonNull; - /** * Exception thrown when a {@link RetryPolicy} has been exhausted. * @@ -37,15 +35,18 @@ public class RetryException extends Exception { /** * Create a new {@code RetryException} for the supplied message and cause. * @param message the detail message - * @param cause the root cause + * @param cause the last exception thrown by the {@link Retryable} operation */ public RetryException(String message, Throwable cause) { - super(message, cause); + super(message, Objects.requireNonNull(cause, "cause must not be null")); } + /** + * Get the the last exception thrown by the {@link Retryable} operation. + */ @Override - public synchronized @NonNull Throwable getCause() { + public final synchronized Throwable getCause() { return Objects.requireNonNull(super.getCause()); } From c38606610c773eed1a473203cd4f77fb754713ec Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Sat, 16 Aug 2025 16:07:37 +0200 Subject: [PATCH 097/591] Supply correct exception to RetryListener.onRetryPolicyExhaustion() Prior to this commit, RetryTemplate supplied the wrong exception to RetryListener.onRetryPolicyExhaustion(). Specifically, the execute() method in RetryTemplate supplied the final, composite RetryException to onRetryPolicyExhaustion() instead of the last exception thrown by the Retryable operation. This commit fixes that bug by ensuring that the last exception thrown by the Retryable operation is supplied to onRetryPolicyExhaustion(). Closes gh-35334 --- .../core/retry/RetryTemplate.java | 2 +- .../core/retry/RetryTemplateTests.java | 81 ++++++++++++++++--- 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java b/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java index 4ca2b33e490f..ee5118070174 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java @@ -174,7 +174,7 @@ public void setRetryListener(RetryListener retryListener) { "Retry policy for operation '%s' exhausted; aborting execution".formatted(retryableName), exceptions.removeLast()); exceptions.forEach(finalException::addSuppressed); - this.retryListener.onRetryPolicyExhaustion(this.retryPolicy, retryable, finalException); + this.retryListener.onRetryPolicyExhaustion(this.retryPolicy, retryable, retryException); throw finalException; } } diff --git a/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java b/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java index 96f47a2d1efc..953177c4b044 100644 --- a/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java +++ b/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java @@ -29,13 +29,20 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments.ArgumentSet; import org.junit.jupiter.params.provider.FieldSource; +import org.mockito.InOrder; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.junit.jupiter.params.provider.Arguments.argumentSet; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; /** - * Integration tests for {@link RetryTemplate} and {@link RetryPolicy}. + * Integration tests for {@link RetryTemplate}, {@link RetryPolicy} and + * {@link RetryListener}. * * @author Mahmoud Ben Hassine * @author Sam Brannen @@ -44,17 +51,22 @@ */ class RetryTemplateTests { - private final RetryTemplate retryTemplate = new RetryTemplate(); - - - @BeforeEach - void configureRetryTemplate() { - var retryPolicy = RetryPolicy.builder() + private final RetryPolicy retryPolicy = + RetryPolicy.builder() .maxAttempts(3) .delay(Duration.ZERO) .build(); - retryTemplate.setRetryPolicy(retryPolicy); + private final RetryTemplate retryTemplate = new RetryTemplate(retryPolicy); + + private final RetryListener retryListener = mock(); + + private final InOrder inOrder = inOrder(retryListener); + + + @BeforeEach + void configureRetryTemplate() { + retryTemplate.setRetryListener(retryListener); } @Test @@ -68,14 +80,18 @@ void retryWithImmediateSuccess() throws Exception { assertThat(invocationCount).hasValue(0); assertThat(retryTemplate.execute(retryable)).isEqualTo("always succeeds"); assertThat(invocationCount).hasValue(1); + + // RetryListener interactions: + verifyNoInteractions(retryListener); } @Test void retryWithSuccessAfterInitialFailures() throws Exception { + Exception exception = new Exception("Boom!"); AtomicInteger invocationCount = new AtomicInteger(); Retryable retryable = () -> { if (invocationCount.incrementAndGet() <= 2) { - throw new Exception("Boom!"); + throw exception; } return "finally succeeded"; }; @@ -83,6 +99,13 @@ void retryWithSuccessAfterInitialFailures() throws Exception { assertThat(invocationCount).hasValue(0); assertThat(retryTemplate.execute(retryable)).isEqualTo("finally succeeded"); assertThat(invocationCount).hasValue(3); + + // RetryListener interactions: + inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); + inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable, exception); + inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); + inOrder.verify(retryListener).onRetrySuccess(retryPolicy, retryable, "finally succeeded"); + inOrder.verifyNoMoreInteractions(); } @Test @@ -110,6 +133,14 @@ public String getName() { .withCause(exception); // 4 = 1 initial invocation + 3 retry attempts assertThat(invocationCount).hasValue(4); + + // RetryListener interactions: + repeat(3, () -> { + inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); + inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable, exception); + }); + inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, exception); + inOrder.verifyNoMoreInteractions(); } @Test @@ -146,6 +177,14 @@ public String getName() { .withCause(exception); // 6 = 1 initial invocation + 5 retry attempts assertThat(invocationCount).hasValue(6); + + // RetryListener interactions: + repeat(5, () -> { + inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); + inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable, exception); + }); + inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, exception); + inOrder.verifyNoMoreInteractions(); } @Test @@ -188,6 +227,15 @@ public String getName() { )); // 3 = 1 initial invocation + 2 retry attempts assertThat(invocationCount).hasValue(3); + + // RetryListener interactions: + repeat(2, () -> { + inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); + inOrder.verify(retryListener).onRetryFailure(eq(retryPolicy), eq(retryable), any(Throwable.class)); + }); + inOrder.verify(retryListener).onRetryPolicyExhaustion( + eq(retryPolicy), eq(retryable), any(IllegalStateException.class)); + inOrder.verifyNoMoreInteractions(); } static final List includesAndExcludesRetryPolicies = List.of( @@ -241,9 +289,24 @@ public String getName() { )); // 3 = 1 initial invocation + 2 retry attempts assertThat(invocationCount).hasValue(3); + + // RetryListener interactions: + repeat(2, () -> { + inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); + inOrder.verify(retryListener).onRetryFailure(eq(retryPolicy), eq(retryable), any(Throwable.class)); + }); + inOrder.verify(retryListener).onRetryPolicyExhaustion( + eq(retryPolicy), eq(retryable), any(CustomFileNotFoundException.class)); + inOrder.verifyNoMoreInteractions(); } + private static void repeat(int times, Runnable runnable) { + for (int i = 0; i < times; i++) { + runnable.run(); + } + } + @SafeVarargs private static final Consumer hasSuppressedExceptionsSatisfyingExactly( ThrowingConsumer... requirements) { From 72afc66507953c2a63a06449016758d1a5e8af0f Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Sun, 17 Aug 2025 17:57:07 +0200 Subject: [PATCH 098/591] Make RetryTemplateTests more robust --- .../core/retry/RetryTemplateTests.java | 56 +++++++++++++------ 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java b/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java index 953177c4b044..f8fe211f229d 100644 --- a/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java +++ b/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java @@ -20,6 +20,7 @@ import java.io.IOException; import java.time.Duration; import java.util.List; +import java.util.Objects; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; @@ -39,6 +40,7 @@ import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; /** * Integration tests for {@link RetryTemplate}, {@link RetryPolicy} and @@ -87,11 +89,10 @@ void retryWithImmediateSuccess() throws Exception { @Test void retryWithSuccessAfterInitialFailures() throws Exception { - Exception exception = new Exception("Boom!"); AtomicInteger invocationCount = new AtomicInteger(); Retryable retryable = () -> { if (invocationCount.incrementAndGet() <= 2) { - throw exception; + throw new CustomException("Boom " + invocationCount.get()); } return "finally succeeded"; }; @@ -102,22 +103,20 @@ void retryWithSuccessAfterInitialFailures() throws Exception { // RetryListener interactions: inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); - inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable, exception); + inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable, new CustomException("Boom 2")); inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); inOrder.verify(retryListener).onRetrySuccess(retryPolicy, retryable, "finally succeeded"); - inOrder.verifyNoMoreInteractions(); + verifyNoMoreInteractions(retryListener); } @Test void retryWithExhaustedPolicy() { var invocationCount = new AtomicInteger(); - var exception = new RuntimeException("Boom!"); var retryable = new Retryable<>() { @Override public String execute() { - invocationCount.incrementAndGet(); - throw exception; + throw new CustomException("Boom " + invocationCount.incrementAndGet()); } @Override @@ -130,17 +129,19 @@ public String getName() { assertThatExceptionOfType(RetryException.class) .isThrownBy(() -> retryTemplate.execute(retryable)) .withMessage("Retry policy for operation 'test' exhausted; aborting execution") - .withCause(exception); + .withCause(new CustomException("Boom 4")); // 4 = 1 initial invocation + 3 retry attempts assertThat(invocationCount).hasValue(4); // RetryListener interactions: + invocationCount.set(1); repeat(3, () -> { inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); - inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable, exception); + inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable, + new CustomException("Boom " + invocationCount.incrementAndGet())); }); - inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, exception); - inOrder.verifyNoMoreInteractions(); + inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, new CustomException("Boom 4")); + verifyNoMoreInteractions(retryListener); } @Test @@ -184,7 +185,7 @@ public String getName() { inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable, exception); }); inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, exception); - inOrder.verifyNoMoreInteractions(); + verifyNoMoreInteractions(retryListener); } @Test @@ -231,11 +232,11 @@ public String getName() { // RetryListener interactions: repeat(2, () -> { inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); - inOrder.verify(retryListener).onRetryFailure(eq(retryPolicy), eq(retryable), any(Throwable.class)); + inOrder.verify(retryListener).onRetryFailure(eq(retryPolicy), eq(retryable), any(Exception.class)); }); inOrder.verify(retryListener).onRetryPolicyExhaustion( eq(retryPolicy), eq(retryable), any(IllegalStateException.class)); - inOrder.verifyNoMoreInteractions(); + verifyNoMoreInteractions(retryListener); } static final List includesAndExcludesRetryPolicies = List.of( @@ -293,11 +294,11 @@ public String getName() { // RetryListener interactions: repeat(2, () -> { inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); - inOrder.verify(retryListener).onRetryFailure(eq(retryPolicy), eq(retryable), any(Throwable.class)); + inOrder.verify(retryListener).onRetryFailure(eq(retryPolicy), eq(retryable), any(IOException.class)); }); inOrder.verify(retryListener).onRetryPolicyExhaustion( eq(retryPolicy), eq(retryable), any(CustomFileNotFoundException.class)); - inOrder.verifyNoMoreInteractions(); + verifyNoMoreInteractions(retryListener); } @@ -318,4 +319,27 @@ private static final Consumer hasSuppressedExceptionsSatisfyingExactl private static class CustomFileNotFoundException extends FileNotFoundException { } + /** + * Custom {@link RuntimeException} that implements {@link #equals(Object)} + * and {@link #hashCode()} for use in assertions that check for equality. + */ + @SuppressWarnings("serial") + private static class CustomException extends RuntimeException { + + CustomException(String message) { + super(message); + } + + @Override + public int hashCode() { + return Objects.hash(getMessage()); + } + + @Override + public boolean equals(Object other) { + return (this == other || + (other instanceof CustomException that && getMessage().equals(that.getMessage()))); + } + } + } From a803ecdf2621549ba5826265e55770bb1956fe61 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Sun, 17 Aug 2025 17:59:14 +0200 Subject: [PATCH 099/591] Test expected behavior for RetryTemplate with zero retries --- .../core/retry/RetryTemplateTests.java | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java b/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java index f8fe211f229d..c3259e108717 100644 --- a/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java +++ b/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java @@ -32,6 +32,9 @@ import org.junit.jupiter.params.provider.FieldSource; import org.mockito.InOrder; +import org.springframework.util.backoff.BackOff; +import org.springframework.util.backoff.FixedBackOff; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.junit.jupiter.params.provider.Arguments.argumentSet; @@ -87,6 +90,60 @@ void retryWithImmediateSuccess() throws Exception { verifyNoInteractions(retryListener); } + @Test + void retryWithInitialFailureAndZeroRetriesRetryPolicy() { + RetryPolicy retryPolicy = throwable -> false; // Zero retries + RetryTemplate retryTemplate = new RetryTemplate(retryPolicy); + retryTemplate.setRetryListener(retryListener); + Exception exception = new RuntimeException("Boom!"); + Retryable retryable = () -> { + throw exception; + }; + + assertThatExceptionOfType(RetryException.class) + .isThrownBy(() -> retryTemplate.execute(retryable)) + .withMessageMatching("Retry policy for operation '.+?' exhausted; aborting execution") + .withCause(exception) + .satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty()); + + // RetryListener interactions: + inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, exception); + verifyNoMoreInteractions(retryListener); + } + + @Test + void retryWithInitialFailureAndZeroRetriesBackOffPolicy() { + RetryPolicy retryPolicy = new RetryPolicy() { + + @Override + public boolean shouldRetry(Throwable throwable) { + return true; + } + + @Override + public BackOff getBackOff() { + return new FixedBackOff(10, 0); // Zero retries + } + }; + + RetryTemplate retryTemplate = new RetryTemplate(retryPolicy); + retryTemplate.setRetryListener(retryListener); + Exception exception = new RuntimeException("Boom!"); + Retryable retryable = () -> { + throw exception; + }; + + assertThatExceptionOfType(RetryException.class) + .isThrownBy(() -> retryTemplate.execute(retryable)) + .withMessageMatching("Retry policy for operation '.+?' exhausted; aborting execution") + .withCause(exception) + .satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty()); + + // RetryListener interactions: + inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, exception); + verifyNoMoreInteractions(retryListener); + } + @Test void retryWithSuccessAfterInitialFailures() throws Exception { AtomicInteger invocationCount = new AtomicInteger(); From ed2fb61ce99a46762ae22e77a52426f7eed89755 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Sun, 17 Aug 2025 18:19:33 +0200 Subject: [PATCH 100/591] Remove duplicated word in Javadoc --- .../java/org/springframework/core/retry/RetryException.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryException.java b/spring-core/src/main/java/org/springframework/core/retry/RetryException.java index dbc377726394..eae4859afa85 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryException.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryException.java @@ -43,7 +43,7 @@ public RetryException(String message, Throwable cause) { /** - * Get the the last exception thrown by the {@link Retryable} operation. + * Get the last exception thrown by the {@link Retryable} operation. */ @Override public final synchronized Throwable getCause() { From 9d57dabe2f1306d0613c0614021a8f31e4a978d3 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Sun, 17 Aug 2025 19:17:12 +0200 Subject: [PATCH 101/591] Rename exception variables to clarify intent --- .../core/retry/RetryTemplate.java | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java b/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java index ee5118070174..274074413bb0 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java @@ -133,8 +133,8 @@ public void setRetryListener(RetryListener retryListener) { Deque exceptions = new ArrayDeque<>(); exceptions.add(initialException); - Throwable retryException = initialException; - while (this.retryPolicy.shouldRetry(retryException)) { + Throwable lastException = initialException; + while (this.retryPolicy.shouldRetry(lastException)) { try { long duration = backOffExecution.nextBackOff(); if (duration == BackOffExecution.STOP) { @@ -159,23 +159,23 @@ public void setRetryListener(RetryListener retryListener) { .formatted(retryableName)); return result; } - catch (Throwable currentAttemptException) { + catch (Throwable currentException) { logger.debug(() -> "Retry attempt for operation '%s' failed due to '%s'" - .formatted(retryableName, currentAttemptException)); - this.retryListener.onRetryFailure(this.retryPolicy, retryable, currentAttemptException); - exceptions.add(currentAttemptException); - retryException = currentAttemptException; + .formatted(retryableName, currentException)); + this.retryListener.onRetryFailure(this.retryPolicy, retryable, currentException); + exceptions.add(currentException); + lastException = currentException; } } // The RetryPolicy has exhausted at this point, so we throw a RetryException with the - // initial exception as the cause and remaining exceptions as suppressed exceptions. - RetryException finalException = new RetryException( + // last exception as the cause and remaining exceptions as suppressed exceptions. + RetryException retryException = new RetryException( "Retry policy for operation '%s' exhausted; aborting execution".formatted(retryableName), exceptions.removeLast()); - exceptions.forEach(finalException::addSuppressed); - this.retryListener.onRetryPolicyExhaustion(this.retryPolicy, retryable, retryException); - throw finalException; + exceptions.forEach(retryException::addSuppressed); + this.retryListener.onRetryPolicyExhaustion(this.retryPolicy, retryable, lastException); + throw retryException; } } From a999dd13f5f61772a44fd7606054010a3be8cd84 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Sun, 17 Aug 2025 19:15:26 +0200 Subject: [PATCH 102/591] Document semantics of RetryException regarding cause and suppressed exceptions Closes gh-35337 --- .../springframework/core/retry/RetryException.java | 5 +++++ .../core/retry/RetryOperations.java | 14 ++++++++------ .../springframework/core/retry/RetryTemplate.java | 14 ++++++++------ 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryException.java b/spring-core/src/main/java/org/springframework/core/retry/RetryException.java index eae4859afa85..694cd04465b2 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryException.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryException.java @@ -22,6 +22,11 @@ /** * Exception thrown when a {@link RetryPolicy} has been exhausted. * + *

    A {@code RetryException} will contain the last exception thrown by the + * {@link Retryable} operation as the {@linkplain #getCause() cause} and any + * exceptions from previous attempts as {@linkplain #getSuppressed() suppressed + * exceptions}. + * * @author Mahmoud Ben Hassine * @since 7.0 * @see RetryOperations diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryOperations.java b/spring-core/src/main/java/org/springframework/core/retry/RetryOperations.java index d69b1570d19c..d125cb5f620c 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryOperations.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryOperations.java @@ -34,15 +34,17 @@ public interface RetryOperations { /** - * Execute the given {@link Retryable} (according to the {@link RetryPolicy} - * configured at the implementation level) until it succeeds, or eventually - * throw an exception if the {@code RetryPolicy} is exhausted. + * Execute the given {@link Retryable} operation according to the {@link RetryPolicy} + * configured at the implementation level. + *

    If the {@code Retryable} succeeds, its result will be returned. Otherwise, a + * {@link RetryException} will be thrown to the caller. The {@code RetryException} + * will contain the last exception thrown by the {@code Retryable} operation as the + * {@linkplain RetryException#getCause() cause} and any exceptions from previous + * attempts as {@linkplain RetryException#getSuppressed() suppressed exceptions}. * @param retryable the {@code Retryable} to execute and retry if needed * @param the type of the result * @return the result of the {@code Retryable}, if any - * @throws RetryException if the {@code RetryPolicy} is exhausted; exceptions - * encountered during retry attempts should be made available as suppressed - * exceptions + * @throws RetryException if the {@code RetryPolicy} is exhausted */ @Nullable R execute(Retryable retryable) throws RetryException; diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java b/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java index 274074413bb0..352278df0c82 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java @@ -104,15 +104,17 @@ public void setRetryListener(RetryListener retryListener) { /** - * Execute the supplied {@link Retryable} according to the configured retry - * and backoff policies. - *

    If the {@code Retryable} succeeds, its result will be returned. Otherwise, - * a {@link RetryException} will be thrown to the caller. + * Execute the supplied {@link Retryable} operation according to the configured + * {@link RetryPolicy}. + *

    If the {@code Retryable} succeeds, its result will be returned. Otherwise, a + * {@link RetryException} will be thrown to the caller. The {@code RetryException} + * will contain the last exception thrown by the {@code Retryable} operation as the + * {@linkplain RetryException#getCause() cause} and any exceptions from previous + * attempts as {@linkplain RetryException#getSuppressed() suppressed exceptions}. * @param retryable the {@code Retryable} to execute and retry if needed * @param the type of the result * @return the result of the {@code Retryable}, if any - * @throws RetryException if the {@code RetryPolicy} is exhausted; exceptions - * encountered during retry attempts are available as suppressed exceptions + * @throws RetryException if the {@code RetryPolicy} is exhausted */ @Override public @Nullable R execute(Retryable retryable) throws RetryException { From 0d2a0d7b9e2da7fff7373a7fe1b79e8f750b9073 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Mon, 18 Aug 2025 10:44:15 +0200 Subject: [PATCH 103/591] Fix '**' parsing within a PathPattern segment Prior to this commit, a regexp path segment ending with a double wilcard (like "/path**") would be incorrectly parsed as a double wildcard segment ("/**"). This commit fixes the incorrect parsing. Fixes gh-35339 --- .../web/util/pattern/InternalPathPatternParser.java | 12 ++++++++++-- .../web/util/pattern/PathPatternParserTests.java | 7 ++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/web/util/pattern/InternalPathPatternParser.java b/spring-web/src/main/java/org/springframework/web/util/pattern/InternalPathPatternParser.java index 7011545ed3fa..3906e72bd595 100644 --- a/spring-web/src/main/java/org/springframework/web/util/pattern/InternalPathPatternParser.java +++ b/spring-web/src/main/java/org/springframework/web/util/pattern/InternalPathPatternParser.java @@ -234,14 +234,22 @@ else if (ch == '}' && !previousBackslash) { } private boolean isDoubleWildcard(char separator) { + // next char is present if ((this.pos + 1) >= this.pathPatternLength) { return false; } + // current char and next char are '*' if (this.pathPatternData[this.pos] != '*' || this.pathPatternData[this.pos + 1] != '*') { return false; } - if ((this.pos + 2) < this.pathPatternLength) { - return this.pathPatternData[this.pos + 2] == separator; + // previous char is a separator, if any + if ((this.pos - 1 >= 0) && (this.pathPatternData[this.pos - 1] != separator)) { + return false; + } + // next char is a separator, if any + if (((this.pos + 2) < this.pathPatternLength) && + this.pathPatternData[this.pos + 2] != separator) { + return false; } return true; } diff --git a/spring-web/src/test/java/org/springframework/web/util/pattern/PathPatternParserTests.java b/spring-web/src/test/java/org/springframework/web/util/pattern/PathPatternParserTests.java index 99c3db01549b..e70fe5e83289 100644 --- a/spring-web/src/test/java/org/springframework/web/util/pattern/PathPatternParserTests.java +++ b/spring-web/src/test/java/org/springframework/web/util/pattern/PathPatternParserTests.java @@ -85,9 +85,14 @@ void wildcardSegmentEndOfPathPatterns() { @Test void regexpSegmentIsNotWildcardSegment() { - // this is not double wildcard, it's / then **acb (an odd, unnecessary use of double *) pathPattern = checkStructure("/**acb"); assertPathElements(pathPattern, SeparatorPathElement.class, RegexPathElement.class); + + pathPattern = checkStructure("/a**bc"); + assertPathElements(pathPattern, SeparatorPathElement.class, RegexPathElement.class); + + pathPattern = checkStructure("/abc**"); + assertPathElements(pathPattern, SeparatorPathElement.class, RegexPathElement.class); } @Test From 3dc2aa79a4862e98f9c11b476d236d1609b232d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Mon, 18 Aug 2025 11:53:38 +0200 Subject: [PATCH 104/591] Fix HttpEntity support with Kotlin Serialization This commit adds HttpEntity type unwrapping logic to KotlinRequestBodyAdvice and KotlinResponseBodyAdvice. Closes gh-35281 --- .../annotation/KotlinRequestBodyAdvice.java | 5 +++ .../annotation/KotlinResponseBodyAdvice.java | 4 ++ ...tResponseBodyMethodProcessorKotlinTests.kt | 44 +++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/KotlinRequestBodyAdvice.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/KotlinRequestBodyAdvice.java index 0bcaa5620347..fdd0f5e29c23 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/KotlinRequestBodyAdvice.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/KotlinRequestBodyAdvice.java @@ -28,6 +28,7 @@ import org.jspecify.annotations.Nullable; import org.springframework.core.MethodParameter; +import org.springframework.http.HttpEntity; import org.springframework.http.converter.AbstractKotlinSerializationHttpMessageConverter; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.SmartHttpMessageConverter; @@ -61,6 +62,10 @@ public boolean supports(MethodParameter methodParameter, Type targetType, for (KParameter p : Objects.requireNonNull(function).getParameters()) { if (KParameter.Kind.VALUE.equals(p.getKind())) { if (index == i++) { + if (HttpEntity.class.isAssignableFrom(parameter.getParameterType())) { + return Collections.singletonMap(KType.class.getName(), + Objects.requireNonNull(p.getType().getArguments().get(0).getType())); + } return Collections.singletonMap(KType.class.getName(), p.getType()); } } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/KotlinResponseBodyAdvice.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/KotlinResponseBodyAdvice.java index fb527bc1f85c..760c9dcdc9ea 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/KotlinResponseBodyAdvice.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/KotlinResponseBodyAdvice.java @@ -26,6 +26,7 @@ import org.jspecify.annotations.Nullable; import org.springframework.core.MethodParameter; +import org.springframework.http.HttpEntity; import org.springframework.http.MediaType; import org.springframework.http.converter.AbstractKotlinSerializationHttpMessageConverter; import org.springframework.http.converter.HttpMessageConverter; @@ -61,6 +62,9 @@ public boolean supports(MethodParameter returnType, Class function = ReflectJvmMapping.getKotlinFunction(Objects.requireNonNull(returnType.getMethod())); KType type = Objects.requireNonNull(function).getReturnType(); + if (HttpEntity.class.isAssignableFrom(returnType.getParameterType())) { + return Collections.singletonMap(KType.class.getName(), Objects.requireNonNull(type.getArguments().get(0).getType())); + } return Collections.singletonMap(KType.class.getName(), type); } diff --git a/spring-webmvc/src/test/kotlin/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorKotlinTests.kt b/spring-webmvc/src/test/kotlin/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorKotlinTests.kt index 6e87030c251e..d81e97844bac 100644 --- a/spring-webmvc/src/test/kotlin/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorKotlinTests.kt +++ b/spring-webmvc/src/test/kotlin/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorKotlinTests.kt @@ -20,6 +20,8 @@ import kotlinx.serialization.Serializable import org.assertj.core.api.Assertions import org.junit.jupiter.api.Test import org.springframework.core.MethodParameter +import org.springframework.http.RequestEntity +import org.springframework.http.ResponseEntity import org.springframework.http.converter.StringHttpMessageConverter import org.springframework.http.converter.json.KotlinSerializationJsonHttpMessageConverter import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean @@ -68,6 +70,22 @@ class RequestResponseBodyMethodProcessorKotlinTests { .contains("\"value\":\"foo\"") } + @Test + fun writeEntityWithKotlinSerializationJsonMessageConverter() { + val method = SampleController::writeMessageEntity::javaMethod.get()!! + val handlerMethod = HandlerMethod(SampleController(), method) + val methodReturnType = handlerMethod.returnType + + val converters = listOf(KotlinSerializationJsonHttpMessageConverter()) + val processor = RequestResponseBodyMethodProcessor(converters, null, listOf(KotlinResponseBodyAdvice())) + + val returnValue: Any? = SampleController().writeMessageEntity().body + processor.handleReturnValue(returnValue, methodReturnType, this.container, this.request) + + Assertions.assertThat(this.servletResponse.contentAsString) + .contains("\"value\":\"foo\"") + } + @Test fun writeGenericTypeWithKotlinSerializationJsonMessageConverter() { val method = SampleController::writeMessages::javaMethod.get()!! @@ -118,6 +136,24 @@ class RequestResponseBodyMethodProcessorKotlinTests { Assertions.assertThat(result).isEqualTo(Message("foo")) } + @Test + @Suppress("UNCHECKED_CAST") + fun readEntityWithKotlinSerializationJsonMessageConverter() { + val content = "{\"value\" : \"foo\"}" + this.servletRequest.setContent(content.toByteArray(StandardCharsets.UTF_8)) + this.servletRequest.setContentType("application/json") + + val converters = listOf(StringHttpMessageConverter(), KotlinSerializationJsonHttpMessageConverter()) + val processor = RequestResponseBodyMethodProcessor(converters, null, listOf(KotlinRequestBodyAdvice())) + + val method = SampleController::readMessageEntity::javaMethod.get()!! + val methodParameter = MethodParameter(method, 0) + + val result = processor.resolveArgument(methodParameter, container, request, factory) as Message + + Assertions.assertThat(result).isEqualTo(Message("foo")) + } + @Suppress("UNCHECKED_CAST") @Test fun readGenericTypeWithKotlinSerializationJsonMessageConverter() { @@ -161,6 +197,10 @@ class RequestResponseBodyMethodProcessorKotlinTests { @ResponseBody fun writeMessage() = Message("foo") + @RequestMapping + @ResponseBody + fun writeMessageEntity() = ResponseEntity.ok(Message("foo")) + @RequestMapping @ResponseBody fun writeMessages() = listOf(Message("foo"), Message("bar")) @@ -169,6 +209,10 @@ class RequestResponseBodyMethodProcessorKotlinTests { @ResponseBody fun readMessage(message: Message) = message.value + @RequestMapping + @ResponseBody + fun readMessageEntity(entity: RequestEntity) = entity.body!!.value + @RequestMapping @ResponseBody fun readMessages(messages: List) = messages.map { it.value }.reduce { acc, string -> "$acc $string" } From 3fba265b60c73a4120a4ff0d49cc47608a16333e Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 19 Aug 2025 09:36:16 +0200 Subject: [PATCH 105/591] Upgrade to Jetty 12.1.0 Jetty 12.1 is Jakarta EE11 compliant. Closes gh-35345 --- framework-platform/framework-platform.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index 50fb2b65dc35..906645a397ec 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -15,7 +15,7 @@ dependencies { api(platform("org.apache.groovy:groovy-bom:5.0.0-rc-1")) api(platform("org.apache.logging.log4j:log4j-bom:3.0.0-beta3")) api(platform("org.assertj:assertj-bom:3.27.3")) - api(platform("org.eclipse.jetty:jetty-bom:12.1.0.beta3")) + api(platform("org.eclipse.jetty:jetty-bom:12.1.0")) api(platform("org.eclipse.jetty.ee11:jetty-ee11-bom:12.1.0.beta3")) api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2")) api(platform("org.jetbrains.kotlinx:kotlinx-serialization-bom:1.9.0")) From 4791565630145adf27615d75c4221a09946e47a5 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 19 Aug 2025 11:12:06 +0200 Subject: [PATCH 106/591] Polishing See gh-35345 --- framework-platform/framework-platform.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index 906645a397ec..47055ceed6bb 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -16,7 +16,7 @@ dependencies { api(platform("org.apache.logging.log4j:log4j-bom:3.0.0-beta3")) api(platform("org.assertj:assertj-bom:3.27.3")) api(platform("org.eclipse.jetty:jetty-bom:12.1.0")) - api(platform("org.eclipse.jetty.ee11:jetty-ee11-bom:12.1.0.beta3")) + api(platform("org.eclipse.jetty.ee11:jetty-ee11-bom:12.1.0")) api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2")) api(platform("org.jetbrains.kotlinx:kotlinx-serialization-bom:1.9.0")) api(platform("org.junit:junit-bom:5.13.4")) From 6d710d482a6785b069e35022e81758953afc21ff Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Mon, 18 Aug 2025 18:15:57 +0200 Subject: [PATCH 107/591] Find annotation on overridden method in type hierarchy with unresolved generics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prior to this commit, the MergedAnnotations support (specifically AnnotationsScanner) and AnnotatedMethod did not find annotations on overridden methods in type hierarchies with unresolved generics. The reason for this is that ResolvableType.resolve() returns null for such an unresolved type, which prevents the search algorithms from considering such methods as override candidates. For example, given the following type hierarchy, the compiler does not generate a method corresponding to processOneAndTwo(Long, String) for GenericInterfaceImpl. Nonetheless, one would expect an invocation of processOneAndTwo(Long, String) to be @⁠Transactional since it is effectively an invocation of processOneAndTwo(Long, C) in GenericAbstractSuperclass, which overrides/implements processOneAndTwo(A, B) in GenericInterface, which is annotated with @⁠Transactional. However, the MergedAnnotations infrastructure currently does not determine that processOneAndTwo(Long, C) is @⁠Transactional since it is not able to determine that processOneAndTwo(Long, C) overrides processOneAndTwo(A, B) because of the unresolved generic C. interface GenericInterface { @⁠Transactional void processOneAndTwo(A value1, B value2); } abstract class GenericAbstractSuperclass implements GenericInterface { @⁠Override public void processOneAndTwo(Long value1, C value2) { } } static GenericInterfaceImpl extends GenericAbstractSuperclass { } To address such issues, this commit changes the logic in AnnotationsScanner.hasSameGenericTypeParameters() and AnnotatedMethod.isOverrideFor() so that they use ResolvableType.toClass() instead of ResolvableType.resolve(). The former returns Object.class for an unresolved generic which in turn allows the search algorithms to properly detect method overrides in such type hierarchies. Closes gh-35342 --- .../core/annotation/AnnotatedMethod.java | 2 +- .../core/annotation/AnnotationsScanner.java | 2 +- .../core/annotation/AnnotatedMethodTests.java | 116 ++++++++++++++++++ .../annotation/MergedAnnotationsTests.java | 29 +++++ .../web/method/HandlerMethodTests.java | 68 +++++++++- 5 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 spring-core/src/test/java/org/springframework/core/annotation/AnnotatedMethodTests.java diff --git a/spring-core/src/main/java/org/springframework/core/annotation/AnnotatedMethod.java b/spring-core/src/main/java/org/springframework/core/annotation/AnnotatedMethod.java index 85048080ea45..ed9ed5253002 100644 --- a/spring-core/src/main/java/org/springframework/core/annotation/AnnotatedMethod.java +++ b/spring-core/src/main/java/org/springframework/core/annotation/AnnotatedMethod.java @@ -204,7 +204,7 @@ private boolean isOverrideFor(Method candidate) { } for (int i = 0; i < paramTypes.length; i++) { if (paramTypes[i] != - ResolvableType.forMethodParameter(candidate, i, this.method.getDeclaringClass()).resolve()) { + ResolvableType.forMethodParameter(candidate, i, this.method.getDeclaringClass()).toClass()) { return false; } } diff --git a/spring-core/src/main/java/org/springframework/core/annotation/AnnotationsScanner.java b/spring-core/src/main/java/org/springframework/core/annotation/AnnotationsScanner.java index e064aa4e45df..e81a08d18b55 100644 --- a/spring-core/src/main/java/org/springframework/core/annotation/AnnotationsScanner.java +++ b/spring-core/src/main/java/org/springframework/core/annotation/AnnotationsScanner.java @@ -379,7 +379,7 @@ private static boolean hasSameGenericTypeParameters( } for (int i = 0; i < rootParameterTypes.length; i++) { Class resolvedParameterType = ResolvableType.forMethodParameter( - candidateMethod, i, sourceDeclaringClass).resolve(); + candidateMethod, i, sourceDeclaringClass).toClass(); if (rootParameterTypes[i] != resolvedParameterType) { return false; } diff --git a/spring-core/src/test/java/org/springframework/core/annotation/AnnotatedMethodTests.java b/spring-core/src/test/java/org/springframework/core/annotation/AnnotatedMethodTests.java new file mode 100644 index 000000000000..1118239d614b --- /dev/null +++ b/spring-core/src/test/java/org/springframework/core/annotation/AnnotatedMethodTests.java @@ -0,0 +1,116 @@ +/* + * Copyright 2002-present 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.core.annotation; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.reflect.Method; + +import org.junit.jupiter.api.Test; + +import org.springframework.core.MethodParameter; +import org.springframework.util.ClassUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link AnnotatedMethod}. + * + * @author Sam Brannen + * @since 6.2.11 + */ +class AnnotatedMethodTests { + + @Test + void shouldFindAnnotationOnMethodInGenericAbstractSuperclass() { + Method processTwo = getMethod("processTwo", String.class); + + AnnotatedMethod annotatedMethod = new AnnotatedMethod(processTwo); + + assertThat(annotatedMethod.hasMethodAnnotation(Handler.class)).isTrue(); + } + + @Test + void shouldFindAnnotationOnMethodInGenericInterface() { + Method processOneAndTwo = getMethod("processOneAndTwo", Long.class, Object.class); + + AnnotatedMethod annotatedMethod = new AnnotatedMethod(processOneAndTwo); + + assertThat(annotatedMethod.hasMethodAnnotation(Handler.class)).isTrue(); + } + + @Test + void shouldFindAnnotationOnMethodParameterInGenericAbstractSuperclass() { + Method processTwo = getMethod("processTwo", String.class); + + AnnotatedMethod annotatedMethod = new AnnotatedMethod(processTwo); + MethodParameter[] methodParameters = annotatedMethod.getMethodParameters(); + + assertThat(methodParameters).hasSize(1); + assertThat(methodParameters[0].hasParameterAnnotation(Param.class)).isTrue(); + } + + @Test + void shouldFindAnnotationOnMethodParameterInGenericInterface() { + Method processOneAndTwo = getMethod("processOneAndTwo", Long.class, Object.class); + + AnnotatedMethod annotatedMethod = new AnnotatedMethod(processOneAndTwo); + MethodParameter[] methodParameters = annotatedMethod.getMethodParameters(); + + assertThat(methodParameters).hasSize(2); + assertThat(methodParameters[0].hasParameterAnnotation(Param.class)).isFalse(); + assertThat(methodParameters[1].hasParameterAnnotation(Param.class)).isTrue(); + } + + + private static Method getMethod(String name, Class...parameterTypes) { + return ClassUtils.getMethod(GenericInterfaceImpl.class, name, parameterTypes); + } + + + @Retention(RetentionPolicy.RUNTIME) + @interface Handler { + } + + @Retention(RetentionPolicy.RUNTIME) + @interface Param { + } + + interface GenericInterface { + + @Handler + void processOneAndTwo(A value1, @Param B value2); + } + + abstract static class GenericAbstractSuperclass implements GenericInterface { + + @Override + public void processOneAndTwo(Long value1, C value2) { + } + + @Handler + public abstract void processTwo(@Param C value); + } + + static class GenericInterfaceImpl extends GenericAbstractSuperclass { + + @Override + public void processTwo(String value) { + } + } + +} diff --git a/spring-core/src/test/java/org/springframework/core/annotation/MergedAnnotationsTests.java b/spring-core/src/test/java/org/springframework/core/annotation/MergedAnnotationsTests.java index 0face9eab769..66a1b7e4bc89 100644 --- a/spring-core/src/test/java/org/springframework/core/annotation/MergedAnnotationsTests.java +++ b/spring-core/src/test/java/org/springframework/core/annotation/MergedAnnotationsTests.java @@ -945,6 +945,15 @@ void getFromMethodWithGenericSuperclass() throws Exception { Order.class).getDistance()).isEqualTo(0); } + @Test + void getFromMethodWithUnresolvedGenericsInGenericTypeHierarchy() { + // The following method is GenericAbstractSuperclass.processOneAndTwo(java.lang.Long, C), + // where 'C' is an unresolved generic, for which ResolvableType.resolve() returns null. + Method method = ClassUtils.getMethod(GenericInterfaceImpl.class, "processOneAndTwo", Long.class, Object.class); + assertThat(MergedAnnotations.from(method, SearchStrategy.TYPE_HIERARCHY) + .get(Transactional.class).isDirectlyPresent()).isTrue(); + } + @Test void getFromMethodWithInterfaceOnSuper() throws Exception { Method method = SubOfImplementsInterfaceWithAnnotatedMethod.class.getMethod("foo"); @@ -3032,6 +3041,26 @@ public void foo(String t) { } } + interface GenericInterface { + + @Transactional + void processOneAndTwo(A value1, B value2); + } + + abstract static class GenericAbstractSuperclass implements GenericInterface { + + @Override + public void processOneAndTwo(Long value1, C value2) { + } + } + + static class GenericInterfaceImpl extends GenericAbstractSuperclass { + // The compiler does not require us to declare a concrete + // processOneAndTwo(Long, String) method, and we intentionally + // do not declare one here. + } + + @Retention(RetentionPolicy.RUNTIME) @Inherited @interface MyRepeatableContainer { diff --git a/spring-web/src/test/java/org/springframework/web/method/HandlerMethodTests.java b/spring-web/src/test/java/org/springframework/web/method/HandlerMethodTests.java index b114133bc167..291ba4925bdd 100644 --- a/spring-web/src/test/java/org/springframework/web/method/HandlerMethodTests.java +++ b/spring-web/src/test/java/org/springframework/web/method/HandlerMethodTests.java @@ -35,32 +35,46 @@ * Tests for {@link HandlerMethod}. * * @author Rossen Stoyanchev + * @author Sam Brannen */ class HandlerMethodTests { @Test - void shouldValidateArgsWithConstraintsDirectlyOnClass() { + void shouldValidateArgsWithConstraintsDirectlyInClass() { Object target = new MyClass(); testValidateArgs(target, List.of("addIntValue", "addPersonAndIntValue", "addPersons", "addPeople", "addNames"), true); testValidateArgs(target, List.of("addPerson", "getPerson", "getIntValue", "addPersonNotValidated"), false); } @Test - void shouldValidateArgsWithConstraintsOnInterface() { + void shouldValidateArgsWithConstraintsInInterface() { Object target = new MyInterfaceImpl(); testValidateArgs(target, List.of("addIntValue", "addPersonAndIntValue", "addPersons", "addPeople"), true); testValidateArgs(target, List.of("addPerson", "addPersonNotValidated", "getPerson", "getIntValue"), false); } @Test - void shouldValidateReturnValueWithConstraintsDirectlyOnClass() { + void shouldValidateArgsWithConstraintsInGenericAbstractSuperclass() { + Object target = new GenericInterfaceImpl(); + shouldValidateArguments(getHandlerMethod(target, "processTwo", String.class), true); + } + + @Test + void shouldValidateArgsWithConstraintsInGenericInterface() { + Object target = new GenericInterfaceImpl(); + shouldValidateArguments(getHandlerMethod(target, "processOne", Long.class), false); + shouldValidateArguments(getHandlerMethod(target, "processOneAndTwo", Long.class, Object.class), true); + } + + @Test + void shouldValidateReturnValueWithConstraintsDirectlyInClass() { Object target = new MyClass(); testValidateReturnValue(target, List.of("getPerson", "getIntValue"), true); testValidateReturnValue(target, List.of("addPerson", "addIntValue", "addPersonNotValidated"), false); } @Test - void shouldValidateReturnValueWithConstraintsOnInterface() { + void shouldValidateReturnValueWithConstraintsInInterface() { Object target = new MyInterfaceImpl(); testValidateReturnValue(target, List.of("getPerson", "getIntValue"), true); testValidateReturnValue(target, List.of("addPerson", "addIntValue", "addPersonNotValidated"), false); @@ -97,9 +111,19 @@ void resolvedFromHandlerMethod() { assertThat(hm3.getResolvedFromHandlerMethod()).isSameAs(hm1); } + + private static void shouldValidateArguments(HandlerMethod handlerMethod, boolean expected) { + if (expected) { + assertThat(handlerMethod.shouldValidateArguments()).as(handlerMethod.getMethod().getName()).isTrue(); + } + else { + assertThat(handlerMethod.shouldValidateArguments()).as(handlerMethod.getMethod().getName()).isFalse(); + } + } + private static void testValidateArgs(Object target, List methodNames, boolean expected) { for (String methodName : methodNames) { - assertThat(getHandlerMethod(target, methodName).shouldValidateArguments()).isEqualTo(expected); + shouldValidateArguments(getHandlerMethod(target, methodName), expected); } } @@ -110,7 +134,11 @@ private static void testValidateReturnValue(Object target, List methodNa } private static HandlerMethod getHandlerMethod(Object target, String methodName) { - Method method = ClassUtils.getMethod(target.getClass(), methodName, (Class[]) null); + return getHandlerMethod(target, methodName, (Class[]) null); + } + + private static HandlerMethod getHandlerMethod(Object target, String methodName, Class... parameterTypes) { + Method method = ClassUtils.getMethod(target.getClass(), methodName, parameterTypes); return new HandlerMethod(target, method).createWithValidateFlags(); } @@ -236,4 +264,32 @@ public Person getPerson() { } } + + interface GenericInterface { + + void processOne(@Valid A value1); + + void processOneAndTwo(A value1, @Max(42) B value2); + } + + abstract static class GenericAbstractSuperclass implements GenericInterface { + + @Override + public void processOne(Long value1) { + } + + @Override + public void processOneAndTwo(Long value1, C value2) { + } + + public abstract void processTwo(@Max(42) C value); + } + + static class GenericInterfaceImpl extends GenericAbstractSuperclass { + + @Override + public void processTwo(String value) { + } + } + } From 5d325ca0fcbfcad87afa49049994992042b2c178 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Tue, 19 Aug 2025 13:32:35 +0200 Subject: [PATCH 108/591] Improve wording for transactional rollback rule semantics Closes gh-35346 --- .../transaction/declarative/rolling-back.adoc | 12 +++++++----- .../transaction/annotation/Transactional.java | 9 ++++++--- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/rolling-back.adoc b/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/rolling-back.adoc index a16f4985f2d4..42ad16cd0e9e 100644 --- a/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/rolling-back.adoc +++ b/framework-docs/modules/ROOT/pages/data-access/transaction/declarative/rolling-back.adoc @@ -86,11 +86,13 @@ rollback rules may be configured via the `rollbackFor`/`noRollbackFor` and `rollbackForClassName`/`noRollbackForClassName` attributes, which allow rules to be defined based on exception types or patterns, respectively. -When a rollback rule is defined with an exception type, that type will be used to match -against the type of a thrown exception and its super types, providing type safety and -avoiding any unintentional matches that may occur when using a pattern. For example, a -value of `jakarta.servlet.ServletException.class` will only match thrown exceptions of -type `jakarta.servlet.ServletException` and its subclasses. +When a rollback rule is defined with an exception type – for example, via `rollbackFor` – +that type will be used to match against the type of a thrown exception. Specifically, +given a configured exception type `C`, a thrown exception of type `T` will be considered +a match against `C` if `T` is equal to `C` or a subclass of `C`. This provides type +safety and avoids any unintentional matches that may occur when using a pattern. For +example, a value of `jakarta.servlet.ServletException.class` will only match thrown +exceptions of type `jakarta.servlet.ServletException` and its subclasses. When a rollback rule is defined with an exception pattern, the pattern can be a fully qualified class name or a substring of a fully qualified class name for an exception type diff --git a/spring-tx/src/main/java/org/springframework/transaction/annotation/Transactional.java b/spring-tx/src/main/java/org/springframework/transaction/annotation/Transactional.java index f1f9a6652a27..da8261863044 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/annotation/Transactional.java +++ b/spring-tx/src/main/java/org/springframework/transaction/annotation/Transactional.java @@ -56,9 +56,12 @@ * {@link #rollbackForClassName}/{@link #noRollbackForClassName}, which allow * rules to be specified as types or patterns, respectively. * - *

    When a rollback rule is defined with an exception type, that type will be - * used to match against the type of a thrown exception and its super types, - * providing type safety and avoiding any unintentional matches that may occur + *

    When a rollback rule is defined with an exception type — for example, + * via {@link #rollbackFor} — that type will be used to match against the + * type of a thrown exception. Specifically, given a configured exception type + * {@code C}, a thrown exception of type {@code T} will be considered a match + * against {@code C} if {@code T} is equal to {@code C} or a subclass of {@code C}. + * This provides type safety and avoids any unintentional matches that may occur * when using a pattern. For example, a value of * {@code jakarta.servlet.ServletException.class} will only match thrown exceptions * of type {@code jakarta.servlet.ServletException} and its subclasses. From 7a2a167f34385902c081d58be00794d5d9e6e325 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Mon, 18 Aug 2025 15:45:19 +0200 Subject: [PATCH 109/591] Upgrade nullability plugin to 0.0.4 This commit also includes related refinements of JdbcTemplate#getSingleColumnRowMapper and ObjectUtils#addObjectToArray. Closes gh-35340 --- build.gradle | 2 +- .../main/java/org/springframework/util/ObjectUtils.java | 7 ++++--- .../java/org/springframework/jdbc/core/JdbcTemplate.java | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/build.gradle b/build.gradle index 01b6cfcd4c4c..d0dce05f0fae 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ plugins { id 'com.github.bjornvester.xjc' version '1.8.2' apply false id 'io.github.goooler.shadow' version '8.1.8' apply false id 'me.champeau.jmh' version '0.7.2' apply false - id "io.spring.nullability" version "0.0.1" apply false + id 'io.spring.nullability' version '0.0.4' apply false } ext { diff --git a/spring-core/src/main/java/org/springframework/util/ObjectUtils.java b/spring-core/src/main/java/org/springframework/util/ObjectUtils.java index 98fedfd795b2..c32075563ead 100644 --- a/spring-core/src/main/java/org/springframework/util/ObjectUtils.java +++ b/spring-core/src/main/java/org/springframework/util/ObjectUtils.java @@ -255,7 +255,7 @@ public static > E caseInsensitiveValueOf(E[] enumValues, Strin * @param obj the object to append * @return the new array (of the same component type; never {@code null}) */ - public static A[] addObjectToArray(A @Nullable [] array, @Nullable O obj) { + public static A[] addObjectToArray(A @Nullable [] array, O obj) { return addObjectToArray(array, obj, (array != null ? array.length : 0)); } @@ -268,17 +268,18 @@ public static A[] addObjectToArray(A @Nullable [] array, @Nulla * @return the new array (of the same component type; never {@code null}) * @since 6.0 */ - public static @Nullable A[] addObjectToArray(A @Nullable [] array, @Nullable O obj, int position) { + public static A[] addObjectToArray(A @Nullable [] array, O obj, int position) { Class componentType = Object.class; if (array != null) { componentType = array.getClass().componentType(); } + // Defensive code for use cases not following the declared nullability else if (obj != null) { componentType = obj.getClass(); } int newArrayLength = (array != null ? array.length + 1 : 1); @SuppressWarnings("unchecked") - @Nullable A[] newArray = (A[]) Array.newInstance(componentType, newArrayLength); + A[] newArray = (A[]) Array.newInstance(componentType, newArrayLength); if (array != null) { System.arraycopy(array, 0, newArray, 0, position); System.arraycopy(array, position, newArray, position + 1, array.length - position); diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/JdbcTemplate.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/JdbcTemplate.java index 734b217ab7c2..096d397cd37d 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/JdbcTemplate.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/JdbcTemplate.java @@ -1413,7 +1413,7 @@ else if (param.getResultSetExtractor() != null) { * @return the RowMapper to use * @see SingleColumnRowMapper */ - protected RowMapper<@Nullable T> getSingleColumnRowMapper(Class requiredType) { + protected RowMapper getSingleColumnRowMapper(Class requiredType) { return new SingleColumnRowMapper<>(requiredType); } From bb2a259d85daf429ecb1330b97a7858891da1000 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Tue, 19 Aug 2025 17:27:39 +0200 Subject: [PATCH 110/591] Wrap exceptionally long lines --- .../springframework/dao/support/DataAccessUtils.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/spring-tx/src/main/java/org/springframework/dao/support/DataAccessUtils.java b/spring-tx/src/main/java/org/springframework/dao/support/DataAccessUtils.java index 9327c8cc9ff1..58309754b55b 100644 --- a/spring-tx/src/main/java/org/springframework/dao/support/DataAccessUtils.java +++ b/spring-tx/src/main/java/org/springframework/dao/support/DataAccessUtils.java @@ -116,7 +116,9 @@ public abstract class DataAccessUtils { * element has been found in the given Collection * @since 6.1 */ - public static Optional<@NonNull T> optionalResult(@Nullable Collection results) throws IncorrectResultSizeDataAccessException { + public static Optional<@NonNull T> optionalResult(@Nullable Collection results) + throws IncorrectResultSizeDataAccessException { + return Optional.ofNullable(singleResult(results)); } @@ -159,7 +161,9 @@ public static Optional optionalResult(@Nullable Iterator results) thro * @throws EmptyResultDataAccessException if no element at all * has been found in the given Collection */ - public static @NonNull T requiredSingleResult(@Nullable Collection results) throws IncorrectResultSizeDataAccessException { + public static @NonNull T requiredSingleResult(@Nullable Collection results) + throws IncorrectResultSizeDataAccessException { + if (CollectionUtils.isEmpty(results)) { throw new EmptyResultDataAccessException(1); } @@ -185,7 +189,9 @@ public static Optional optionalResult(@Nullable Iterator results) thro * has been found in the given Collection * @since 5.0.2 */ - public static T nullableSingleResult(@Nullable Collection results) throws IncorrectResultSizeDataAccessException { + public static T nullableSingleResult(@Nullable Collection results) + throws IncorrectResultSizeDataAccessException { + // This is identical to the requiredSingleResult implementation but differs in the // semantics of the incoming Collection (which we currently can't formally express) if (CollectionUtils.isEmpty(results)) { From 887ef75700efdecba850e5e4f141c83473638f18 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Tue, 19 Aug 2025 17:30:47 +0200 Subject: [PATCH 111/591] =?UTF-8?q?Remove=20redundant=20declarations=20of?= =?UTF-8?q?=20JSpecify's=20@=E2=81=A0NonNull=20annotation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes gh-35341 --- .../beans/ExtendedBeanInfoFactory.java | 4 +--- .../core/annotation/TypeMappedAnnotations.java | 3 +-- .../core/annotation/AnnotationsScannerTests.java | 13 ++++++------- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/spring-beans/src/main/java/org/springframework/beans/ExtendedBeanInfoFactory.java b/spring-beans/src/main/java/org/springframework/beans/ExtendedBeanInfoFactory.java index 8532d26e40ee..2a80f47584a2 100644 --- a/spring-beans/src/main/java/org/springframework/beans/ExtendedBeanInfoFactory.java +++ b/spring-beans/src/main/java/org/springframework/beans/ExtendedBeanInfoFactory.java @@ -20,8 +20,6 @@ import java.beans.IntrospectionException; import java.lang.reflect.Method; -import org.jspecify.annotations.NonNull; - import org.springframework.core.Ordered; /** @@ -44,7 +42,7 @@ public class ExtendedBeanInfoFactory extends StandardBeanInfoFactory { @Override - public @NonNull BeanInfo getBeanInfo(Class beanClass) throws IntrospectionException { + public BeanInfo getBeanInfo(Class beanClass) throws IntrospectionException { BeanInfo beanInfo = super.getBeanInfo(beanClass); return (supports(beanClass) ? new ExtendedBeanInfo(beanInfo) : beanInfo); } diff --git a/spring-core/src/main/java/org/springframework/core/annotation/TypeMappedAnnotations.java b/spring-core/src/main/java/org/springframework/core/annotation/TypeMappedAnnotations.java index 310348a5cf8a..2c20d6766b0d 100644 --- a/spring-core/src/main/java/org/springframework/core/annotation/TypeMappedAnnotations.java +++ b/spring-core/src/main/java/org/springframework/core/annotation/TypeMappedAnnotations.java @@ -29,7 +29,6 @@ import java.util.stream.Stream; import java.util.stream.StreamSupport; -import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; /** @@ -483,7 +482,7 @@ private void addAggregateAnnotations(List aggregateAnnotations, @Nul } @Override - public @NonNull List finish(@Nullable List processResult) { + public List finish(@Nullable List processResult) { return this.aggregates; } } diff --git a/spring-core/src/test/java/org/springframework/core/annotation/AnnotationsScannerTests.java b/spring-core/src/test/java/org/springframework/core/annotation/AnnotationsScannerTests.java index 6cb5c2b2248a..c1f06239b783 100644 --- a/spring-core/src/test/java/org/springframework/core/annotation/AnnotationsScannerTests.java +++ b/spring-core/src/test/java/org/springframework/core/annotation/AnnotationsScannerTests.java @@ -29,7 +29,6 @@ import java.util.function.Predicate; import java.util.stream.Stream; -import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; @@ -470,13 +469,13 @@ void scanWhenProcessorReturnsFromDoWithAggregateExitsEarly() { new AnnotationsProcessor() { @Override - public @NonNull String doWithAggregate(Object context, int aggregateIndex) { + public String doWithAggregate(Object context, int aggregateIndex) { return ""; } @Override - public @NonNull String doWithAnnotations(Object context, int aggregateIndex, - @Nullable Object source, @Nullable Annotation @Nullable [] annotations) { + public String doWithAnnotations(Object context, int aggregateIndex, + @Nullable Object source, @Nullable Annotation[] annotations) { throw new IllegalStateException("Should not call"); } @@ -502,13 +501,13 @@ void scanWhenProcessorHasFinishMethodUsesFinishResult() { new AnnotationsProcessor() { @Override - public @NonNull String doWithAnnotations(Object context, int aggregateIndex, - @Nullable Object source, @Nullable Annotation @Nullable [] annotations) { + public String doWithAnnotations(Object context, int aggregateIndex, + @Nullable Object source, @Nullable Annotation[] annotations) { return "K"; } @Override - public @NonNull String finish(@Nullable String result) { + public String finish(@Nullable String result) { return "O" + result; } From fce7b3d420665d8f3d6bf2a9d0deb4afee8caa1f Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 19 Aug 2025 21:43:13 +0200 Subject: [PATCH 112/591] Remove Undertow-specific support and testing Undertow does not support Servlet 6.1, we need to remove compatibility tests as well as Undertow-specific classes for WebSocket and reactive support. Closes gh-35354 --- .../ROOT/pages/core/databuffer-codec.adoc | 4 +- .../modules/ROOT/pages/overview.adoc | 2 +- .../modules/ROOT/pages/web-reactive.adoc | 2 +- .../ROOT/pages/web/webflux-websocket.adoc | 4 +- .../modules/ROOT/pages/web/webflux.adoc | 2 +- .../modules/ROOT/pages/web/webflux/http2.adoc | 2 +- .../ROOT/pages/web/webflux/new-framework.adoc | 8 +- .../pages/web/webflux/reactive-spring.adoc | 34 +- framework-platform/framework-platform.gradle | 3 - spring-web/spring-web.gradle | 1 - .../http/support/HeadersAdapterBenchmark.java | 3 +- .../AbstractListenerReadPublisher.java | 5 +- .../AbstractListenerWriteProcessor.java | 4 +- .../reactive/UndertowHeadersAdapter.java | 272 ---------- .../reactive/UndertowHttpHandlerAdapter.java | 141 ------ .../reactive/UndertowServerHttpRequest.java | 197 ------- .../reactive/UndertowServerHttpResponse.java | 344 ------------- .../http/server/reactive/package-info.java | 2 +- .../StandardMultipartHttpServletRequest.java | 2 +- .../reactive/CookieIntegrationTests.java | 4 - .../DefaultServerHttpRequestBuilderTests.java | 2 - .../server/reactive/HeadersAdaptersTests.java | 5 - .../reactive/ZeroCopyIntegrationTests.java | 3 +- ...ndardMultipartHttpServletRequestTests.java | 19 - .../AbstractHttpHandlerIntegrationTests.java | 3 +- .../bootstrap/UndertowHttpServer.java | 61 --- spring-webflux/spring-webflux.gradle | 2 - .../function/server/RouterFunctions.java | 6 +- .../AbstractListenerWebSocketSession.java | 4 +- .../UndertowWebSocketHandlerAdapter.java | 108 ---- .../adapter/UndertowWebSocketSession.java | 141 ------ .../client/UndertowWebSocketClient.java | 260 ---------- .../support/HandshakeWebSocketService.java | 8 - .../UndertowRequestUpgradeStrategy.java | 117 ----- ...ltipartRouterFunctionIntegrationTests.java | 14 - .../ContextPathIntegrationTests.java | 4 +- .../MultipartWebClientIntegrationTests.java | 4 - .../annotation/SseIntegrationTests.java | 6 +- ...ractReactiveWebSocketIntegrationTests.java | 19 +- .../annotation/CoroutinesIntegrationTests.kt | 3 - spring-websocket/spring-websocket.gradle | 2 - .../sockjs/client/UndertowXhrTransport.java | 479 ------------------ .../AbstractWebSocketIntegrationTests.java | 3 +- .../web/socket/UndertowTestServer.java | 181 ------- .../UndertowSockJsIntegrationTests.java | 50 -- 45 files changed, 28 insertions(+), 2512 deletions(-) delete mode 100644 spring-web/src/main/java/org/springframework/http/server/reactive/UndertowHeadersAdapter.java delete mode 100644 spring-web/src/main/java/org/springframework/http/server/reactive/UndertowHttpHandlerAdapter.java delete mode 100644 spring-web/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpRequest.java delete mode 100644 spring-web/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpResponse.java delete mode 100644 spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/server/reactive/bootstrap/UndertowHttpServer.java delete mode 100644 spring-webflux/src/main/java/org/springframework/web/reactive/socket/adapter/UndertowWebSocketHandlerAdapter.java delete mode 100644 spring-webflux/src/main/java/org/springframework/web/reactive/socket/adapter/UndertowWebSocketSession.java delete mode 100644 spring-webflux/src/main/java/org/springframework/web/reactive/socket/client/UndertowWebSocketClient.java delete mode 100644 spring-webflux/src/main/java/org/springframework/web/reactive/socket/server/upgrade/UndertowRequestUpgradeStrategy.java delete mode 100644 spring-websocket/src/main/java/org/springframework/web/socket/sockjs/client/UndertowXhrTransport.java delete mode 100644 spring-websocket/src/test/java/org/springframework/web/socket/UndertowTestServer.java delete mode 100644 spring-websocket/src/test/java/org/springframework/web/socket/sockjs/client/UndertowSockJsIntegrationTests.java diff --git a/framework-docs/modules/ROOT/pages/core/databuffer-codec.adoc b/framework-docs/modules/ROOT/pages/core/databuffer-codec.adoc index cdd8c6c15a7e..af18ce82e605 100644 --- a/framework-docs/modules/ROOT/pages/core/databuffer-codec.adoc +++ b/framework-docs/modules/ROOT/pages/core/databuffer-codec.adoc @@ -3,8 +3,8 @@ Java NIO provides `ByteBuffer` but many libraries build their own byte buffer API on top, especially for network operations where reusing buffers and/or using direct buffers is -beneficial for performance. For example Netty has the `ByteBuf` hierarchy, Undertow uses -XNIO, Jetty uses pooled byte buffers with a callback to be released, and so on. +beneficial for performance. For example Netty has the `ByteBuf` hierarchy, +Jetty uses pooled byte buffers with a callback to be released, and so on. The `spring-core` module provides a set of abstractions to work with various byte buffer APIs as follows: diff --git a/framework-docs/modules/ROOT/pages/overview.adoc b/framework-docs/modules/ROOT/pages/overview.adoc index e7ff8af9a18b..8ac7c152c6b8 100644 --- a/framework-docs/modules/ROOT/pages/overview.adoc +++ b/framework-docs/modules/ROOT/pages/overview.adoc @@ -73,7 +73,7 @@ As of Spring Framework 6.0, Spring has been upgraded to the Jakarta EE 9 level traditional `javax` packages. With EE 9 as the minimum and EE 10 supported already, Spring is prepared to provide out-of-the-box support for the further evolution of the Jakarta EE APIs. Spring Framework 6.0 is fully compatible with Tomcat 10.1, -Jetty 11 and Undertow 2.3 as web servers, and also with Hibernate ORM 6.1. +Jetty 11 as web servers, and also with Hibernate ORM 6.1. Over time, the role of Java/Jakarta EE in application development has evolved. In the early days of J2EE and Spring, applications were created to be deployed to an application diff --git a/framework-docs/modules/ROOT/pages/web-reactive.adoc b/framework-docs/modules/ROOT/pages/web-reactive.adoc index ff774a425144..eea40b37309f 100644 --- a/framework-docs/modules/ROOT/pages/web-reactive.adoc +++ b/framework-docs/modules/ROOT/pages/web-reactive.adoc @@ -3,7 +3,7 @@ This part of the documentation covers support for reactive-stack web applications built on a {reactive-streams-site}/[Reactive Streams] API to run on non-blocking servers, -such as Netty, Undertow, and Servlet containers. Individual chapters cover +such as Netty and Servlet containers. Individual chapters cover the xref:web/webflux.adoc#webflux[Spring WebFlux] framework, the reactive xref:web/webflux-webclient.adoc[`WebClient`], support for xref:web/webflux-test.adoc[testing], diff --git a/framework-docs/modules/ROOT/pages/web/webflux-websocket.adoc b/framework-docs/modules/ROOT/pages/web/webflux-websocket.adoc index 5c5dce2a3f9c..cd75f1549907 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux-websocket.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux-websocket.adoc @@ -367,7 +367,7 @@ subsequently use `DataBufferUtils.release(dataBuffer)` when the buffers are cons `WebSocketHandlerAdapter` delegates to a `WebSocketService`. By default, that is an instance of `HandshakeWebSocketService`, which performs basic checks on the WebSocket request and then uses `RequestUpgradeStrategy` for the server in use. Currently, there is built-in -support for Reactor Netty, Tomcat, Jetty, and Undertow. +support for Reactor Netty, Tomcat, and Jetty. `HandshakeWebSocketService` exposes a `sessionAttributePredicate` property that allows setting a `Predicate` to extract attributes from the `WebSession` and insert them @@ -446,7 +446,7 @@ specify CORS settings by URL pattern. If both are specified, they are combined b === Client Spring WebFlux provides a `WebSocketClient` abstraction with implementations for -Reactor Netty, Tomcat, Jetty, Undertow, and standard Java (that is, JSR-356). +Reactor Netty, Tomcat, Jetty, and standard Java (that is, JSR-356). NOTE: The Tomcat client is effectively an extension of the standard Java one with some extra functionality in the `WebSocketSession` handling to take advantage of the Tomcat-specific diff --git a/framework-docs/modules/ROOT/pages/web/webflux.adoc b/framework-docs/modules/ROOT/pages/web/webflux.adoc index ffbe046f5bd4..ffc5729b79b7 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux.adoc @@ -8,7 +8,7 @@ The original web framework included in the Spring Framework, Spring Web MVC, was purpose-built for the Servlet API and Servlet containers. The reactive-stack web framework, Spring WebFlux, was added later in version 5.0. It is fully non-blocking, supports {reactive-streams-site}/[Reactive Streams] back pressure, and runs on such servers as -Netty, Undertow, and Servlet containers. +Netty, and Servlet containers. Both web frameworks mirror the names of their source modules ({spring-framework-code}/spring-webmvc[spring-webmvc] and diff --git a/framework-docs/modules/ROOT/pages/web/webflux/http2.adoc b/framework-docs/modules/ROOT/pages/web/webflux/http2.adoc index 1b5a9e643a7e..c9a5f19080b1 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/http2.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/http2.adoc @@ -4,6 +4,6 @@ [.small]#xref:web/webmvc/mvc-http2.adoc[See equivalent in the Servlet stack]# -HTTP/2 is supported with Reactor Netty, Tomcat, Jetty, and Undertow. However, there are +HTTP/2 is supported with Reactor Netty, Tomcat, and Jetty. However, there are considerations related to server configuration. For more details, see the {spring-framework-wiki}/HTTP-2-support[HTTP/2 wiki page]. diff --git a/framework-docs/modules/ROOT/pages/web/webflux/new-framework.adoc b/framework-docs/modules/ROOT/pages/web/webflux/new-framework.adoc index a6dfda382304..f8f0062ceb18 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/new-framework.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/new-framework.adoc @@ -127,7 +127,7 @@ You have maximum choice of libraries, since, historically, most are blocking. * If you are already shopping for a non-blocking web stack, Spring WebFlux offers the same execution model benefits as others in this space and also provides a choice of servers -(Netty, Tomcat, Jetty, Undertow, and Servlet containers), a choice of programming models +(Netty, Tomcat, Jetty, and Servlet containers), a choice of programming models (annotated controllers and functional web endpoints), and a choice of reactive libraries (Reactor, RxJava, or other). @@ -165,7 +165,7 @@ unsure what benefits to look for, start by learning about how non-blocking I/O w == Servers Spring WebFlux is supported on Tomcat, Jetty, Servlet containers, as well as on -non-Servlet runtimes such as Netty and Undertow. All servers are adapted to a low-level, +non-Servlet runtimes such as Netty. All servers are adapted to a low-level, xref:web/webflux/reactive-spring.adoc#webflux-httphandler[common API] so that higher-level xref:web/webflux/new-framework.adoc#webflux-programming-models[programming models] can be supported across servers. @@ -175,7 +175,7 @@ xref:web/webflux/dispatcher-handler.adoc#webflux-framework-config[WebFlux infras lines of code. Spring Boot has a WebFlux starter that automates these steps. By default, the starter uses -Netty, but it is easy to switch to Tomcat, Jetty, or Undertow by changing your +Netty, but it is easy to switch to Tomcat, or Jetty by changing your Maven or Gradle dependencies. Spring Boot defaults to Netty, because it is more widely used in the asynchronous, non-blocking space and lets a client and a server share resources. @@ -188,8 +188,6 @@ adapter. It is not exposed for direct use. NOTE: It is strongly advised not to map Servlet filters or directly manipulate the Servlet API in the context of a WebFlux application. For the reasons listed above, mixing blocking I/O and non-blocking I/O in the same context will cause runtime issues. -For Undertow, Spring WebFlux uses Undertow APIs directly without the Servlet API. - [[webflux-performance]] == Performance diff --git a/framework-docs/modules/ROOT/pages/web/webflux/reactive-spring.adoc b/framework-docs/modules/ROOT/pages/web/webflux/reactive-spring.adoc index 6301994d0bac..d869b1fb2aa7 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/reactive-spring.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/reactive-spring.adoc @@ -7,7 +7,7 @@ applications: * For server request processing there are two levels of support. ** xref:web/webflux/reactive-spring.adoc#webflux-httphandler[HttpHandler]: Basic contract for HTTP request handling with non-blocking I/O and Reactive Streams back pressure, along with adapters for Reactor Netty, -Undertow, Tomcat, Jetty, and any Servlet container. +Tomcat, Jetty, and any Servlet container. ** xref:web/webflux/reactive-spring.adoc#webflux-web-handler-api[`WebHandler` API]: Slightly higher level, general-purpose web API for request handling, on top of which concrete programming models such as annotated controllers and functional endpoints are built. @@ -40,10 +40,6 @@ The following table describes the supported server APIs: | Netty API | {reactor-github-org}/reactor-netty[Reactor Netty] -| Undertow -| Undertow API -| spring-web: Undertow to Reactive Streams bridge - | Tomcat | Servlet non-blocking I/O; Tomcat API to read and write ByteBuffers vs byte[] | spring-web: Servlet non-blocking I/O to Reactive Streams bridge @@ -67,10 +63,6 @@ The following table describes server dependencies (also see |io.projectreactor.netty |reactor-netty -|Undertow -|io.undertow -|undertow-core - |Tomcat |org.apache.tomcat.embed |tomcat-embed-core @@ -104,30 +96,6 @@ Kotlin:: ---- ====== -*Undertow* -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes"] ----- - HttpHandler handler = ... - UndertowHttpHandlerAdapter adapter = new UndertowHttpHandlerAdapter(handler); - Undertow server = Undertow.builder().addHttpListener(port, host).setHandler(adapter).build(); - server.start(); ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes"] ----- - val handler: HttpHandler = ... - val adapter = UndertowHttpHandlerAdapter(handler) - val server = Undertow.builder().addHttpListener(port, host).setHandler(adapter).build() - server.start() ----- -====== - *Tomcat* [tabs] ====== diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index 47055ceed6bb..70808c024711 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -54,9 +54,6 @@ dependencies { api("io.r2dbc:r2dbc-spi:1.0.0.RELEASE") api("io.reactivex.rxjava3:rxjava:3.1.10") api("io.smallrye.reactive:mutiny:1.10.0") - api("io.undertow:undertow-core:2.3.18.Final") - api("io.undertow:undertow-servlet:2.3.18.Final") - api("io.undertow:undertow-websockets-jsr:2.3.18.Final") api("io.vavr:vavr:0.10.4") api("jakarta.activation:jakarta.activation-api:2.1.3") api("jakarta.annotation:jakarta.annotation-api:3.0.0") diff --git a/spring-web/spring-web.gradle b/spring-web/spring-web.gradle index 58b566fa37e2..62a50956d608 100644 --- a/spring-web/spring-web.gradle +++ b/spring-web/spring-web.gradle @@ -28,7 +28,6 @@ dependencies { optional("io.netty:netty-transport") optional("io.projectreactor.netty:reactor-netty-http") optional("io.reactivex.rxjava3:rxjava") - optional("io.undertow:undertow-core") optional("jakarta.el:jakarta.el-api") optional("jakarta.faces:jakarta.faces-api") optional("jakarta.json.bind:jakarta.json.bind-api") diff --git a/spring-web/src/jmh/java/org/springframework/http/support/HeadersAdapterBenchmark.java b/spring-web/src/jmh/java/org/springframework/http/support/HeadersAdapterBenchmark.java index c1499e0ea125..1b2823dccdca 100644 --- a/spring-web/src/jmh/java/org/springframework/http/support/HeadersAdapterBenchmark.java +++ b/spring-web/src/jmh/java/org/springframework/http/support/HeadersAdapterBenchmark.java @@ -84,9 +84,8 @@ public void initImplementationNew() { case "Netty" -> new Netty4HeadersAdapter(new DefaultHttpHeaders()); case "HttpComponents" -> new HttpComponentsHeadersAdapter(new HttpGet("https://example.com")); case "Jetty" -> new JettyHeadersAdapter(HttpFields.build()); - // FIXME tomcat/undertow implementations (in another package) + // FIXME tomcat implementations (in another package) // case "Tomcat" -> new TomcatHeadersAdapter(new MimeHeaders()); -// case "Undertow" -> new UndertowHeadersAdapter(new HeaderMap()); default -> throw new IllegalArgumentException("Unsupported implementation: " + this.implementation); }; initHeaders(); diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/AbstractListenerReadPublisher.java b/spring-web/src/main/java/org/springframework/http/server/reactive/AbstractListenerReadPublisher.java index d3fc98bc5f18..cbeee7cfcdcf 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/AbstractListenerReadPublisher.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/AbstractListenerReadPublisher.java @@ -37,9 +37,8 @@ * event-listener read APIs and Reactive Streams. * *

    Specifically a base class for reading from the HTTP request body with - * Servlet non-blocking I/O and Undertow XNIO as well as handling incoming - * WebSocket messages with standard Jakarta WebSocket (JSR-356), Jetty, and - * Undertow. + * Servlet non-blocking I/O as well as handling incoming + * WebSocket messages with standard Jakarta WebSocket (JSR-356), and Jetty. * * @author Arjen Poutsma * @author Violeta Georgieva diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/AbstractListenerWriteProcessor.java b/spring-web/src/main/java/org/springframework/http/server/reactive/AbstractListenerWriteProcessor.java index 8ffbbe33d355..d79d7328182f 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/AbstractListenerWriteProcessor.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/AbstractListenerWriteProcessor.java @@ -34,8 +34,8 @@ * event-listener write APIs and Reactive Streams. * *

    Specifically a base class for writing to the HTTP response body with - * Servlet non-blocking I/O and Undertow XNIO as well for writing WebSocket - * messages through the Jakarta WebSocket API (JSR-356), Jetty, and Undertow. + * Servlet non-blocking I/O as well for writing WebSocket + * messages through the Jakarta WebSocket API (JSR-356), and Jetty. * * @author Arjen Poutsma * @author Violeta Georgieva diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowHeadersAdapter.java b/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowHeadersAdapter.java deleted file mode 100644 index 46eee8483537..000000000000 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowHeadersAdapter.java +++ /dev/null @@ -1,272 +0,0 @@ -/* - * Copyright 2002-present 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.http.server.reactive; - -import java.util.AbstractSet; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -import io.undertow.util.HeaderMap; -import io.undertow.util.HeaderValues; -import io.undertow.util.HttpString; -import org.jspecify.annotations.Nullable; - -import org.springframework.util.CollectionUtils; -import org.springframework.util.MultiValueMap; - -/** - * {@code MultiValueMap} implementation for wrapping Undertow HTTP headers. - * - * @author Brian Clozel - * @author Sam Brannen - * @since 5.1.1 - */ -class UndertowHeadersAdapter implements MultiValueMap { - - private final HeaderMap headers; - - - UndertowHeadersAdapter(HeaderMap headers) { - this.headers = headers; - } - - - @Override - public String getFirst(String key) { - return this.headers.getFirst(key); - } - - @Override - public void add(String key, @Nullable String value) { - this.headers.add(HttpString.tryFromString(key), value); - } - - @Override - @SuppressWarnings("unchecked") - public void addAll(String key, List values) { - this.headers.addAll(HttpString.tryFromString(key), (List) values); - } - - @Override - public void addAll(MultiValueMap values) { - values.forEach((key, list) -> this.headers.addAll(HttpString.tryFromString(key), list)); - } - - @Override - public void set(String key, @Nullable String value) { - this.headers.put(HttpString.tryFromString(key), value); - } - - @Override - public void setAll(Map values) { - values.forEach((key, list) -> this.headers.put(HttpString.tryFromString(key), list)); - } - - @Override - public Map toSingleValueMap() { - Map singleValueMap = CollectionUtils.newLinkedHashMap(this.headers.size()); - this.headers.forEach(values -> - singleValueMap.put(values.getHeaderName().toString(), values.getFirst())); - return singleValueMap; - } - - @Override - public int size() { - return this.headers.size(); - } - - @Override - public boolean isEmpty() { - return (this.headers.size() == 0); - } - - @Override - public boolean containsKey(Object key) { - return (key instanceof String headerName && this.headers.contains(headerName)); - } - - @Override - public boolean containsValue(Object value) { - return (value instanceof String && - this.headers.getHeaderNames().stream() - .map(this.headers::get) - .anyMatch(values -> values.contains(value))); - } - - @Override - public @Nullable List get(Object key) { - return (key instanceof String headerName ? this.headers.get(headerName) : null); - } - - @Override - public @Nullable List put(String key, List value) { - HeaderValues previousValues = this.headers.get(key); - this.headers.putAll(HttpString.tryFromString(key), value); - return previousValues; - } - - @Override - public @Nullable List remove(Object key) { - if (key instanceof String headerName) { - Collection removed = this.headers.remove(headerName); - if (removed != null) { - return new ArrayList<>(removed); - } - } - return null; - } - - @Override - public void putAll(Map> map) { - map.forEach((key, values) -> - this.headers.putAll(HttpString.tryFromString(key), values)); - } - - @Override - public void clear() { - this.headers.clear(); - } - - @Override - public Set keySet() { - return new HeaderNames(); - } - - @Override - public Collection> values() { - return this.headers.getHeaderNames().stream() - .map(this.headers::get) - .collect(Collectors.toList()); - } - - @Override - public Set>> entrySet() { - return new AbstractSet<>() { - @Override - public Iterator>> iterator() { - return new EntryIterator(); - } - - @Override - public int size() { - return headers.size(); - } - }; - } - - - @Override - public String toString() { - return org.springframework.http.HttpHeaders.formatHeaders(this); - } - - - private class EntryIterator implements Iterator>> { - - private final Iterator names = headers.getHeaderNames().iterator(); - - @Override - public boolean hasNext() { - return this.names.hasNext(); - } - - @Override - public Entry> next() { - return new HeaderEntry(this.names.next()); - } - } - - - private class HeaderEntry implements Entry> { - - private final HttpString key; - - HeaderEntry(HttpString key) { - this.key = key; - } - - @Override - public String getKey() { - return this.key.toString(); - } - - @Override - public List getValue() { - return headers.get(this.key); - } - - @Override - public List setValue(List value) { - List previousValues = headers.get(this.key); - headers.putAll(this.key, value); - return previousValues; - } - } - - - private class HeaderNames extends AbstractSet { - - @Override - public Iterator iterator() { - return new HeaderNamesIterator(headers.getHeaderNames().iterator()); - } - - @Override - public int size() { - return headers.getHeaderNames().size(); - } - } - - private final class HeaderNamesIterator implements Iterator { - - private final Iterator iterator; - - private @Nullable String currentName; - - private HeaderNamesIterator(Iterator iterator) { - this.iterator = iterator; - } - - @Override - public boolean hasNext() { - return this.iterator.hasNext(); - } - - @Override - public String next() { - this.currentName = this.iterator.next().toString(); - return this.currentName; - } - - @Override - public void remove() { - if (this.currentName == null) { - throw new IllegalStateException("No current Header in iterator"); - } - if (!headers.contains(this.currentName)) { - throw new IllegalStateException("Header not present: " + this.currentName); - } - headers.remove(this.currentName); - } - } - -} diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowHttpHandlerAdapter.java b/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowHttpHandlerAdapter.java deleted file mode 100644 index 8028ab37c408..000000000000 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowHttpHandlerAdapter.java +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright 2002-present 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.http.server.reactive; - -import java.io.IOException; -import java.net.URISyntaxException; - -import io.undertow.server.HttpServerExchange; -import org.apache.commons.logging.Log; -import org.reactivestreams.Subscriber; -import org.reactivestreams.Subscription; - -import org.springframework.core.io.buffer.DataBufferFactory; -import org.springframework.core.io.buffer.DefaultDataBufferFactory; -import org.springframework.http.HttpLogging; -import org.springframework.http.HttpMethod; -import org.springframework.util.Assert; - -/** - * Adapt {@link HttpHandler} to the Undertow {@link io.undertow.server.HttpHandler}. - * - * @author Marek Hawrylczak - * @author Rossen Stoyanchev - * @author Arjen Poutsma - * @since 5.0 - */ -public class UndertowHttpHandlerAdapter implements io.undertow.server.HttpHandler { - - private static final Log logger = HttpLogging.forLogName(UndertowHttpHandlerAdapter.class); - - - private final HttpHandler httpHandler; - - private DataBufferFactory bufferFactory = DefaultDataBufferFactory.sharedInstance; - - - public UndertowHttpHandlerAdapter(HttpHandler httpHandler) { - Assert.notNull(httpHandler, "HttpHandler must not be null"); - this.httpHandler = httpHandler; - } - - - public void setDataBufferFactory(DataBufferFactory bufferFactory) { - Assert.notNull(bufferFactory, "DataBufferFactory must not be null"); - this.bufferFactory = bufferFactory; - } - - public DataBufferFactory getDataBufferFactory() { - return this.bufferFactory; - } - - - @Override - public void handleRequest(HttpServerExchange exchange) { - exchange.dispatch(() -> { - UndertowServerHttpRequest request = null; - try { - request = new UndertowServerHttpRequest(exchange, getDataBufferFactory()); - } - catch (URISyntaxException ex) { - if (logger.isWarnEnabled()) { - logger.debug("Failed to get request URI: " + ex.getMessage()); - } - exchange.setStatusCode(400); - return; - } - ServerHttpResponse response = new UndertowServerHttpResponse(exchange, getDataBufferFactory(), request); - - if (request.getMethod() == HttpMethod.HEAD) { - response = new HttpHeadResponseDecorator(response); - } - - HandlerResultSubscriber resultSubscriber = new HandlerResultSubscriber(exchange, request); - this.httpHandler.handle(request, response).subscribe(resultSubscriber); - }); - } - - - private static class HandlerResultSubscriber implements Subscriber { - - private final HttpServerExchange exchange; - - private final String logPrefix; - - - public HandlerResultSubscriber(HttpServerExchange exchange, UndertowServerHttpRequest request) { - this.exchange = exchange; - this.logPrefix = request.getLogPrefix(); - } - - @Override - public void onSubscribe(Subscription subscription) { - subscription.request(Long.MAX_VALUE); - } - - @Override - public void onNext(Void aVoid) { - // no-op - } - - @Override - public void onError(Throwable ex) { - logger.trace(this.logPrefix + "Failed to complete: " + ex.getMessage()); - if (this.exchange.isResponseStarted()) { - try { - logger.debug(this.logPrefix + "Closing connection"); - this.exchange.getConnection().close(); - } - catch (IOException ex2) { - // ignore - } - } - else { - logger.debug(this.logPrefix + "Setting HttpServerExchange status to 500 Server Error"); - this.exchange.setStatusCode(500); - this.exchange.endExchange(); - } - } - - @Override - public void onComplete() { - logger.trace(this.logPrefix + "Handling completed"); - this.exchange.endExchange(); - } - } - -} diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpRequest.java b/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpRequest.java deleted file mode 100644 index 1af3d3d8cad9..000000000000 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpRequest.java +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Copyright 2002-present 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.http.server.reactive; - -import java.io.IOException; -import java.net.InetSocketAddress; -import java.net.URI; -import java.net.URISyntaxException; -import java.nio.ByteBuffer; -import java.util.concurrent.atomic.AtomicLong; - -import javax.net.ssl.SSLSession; - -import io.undertow.connector.ByteBufferPool; -import io.undertow.connector.PooledByteBuffer; -import io.undertow.server.HttpServerExchange; -import io.undertow.server.handlers.Cookie; -import org.jspecify.annotations.Nullable; -import org.xnio.channels.StreamSourceChannel; -import reactor.core.publisher.Flux; - -import org.springframework.core.io.buffer.DataBuffer; -import org.springframework.core.io.buffer.DataBufferFactory; -import org.springframework.http.HttpCookie; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.util.Assert; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.util.ObjectUtils; -import org.springframework.util.StringUtils; - -/** - * Adapt {@link ServerHttpRequest} to the Undertow {@link HttpServerExchange}. - * - * @author Marek Hawrylczak - * @author Rossen Stoyanchev - * @author Juergen Hoeller - * @since 5.0 - */ -class UndertowServerHttpRequest extends AbstractServerHttpRequest { - - private static final AtomicLong logPrefixIndex = new AtomicLong(); - - - private final HttpServerExchange exchange; - - private final RequestBodyPublisher body; - - - public UndertowServerHttpRequest(HttpServerExchange exchange, DataBufferFactory bufferFactory) - throws URISyntaxException { - - super(HttpMethod.valueOf(exchange.getRequestMethod().toString()), initUri(exchange), "", - new HttpHeaders(new UndertowHeadersAdapter(exchange.getRequestHeaders()))); - this.exchange = exchange; - this.body = new RequestBodyPublisher(exchange, bufferFactory); - this.body.registerListeners(exchange); - } - - private static URI initUri(HttpServerExchange exchange) throws URISyntaxException { - Assert.notNull(exchange, "HttpServerExchange is required"); - String requestURL = exchange.getRequestURL(); - String query = exchange.getQueryString(); - String requestUriAndQuery = (StringUtils.hasLength(query) ? requestURL + "?" + query : requestURL); - return new URI(requestUriAndQuery); - } - - @Override - protected MultiValueMap initCookies() { - MultiValueMap cookies = new LinkedMultiValueMap<>(); - for (Cookie cookie : this.exchange.requestCookies()) { - HttpCookie httpCookie = new HttpCookie(cookie.getName(), cookie.getValue()); - cookies.add(cookie.getName(), httpCookie); - } - return cookies; - } - - @Override - public @Nullable InetSocketAddress getLocalAddress() { - return this.exchange.getDestinationAddress(); - } - - @Override - public @Nullable InetSocketAddress getRemoteAddress() { - return this.exchange.getSourceAddress(); - } - - @Override - protected @Nullable SslInfo initSslInfo() { - SSLSession session = this.exchange.getConnection().getSslSession(); - if (session != null) { - return new DefaultSslInfo(session); - } - return null; - } - - @Override - public Flux getBody() { - return Flux.from(this.body); - } - - @SuppressWarnings("unchecked") - @Override - public T getNativeRequest() { - return (T) this.exchange; - } - - @Override - protected String initId() { - return ObjectUtils.getIdentityHexString(this.exchange.getConnection()) + - "-" + logPrefixIndex.incrementAndGet(); - } - - - private class RequestBodyPublisher extends AbstractListenerReadPublisher { - - private final StreamSourceChannel channel; - - private final DataBufferFactory bufferFactory; - - private final ByteBufferPool byteBufferPool; - - public RequestBodyPublisher(HttpServerExchange exchange, DataBufferFactory bufferFactory) { - super(UndertowServerHttpRequest.this.getLogPrefix()); - this.channel = exchange.getRequestChannel(); - this.bufferFactory = bufferFactory; - this.byteBufferPool = exchange.getConnection().getByteBufferPool(); - } - - private void registerListeners(HttpServerExchange exchange) { - exchange.addExchangeCompleteListener((ex, next) -> { - onAllDataRead(); - next.proceed(); - }); - this.channel.getReadSetter().set(c -> onDataAvailable()); - this.channel.getCloseSetter().set(c -> onAllDataRead()); - this.channel.resumeReads(); - } - - @Override - protected void checkOnDataAvailable() { - this.channel.resumeReads(); - // We are allowed to try, it will return null if data is not available - onDataAvailable(); - } - - @Override - protected void readingPaused() { - this.channel.suspendReads(); - } - - @Override - protected @Nullable DataBuffer read() throws IOException { - PooledByteBuffer pooledByteBuffer = this.byteBufferPool.allocate(); - try (pooledByteBuffer) { - ByteBuffer byteBuffer = pooledByteBuffer.getBuffer(); - int read = this.channel.read(byteBuffer); - - if (rsReadLogger.isTraceEnabled()) { - rsReadLogger.trace(getLogPrefix() + "Read " + read + (read != -1 ? " bytes" : "")); - } - - if (read > 0) { - byteBuffer.flip(); - DataBuffer dataBuffer = this.bufferFactory.allocateBuffer(read); - dataBuffer.write(byteBuffer); - return dataBuffer; - } - else if (read == -1) { - onAllDataRead(); - } - return null; - } - } - - @Override - protected void discardData() { - // Nothing to discard since we pass data buffers on immediately.. - } - } - -} diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpResponse.java b/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpResponse.java deleted file mode 100644 index 1206a6928800..000000000000 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpResponse.java +++ /dev/null @@ -1,344 +0,0 @@ -/* - * Copyright 2002-present 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.http.server.reactive; - -import java.io.IOException; -import java.nio.ByteBuffer; -import java.nio.channels.FileChannel; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; - -import io.undertow.server.HttpServerExchange; -import io.undertow.server.handlers.Cookie; -import io.undertow.server.handlers.CookieImpl; -import org.jspecify.annotations.Nullable; -import org.reactivestreams.Processor; -import org.reactivestreams.Publisher; -import org.xnio.channels.StreamSinkChannel; -import reactor.core.publisher.Mono; -import reactor.core.publisher.MonoSink; - -import org.springframework.core.io.buffer.DataBuffer; -import org.springframework.core.io.buffer.DataBufferFactory; -import org.springframework.core.io.buffer.DataBufferUtils; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatusCode; -import org.springframework.http.ResponseCookie; -import org.springframework.http.ZeroCopyHttpOutputMessage; -import org.springframework.util.Assert; - -/** - * Adapt {@link ServerHttpResponse} to the Undertow {@link HttpServerExchange}. - * - * @author Marek Hawrylczak - * @author Rossen Stoyanchev - * @author Arjen Poutsma - * @author Juergen Hoeller - * @since 5.0 - */ -class UndertowServerHttpResponse extends AbstractListenerServerHttpResponse implements ZeroCopyHttpOutputMessage { - - private final HttpServerExchange exchange; - - private final UndertowServerHttpRequest request; - - private @Nullable StreamSinkChannel responseChannel; - - - UndertowServerHttpResponse( - HttpServerExchange exchange, DataBufferFactory bufferFactory, UndertowServerHttpRequest request) { - - super(bufferFactory, createHeaders(exchange)); - this.exchange = exchange; - this.request = request; - } - - private static HttpHeaders createHeaders(HttpServerExchange exchange) { - Assert.notNull(exchange, "HttpServerExchange must not be null"); - UndertowHeadersAdapter headersMap = new UndertowHeadersAdapter(exchange.getResponseHeaders()); - return new HttpHeaders(headersMap); - } - - - @SuppressWarnings("unchecked") - @Override - public T getNativeResponse() { - return (T) this.exchange; - } - - @Override - public HttpStatusCode getStatusCode() { - HttpStatusCode status = super.getStatusCode(); - return (status != null ? status : HttpStatusCode.valueOf(this.exchange.getStatusCode())); - } - - @Override - protected void applyStatusCode() { - HttpStatusCode status = super.getStatusCode(); - if (status != null) { - this.exchange.setStatusCode(status.value()); - } - } - - @Override - protected void applyHeaders() { - } - - @Override - protected void applyCookies() { - for (String name : getCookies().keySet()) { - for (ResponseCookie httpCookie : getCookies().get(name)) { - Cookie cookie = new CookieImpl(name, httpCookie.getValue()); - if (!httpCookie.getMaxAge().isNegative()) { - cookie.setMaxAge((int) httpCookie.getMaxAge().getSeconds()); - } - if (httpCookie.getDomain() != null) { - cookie.setDomain(httpCookie.getDomain()); - } - if (httpCookie.getPath() != null) { - cookie.setPath(httpCookie.getPath()); - } - cookie.setSecure(httpCookie.isSecure()); - cookie.setHttpOnly(httpCookie.isHttpOnly()); - // TODO: add "Partitioned" attribute when Undertow supports it - cookie.setSameSiteMode(httpCookie.getSameSite()); - this.exchange.setResponseCookie(cookie); - } - } - } - - @Override - public Mono writeWith(Path file, long position, long count) { - return doCommit(() -> - Mono.create(sink -> { - try { - FileChannel source = FileChannel.open(file, StandardOpenOption.READ); - TransferBodyListener listener = new TransferBodyListener(source, position, count, sink); - sink.onDispose(listener::closeSource); - StreamSinkChannel destination = this.exchange.getResponseChannel(); - destination.getWriteSetter().set(listener::transfer); - listener.transfer(destination); - } - catch (IOException ex) { - sink.error(ex); - } - })); - } - - @Override - protected Processor, Void> createBodyFlushProcessor() { - return new ResponseBodyFlushProcessor(); - } - - private ResponseBodyProcessor createBodyProcessor() { - if (this.responseChannel == null) { - this.responseChannel = this.exchange.getResponseChannel(); - } - return new ResponseBodyProcessor(this.responseChannel); - } - - - private class ResponseBodyProcessor extends AbstractListenerWriteProcessor { - - private final StreamSinkChannel channel; - - private volatile @Nullable ByteBuffer byteBuffer; - - /** Keep track of write listener calls, for {@link #writePossible}. */ - private volatile boolean writePossible; - - - public ResponseBodyProcessor(StreamSinkChannel channel) { - super(request.getLogPrefix()); - Assert.notNull(channel, "StreamSinkChannel must not be null"); - this.channel = channel; - this.channel.getWriteSetter().set(c -> { - this.writePossible = true; - onWritePossible(); - }); - this.channel.suspendWrites(); - } - - @Override - protected boolean isWritePossible() { - this.channel.resumeWrites(); - return this.writePossible; - } - - @Override - protected boolean write(DataBuffer dataBuffer) throws IOException { - ByteBuffer buffer = this.byteBuffer; - if (buffer == null) { - return false; - } - - // Track write listener calls from here on. - this.writePossible = false; - - // In case of IOException, onError handling should call discardData(DataBuffer).. - int total = buffer.remaining(); - int written = writeByteBuffer(buffer); - - if (rsWriteLogger.isTraceEnabled()) { - rsWriteLogger.trace(getLogPrefix() + "Wrote " + written + " of " + total + " bytes"); - } - if (written != total) { - return false; - } - - // We wrote all, so can still write more. - this.writePossible = true; - - DataBufferUtils.release(dataBuffer); - this.byteBuffer = null; - return true; - } - - private int writeByteBuffer(ByteBuffer byteBuffer) throws IOException { - int written; - int totalWritten = 0; - do { - written = this.channel.write(byteBuffer); - totalWritten += written; - } - while (byteBuffer.hasRemaining() && written > 0); - return totalWritten; - } - - @Override - protected void dataReceived(DataBuffer dataBuffer) { - super.dataReceived(dataBuffer); - ByteBuffer byteBuffer = ByteBuffer.allocate(dataBuffer.readableByteCount()); - dataBuffer.toByteBuffer(byteBuffer); - this.byteBuffer = byteBuffer; - } - - @Override - protected boolean isDataEmpty(DataBuffer dataBuffer) { - return (dataBuffer.readableByteCount() == 0); - } - - @Override - protected void writingComplete() { - this.channel.getWriteSetter().set(null); - this.channel.resumeWrites(); - } - - @Override - protected void writingFailed(Throwable ex) { - cancel(); - onError(ex); - } - - @Override - protected void discardData(DataBuffer dataBuffer) { - DataBufferUtils.release(dataBuffer); - } - } - - - private class ResponseBodyFlushProcessor extends AbstractListenerWriteFlushProcessor { - - public ResponseBodyFlushProcessor() { - super(request.getLogPrefix()); - } - - @Override - protected Processor createWriteProcessor() { - return UndertowServerHttpResponse.this.createBodyProcessor(); - } - - @Override - protected void flush() throws IOException { - StreamSinkChannel channel = UndertowServerHttpResponse.this.responseChannel; - if (channel != null) { - if (rsWriteFlushLogger.isTraceEnabled()) { - rsWriteFlushLogger.trace(getLogPrefix() + "flush"); - } - channel.flush(); - } - } - - @Override - protected boolean isWritePossible() { - StreamSinkChannel channel = UndertowServerHttpResponse.this.responseChannel; - if (channel != null) { - // We can always call flush, just ensure writes are on. - channel.resumeWrites(); - return true; - } - return false; - } - - @Override - protected boolean isFlushPending() { - return false; - } - } - - - private static class TransferBodyListener { - - private final FileChannel source; - - private final MonoSink sink; - - private long position; - - private long count; - - - public TransferBodyListener(FileChannel source, long position, long count, MonoSink sink) { - this.source = source; - this.sink = sink; - this.position = position; - this.count = count; - } - - public void transfer(StreamSinkChannel destination) { - try { - while (this.count > 0) { - long len = destination.transferFrom(this.source, this.position, this.count); - if (len != 0) { - this.position += len; - this.count -= len; - } - else { - destination.resumeWrites(); - return; - } - } - this.sink.success(); - } - catch (IOException ex) { - this.sink.error(ex); - } - - } - - public void closeSource() { - try { - this.source.close(); - } - catch (IOException ignore) { - } - } - - - } - -} diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/package-info.java b/spring-web/src/main/java/org/springframework/http/server/reactive/package-info.java index 10c41e5feb1e..bef99d51c634 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/package-info.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/package-info.java @@ -5,7 +5,7 @@ * {@link org.springframework.http.server.reactive.HttpHandler} for processing. * *

    Also provides implementations adapting to different runtimes - * including Servlet containers, Netty + Reactor IO, and Undertow. + * including Servlet containers and Netty + Reactor IO. */ @NullMarked package org.springframework.http.server.reactive; diff --git a/spring-web/src/main/java/org/springframework/web/multipart/support/StandardMultipartHttpServletRequest.java b/spring-web/src/main/java/org/springframework/web/multipart/support/StandardMultipartHttpServletRequest.java index fb20b1c274ac..9e9de67ee575 100644 --- a/spring-web/src/main/java/org/springframework/web/multipart/support/StandardMultipartHttpServletRequest.java +++ b/spring-web/src/main/java/org/springframework/web/multipart/support/StandardMultipartHttpServletRequest.java @@ -266,7 +266,7 @@ public void transferTo(File dest) throws IOException, IllegalStateException { if (dest.isAbsolute() && !dest.exists()) { // Servlet Part.write is not guaranteed to support absolute file paths: // may translate the given path to a relative location within a temp dir - // (for example, on Jetty whereas Tomcat and Undertow detect absolute paths). + // (for example, on Jetty whereas Tomcat detects absolute paths). // At least we offloaded the file from memory storage; it'll get deleted // from the temp dir eventually in any case. And for our user's purposes, // we can manually copy it to the requested location as a fallback. diff --git a/spring-web/src/test/java/org/springframework/http/server/reactive/CookieIntegrationTests.java b/spring-web/src/test/java/org/springframework/http/server/reactive/CookieIntegrationTests.java index 29fdb8b6355d..3fc79cc8ca64 100644 --- a/spring-web/src/test/java/org/springframework/http/server/reactive/CookieIntegrationTests.java +++ b/spring-web/src/test/java/org/springframework/http/server/reactive/CookieIntegrationTests.java @@ -31,7 +31,6 @@ import org.springframework.web.testfixture.http.server.reactive.bootstrap.AbstractHttpHandlerIntegrationTests; import org.springframework.web.testfixture.http.server.reactive.bootstrap.HttpServer; import org.springframework.web.testfixture.http.server.reactive.bootstrap.JettyHttpServer; -import org.springframework.web.testfixture.http.server.reactive.bootstrap.UndertowHttpServer; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assumptions.assumeFalse; @@ -80,7 +79,6 @@ public void basicTest(HttpServer httpServer) throws Exception { @ParameterizedHttpServerTest public void partitionedAttributeTest(HttpServer httpServer) throws Exception { - assumeFalse(httpServer instanceof UndertowHttpServer, "Undertow does not support Partitioned cookies"); assumeFalse(httpServer instanceof JettyHttpServer, "Jetty does not support Servlet 6.1 yet"); startServer(httpServer); @@ -100,8 +98,6 @@ public void partitionedAttributeTest(HttpServer httpServer) throws Exception { @ParameterizedHttpServerTest public void cookiesWithSameNameTest(HttpServer httpServer) throws Exception { - assumeFalse(httpServer instanceof UndertowHttpServer, "Bug in Undertow in Cookies with same name handling"); - startServer(httpServer); URI url = new URI("http://localhost:" + port); diff --git a/spring-web/src/test/java/org/springframework/http/server/reactive/DefaultServerHttpRequestBuilderTests.java b/spring-web/src/test/java/org/springframework/http/server/reactive/DefaultServerHttpRequestBuilderTests.java index 2b61a7f00153..32eef589dbd8 100644 --- a/spring-web/src/test/java/org/springframework/http/server/reactive/DefaultServerHttpRequestBuilderTests.java +++ b/spring-web/src/test/java/org/springframework/http/server/reactive/DefaultServerHttpRequestBuilderTests.java @@ -21,7 +21,6 @@ import io.netty.handler.codec.http.DefaultHttpHeaders; import io.netty.handler.codec.http.ReadOnlyHttpHeaders; -import io.undertow.util.HeaderMap; import org.apache.tomcat.util.http.MimeHeaders; import org.eclipse.jetty.http.HttpFields; import org.junit.jupiter.params.ParameterizedTest; @@ -102,7 +101,6 @@ static Stream headers() { initHeader("Map", CollectionUtils.toMultiValueMap(new LinkedCaseInsensitiveMap<>(8, Locale.ENGLISH))), initHeader("Netty", new Netty4HeadersAdapter(new DefaultHttpHeaders())), initHeader("Tomcat", new TomcatHeadersAdapter(new MimeHeaders())), - initHeader("Undertow", new UndertowHeadersAdapter(new HeaderMap())), initHeader("Jetty", new JettyHeadersAdapter(HttpFields.build())), //immutable versions of some headers argumentSet("Netty immutable", new Netty4HeadersAdapter(new ReadOnlyHttpHeaders(false, diff --git a/spring-web/src/test/java/org/springframework/http/server/reactive/HeadersAdaptersTests.java b/spring-web/src/test/java/org/springframework/http/server/reactive/HeadersAdaptersTests.java index 05695f5bb89d..d90e68d4b011 100644 --- a/spring-web/src/test/java/org/springframework/http/server/reactive/HeadersAdaptersTests.java +++ b/spring-web/src/test/java/org/springframework/http/server/reactive/HeadersAdaptersTests.java @@ -29,8 +29,6 @@ import java.util.stream.Stream; import io.netty.handler.codec.http.DefaultHttpHeaders; -import io.undertow.util.HeaderMap; -import io.undertow.util.HttpString; import org.apache.hc.client5.http.classic.methods.HttpGet; import org.apache.tomcat.util.http.MimeHeaders; import org.eclipse.jetty.http.HttpFields; @@ -273,7 +271,6 @@ static Stream headers() { argumentSet("Map", CollectionUtils.toMultiValueMap(new LinkedCaseInsensitiveMap<>(8, Locale.ENGLISH))), argumentSet("Netty", new Netty4HeadersAdapter(new DefaultHttpHeaders())), argumentSet("Tomcat", new TomcatHeadersAdapter(new MimeHeaders())), - argumentSet("Undertow", new UndertowHeadersAdapter(new HeaderMap())), argumentSet("Jetty", new JettyHeadersAdapter(HttpFields.build())), argumentSet("HttpComponents", new HttpComponentsHeadersAdapter(new HttpGet("https://example.com"))) ); @@ -291,8 +288,6 @@ static Stream nativeHeadersWithCasedEntries() { argumentSet("Netty", new Netty4HeadersAdapter(withHeaders(new DefaultHttpHeaders(), h -> h::add))), argumentSet("Tomcat", new TomcatHeadersAdapter(withHeaders(new MimeHeaders(), h -> (k, v) -> h.addValue(k).setString(v)))), - argumentSet("Undertow", new UndertowHeadersAdapter(withHeaders(new HeaderMap(), - h -> (k, v) -> h.add(HttpString.tryFromString(k), v)))), argumentSet("Jetty", new JettyHeadersAdapter(withHeaders(HttpFields.build(), h -> h::add))), argumentSet("HttpComponents", new HttpComponentsHeadersAdapter(withHeaders(new HttpGet("https://example.com"), h -> h::addHeader))) diff --git a/spring-web/src/test/java/org/springframework/http/server/reactive/ZeroCopyIntegrationTests.java b/spring-web/src/test/java/org/springframework/http/server/reactive/ZeroCopyIntegrationTests.java index d6c55c5e02ae..f290c7c54e0e 100644 --- a/spring-web/src/test/java/org/springframework/http/server/reactive/ZeroCopyIntegrationTests.java +++ b/spring-web/src/test/java/org/springframework/http/server/reactive/ZeroCopyIntegrationTests.java @@ -32,7 +32,6 @@ import org.springframework.web.testfixture.http.server.reactive.bootstrap.HttpServer; import org.springframework.web.testfixture.http.server.reactive.bootstrap.JettyCoreHttpServer; import org.springframework.web.testfixture.http.server.reactive.bootstrap.ReactorHttpServer; -import org.springframework.web.testfixture.http.server.reactive.bootstrap.UndertowHttpServer; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -55,7 +54,7 @@ protected HttpHandler createHttpHandler() { @ParameterizedHttpServerTest void zeroCopy(HttpServer httpServer) throws Exception { - assumeTrue(httpServer instanceof ReactorHttpServer || httpServer instanceof UndertowHttpServer || + assumeTrue(httpServer instanceof ReactorHttpServer || httpServer instanceof JettyCoreHttpServer, "Zero-copy does not support Servlet"); startServer(httpServer); diff --git a/spring-web/src/test/java/org/springframework/web/multipart/support/StandardMultipartHttpServletRequestTests.java b/spring-web/src/test/java/org/springframework/web/multipart/support/StandardMultipartHttpServletRequestTests.java index 5b92c083d588..ea7c68914b31 100644 --- a/spring-web/src/test/java/org/springframework/web/multipart/support/StandardMultipartHttpServletRequestTests.java +++ b/spring-web/src/test/java/org/springframework/web/multipart/support/StandardMultipartHttpServletRequestTests.java @@ -123,15 +123,6 @@ void jetty12MaxLengthExceededException() { .isThrownBy(() -> requestWithException(ex)).withCause(ex); } - @Test // gh-32549 - void undertowRequestTooBigException() { - IOException ex = new IOException("Connection terminated as request was larger than 10000"); - - assertThatExceptionOfType(MaxUploadSizeExceededException.class) - .isThrownBy(() -> requestWithException(ex)).withCause(ex); - } - - private static StandardMultipartHttpServletRequest requestWithPart(String name, String disposition, String content) { MockHttpServletRequest request = new MockHttpServletRequest(); MockPart part = new MockPart(name, null, content.getBytes(StandardCharsets.UTF_8)); @@ -150,14 +141,4 @@ public Collection getParts() throws ServletException { return new StandardMultipartHttpServletRequest(request); } - private static StandardMultipartHttpServletRequest requestWithException(IOException ex) { - MockHttpServletRequest request = new MockHttpServletRequest() { - @Override - public Collection getParts() throws IOException { - throw ex; - } - }; - return new StandardMultipartHttpServletRequest(request); - } - } diff --git a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/server/reactive/bootstrap/AbstractHttpHandlerIntegrationTests.java b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/server/reactive/bootstrap/AbstractHttpHandlerIntegrationTests.java index b08f66c090f5..6145f1ababbc 100644 --- a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/server/reactive/bootstrap/AbstractHttpHandlerIntegrationTests.java +++ b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/server/reactive/bootstrap/AbstractHttpHandlerIntegrationTests.java @@ -128,8 +128,7 @@ static Stream httpServers() { argumentSet("Jetty", new JettyHttpServer()), argumentSet("Jetty Core", new JettyCoreHttpServer()), argumentSet("Reactor Netty", new ReactorHttpServer()), - argumentSet("Tomcat", new TomcatHttpServer()), - argumentSet("Undertow", new UndertowHttpServer()) + argumentSet("Tomcat", new TomcatHttpServer()) ); } diff --git a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/server/reactive/bootstrap/UndertowHttpServer.java b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/server/reactive/bootstrap/UndertowHttpServer.java deleted file mode 100644 index d021b2cdd2a5..000000000000 --- a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/http/server/reactive/bootstrap/UndertowHttpServer.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2002-present 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.testfixture.http.server.reactive.bootstrap; - -import java.net.InetSocketAddress; - -import io.undertow.Undertow; - -import org.springframework.http.server.reactive.UndertowHttpHandlerAdapter; - -/** - * @author Marek Hawrylczak - */ -public class UndertowHttpServer extends AbstractHttpServer { - - private Undertow server; - - - @Override - protected void initServer() throws Exception { - this.server = Undertow.builder().addHttpListener(getPort(), getHost()) - .setHandler(initHttpHandlerAdapter()) - .build(); - } - - private UndertowHttpHandlerAdapter initHttpHandlerAdapter() { - return new UndertowHttpHandlerAdapter(resolveHttpHandler()); - } - - @Override - protected void startInternal() { - this.server.start(); - Undertow.ListenerInfo info = this.server.getListenerInfo().get(0); - setPort(((InetSocketAddress) info.getAddress()).getPort()); - } - - @Override - protected void stopInternal() { - this.server.stop(); - } - - @Override - protected void resetInternal() { - this.server = null; - } - -} diff --git a/spring-webflux/spring-webflux.gradle b/spring-webflux/spring-webflux.gradle index 85bc6bcaf201..dad853c54300 100644 --- a/spring-webflux/spring-webflux.gradle +++ b/spring-webflux/spring-webflux.gradle @@ -15,7 +15,6 @@ dependencies { optional("com.fasterxml.jackson.dataformat:jackson-dataformat-smile") optional("com.google.protobuf:protobuf-java-util") optional("io.projectreactor.netty:reactor-netty-http") - optional("io.undertow:undertow-websockets-jsr") optional("jakarta.servlet:jakarta.servlet-api") optional("jakarta.validation:jakarta.validation-api") optional("jakarta.websocket:jakarta.websocket-api") @@ -45,7 +44,6 @@ dependencies { testImplementation("io.micrometer:micrometer-observation-test") testImplementation("io.projectreactor:reactor-test") testImplementation("io.reactivex.rxjava3:rxjava") - testImplementation("io.undertow:undertow-core") testImplementation("jakarta.xml.bind:jakarta.xml.bind-api") testImplementation("jakarta.validation:jakarta.validation-api") testImplementation("org.apache.httpcomponents.client5:httpclient5") diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RouterFunctions.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RouterFunctions.java index 50bdbac28891..834aeef8b5b0 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RouterFunctions.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/RouterFunctions.java @@ -56,7 +56,7 @@ * *

    Additionally, this class can {@linkplain #toHttpHandler(RouterFunction) transform} * a {@code RouterFunction} into an {@code HttpHandler}, which can be run in Servlet - * environments, Reactor, or Undertow. + * environments, or Reactor. * * @author Arjen Poutsma * @author Sebastien Deleuze @@ -272,8 +272,6 @@ public static RouterFunction resources(Function *

  • Reactor using the * {@link org.springframework.http.server.reactive.ReactorHttpHandlerAdapter} - *
  • Undertow using the - * {@link org.springframework.http.server.reactive.UndertowHttpHandlerAdapter}
  • * *

    Note that {@code HttpWebHandlerAdapter} also implements {@link WebHandler}, * allowing for additional filter and exception handler registration through @@ -294,8 +292,6 @@ public static HttpHandler toHttpHandler(RouterFunction routerFunction) { * {@link org.springframework.http.server.reactive.ServletHttpHandlerAdapter} *

  • Reactor using the * {@link org.springframework.http.server.reactive.ReactorHttpHandlerAdapter}
  • - *
  • Undertow using the - * {@link org.springframework.http.server.reactive.UndertowHttpHandlerAdapter}
  • * * @param routerFunction the router function to convert * @param strategies the strategies to use diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/socket/adapter/AbstractListenerWebSocketSession.java b/spring-webflux/src/main/java/org/springframework/web/reactive/socket/adapter/AbstractListenerWebSocketSession.java index 25c85eaac40f..ee985c60fcf1 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/socket/adapter/AbstractListenerWebSocketSession.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/socket/adapter/AbstractListenerWebSocketSession.java @@ -42,8 +42,8 @@ /** * Base class for {@link WebSocketSession} implementations that bridge between - * event-listener WebSocket APIs (for example, Jakarta WebSocket API (JSR-356), Jetty, - * Undertow) and Reactive Streams. + * event-listener WebSocket APIs (for example, Jakarta WebSocket API (JSR-356), Jetty) + * and Reactive Streams. * *

    Also implements {@code Subscriber} so it can be used to subscribe to * the completion of {@link WebSocketHandler#handle(WebSocketSession)}. diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/socket/adapter/UndertowWebSocketHandlerAdapter.java b/spring-webflux/src/main/java/org/springframework/web/reactive/socket/adapter/UndertowWebSocketHandlerAdapter.java deleted file mode 100644 index 5fe77a4147a0..000000000000 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/socket/adapter/UndertowWebSocketHandlerAdapter.java +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright 2002-present the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.web.reactive.socket.adapter; - -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.List; - -import io.undertow.websockets.WebSocketConnectionCallback; -import io.undertow.websockets.core.AbstractReceiveListener; -import io.undertow.websockets.core.BufferedBinaryMessage; -import io.undertow.websockets.core.BufferedTextMessage; -import io.undertow.websockets.core.CloseMessage; -import io.undertow.websockets.core.WebSocketChannel; - -import org.springframework.core.io.buffer.DataBuffer; -import org.springframework.util.Assert; -import org.springframework.web.reactive.socket.CloseStatus; -import org.springframework.web.reactive.socket.WebSocketHandler; -import org.springframework.web.reactive.socket.WebSocketMessage; -import org.springframework.web.reactive.socket.WebSocketMessage.Type; - -/** - * Undertow {@link WebSocketConnectionCallback} implementation that adapts and - * delegates to a Spring {@link WebSocketHandler}. - * - * @author Violeta Georgieva - * @author Rossen Stoyanchev - * @since 5.0 - */ -public class UndertowWebSocketHandlerAdapter extends AbstractReceiveListener { - - private final UndertowWebSocketSession session; - - - public UndertowWebSocketHandlerAdapter(UndertowWebSocketSession session) { - Assert.notNull(session, "UndertowWebSocketSession is required"); - this.session = session; - } - - - @Override - protected void onFullTextMessage(WebSocketChannel channel, BufferedTextMessage message) { - this.session.handleMessage(Type.TEXT, toMessage(Type.TEXT, message.getData())); - } - - @Override - @SuppressWarnings("deprecation") - protected void onFullBinaryMessage(WebSocketChannel channel, BufferedBinaryMessage message) { - this.session.handleMessage(Type.BINARY, toMessage(Type.BINARY, message.getData().getResource())); - message.getData().free(); - } - - @Override - @SuppressWarnings("deprecation") - protected void onFullPongMessage(WebSocketChannel channel, BufferedBinaryMessage message) { - this.session.handleMessage(Type.PONG, toMessage(Type.PONG, message.getData().getResource())); - message.getData().free(); - } - - @Override - @SuppressWarnings("deprecation") - protected void onFullCloseMessage(WebSocketChannel channel, BufferedBinaryMessage message) { - CloseMessage closeMessage = new CloseMessage(message.getData().getResource()); - this.session.handleClose(CloseStatus.create(closeMessage.getCode(), closeMessage.getReason())); - message.getData().free(); - } - - @Override - protected void onError(WebSocketChannel channel, Throwable error) { - this.session.handleError(error); - } - - private WebSocketMessage toMessage(Type type, T message) { - if (Type.TEXT.equals(type)) { - byte[] bytes = ((String) message).getBytes(StandardCharsets.UTF_8); - return new WebSocketMessage(Type.TEXT, this.session.bufferFactory().wrap(bytes)); - } - else if (Type.BINARY.equals(type) || Type.PONG.equals(type)) { - ByteBuffer[] byteBuffers = (ByteBuffer[]) message; - List dataBuffers = new ArrayList<>(byteBuffers.length); - for (ByteBuffer byteBuffer : byteBuffers) { - dataBuffers.add(this.session.bufferFactory().wrap(byteBuffer)); - } - DataBuffer joined = this.session.bufferFactory().join(dataBuffers); - return new WebSocketMessage(type, joined); - } - else { - throw new IllegalArgumentException("Unexpected message type: " + message); - } - } - -} diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/socket/adapter/UndertowWebSocketSession.java b/spring-webflux/src/main/java/org/springframework/web/reactive/socket/adapter/UndertowWebSocketSession.java deleted file mode 100644 index cc51ed224dbd..000000000000 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/socket/adapter/UndertowWebSocketSession.java +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright 2002-present the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.web.reactive.socket.adapter; - -import java.io.IOException; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; - -import io.undertow.websockets.core.CloseMessage; -import io.undertow.websockets.core.WebSocketCallback; -import io.undertow.websockets.core.WebSocketChannel; -import io.undertow.websockets.core.WebSockets; -import org.jspecify.annotations.Nullable; -import reactor.core.publisher.Mono; -import reactor.core.publisher.Sinks; - -import org.springframework.core.io.buffer.DataBuffer; -import org.springframework.core.io.buffer.DataBufferFactory; -import org.springframework.core.io.buffer.DataBufferUtils; -import org.springframework.util.ObjectUtils; -import org.springframework.web.reactive.socket.CloseStatus; -import org.springframework.web.reactive.socket.HandshakeInfo; -import org.springframework.web.reactive.socket.WebSocketMessage; -import org.springframework.web.reactive.socket.WebSocketSession; - -/** - * Spring {@link WebSocketSession} implementation that adapts to an Undertow - * {@link io.undertow.websockets.core.WebSocketChannel}. - * - * @author Violeta Georgieva - * @author Rossen Stoyanchev - * @since 5.0 - */ -public class UndertowWebSocketSession extends AbstractListenerWebSocketSession { - - public UndertowWebSocketSession(WebSocketChannel channel, HandshakeInfo info, DataBufferFactory factory) { - this(channel, info, factory, null); - } - - public UndertowWebSocketSession(WebSocketChannel channel, HandshakeInfo info, - DataBufferFactory factory, Sinks.@Nullable Empty completionSink) { - - super(channel, ObjectUtils.getIdentityHexString(channel), info, factory, completionSink); - suspendReceiving(); - } - - - @Override - protected boolean canSuspendReceiving() { - return true; - } - - @Override - protected void suspendReceiving() { - getDelegate().suspendReceives(); - } - - @Override - protected void resumeReceiving() { - getDelegate().resumeReceives(); - } - - @Override - protected boolean sendMessage(WebSocketMessage message) throws IOException { - DataBuffer dataBuffer = message.getPayload(); - WebSocketChannel channel = getDelegate(); - if (WebSocketMessage.Type.TEXT.equals(message.getType())) { - getSendProcessor().setReadyToSend(false); - String text = dataBuffer.toString(StandardCharsets.UTF_8); - WebSockets.sendText(text, channel, new SendProcessorCallback(message.getPayload())); - } - else { - getSendProcessor().setReadyToSend(false); - try (DataBuffer.ByteBufferIterator iterator = dataBuffer.readableByteBuffers()) { - while (iterator.hasNext()) { - ByteBuffer byteBuffer = iterator.next(); - switch (message.getType()) { - case BINARY -> WebSockets.sendBinary(byteBuffer, channel, new SendProcessorCallback(dataBuffer)); - case PING -> WebSockets.sendPing(byteBuffer, channel, new SendProcessorCallback(dataBuffer)); - case PONG -> WebSockets.sendPong(byteBuffer, channel, new SendProcessorCallback(dataBuffer)); - default -> throw new IllegalArgumentException("Unexpected message type: " + message.getType()); - } - } - } - } - return true; - } - - @Override - public boolean isOpen() { - return getDelegate().isOpen(); - } - - @Override - public Mono close(CloseStatus status) { - CloseMessage cm = new CloseMessage(status.getCode(), status.getReason()); - if (!getDelegate().isCloseFrameSent()) { - WebSockets.sendClose(cm, getDelegate(), null); - } - return Mono.empty(); - } - - - private final class SendProcessorCallback implements WebSocketCallback { - - private final DataBuffer payload; - - SendProcessorCallback(DataBuffer payload) { - this.payload = payload; - } - - @Override - public void complete(WebSocketChannel channel, Void context) { - DataBufferUtils.release(this.payload); - getSendProcessor().setReadyToSend(true); - getSendProcessor().onWritePossible(); - } - - @Override - public void onError(WebSocketChannel channel, Void context, Throwable throwable) { - DataBufferUtils.release(this.payload); - getSendProcessor().cancel(); - getSendProcessor().onError(throwable); - } - } - -} diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/socket/client/UndertowWebSocketClient.java b/spring-webflux/src/main/java/org/springframework/web/reactive/socket/client/UndertowWebSocketClient.java deleted file mode 100644 index 6a1aa76b4e4a..000000000000 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/socket/client/UndertowWebSocketClient.java +++ /dev/null @@ -1,260 +0,0 @@ -/* - * Copyright 2002-present the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.web.reactive.socket.client; - -import java.io.IOException; -import java.net.URI; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.function.Consumer; - -import io.undertow.connector.ByteBufferPool; -import io.undertow.server.DefaultByteBufferPool; -import io.undertow.websockets.client.WebSocketClient.ConnectionBuilder; -import io.undertow.websockets.client.WebSocketClientNegotiation; -import io.undertow.websockets.core.WebSocketChannel; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.jspecify.annotations.Nullable; -import org.xnio.IoFuture; -import org.xnio.XnioWorker; -import reactor.core.publisher.Mono; -import reactor.core.publisher.Sinks; - -import org.springframework.core.io.buffer.DataBufferFactory; -import org.springframework.core.io.buffer.DefaultDataBufferFactory; -import org.springframework.http.HttpHeaders; -import org.springframework.util.Assert; -import org.springframework.web.reactive.socket.HandshakeInfo; -import org.springframework.web.reactive.socket.WebSocketHandler; -import org.springframework.web.reactive.socket.adapter.ContextWebSocketHandler; -import org.springframework.web.reactive.socket.adapter.UndertowWebSocketHandlerAdapter; -import org.springframework.web.reactive.socket.adapter.UndertowWebSocketSession; - -/** - * Undertow based implementation of {@link WebSocketClient}. - * - * @author Violeta Georgieva - * @author Rossen Stoyanchev - * @since 5.0 - */ -public class UndertowWebSocketClient implements WebSocketClient { - - private static final Log logger = LogFactory.getLog(UndertowWebSocketClient.class); - - private static final int DEFAULT_POOL_BUFFER_SIZE = 8192; - - - private final XnioWorker worker; - - private ByteBufferPool byteBufferPool; - - private final Consumer builderConsumer; - - - /** - * Constructor with the {@link XnioWorker} to pass to - * {@link io.undertow.websockets.client.WebSocketClient#connectionBuilder}. - * @param worker the Xnio worker - */ - public UndertowWebSocketClient(XnioWorker worker) { - this(worker, builder -> { - }); - } - - /** - * Alternate constructor providing additional control over the - * {@link ConnectionBuilder} for each WebSocket connection. - * @param worker the Xnio worker to use to create {@code ConnectionBuilder}'s - * @param builderConsumer a consumer to configure {@code ConnectionBuilder}'s - */ - public UndertowWebSocketClient(XnioWorker worker, Consumer builderConsumer) { - this(worker, new DefaultByteBufferPool(false, DEFAULT_POOL_BUFFER_SIZE), builderConsumer); - } - - /** - * Alternate constructor providing additional control over the - * {@link ConnectionBuilder} for each WebSocket connection. - * @param worker the Xnio worker to use to create {@code ConnectionBuilder}'s - * @param byteBufferPool the ByteBufferPool to use to create {@code ConnectionBuilder}'s - * @param builderConsumer a consumer to configure {@code ConnectionBuilder}'s - * @since 5.0.8 - */ - public UndertowWebSocketClient(XnioWorker worker, ByteBufferPool byteBufferPool, - Consumer builderConsumer) { - - Assert.notNull(worker, "XnioWorker must not be null"); - Assert.notNull(byteBufferPool, "ByteBufferPool must not be null"); - this.worker = worker; - this.byteBufferPool = byteBufferPool; - this.builderConsumer = builderConsumer; - } - - - /** - * Return the configured {@link XnioWorker}. - */ - public XnioWorker getXnioWorker() { - return this.worker; - } - - /** - * Set the {@link io.undertow.connector.ByteBufferPool ByteBufferPool} to pass to - * {@link io.undertow.websockets.client.WebSocketClient#connectionBuilder}. - *

    By default an indirect {@link io.undertow.server.DefaultByteBufferPool} - * with a buffer size of 8192 is used. - * @since 5.0.8 - * @see #DEFAULT_POOL_BUFFER_SIZE - */ - public void setByteBufferPool(ByteBufferPool byteBufferPool) { - Assert.notNull(byteBufferPool, "ByteBufferPool must not be null"); - this.byteBufferPool = byteBufferPool; - } - - /** - * Return the {@link io.undertow.connector.ByteBufferPool} currently used - * for newly created WebSocket sessions by this client. - * @return the byte buffer pool - * @since 5.0.8 - */ - public ByteBufferPool getByteBufferPool() { - return this.byteBufferPool; - } - - /** - * Return the configured Consumer<ConnectionBuilder>. - */ - public Consumer getConnectionBuilderConsumer() { - return this.builderConsumer; - } - - - @Override - public Mono execute(URI url, WebSocketHandler handler) { - return execute(url, new HttpHeaders(), handler); - } - - @Override - public Mono execute(URI url, HttpHeaders headers, WebSocketHandler handler) { - return executeInternal(url, headers, handler); - } - - private Mono executeInternal(URI url, HttpHeaders headers, WebSocketHandler handler) { - Sinks.Empty completion = Sinks.empty(); - return Mono.deferContextual( - contextView -> { - if (logger.isDebugEnabled()) { - logger.debug("Connecting to " + url); - } - List protocols = handler.getSubProtocols(); - ConnectionBuilder builder = createConnectionBuilder(url); - DefaultNegotiation negotiation = new DefaultNegotiation(protocols, headers, builder); - builder.setClientNegotiation(negotiation); - builder.connect().addNotifier( - new IoFuture.HandlingNotifier<>() { - @Override - public void handleDone(WebSocketChannel channel, Object attachment) { - handleChannel(url, ContextWebSocketHandler.decorate(handler, contextView), - completion, negotiation, channel); - } - @Override - public void handleFailed(IOException ex, Object attachment) { - // Ignore result: can't overflow, ok if not first or no one listens - completion.tryEmitError( - new IllegalStateException("Failed to connect to " + url, ex)); - } - }, null); - return completion.asMono(); - }); - } - - /** - * Create a {@link ConnectionBuilder} for the given URI. - *

    The default implementation creates a builder with the configured - * {@link #getXnioWorker() XnioWorker} and {@link #getByteBufferPool() ByteBufferPool} and - * then passes it to the {@link #getConnectionBuilderConsumer() consumer} - * provided at construction time. - */ - protected ConnectionBuilder createConnectionBuilder(URI url) { - ConnectionBuilder builder = io.undertow.websockets.client.WebSocketClient - .connectionBuilder(getXnioWorker(), getByteBufferPool(), url); - this.builderConsumer.accept(builder); - return builder; - } - - private void handleChannel(URI url, WebSocketHandler handler, Sinks.Empty completionSink, - DefaultNegotiation negotiation, WebSocketChannel channel) { - - HandshakeInfo info = createHandshakeInfo(url, negotiation); - DataBufferFactory bufferFactory = DefaultDataBufferFactory.sharedInstance; - UndertowWebSocketSession session = new UndertowWebSocketSession(channel, info, bufferFactory, completionSink); - UndertowWebSocketHandlerAdapter adapter = new UndertowWebSocketHandlerAdapter(session); - - channel.getReceiveSetter().set(adapter); - channel.resumeReceives(); - - handler.handle(session) - .checkpoint(url + " [UndertowWebSocketClient]") - .subscribe(session); - } - - private HandshakeInfo createHandshakeInfo(URI url, DefaultNegotiation negotiation) { - HttpHeaders responseHeaders = negotiation.getResponseHeaders(); - String protocol = responseHeaders.getFirst("Sec-WebSocket-Protocol"); - return new HandshakeInfo(url, responseHeaders, Mono.empty(), protocol); - } - - - private static final class DefaultNegotiation extends WebSocketClientNegotiation { - - private final HttpHeaders requestHeaders; - - private final HttpHeaders responseHeaders = new HttpHeaders(); - - private final @Nullable WebSocketClientNegotiation delegate; - - public DefaultNegotiation(List protocols, HttpHeaders requestHeaders, - ConnectionBuilder connectionBuilder) { - - super(protocols, Collections.emptyList()); - this.requestHeaders = requestHeaders; - this.delegate = connectionBuilder.getClientNegotiation(); - } - - public HttpHeaders getResponseHeaders() { - return this.responseHeaders; - } - - @Override - public void beforeRequest(Map> headers) { - this.requestHeaders.forEach(headers::put); - if (this.delegate != null) { - this.delegate.beforeRequest(headers); - } - } - - @Override - public void afterRequest(Map> headers) { - this.responseHeaders.putAll(headers); - if (this.delegate != null) { - this.delegate.afterRequest(headers); - } - } - } - -} diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/socket/server/support/HandshakeWebSocketService.java b/spring-webflux/src/main/java/org/springframework/web/reactive/socket/server/support/HandshakeWebSocketService.java index d92375a0d82a..f5eeaf2d1fd8 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/socket/server/support/HandshakeWebSocketService.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/socket/server/support/HandshakeWebSocketService.java @@ -48,7 +48,6 @@ import org.springframework.web.reactive.socket.server.upgrade.JettyRequestUpgradeStrategy; import org.springframework.web.reactive.socket.server.upgrade.ReactorNettyRequestUpgradeStrategy; import org.springframework.web.reactive.socket.server.upgrade.StandardWebSocketUpgradeStrategy; -import org.springframework.web.reactive.socket.server.upgrade.UndertowRequestUpgradeStrategy; import org.springframework.web.server.MethodNotAllowedException; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.ServerWebInputException; @@ -79,8 +78,6 @@ public class HandshakeWebSocketService implements WebSocketService, Lifecycle { private static final boolean jettyCoreWsPresent; - private static final boolean undertowWsPresent; - private static final boolean reactorNettyPresent; static { @@ -89,8 +86,6 @@ public class HandshakeWebSocketService implements WebSocketService, Lifecycle { "org.eclipse.jetty.ee11.websocket.server.JettyWebSocketServerContainer", classLoader); jettyCoreWsPresent = ClassUtils.isPresent( "org.eclipse.jetty.websocket.server.ServerWebSocketContainer", classLoader); - undertowWsPresent = ClassUtils.isPresent( - "io.undertow.websockets.WebSocketProtocolHandshakeHandler", classLoader); reactorNettyPresent = ClassUtils.isPresent( "reactor.netty.http.server.HttpServerResponse", classLoader); } @@ -276,9 +271,6 @@ static RequestUpgradeStrategy initUpgradeStrategy() { else if (jettyCoreWsPresent) { return new JettyCoreRequestUpgradeStrategy(); } - else if (undertowWsPresent) { - return new UndertowRequestUpgradeStrategy(); - } else if (reactorNettyPresent) { return new ReactorNettyRequestUpgradeStrategy(); } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/socket/server/upgrade/UndertowRequestUpgradeStrategy.java b/spring-webflux/src/main/java/org/springframework/web/reactive/socket/server/upgrade/UndertowRequestUpgradeStrategy.java deleted file mode 100644 index 93ac308123c1..000000000000 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/socket/server/upgrade/UndertowRequestUpgradeStrategy.java +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright 2002-present the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.web.reactive.socket.server.upgrade; - -import java.util.Collections; -import java.util.List; -import java.util.Set; -import java.util.function.Supplier; - -import io.undertow.server.HttpServerExchange; -import io.undertow.websockets.WebSocketConnectionCallback; -import io.undertow.websockets.WebSocketProtocolHandshakeHandler; -import io.undertow.websockets.core.WebSocketChannel; -import io.undertow.websockets.core.protocol.Handshake; -import io.undertow.websockets.core.protocol.version13.Hybi13Handshake; -import io.undertow.websockets.spi.WebSocketHttpExchange; -import org.jspecify.annotations.Nullable; -import reactor.core.publisher.Mono; - -import org.springframework.core.io.buffer.DataBufferFactory; -import org.springframework.http.server.reactive.ServerHttpRequestDecorator; -import org.springframework.web.reactive.socket.HandshakeInfo; -import org.springframework.web.reactive.socket.WebSocketHandler; -import org.springframework.web.reactive.socket.adapter.ContextWebSocketHandler; -import org.springframework.web.reactive.socket.adapter.UndertowWebSocketHandlerAdapter; -import org.springframework.web.reactive.socket.adapter.UndertowWebSocketSession; -import org.springframework.web.reactive.socket.server.RequestUpgradeStrategy; -import org.springframework.web.server.ServerWebExchange; - -/** - * A WebSocket {@code RequestUpgradeStrategy} for Undertow. - * - * @author Violeta Georgieva - * @author Rossen Stoyanchev - * @author Brian Clozel - * @since 5.0 - */ -public class UndertowRequestUpgradeStrategy implements RequestUpgradeStrategy { - - @Override - public Mono upgrade(ServerWebExchange exchange, WebSocketHandler handler, - @Nullable String subProtocol, Supplier handshakeInfoFactory) { - - HttpServerExchange httpExchange = ServerHttpRequestDecorator.getNativeRequest(exchange.getRequest()); - - Set protocols = (subProtocol != null ? Collections.singleton(subProtocol) : Collections.emptySet()); - Hybi13Handshake handshake = new Hybi13Handshake(protocols, false); - List handshakes = Collections.singletonList(handshake); - - HandshakeInfo handshakeInfo = handshakeInfoFactory.get(); - DataBufferFactory bufferFactory = exchange.getResponse().bufferFactory(); - - // Trigger WebFlux preCommit actions and upgrade - return exchange.getResponse().setComplete() - .then(Mono.deferContextual(contextView -> { - DefaultCallback callback = new DefaultCallback( - handshakeInfo, - ContextWebSocketHandler.decorate(handler, contextView), - bufferFactory); - try { - new WebSocketProtocolHandshakeHandler(handshakes, callback).handleRequest(httpExchange); - } - catch (Exception ex) { - return Mono.error(ex); - } - return Mono.empty(); - })); - } - - - private static class DefaultCallback implements WebSocketConnectionCallback { - - private final HandshakeInfo handshakeInfo; - - private final WebSocketHandler handler; - - private final DataBufferFactory bufferFactory; - - public DefaultCallback(HandshakeInfo handshakeInfo, WebSocketHandler handler, DataBufferFactory bufferFactory) { - this.handshakeInfo = handshakeInfo; - this.handler = handler; - this.bufferFactory = bufferFactory; - } - - @Override - public void onConnect(WebSocketHttpExchange exchange, WebSocketChannel channel) { - UndertowWebSocketSession session = createSession(channel); - UndertowWebSocketHandlerAdapter adapter = new UndertowWebSocketHandlerAdapter(session); - - channel.getReceiveSetter().set(adapter); - channel.resumeReceives(); - - this.handler.handle(session) - .checkpoint(exchange.getRequestURI() + " [UndertowRequestUpgradeStrategy]") - .subscribe(session); - } - - private UndertowWebSocketSession createSession(WebSocketChannel channel) { - return new UndertowWebSocketSession(channel, this.handshakeInfo, this.bufferFactory); - } - } - -} diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/MultipartRouterFunctionIntegrationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/MultipartRouterFunctionIntegrationTests.java index 8c1e1c83e78b..bc959ea2f542 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/MultipartRouterFunctionIntegrationTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/MultipartRouterFunctionIntegrationTests.java @@ -25,8 +25,6 @@ import java.util.List; import java.util.Map; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; @@ -49,11 +47,9 @@ import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.testfixture.http.server.reactive.bootstrap.HttpServer; -import org.springframework.web.testfixture.http.server.reactive.bootstrap.UndertowHttpServer; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.fail; -import static org.junit.jupiter.api.Assumptions.assumeFalse; import static org.springframework.web.reactive.function.server.RouterFunctions.route; /** @@ -104,18 +100,9 @@ void parts(HttpServer httpServer) throws Exception { @ParameterizedHttpServerTest void transferTo(HttpServer httpServer) throws Exception { - // TODO Determine why Undertow fails: https://github.com/spring-projects/spring-framework/issues/25310 - assumeFalse(httpServer instanceof UndertowHttpServer, "Undertow currently fails with transferTo"); verifyTransferTo(httpServer); } - @Disabled("Unstable on Undertow: https://github.com/spring-projects/spring-framework/issues/25310") - // Using @RepeatedTest(100), this test fails approximately 10% - 20% of the time. - @Test - void transferToWithUndertow() throws Exception { - verifyTransferTo(new UndertowHttpServer()); - } - private void verifyTransferTo(HttpServer httpServer) throws Exception { startServer(httpServer); @@ -162,7 +149,6 @@ void partData(HttpServer httpServer) throws Exception { @ParameterizedHttpServerTest void proxy(HttpServer httpServer) throws Exception { - assumeFalse(httpServer instanceof UndertowHttpServer, "Undertow currently fails proxying requests"); startServer(httpServer); Mono> result = webClient diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ContextPathIntegrationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ContextPathIntegrationTests.java index acae680fbb25..882353a866d4 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ContextPathIntegrationTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ContextPathIntegrationTests.java @@ -38,7 +38,6 @@ import org.springframework.web.testfixture.http.server.reactive.bootstrap.JettyHttpServer; import org.springframework.web.testfixture.http.server.reactive.bootstrap.ReactorHttpServer; import org.springframework.web.testfixture.http.server.reactive.bootstrap.TomcatHttpServer; -import org.springframework.web.testfixture.http.server.reactive.bootstrap.UndertowHttpServer; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.params.provider.Arguments.argumentSet; @@ -55,8 +54,7 @@ static Stream httpServers() { argumentSet("Jetty", new JettyHttpServer()), argumentSet("Jetty Core", new JettyCoreHttpServer()), argumentSet("Reactor Netty", new ReactorHttpServer()), - argumentSet("Tomcat", new TomcatHttpServer()), - argumentSet("Undertow", new UndertowHttpServer()) + argumentSet("Tomcat", new TomcatHttpServer()) ); } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/MultipartWebClientIntegrationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/MultipartWebClientIntegrationTests.java index 0b15010e4b97..580f7c58d39f 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/MultipartWebClientIntegrationTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/MultipartWebClientIntegrationTests.java @@ -60,10 +60,8 @@ import org.springframework.web.server.adapter.WebHttpHandlerBuilder; import org.springframework.web.testfixture.http.server.reactive.bootstrap.AbstractHttpHandlerIntegrationTests; import org.springframework.web.testfixture.http.server.reactive.bootstrap.HttpServer; -import org.springframework.web.testfixture.http.server.reactive.bootstrap.UndertowHttpServer; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assumptions.assumeFalse; class MultipartWebClientIntegrationTests extends AbstractHttpHandlerIntegrationTests { @@ -168,8 +166,6 @@ void filePartsMono(HttpServer httpServer) throws Exception { @ParameterizedHttpServerTest void transferTo(HttpServer httpServer) throws Exception { - // TODO Determine why Undertow fails: https://github.com/spring-projects/spring-framework/issues/25310 - assumeFalse(httpServer instanceof UndertowHttpServer, "Undertow currently fails with transferTo"); startServer(httpServer); Flux result = webClient diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/SseIntegrationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/SseIntegrationTests.java index e4fb91a771be..7b4677bc1f83 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/SseIntegrationTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/SseIntegrationTests.java @@ -57,7 +57,6 @@ import org.springframework.web.testfixture.http.server.reactive.bootstrap.JettyHttpServer; import org.springframework.web.testfixture.http.server.reactive.bootstrap.ReactorHttpServer; import org.springframework.web.testfixture.http.server.reactive.bootstrap.TomcatHttpServer; -import org.springframework.web.testfixture.http.server.reactive.bootstrap.UndertowHttpServer; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -314,10 +313,7 @@ static Stream arguments() { args(new ReactorHttpServer(), new HttpComponentsClientHttpConnector()), args(new TomcatHttpServer(), new ReactorClientHttpConnector()), args(new TomcatHttpServer(), new JettyClientHttpConnector()), - args(new TomcatHttpServer(), new HttpComponentsClientHttpConnector()), - args(new UndertowHttpServer(), new ReactorClientHttpConnector()), - args(new UndertowHttpServer(), new JettyClientHttpConnector()), - args(new UndertowHttpServer(), new HttpComponentsClientHttpConnector()) + args(new TomcatHttpServer(), new HttpComponentsClientHttpConnector()) ); } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/socket/AbstractReactiveWebSocketIntegrationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/socket/AbstractReactiveWebSocketIntegrationTests.java index 45959daa5bbf..30c384d5c6d4 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/socket/AbstractReactiveWebSocketIntegrationTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/socket/AbstractReactiveWebSocketIntegrationTests.java @@ -33,8 +33,6 @@ import org.junit.jupiter.api.Named; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; -import org.xnio.OptionMap; -import org.xnio.Xnio; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.util.function.Tuple3; @@ -50,7 +48,6 @@ import org.springframework.web.reactive.socket.client.JettyWebSocketClient; import org.springframework.web.reactive.socket.client.ReactorNettyWebSocketClient; import org.springframework.web.reactive.socket.client.TomcatWebSocketClient; -import org.springframework.web.reactive.socket.client.UndertowWebSocketClient; import org.springframework.web.reactive.socket.client.WebSocketClient; import org.springframework.web.reactive.socket.server.RequestUpgradeStrategy; import org.springframework.web.reactive.socket.server.WebSocketService; @@ -60,7 +57,6 @@ import org.springframework.web.reactive.socket.server.upgrade.JettyRequestUpgradeStrategy; import org.springframework.web.reactive.socket.server.upgrade.ReactorNettyRequestUpgradeStrategy; import org.springframework.web.reactive.socket.server.upgrade.StandardWebSocketUpgradeStrategy; -import org.springframework.web.reactive.socket.server.upgrade.UndertowRequestUpgradeStrategy; import org.springframework.web.server.WebFilter; import org.springframework.web.server.adapter.WebHttpHandlerBuilder; import org.springframework.web.testfixture.http.server.reactive.bootstrap.HttpServer; @@ -68,7 +64,6 @@ import org.springframework.web.testfixture.http.server.reactive.bootstrap.JettyHttpServer; import org.springframework.web.testfixture.http.server.reactive.bootstrap.ReactorHttpServer; import org.springframework.web.testfixture.http.server.reactive.bootstrap.TomcatHttpServer; -import org.springframework.web.testfixture.http.server.reactive.bootstrap.UndertowHttpServer; import static org.junit.jupiter.api.Named.named; @@ -97,8 +92,7 @@ static Stream arguments() throws IOException { List> clients = List.of( named(TomcatWebSocketClient.class.getSimpleName(), new TomcatWebSocketClient()), named(JettyWebSocketClient.class.getSimpleName(), new JettyWebSocketClient()), - named(ReactorNettyWebSocketClient.class.getSimpleName(), new ReactorNettyWebSocketClient()), - named(UndertowWebSocketClient.class.getSimpleName(), new UndertowWebSocketClient(Xnio.getInstance().createWorker(OptionMap.EMPTY))) + named(ReactorNettyWebSocketClient.class.getSimpleName(), new ReactorNettyWebSocketClient()) ); Map, Class> servers = new LinkedHashMap<>(); @@ -107,7 +101,6 @@ static Stream arguments() throws IOException { servers.put(named(JettyHttpServer.class.getSimpleName(), new JettyHttpServer()), JettyConfig.class); servers.put(named(JettyCoreHttpServer.class.getSimpleName(), new JettyCoreHttpServer()), JettyCoreConfig.class); servers.put(named(ReactorHttpServer.class.getSimpleName(), new ReactorHttpServer()), ReactorNettyConfig.class); - servers.put(named(UndertowHttpServer.class.getSimpleName(), new UndertowHttpServer()), UndertowConfig.class); // Try each client once against each server @@ -242,14 +235,4 @@ protected RequestUpgradeStrategy getUpgradeStrategy() { } } - - @Configuration - static class UndertowConfig extends AbstractHandlerAdapterConfig { - - @Override - protected RequestUpgradeStrategy getUpgradeStrategy() { - return new UndertowRequestUpgradeStrategy(); - } - } - } diff --git a/spring-webflux/src/test/kotlin/org/springframework/web/reactive/result/method/annotation/CoroutinesIntegrationTests.kt b/spring-webflux/src/test/kotlin/org/springframework/web/reactive/result/method/annotation/CoroutinesIntegrationTests.kt index 5dc8e3c80558..b932e5204b42 100644 --- a/spring-webflux/src/test/kotlin/org/springframework/web/reactive/result/method/annotation/CoroutinesIntegrationTests.kt +++ b/spring-webflux/src/test/kotlin/org/springframework/web/reactive/result/method/annotation/CoroutinesIntegrationTests.kt @@ -38,7 +38,6 @@ import org.springframework.web.bind.annotation.RestController import org.springframework.web.client.HttpServerErrorException import org.springframework.web.reactive.config.EnableWebFlux import org.springframework.web.testfixture.http.server.reactive.bootstrap.HttpServer -import org.springframework.web.testfixture.http.server.reactive.bootstrap.UndertowHttpServer import reactor.core.publisher.Flux class CoroutinesIntegrationTests : AbstractRequestMappingIntegrationTests() { @@ -116,8 +115,6 @@ class CoroutinesIntegrationTests : AbstractRequestMappingIntegrationTests() { @ParameterizedHttpServerTest fun `Suspending handler method returning ResponseEntity of Flux `(httpServer: HttpServer) { - assumeFalse(httpServer is UndertowHttpServer, "Undertow currently fails") - startServer(httpServer) val entity = performGet("/entity-flux", HttpHeaders.EMPTY, String::class.java) diff --git a/spring-websocket/spring-websocket.gradle b/spring-websocket/spring-websocket.gradle index 2f9bcc1c795f..268e2b29db06 100644 --- a/spring-websocket/spring-websocket.gradle +++ b/spring-websocket/spring-websocket.gradle @@ -7,8 +7,6 @@ dependencies { optional(project(":spring-messaging")) optional(project(":spring-webmvc")) optional("com.fasterxml.jackson.core:jackson-databind") - optional("io.undertow:undertow-servlet") - optional("io.undertow:undertow-websockets-jsr") optional("jakarta.servlet:jakarta.servlet-api") optional("jakarta.websocket:jakarta.websocket-api") optional("jakarta.websocket:jakarta.websocket-client-api") diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/client/UndertowXhrTransport.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/client/UndertowXhrTransport.java deleted file mode 100644 index 9cb80ea23679..000000000000 --- a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/client/UndertowXhrTransport.java +++ /dev/null @@ -1,479 +0,0 @@ -/* - * Copyright 2002-present 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.socket.sockjs.client; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.net.URI; -import java.nio.ByteBuffer; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.CountDownLatch; - -import io.undertow.client.ClientCallback; -import io.undertow.client.ClientConnection; -import io.undertow.client.ClientExchange; -import io.undertow.client.ClientRequest; -import io.undertow.client.ClientResponse; -import io.undertow.client.UndertowClient; -import io.undertow.connector.ByteBufferPool; -import io.undertow.connector.PooledByteBuffer; -import io.undertow.server.DefaultByteBufferPool; -import io.undertow.util.AttachmentKey; -import io.undertow.util.HeaderMap; -import io.undertow.util.HttpString; -import io.undertow.util.Methods; -import io.undertow.util.StringReadChannelListener; -import org.jspecify.annotations.Nullable; -import org.xnio.ChannelListener; -import org.xnio.ChannelListeners; -import org.xnio.IoUtils; -import org.xnio.OptionMap; -import org.xnio.Options; -import org.xnio.Xnio; -import org.xnio.XnioWorker; -import org.xnio.channels.StreamSinkChannel; -import org.xnio.channels.StreamSourceChannel; - -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatusCode; -import org.springframework.http.ResponseEntity; -import org.springframework.util.Assert; -import org.springframework.util.StreamUtils; -import org.springframework.util.StringUtils; -import org.springframework.web.client.HttpServerErrorException; -import org.springframework.web.socket.CloseStatus; -import org.springframework.web.socket.TextMessage; -import org.springframework.web.socket.WebSocketHandler; -import org.springframework.web.socket.WebSocketSession; -import org.springframework.web.socket.sockjs.SockJsException; -import org.springframework.web.socket.sockjs.SockJsTransportFailureException; -import org.springframework.web.socket.sockjs.frame.SockJsFrame; - -/** - * An XHR transport based on Undertow's {@link io.undertow.client.UndertowClient}. - * - *

    Requires Undertow 1.3 or 1.4, including XNIO. - * - *

    When used for testing purposes (for example, load testing) or for specific use cases - * (like HTTPS configuration), a custom {@link OptionMap} should be provided: - * - *

    - * OptionMap optionMap = OptionMap.builder()
    - *   .set(Options.WORKER_IO_THREADS, 8)
    - *   .set(Options.TCP_NODELAY, true)
    - *   .set(Options.KEEP_ALIVE, true)
    - *   .set(Options.WORKER_NAME, "SockJSClient")
    - *   .getMap();
    - *
    - * UndertowXhrTransport transport = new UndertowXhrTransport(optionMap);
    - * 
    - * - * @author Brian Clozel - * @author Rossen Stoyanchev - * @since 4.1.2 - * @see org.xnio.Options - */ -public class UndertowXhrTransport extends AbstractXhrTransport { - - private static final AttachmentKey RESPONSE_BODY = AttachmentKey.create(String.class); - - - private final OptionMap optionMap; - - private final UndertowClient httpClient; - - private final XnioWorker worker; - - private final ByteBufferPool bufferPool; - - - public UndertowXhrTransport() throws IOException { - this(OptionMap.builder().parse(Options.WORKER_NAME, "SockJSClient").getMap()); - } - - public UndertowXhrTransport(OptionMap optionMap) throws IOException { - Assert.notNull(optionMap, "OptionMap is required"); - this.optionMap = optionMap; - this.httpClient = UndertowClient.getInstance(); - this.worker = Xnio.getInstance().createWorker(optionMap); - this.bufferPool = new DefaultByteBufferPool(false, 1024, -1, 2); - } - - - /** - * Return Undertow's native HTTP client. - */ - public UndertowClient getHttpClient() { - return this.httpClient; - } - - /** - * Return the {@link org.xnio.XnioWorker} backing the I/O operations - * for Undertow's HTTP client. - * @see org.xnio.Xnio - */ - public XnioWorker getWorker() { - return this.worker; - } - - - @Override - protected void connectInternal(TransportRequest request, WebSocketHandler handler, URI receiveUrl, - HttpHeaders handshakeHeaders, XhrClientSockJsSession session, - CompletableFuture connectFuture) { - - executeReceiveRequest(request, receiveUrl, handshakeHeaders, session, connectFuture); - } - - private void executeReceiveRequest(final TransportRequest transportRequest, - final URI url, final HttpHeaders headers, final XhrClientSockJsSession session, - final CompletableFuture connectFuture) { - - if (logger.isTraceEnabled()) { - logger.trace("Starting XHR receive request for " + url); - } - - ClientCallback clientCallback = new ClientCallback<>() { - @Override - public void completed(ClientConnection connection) { - ClientRequest request = new ClientRequest().setMethod(Methods.POST).setPath(url.getPath()); - HttpString headerName = HttpString.tryFromString(HttpHeaders.HOST); - request.getRequestHeaders().add(headerName, url.getHost()); - addHttpHeaders(request, headers); - HttpHeaders httpHeaders = transportRequest.getHttpRequestHeaders(); - connection.sendRequest(request, createReceiveCallback(transportRequest, - url, httpHeaders, session, connectFuture)); - } - - @Override - public void failed(IOException ex) { - throw new SockJsTransportFailureException("Failed to execute request to " + url, ex); - } - }; - - this.httpClient.connect(clientCallback, url, this.worker, this.bufferPool, this.optionMap); - } - - private static void addHttpHeaders(ClientRequest request, HttpHeaders headers) { - HeaderMap headerMap = request.getRequestHeaders(); - headers.forEach((key, values) -> { - for (String value : values) { - headerMap.add(HttpString.tryFromString(key), value); - } - }); - } - - private ClientCallback createReceiveCallback(final TransportRequest transportRequest, - final URI url, final HttpHeaders headers, final XhrClientSockJsSession sockJsSession, - final CompletableFuture connectFuture) { - - return new ClientCallback<>() { - @Override - public void completed(final ClientExchange exchange) { - exchange.setResponseListener(new ClientCallback<>() { - @Override - public void completed(ClientExchange result) { - ClientResponse response = result.getResponse(); - if (response.getResponseCode() != 200) { - HttpStatusCode status = HttpStatusCode.valueOf(response.getResponseCode()); - IoUtils.safeClose(result.getConnection()); - onFailure(new HttpServerErrorException(status, "Unexpected XHR receive status")); - } - else { - SockJsResponseListener listener = new SockJsResponseListener( - transportRequest, result.getConnection(), url, headers, - sockJsSession, connectFuture); - listener.setup(result.getResponseChannel()); - } - if (logger.isTraceEnabled()) { - logger.trace("XHR receive headers: " + toHttpHeaders(response.getResponseHeaders())); - } - try { - StreamSinkChannel channel = result.getRequestChannel(); - channel.shutdownWrites(); - if (!channel.flush()) { - channel.getWriteSetter().set(ChannelListeners.flushingChannelListener(null, null)); - channel.resumeWrites(); - } - } - catch (IOException exc) { - IoUtils.safeClose(result.getConnection()); - onFailure(exc); - } - } - - @Override - public void failed(IOException exc) { - IoUtils.safeClose(exchange.getConnection()); - onFailure(exc); - } - }); - } - - @Override - public void failed(IOException exc) { - onFailure(exc); - } - - private void onFailure(Throwable failure) { - if (connectFuture.completeExceptionally(failure)) { - return; - } - if (sockJsSession.isDisconnected()) { - sockJsSession.afterTransportClosed(null); - } - else { - sockJsSession.handleTransportError(failure); - sockJsSession.afterTransportClosed(new CloseStatus(1006, failure.getMessage())); - } - } - }; - } - - private static HttpHeaders toHttpHeaders(HeaderMap headerMap) { - HttpHeaders httpHeaders = new HttpHeaders(); - for (HttpString name : headerMap.getHeaderNames()) { - for (String value : headerMap.get(name)) { - httpHeaders.add(name.toString(), value); - } - } - return httpHeaders; - } - - @Override - protected ResponseEntity executeInfoRequestInternal(URI infoUrl, HttpHeaders headers) { - return executeRequest(infoUrl, Methods.GET, headers, null); - } - - @Override - protected ResponseEntity executeSendRequestInternal(URI url, HttpHeaders headers, TextMessage message) { - return executeRequest(url, Methods.POST, headers, message.getPayload()); - } - - protected ResponseEntity executeRequest( - URI url, HttpString method, HttpHeaders headers, @Nullable String body) { - - CountDownLatch latch = new CountDownLatch(1); - List responses = new CopyOnWriteArrayList<>(); - - try { - ClientConnection connection = - this.httpClient.connect(url, this.worker, this.bufferPool, this.optionMap).get(); - try { - ClientRequest request = new ClientRequest().setMethod(method).setPath(url.getPath()); - request.getRequestHeaders().add(HttpString.tryFromString(HttpHeaders.HOST), url.getHost()); - if (StringUtils.hasLength(body)) { - HttpString headerName = HttpString.tryFromString(HttpHeaders.CONTENT_LENGTH); - request.getRequestHeaders().add(headerName, body.length()); - } - addHttpHeaders(request, headers); - connection.sendRequest(request, createRequestCallback(body, responses, latch)); - - latch.await(); - ClientResponse response = responses.iterator().next(); - HttpStatusCode status = HttpStatusCode.valueOf(response.getResponseCode()); - HttpHeaders responseHeaders = toHttpHeaders(response.getResponseHeaders()); - String responseBody = response.getAttachment(RESPONSE_BODY); - return (responseBody != null ? - new ResponseEntity<>(responseBody, responseHeaders, status) : - new ResponseEntity<>(responseHeaders, status)); - } - finally { - IoUtils.safeClose(connection); - } - } - catch (IOException ex) { - throw new SockJsTransportFailureException("Failed to execute request to " + url, ex); - } - catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - throw new SockJsTransportFailureException("Interrupted while processing request to " + url, ex); - } - } - - private ClientCallback createRequestCallback(final @Nullable String body, - final List responses, final CountDownLatch latch) { - - return new ClientCallback<>() { - @Override - public void completed(ClientExchange result) { - result.setResponseListener(new ClientCallback<>() { - @Override - public void completed(final ClientExchange result) { - responses.add(result.getResponse()); - new StringReadChannelListener(result.getConnection().getBufferPool()) { - @Override - protected void stringDone(String string) { - result.getResponse().putAttachment(RESPONSE_BODY, string); - latch.countDown(); - } - @Override - protected void error(IOException ex) { - onFailure(latch, ex); - } - }.setup(result.getResponseChannel()); - } - @Override - public void failed(IOException ex) { - onFailure(latch, ex); - } - }); - try { - if (body != null) { - result.getRequestChannel().write(ByteBuffer.wrap(body.getBytes())); - } - result.getRequestChannel().shutdownWrites(); - if (!result.getRequestChannel().flush()) { - result.getRequestChannel().getWriteSetter() - .set(ChannelListeners.flushingChannelListener(null, null)); - result.getRequestChannel().resumeWrites(); - } - } - catch (IOException ex) { - onFailure(latch, ex); - } - } - - @Override - public void failed(IOException ex) { - onFailure(latch, ex); - } - - private void onFailure(CountDownLatch latch, IOException ex) { - latch.countDown(); - throw new SockJsTransportFailureException("Failed to execute request", ex); - } - }; - } - - - private class SockJsResponseListener implements ChannelListener { - - private final TransportRequest request; - - private final ClientConnection connection; - - private final URI url; - - private final HttpHeaders headers; - - private final XhrClientSockJsSession session; - - private final CompletableFuture connectFuture; - - private final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - - public SockJsResponseListener(TransportRequest request, ClientConnection connection, URI url, - HttpHeaders headers, XhrClientSockJsSession sockJsSession, - CompletableFuture connectFuture) { - - this.request = request; - this.connection = connection; - this.url = url; - this.headers = headers; - this.session = sockJsSession; - this.connectFuture = connectFuture; - } - - public void setup(StreamSourceChannel channel) { - channel.suspendReads(); - channel.getReadSetter().set(this); - channel.resumeReads(); - } - - @Override - public void handleEvent(StreamSourceChannel channel) { - if (this.session.isDisconnected()) { - if (logger.isDebugEnabled()) { - logger.debug("SockJS sockJsSession closed, closing response."); - } - IoUtils.safeClose(this.connection); - throw new SockJsException("Session closed.", this.session.getId(), null); - } - - try (PooledByteBuffer pooled = bufferPool.allocate()) { - int r; - do { - ByteBuffer buffer = pooled.getBuffer(); - buffer.clear(); - r = channel.read(buffer); - buffer.flip(); - if (r == 0) { - return; - } - else if (r == -1) { - onSuccess(); - } - else { - while (buffer.hasRemaining()) { - int b = buffer.get(); - if (b == '\n') { - handleFrame(); - } - else { - this.outputStream.write(b); - } - } - } - } - while (r > 0); - } - catch (IOException exc) { - onFailure(exc); - } - } - - private void handleFrame() { - String content = StreamUtils.copyToString(this.outputStream, SockJsFrame.CHARSET); - this.outputStream.reset(); - if (logger.isTraceEnabled()) { - logger.trace("XHR content received: " + content); - } - if (!PRELUDE.equals(content)) { - this.session.handleFrame(content); - } - } - - public void onSuccess() { - if (this.outputStream.size() > 0) { - handleFrame(); - } - if (logger.isTraceEnabled()) { - logger.trace("XHR receive request completed."); - } - IoUtils.safeClose(this.connection); - executeReceiveRequest(this.request, this.url, this.headers, this.session, this.connectFuture); - } - - public void onFailure(Throwable failure) { - IoUtils.safeClose(this.connection); - if (this.connectFuture.completeExceptionally(failure)) { - return; - } - if (this.session.isDisconnected()) { - this.session.afterTransportClosed(null); - } - else { - this.session.handleTransportError(failure); - this.session.afterTransportClosed(new CloseStatus(1006, failure.getMessage())); - } - } - } - -} diff --git a/spring-websocket/src/test/java/org/springframework/web/socket/AbstractWebSocketIntegrationTests.java b/spring-websocket/src/test/java/org/springframework/web/socket/AbstractWebSocketIntegrationTests.java index 3cfc0eea0d68..ae7baaf033be 100644 --- a/spring-websocket/src/test/java/org/springframework/web/socket/AbstractWebSocketIntegrationTests.java +++ b/spring-websocket/src/test/java/org/springframework/web/socket/AbstractWebSocketIntegrationTests.java @@ -55,8 +55,7 @@ public abstract class AbstractWebSocketIntegrationTests { static Stream argumentsFactory() { return Stream.of( arguments(named("Jetty", new JettyWebSocketTestServer()), named("Standard", new StandardWebSocketClient())), - arguments(named("Tomcat", new TomcatWebSocketTestServer()), named("Standard", new StandardWebSocketClient())), - arguments(named("Undertow", new UndertowTestServer()), named("Standard", new StandardWebSocketClient()))); + arguments(named("Tomcat", new TomcatWebSocketTestServer()), named("Standard", new StandardWebSocketClient()))); } diff --git a/spring-websocket/src/test/java/org/springframework/web/socket/UndertowTestServer.java b/spring-websocket/src/test/java/org/springframework/web/socket/UndertowTestServer.java deleted file mode 100644 index 042fb7c36101..000000000000 --- a/spring-websocket/src/test/java/org/springframework/web/socket/UndertowTestServer.java +++ /dev/null @@ -1,181 +0,0 @@ -/* - * Copyright 2002-present 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.socket; - -import java.io.IOException; -import java.net.InetSocketAddress; - -import io.undertow.Undertow; -import io.undertow.server.HttpHandler; -import io.undertow.servlet.api.DeploymentInfo; -import io.undertow.servlet.api.DeploymentManager; -import io.undertow.servlet.api.FilterInfo; -import io.undertow.servlet.api.InstanceFactory; -import io.undertow.servlet.api.InstanceHandle; -import io.undertow.servlet.api.ServletInfo; -import io.undertow.websockets.jsr.WebSocketDeploymentInfo; -import jakarta.servlet.DispatcherType; -import jakarta.servlet.Filter; -import jakarta.servlet.Servlet; -import jakarta.servlet.ServletContext; -import jakarta.servlet.ServletException; -import org.xnio.OptionMap; -import org.xnio.Xnio; - -import org.springframework.web.context.WebApplicationContext; -import org.springframework.web.servlet.DispatcherServlet; - -import static io.undertow.servlet.Servlets.defaultContainer; -import static io.undertow.servlet.Servlets.deployment; -import static io.undertow.servlet.Servlets.servlet; - -/** - * Undertow-based {@link WebSocketTestServer}. - * - * @author Rossen Stoyanchev - * @author Sam Brannen - */ -public class UndertowTestServer implements WebSocketTestServer { - - private int port; - - private Undertow server; - - private DeploymentManager manager; - - - @Override - public void setup() { - } - - @Override - @SuppressWarnings("deprecation") - public void deployConfig(WebApplicationContext wac, Filter... filters) { - DispatcherServletInstanceFactory servletFactory = new DispatcherServletInstanceFactory(wac); - // manually building WebSocketDeploymentInfo in order to avoid class cast exceptions - // with tomcat's implementation when using undertow 1.1.0+ - WebSocketDeploymentInfo info = new WebSocketDeploymentInfo(); - try { - info.setWorker(Xnio.getInstance().createWorker(OptionMap.EMPTY)); - info.setBuffers(new org.xnio.ByteBufferSlicePool(1024,1024)); - } - catch (IOException ex) { - throw new IllegalStateException(ex); - } - - ServletInfo servletInfo = servlet("DispatcherServlet", DispatcherServlet.class, servletFactory) - .addMapping("/").setAsyncSupported(true); - DeploymentInfo servletBuilder = deployment() - .setClassLoader(UndertowTestServer.class.getClassLoader()) - .setDeploymentName("undertow-websocket-test") - .setContextPath("/") - .addServlet(servletInfo) - .addServletContextAttribute(WebSocketDeploymentInfo.ATTRIBUTE_NAME, info); - for (final Filter filter : filters) { - String filterName = filter.getClass().getName(); - FilterInstanceFactory filterFactory = new FilterInstanceFactory(filter); - FilterInfo filterInfo = new FilterInfo(filterName, filter.getClass(), filterFactory); - servletBuilder.addFilter(filterInfo.setAsyncSupported(true)); - for (DispatcherType type : DispatcherType.values()) { - servletBuilder.addFilterUrlMapping(filterName, "/*", type); - } - } - try { - this.manager = defaultContainer().addDeployment(servletBuilder); - this.manager.deploy(); - HttpHandler httpHandler = this.manager.start(); - this.server = Undertow.builder().addHttpListener(0, "localhost").setHandler(httpHandler).build(); - } - catch (ServletException ex) { - throw new IllegalStateException(ex); - } - } - - @Override - public void undeployConfig() { - this.manager.undeploy(); - } - - @Override - public void start() { - this.server.start(); - Undertow.ListenerInfo info = this.server.getListenerInfo().get(0); - this.port = ((InetSocketAddress) info.getAddress()).getPort(); - } - - @Override - public void stop() { - this.server.stop(); - this.port = 0; - } - - @Override - public int getPort() { - return this.port; - } - - @Override - public ServletContext getServletContext() { - return this.manager.getDeployment().getServletContext(); - } - - - private static class DispatcherServletInstanceFactory implements InstanceFactory { - - private final WebApplicationContext wac; - - public DispatcherServletInstanceFactory(WebApplicationContext wac) { - this.wac = wac; - } - - @Override - public InstanceHandle createInstance() { - return new InstanceHandle<>() { - @Override - public Servlet getInstance() { - return new DispatcherServlet(wac); - } - @Override - public void release() { - } - }; - } - } - - - private static class FilterInstanceFactory implements InstanceFactory { - - private final Filter filter; - - private FilterInstanceFactory(Filter filter) { - this.filter = filter; - } - - @Override - public InstanceHandle createInstance() { - return new InstanceHandle<>() { - @Override - public Filter getInstance() { - return filter; - } - @Override - public void release() {} - }; - } - } - -} diff --git a/spring-websocket/src/test/java/org/springframework/web/socket/sockjs/client/UndertowSockJsIntegrationTests.java b/spring-websocket/src/test/java/org/springframework/web/socket/sockjs/client/UndertowSockJsIntegrationTests.java deleted file mode 100644 index 80dd17dda6be..000000000000 --- a/spring-websocket/src/test/java/org/springframework/web/socket/sockjs/client/UndertowSockJsIntegrationTests.java +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2002-present 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.socket.sockjs.client; - -import java.io.IOException; - -import org.springframework.web.socket.UndertowTestServer; -import org.springframework.web.socket.WebSocketTestServer; -import org.springframework.web.socket.client.standard.StandardWebSocketClient; - -/** - * @author Brian Clozel - */ -class UndertowSockJsIntegrationTests extends AbstractSockJsIntegrationTests { - - @Override - protected WebSocketTestServer createWebSocketTestServer() { - return new UndertowTestServer(); - } - - @Override - protected Transport createWebSocketTransport() { - return new WebSocketTransport(new StandardWebSocketClient()); - } - - @Override - protected AbstractXhrTransport createXhrTransport() { - try { - return new UndertowXhrTransport(); - } - catch (IOException ex) { - throw new IllegalStateException("Could not create UndertowXhrTransport"); - } - } - -} From c942b21deaab4e6ec6d28f171841a4288f3ac18e Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Wed, 20 Aug 2025 14:38:36 +0200 Subject: [PATCH 113/591] Generate consistent validation error messages in RetryPolicy Closes gh-35355 --- .../core/retry/RetryPolicy.java | 34 ++++++++++++------- .../core/retry/RetryPolicyTests.java | 17 ++++------ 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryPolicy.java b/spring-core/src/main/java/org/springframework/core/retry/RetryPolicy.java index 83f2bab25604..16b6ee2473e8 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryPolicy.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryPolicy.java @@ -87,7 +87,7 @@ static RetryPolicy withDefaults() { * @see FixedBackOff */ static RetryPolicy withMaxAttempts(long maxAttempts) { - Assert.isTrue(maxAttempts > 0, "Max attempts must be greater than zero"); + assertMaxAttemptsIsPositive(maxAttempts); return builder().backOff(new FixedBackOff(Builder.DEFAULT_DELAY, maxAttempts)).build(); } @@ -100,6 +100,22 @@ static Builder builder() { } + private static void assertMaxAttemptsIsPositive(long maxAttempts) { + Assert.isTrue(maxAttempts > 0, + () -> "Invalid maxAttempts (%d): must be greater than zero.".formatted(maxAttempts)); + } + + private static void assertIsPositive(String name, Duration duration) { + Assert.isTrue((!duration.isNegative() && !duration.isZero()), + () -> "Invalid %s (%dms): must be greater than zero.".formatted(name, duration.toMillis())); + } + + private static void assertIsNotNegative(String name, Duration duration) { + Assert.isTrue(!duration.isNegative(), + () -> "Invalid %s (%dms): must be greater than or equal to zero.".formatted(name, duration.toMillis())); + } + + /** * Fluent API for configuring a {@link RetryPolicy} with common configuration * options. @@ -180,7 +196,7 @@ public Builder backOff(BackOff backOff) { * @return this {@code Builder} instance for chained method invocations */ public Builder maxAttempts(long maxAttempts) { - Assert.isTrue(maxAttempts > 0, "Max attempts must be greater than zero"); + assertMaxAttemptsIsPositive(maxAttempts); this.maxAttempts = maxAttempts; return this; } @@ -201,8 +217,7 @@ public Builder maxAttempts(long maxAttempts) { * @see #maxDelay(Duration) */ public Builder delay(Duration delay) { - Assert.isTrue(!delay.isNegative(), - () -> "Invalid delay (%dms): must be >= 0.".formatted(delay.toMillis())); + assertIsNotNegative("delay", delay); this.delay = delay; return this; } @@ -227,8 +242,7 @@ public Builder delay(Duration delay) { * @see #maxDelay(Duration) */ public Builder jitter(Duration jitter) { - Assert.isTrue(!jitter.isNegative(), - () -> "Invalid jitter (%dms): must be >= 0.".formatted(jitter.toMillis())); + assertIsNotNegative("jitter", jitter); this.jitter = jitter; return this; } @@ -243,6 +257,7 @@ public Builder jitter(Duration jitter) { *

    The supplied value will override any previously configured value. *

    You should not specify this configuration option if you have * configured a custom {@link #backOff(BackOff) BackOff} strategy. + * @param multiplier the multiplier value; must be greater than or equal to 1 * @return this {@code Builder} instance for chained method invocations * @see #delay(Duration) * @see #jitter(Duration) @@ -264,7 +279,7 @@ public Builder multiplier(double multiplier) { *

    The supplied value will override any previously configured value. *

    You should not specify this configuration option if you have * configured a custom {@link #backOff(BackOff) BackOff} strategy. - * @param maxDelay the maximum delay; must be positive + * @param maxDelay the maximum delay; must be greater than zero * @return this {@code Builder} instance for chained method invocations * @see #delay(Duration) * @see #jitter(Duration) @@ -403,11 +418,6 @@ public RetryPolicy build() { } return new DefaultRetryPolicy(this.includes, this.excludes, this.predicate, backOff); } - - private static void assertIsPositive(String name, Duration duration) { - Assert.isTrue((!duration.isNegative() && !duration.isZero()), - () -> "Invalid duration (%dms): %s must be positive.".formatted(duration.toMillis(), name)); - } } } diff --git a/spring-core/src/test/java/org/springframework/core/retry/RetryPolicyTests.java b/spring-core/src/test/java/org/springframework/core/retry/RetryPolicyTests.java index 62b0a42e3f4c..8f86350fc087 100644 --- a/spring-core/src/test/java/org/springframework/core/retry/RetryPolicyTests.java +++ b/spring-core/src/test/java/org/springframework/core/retry/RetryPolicyTests.java @@ -67,10 +67,10 @@ void withDefaults() { void withMaxAttemptsPreconditions() { assertThatIllegalArgumentException() .isThrownBy(() -> RetryPolicy.withMaxAttempts(0)) - .withMessage("Max attempts must be greater than zero"); + .withMessage("Invalid maxAttempts (0): must be greater than zero."); assertThatIllegalArgumentException() .isThrownBy(() -> RetryPolicy.withMaxAttempts(-1)) - .withMessage("Max attempts must be greater than zero"); + .withMessage("Invalid maxAttempts (-1): must be greater than zero."); } @Test @@ -117,10 +117,10 @@ void backOff() { void maxAttemptsPreconditions() { assertThatIllegalArgumentException() .isThrownBy(() -> RetryPolicy.builder().maxAttempts(0)) - .withMessage("Max attempts must be greater than zero"); + .withMessage("Invalid maxAttempts (0): must be greater than zero."); assertThatIllegalArgumentException() .isThrownBy(() -> RetryPolicy.builder().maxAttempts(-1)) - .withMessage("Max attempts must be greater than zero"); + .withMessage("Invalid maxAttempts (-1): must be greater than zero."); } @Test @@ -141,7 +141,7 @@ void maxAttempts() { void delayPreconditions() { assertThatIllegalArgumentException() .isThrownBy(() -> RetryPolicy.builder().delay(Duration.ofMillis(-1))) - .withMessage("Invalid delay (-1ms): must be >= 0."); + .withMessage("Invalid delay (-1ms): must be greater than or equal to zero."); } @Test @@ -162,7 +162,7 @@ void delay() { void jitterPreconditions() { assertThatIllegalArgumentException() .isThrownBy(() -> RetryPolicy.builder().jitter(Duration.ofMillis(-1))) - .withMessage("Invalid jitter (-1ms): must be >= 0."); + .withMessage("Invalid jitter (-1ms): must be greater than or equal to zero."); } @Test @@ -208,12 +208,9 @@ void multiplier() { @Test void maxDelayPreconditions() { - assertThatIllegalArgumentException() - .isThrownBy(() -> RetryPolicy.builder().maxDelay(Duration.ZERO)) - .withMessage("Invalid duration (0ms): maxDelay must be positive."); assertThatIllegalArgumentException() .isThrownBy(() -> RetryPolicy.builder().maxDelay(Duration.ofMillis(-1))) - .withMessage("Invalid duration (-1ms): maxDelay must be positive."); + .withMessage("Invalid maxDelay (-1ms): must be greater than zero."); } @Test From 32697518875f6ab270a5a9899d398228068e8920 Mon Sep 17 00:00:00 2001 From: Tommy Ludwig <8924140+shakuzen@users.noreply.github.com> Date: Thu, 31 Jul 2025 18:05:48 +0900 Subject: [PATCH 114/591] OTel semantic conventions for HTTP server for Servlet-based instrumentation Adds an ObservationDocumentation and ObservationConvention implementation that follows the OpenTelemetry semantic convention for HTTP Server metrics and spans. See gh-35358 --- ...tryServerHttpObservationDocumentation.java | 156 ++++++++++++ ...tryServerRequestObservationConvention.java | 232 ++++++++++++++++++ 2 files changed, 388 insertions(+) create mode 100644 spring-web/src/main/java/org/springframework/http/server/observation/OpenTelemetryServerHttpObservationDocumentation.java create mode 100644 spring-web/src/main/java/org/springframework/http/server/observation/OpenTelemetryServerRequestObservationConvention.java diff --git a/spring-web/src/main/java/org/springframework/http/server/observation/OpenTelemetryServerHttpObservationDocumentation.java b/spring-web/src/main/java/org/springframework/http/server/observation/OpenTelemetryServerHttpObservationDocumentation.java new file mode 100644 index 000000000000..ec2d70c9a5a9 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/server/observation/OpenTelemetryServerHttpObservationDocumentation.java @@ -0,0 +1,156 @@ +/* + * Copyright 2002-present 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.http.server.observation; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.docs.KeyName; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; +import io.micrometer.observation.docs.ObservationDocumentation; + +/** + * Documented {@link KeyValue KeyValues} for the HTTP server + * observations for Servlet-based web applications, following the stable OpenTelemetry semantic conventions. + * + *

    This class is used by automated tools to document KeyValues attached to the + * HTTP server observations. + * + * @author Brian Clozel + * @author Tommy Ludwig + * @since 7.0 + * @see OpenTelemetry Semantic Conventions for HTTP Metrics (v1.36.0) + * @see OpenTelemetry Semantic Conventions for HTTP Spans (v1.36.0) + */ +public enum OpenTelemetryServerHttpObservationDocumentation implements ObservationDocumentation { + + /** + * HTTP request observations for Servlet-based servers. + */ + HTTP_SERVLET_SERVER_REQUESTS { + @Override + public Class> getDefaultConvention() { + return OpenTelemetryServerRequestObservationConvention.class; + } + + @Override + public KeyName[] getLowCardinalityKeyNames() { + return LowCardinalityKeyNames.values(); + } + + @Override + public KeyName[] getHighCardinalityKeyNames() { + return HighCardinalityKeyNames.values(); + } + + }; + + public enum LowCardinalityKeyNames implements KeyName { + + /** + * Name of the HTTP request method or {@value KeyValue#NONE_VALUE} if the + * request was not received properly. Normalized to known methods defined in internet standards. + */ + METHOD { + @Override + public String asString() { + return "http.request.method"; + } + + }, + + /** + * HTTP response raw status code, or {@code "UNKNOWN"} if no response was + * created. + */ + STATUS { + @Override + public String asString() { + return "http.response.status_code"; + } + }, + + /** + * URI pattern for the matching handler if available, falling back to + * {@code REDIRECTION} for 3xx responses, {@code NOT_FOUND} for 404 + * responses, {@code root} for requests with no path info, and + * {@code UNKNOWN} for all other requests. + */ + ROUTE { + @Override + public String asString() { + return "http.route"; + } + }, + + /** + * Name of the exception thrown during the exchange, or + * {@value KeyValue#NONE_VALUE} if no exception was thrown. + */ + EXCEPTION { + @Override + public String asString() { + return "error.type"; + } + }, + + /** + * The scheme of the original client request, if known (e.g. from Forwarded#proto, X-Forwarded-Proto, or a similar header). Otherwise, the scheme of the immediate peer request. + */ + SCHEME { + @Override + public String asString() { + return "url.scheme"; + } + }, + + /** + * Outcome of the HTTP server exchange. + * @see org.springframework.http.HttpStatus.Series + */ + OUTCOME { + @Override + public String asString() { + return "outcome"; + } + } + } + + public enum HighCardinalityKeyNames implements KeyName { + + /** + * HTTP request URL. + */ + URL_PATH { + @Override + public String asString() { + return "url.path"; + } + }, + + /** + * Original HTTP method sent by the client in the request line. + */ + METHOD_ORIGINAL { + @Override + public String asString() { + return "http.request.method_original"; + } + } + + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/server/observation/OpenTelemetryServerRequestObservationConvention.java b/spring-web/src/main/java/org/springframework/http/server/observation/OpenTelemetryServerRequestObservationConvention.java new file mode 100644 index 000000000000..94dfe50f2470 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/server/observation/OpenTelemetryServerRequestObservationConvention.java @@ -0,0 +1,232 @@ +/* + * Copyright 2002-present 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.http.server.observation; + +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; +import org.jspecify.annotations.Nullable; + +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.server.observation.OpenTelemetryServerHttpObservationDocumentation.HighCardinalityKeyNames; +import org.springframework.http.server.observation.OpenTelemetryServerHttpObservationDocumentation.LowCardinalityKeyNames; +import org.springframework.util.StringUtils; + +/** + * A {@link ServerRequestObservationConvention} based on the stable OpenTelemetry semantic conventions. + * + * @author Brian Clozel + * @author Tommy Ludwig + * @since 7.0 + * @see OpenTelemetryServerHttpObservationDocumentation + */ +public class OpenTelemetryServerRequestObservationConvention implements ServerRequestObservationConvention { + + private static final String NAME = "http.server.request.duration"; + + private static final KeyValue METHOD_UNKNOWN = KeyValue.of(LowCardinalityKeyNames.METHOD, "_OTHER"); + + private static final KeyValue SCHEME_UNKNOWN = KeyValue.of(LowCardinalityKeyNames.SCHEME, "UNKNOWN"); + + private static final KeyValue STATUS_UNKNOWN = KeyValue.of(LowCardinalityKeyNames.STATUS, "UNKNOWN"); + + private static final KeyValue HTTP_OUTCOME_SUCCESS = KeyValue.of(LowCardinalityKeyNames.OUTCOME, "SUCCESS"); + + private static final KeyValue HTTP_OUTCOME_UNKNOWN = KeyValue.of(LowCardinalityKeyNames.OUTCOME, "UNKNOWN"); + + private static final KeyValue ROUTE_UNKNOWN = KeyValue.of(LowCardinalityKeyNames.ROUTE, "UNKNOWN"); + + private static final KeyValue ROUTE_ROOT = KeyValue.of(LowCardinalityKeyNames.ROUTE, "root"); + + private static final KeyValue ROUTE_NOT_FOUND = KeyValue.of(LowCardinalityKeyNames.ROUTE, "NOT_FOUND"); + + private static final KeyValue ROUTE_REDIRECTION = KeyValue.of(LowCardinalityKeyNames.ROUTE, "REDIRECTION"); + + private static final KeyValue EXCEPTION_NONE = KeyValue.of(LowCardinalityKeyNames.EXCEPTION, KeyValue.NONE_VALUE); + + private static final KeyValue HTTP_URL_UNKNOWN = KeyValue.of(HighCardinalityKeyNames.URL_PATH, "UNKNOWN"); + + private static final KeyValue ORIGINAL_METHOD_UNKNOWN = KeyValue.of(HighCardinalityKeyNames.METHOD_ORIGINAL, "UNKNOWN"); + + private static final Set HTTP_METHODS = Stream.of(HttpMethod.values()).map(HttpMethod::name).collect(Collectors.toUnmodifiableSet()); + + + /** + * Create a convention. + */ + public OpenTelemetryServerRequestObservationConvention() { + } + + + @Override + public String getName() { + return NAME; + } + + /** + * HTTP span names SHOULD be {@code {method} {target}} if there is a (low-cardinality) {@code target} + * available. If there is no (low-cardinality) {@code {target}} available, HTTP span names + * SHOULD be {@code {method}}. + *

    + * The {@code {method}} MUST be {@code {http.request.method}} if the method represents the original + * method known to the instrumentation. In other cases (when Customize Toolbar… is + * set to {@code _OTHER}), {@code {method}} MUST be HTTP. + *

    + * The {@code target} SHOULD be the {@code {http.route}}. + * @param context context + * @return contextual name + * @see OpenTelemetry Semantic Convention HTTP Span Name (v1.36.0) + */ + @Override + public String getContextualName(ServerRequestObservationContext context) { + if (context.getCarrier() == null) { + return "HTTP"; + } + String maybeMethod = getMethodValue(context); + String method = maybeMethod == null ? "HTTP" : maybeMethod; + String target = context.getPathPattern(); + if (target != null) { + return method + " " + target; + } + return method; + } + + @Override + public KeyValues getLowCardinalityKeyValues(ServerRequestObservationContext context) { + // Make sure that KeyValues entries are already sorted by name for better performance + return KeyValues.of(exception(context), method(context), status(context), pathTemplate(context), outcome(context), scheme(context)); + } + + @Override + public KeyValues getHighCardinalityKeyValues(ServerRequestObservationContext context) { + // Make sure that KeyValues entries are already sorted by name for better performance + return KeyValues.of(methodOriginal(context), httpUrl(context)); + } + + protected KeyValue method(ServerRequestObservationContext context) { + String method = getMethodValue(context); + if (method != null) { + return KeyValue.of(LowCardinalityKeyNames.METHOD, method); + } + return METHOD_UNKNOWN; + } + + protected @Nullable String getMethodValue(ServerRequestObservationContext context) { + if (context.getCarrier() != null) { + String httpMethod = context.getCarrier().getMethod(); + if (HTTP_METHODS.contains(httpMethod)) { + return httpMethod; + } + } + return null; + } + + protected KeyValue scheme(ServerRequestObservationContext context) { + if (context.getCarrier() != null) { + return KeyValue.of(LowCardinalityKeyNames.SCHEME, context.getCarrier().getScheme()); + } + return SCHEME_UNKNOWN; + } + + protected KeyValue status(ServerRequestObservationContext context) { + return (context.getResponse() != null) ? + KeyValue.of(LowCardinalityKeyNames.STATUS, Integer.toString(context.getResponse().getStatus())) : + STATUS_UNKNOWN; + } + + protected KeyValue pathTemplate(ServerRequestObservationContext context) { + if (context.getCarrier() != null) { + String pattern = context.getPathPattern(); + if (pattern != null) { + if (pattern.isEmpty()) { + return ROUTE_ROOT; + } + return KeyValue.of(LowCardinalityKeyNames.ROUTE, pattern); + } + if (context.getResponse() != null) { + HttpStatus status = HttpStatus.resolve(context.getResponse().getStatus()); + if (status != null) { + if (status.is3xxRedirection()) { + return ROUTE_REDIRECTION; + } + if (status == HttpStatus.NOT_FOUND) { + return ROUTE_NOT_FOUND; + } + } + } + } + return ROUTE_UNKNOWN; + } + + protected KeyValue exception(ServerRequestObservationContext context) { + Throwable error = context.getError(); + if (error != null) { + String simpleName = error.getClass().getSimpleName(); + return KeyValue.of(LowCardinalityKeyNames.EXCEPTION, + StringUtils.hasText(simpleName) ? simpleName : error.getClass().getName()); + } + return EXCEPTION_NONE; + } + + protected KeyValue outcome(ServerRequestObservationContext context) { + try { + if (context.getResponse() != null) { + HttpStatusCode statusCode = HttpStatusCode.valueOf(context.getResponse().getStatus()); + return HttpOutcome.forStatus(statusCode); + } + } + catch (IllegalArgumentException ex) { + return HTTP_OUTCOME_UNKNOWN; + } + return HTTP_OUTCOME_UNKNOWN; + } + + protected KeyValue httpUrl(ServerRequestObservationContext context) { + if (context.getCarrier() != null) { + return KeyValue.of(HighCardinalityKeyNames.URL_PATH, context.getCarrier().getRequestURI()); + } + return HTTP_URL_UNKNOWN; + } + + protected KeyValue methodOriginal(ServerRequestObservationContext context) { + if (context.getCarrier() != null) { + return KeyValue.of(HighCardinalityKeyNames.METHOD_ORIGINAL, context.getCarrier().getMethod()); + } + return ORIGINAL_METHOD_UNKNOWN; + } + + static class HttpOutcome { + + static KeyValue forStatus(HttpStatusCode statusCode) { + if (statusCode.is2xxSuccessful()) { + return HTTP_OUTCOME_SUCCESS; + } + else if (statusCode instanceof HttpStatus status) { + return KeyValue.of(LowCardinalityKeyNames.OUTCOME, status.series().name()); + } + else { + return HTTP_OUTCOME_UNKNOWN; + } + } + } + +} From 7e45f609a288681d6355f82499efa30f311eb504 Mon Sep 17 00:00:00 2001 From: Tommy Ludwig <8924140+shakuzen@users.noreply.github.com> Date: Thu, 31 Jul 2025 19:06:31 +0900 Subject: [PATCH 115/591] Add test for OpenTelemetryServerRequestObservationConvention See gh-35358 --- ...tryServerHttpObservationDocumentation.java | 2 +- ...tryServerRequestObservationConvention.java | 5 +- ...rverRequestObservationConventionTests.java | 162 ++++++++++++++++++ 3 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 spring-web/src/test/java/org/springframework/http/server/observation/OpenTelemetryServerRequestObservationConventionTests.java diff --git a/spring-web/src/main/java/org/springframework/http/server/observation/OpenTelemetryServerHttpObservationDocumentation.java b/spring-web/src/main/java/org/springframework/http/server/observation/OpenTelemetryServerHttpObservationDocumentation.java index ec2d70c9a5a9..7ab2740ba8b5 100644 --- a/spring-web/src/main/java/org/springframework/http/server/observation/OpenTelemetryServerHttpObservationDocumentation.java +++ b/spring-web/src/main/java/org/springframework/http/server/observation/OpenTelemetryServerHttpObservationDocumentation.java @@ -97,7 +97,7 @@ public String asString() { }, /** - * Name of the exception thrown during the exchange, or + * Fully qualified name of the exception thrown during the exchange, or * {@value KeyValue#NONE_VALUE} if no exception was thrown. */ EXCEPTION { diff --git a/spring-web/src/main/java/org/springframework/http/server/observation/OpenTelemetryServerRequestObservationConvention.java b/spring-web/src/main/java/org/springframework/http/server/observation/OpenTelemetryServerRequestObservationConvention.java index 94dfe50f2470..8a1dcfd95a72 100644 --- a/spring-web/src/main/java/org/springframework/http/server/observation/OpenTelemetryServerRequestObservationConvention.java +++ b/spring-web/src/main/java/org/springframework/http/server/observation/OpenTelemetryServerRequestObservationConvention.java @@ -29,7 +29,6 @@ import org.springframework.http.HttpStatusCode; import org.springframework.http.server.observation.OpenTelemetryServerHttpObservationDocumentation.HighCardinalityKeyNames; import org.springframework.http.server.observation.OpenTelemetryServerHttpObservationDocumentation.LowCardinalityKeyNames; -import org.springframework.util.StringUtils; /** * A {@link ServerRequestObservationConvention} based on the stable OpenTelemetry semantic conventions. @@ -180,9 +179,7 @@ protected KeyValue pathTemplate(ServerRequestObservationContext context) { protected KeyValue exception(ServerRequestObservationContext context) { Throwable error = context.getError(); if (error != null) { - String simpleName = error.getClass().getSimpleName(); - return KeyValue.of(LowCardinalityKeyNames.EXCEPTION, - StringUtils.hasText(simpleName) ? simpleName : error.getClass().getName()); + return KeyValue.of(LowCardinalityKeyNames.EXCEPTION, error.getClass().getName()); } return EXCEPTION_NONE; } diff --git a/spring-web/src/test/java/org/springframework/http/server/observation/OpenTelemetryServerRequestObservationConventionTests.java b/spring-web/src/test/java/org/springframework/http/server/observation/OpenTelemetryServerRequestObservationConventionTests.java new file mode 100644 index 000000000000..03ee93e1e604 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/server/observation/OpenTelemetryServerRequestObservationConventionTests.java @@ -0,0 +1,162 @@ +/* + * Copyright 2002-present 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.http.server.observation; + +import io.micrometer.common.KeyValue; +import io.micrometer.observation.Observation; +import org.junit.jupiter.api.Test; + +import org.springframework.web.testfixture.servlet.MockHttpServletRequest; +import org.springframework.web.testfixture.servlet.MockHttpServletResponse; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link OpenTelemetryServerRequestObservationConvention}. + * @author Brian Clozel + * @author Tommy Ludwig + */ +class OpenTelemetryServerRequestObservationConventionTests { + + private final OpenTelemetryServerRequestObservationConvention convention = new OpenTelemetryServerRequestObservationConvention(); + + private final MockHttpServletRequest request = new MockHttpServletRequest("GET", "/test/resource"); + + private final MockHttpServletResponse response = new MockHttpServletResponse(); + + private final ServerRequestObservationContext context = new ServerRequestObservationContext(this.request, this.response); + + + @Test + void shouldHaveName() { + assertThat(convention.getName()).isEqualTo("http.server.request.duration"); + } + + @Test + void shouldHaveContextualName() { + assertThat(convention.getContextualName(this.context)).isEqualTo("GET"); + } + + @Test + void contextualNameShouldUsePathPatternWhenAvailable() { + this.context.setPathPattern("/test/{name}"); + assertThat(convention.getContextualName(this.context)).isEqualTo("GET /test/{name}"); + } + + @Test + void supportsOnlyHttpRequestsObservationContext() { + assertThat(this.convention.supportsContext(this.context)).isTrue(); + assertThat(this.convention.supportsContext(new Observation.Context())).isFalse(); + } + + @Test + void addsKeyValuesForExchange() { + this.request.setMethod("POST"); + this.request.setRequestURI("/test/resource"); + + assertThat(this.convention.getLowCardinalityKeyValues(this.context)).hasSize(6) + .contains(KeyValue.of("http.request.method", "POST"), KeyValue.of("http.route", "UNKNOWN"), KeyValue.of("http.response.status_code", "200"), + KeyValue.of("error.type", "none"), KeyValue.of("outcome", "SUCCESS"), KeyValue.of("url.scheme", "http")); + assertThat(this.convention.getHighCardinalityKeyValues(this.context)).hasSize(2) + .contains(KeyValue.of("url.path", "/test/resource"), KeyValue.of("http.request.method_original", "POST")); + } + + @Test + void addsKeyValuesForExchangeWithPathPattern() { + this.request.setRequestURI("/test/resource"); + this.context.setPathPattern("/test/{name}"); + + assertThat(this.convention.getLowCardinalityKeyValues(this.context)).hasSize(6) + .contains(KeyValue.of("http.request.method", "GET"), KeyValue.of("http.route", "/test/{name}"), KeyValue.of("http.response.status_code", "200"), + KeyValue.of("error.type", "none"), KeyValue.of("outcome", "SUCCESS"), KeyValue.of("url.scheme", "http")); + assertThat(this.convention.getHighCardinalityKeyValues(this.context)).hasSize(2) + .contains(KeyValue.of("url.path", "/test/resource"), KeyValue.of("http.request.method_original", "GET")); + } + + @Test + void addsKeyValuesForErrorExchange() { + this.request.setRequestURI("/test/resource"); + this.context.setError(new IllegalArgumentException("custom error")); + this.response.setStatus(500); + + assertThat(this.convention.getLowCardinalityKeyValues(this.context)).hasSize(6) + .contains(KeyValue.of("http.request.method", "GET"), KeyValue.of("http.route", "UNKNOWN"), KeyValue.of("http.response.status_code", "500"), + KeyValue.of("error.type", "java.lang.IllegalArgumentException"), KeyValue.of("outcome", "SERVER_ERROR"), KeyValue.of("url.scheme", "http")); + assertThat(this.convention.getHighCardinalityKeyValues(this.context)).hasSize(2) + .contains(KeyValue.of("url.path", "/test/resource"), KeyValue.of("http.request.method_original", "GET")); + } + + @Test + void addsKeyValuesForRedirectExchange() { + this.request.setRequestURI("/test/redirect"); + this.response.setStatus(302); + this.response.addHeader("Location", "https://example.org/other"); + + assertThat(this.convention.getLowCardinalityKeyValues(this.context)).hasSize(6) + .contains(KeyValue.of("http.request.method", "GET"), KeyValue.of("http.route", "REDIRECTION"), KeyValue.of("http.response.status_code", "302"), + KeyValue.of("error.type", "none"), KeyValue.of("outcome", "REDIRECTION"), KeyValue.of("url.scheme", "http")); + assertThat(this.convention.getHighCardinalityKeyValues(this.context)).hasSize(2) + .contains(KeyValue.of("url.path", "/test/redirect"), KeyValue.of("http.request.method_original", "GET")); + } + + @Test + void addsKeyValuesForNotFoundExchange() { + this.request.setRequestURI("/test/notFound"); + this.response.setStatus(404); + + assertThat(this.convention.getLowCardinalityKeyValues(this.context)).hasSize(6) + .contains(KeyValue.of("http.request.method", "GET"), KeyValue.of("http.route", "NOT_FOUND"), KeyValue.of("http.response.status_code", "404"), + KeyValue.of("error.type", "none"), KeyValue.of("outcome", "CLIENT_ERROR"), KeyValue.of("url.scheme", "http")); + assertThat(this.convention.getHighCardinalityKeyValues(this.context)).hasSize(2) + .contains(KeyValue.of("url.path", "/test/notFound"), KeyValue.of("http.request.method_original", "GET")); + } + + @Test + void addsKeyValuesForUnknownHttpMethodExchange() { + this.request.setMethod("SPRING"); + this.request.setRequestURI("/test"); + this.response.setStatus(404); + + assertThat(this.convention.getContextualName(this.context)).isEqualTo("HTTP"); + assertThat(this.convention.getLowCardinalityKeyValues(this.context)).hasSize(6) + .contains(KeyValue.of("http.request.method", "_OTHER"), KeyValue.of("http.route", "NOT_FOUND"), KeyValue.of("http.response.status_code", "404"), + KeyValue.of("error.type", "none"), KeyValue.of("outcome", "CLIENT_ERROR"), KeyValue.of("url.scheme", "http")); + assertThat(this.convention.getHighCardinalityKeyValues(this.context)).hasSize(2) + .contains(KeyValue.of("url.path", "/test"), KeyValue.of("http.request.method_original", "SPRING")); + } + + @Test + void setsContextualNameWithPathPatternButInvalidMethod() { + this.request.setMethod("CUSTOM"); + this.context.setPathPattern("/test/{name}"); + + assertThat(this.convention.getContextualName(this.context)).isEqualTo("HTTP /test/{name}"); + } + + @Test + void addsKeyValuesForInvalidStatusExchange() { + this.request.setRequestURI("/test/invalidStatus"); + this.response.setStatus(0); + + assertThat(this.convention.getLowCardinalityKeyValues(this.context)).hasSize(6) + .contains(KeyValue.of("http.request.method", "GET"), KeyValue.of("http.route", "UNKNOWN"), KeyValue.of("http.response.status_code", "0"), + KeyValue.of("error.type", "none"), KeyValue.of("outcome", "UNKNOWN"), KeyValue.of("url.scheme", "http")); + assertThat(this.convention.getHighCardinalityKeyValues(this.context)).hasSize(2) + .contains(KeyValue.of("url.path", "/test/invalidStatus"), KeyValue.of("http.request.method_original", "GET")); + } + +} From 208bb48254929aa5d62f9a9245877de581a82e9b Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Wed, 20 Aug 2025 15:50:27 +0200 Subject: [PATCH 116/591] Document OpenTelemetry HTTP server convention Closes gh-35358 --- .../ROOT/pages/integration/observability.adoc | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/integration/observability.adoc b/framework-docs/modules/ROOT/pages/integration/observability.adoc index c9129ef1e544..8b3e163ef4d4 100644 --- a/framework-docs/modules/ROOT/pages/integration/observability.adoc +++ b/framework-docs/modules/ROOT/pages/integration/observability.adoc @@ -189,13 +189,13 @@ This observation uses the `io.micrometer.jakarta9.instrument.jms.DefaultJmsProce [[observability.http-server]] == HTTP Server instrumentation -HTTP server exchange observations are created with the name `"http.server.requests"` for Servlet and Reactive applications. +HTTP server exchange observations are created with the name `"http.server.requests"` for Servlet and Reactive applications, +or "http.server.request.duration" if using the OpenTelemetry convention. [[observability.http-server.servlet]] === Servlet applications Applications need to configure the `org.springframework.web.filter.ServerHttpObservationFilter` Servlet filter in their application. -It uses the `org.springframework.http.server.observation.DefaultServerRequestObservationConvention` by default, backed by the `ServerRequestObservationContext`. This will only record an observation as an error if the `Exception` has not been handled by the web framework and has bubbled up to the Servlet filter. Typically, all exceptions handled by Spring MVC's `@ExceptionHandler` and xref:web/webmvc/mvc-ann-rest-exceptions.adoc[`ProblemDetail` support] will not be recorded with the observation. @@ -207,6 +207,11 @@ NOTE: Because the instrumentation is done at the Servlet Filter level, the obser Typically, Servlet container error handling is performed at a lower level and won't have any active observation or span. For this use case, a container-specific implementation is required, such as a `org.apache.catalina.Valve` for Tomcat; this is outside the scope of this project. +[[observability.http-server.servlet.default]] +==== Default Semantic Convention + +It uses the `org.springframework.http.server.observation.DefaultServerRequestObservationConvention` by default, backed by the `ServerRequestObservationContext`. + By default, the following `KeyValues` are created: .Low cardinality Keys @@ -228,6 +233,16 @@ By default, the following `KeyValues` are created: |`http.url` _(required)_|HTTP request URI. |=== + +[[observability.http-server.servlet.otel]] +==== OpenTelemetry Semantic Convention + +An OpenTelemetry variant is available with `org.springframework.http.server.observation.OpenTelemetryServerRequestObservationConvention`, backed by the `ServerRequestObservationContext`. + +This variant complies with the https://github.com/open-telemetry/semantic-conventions/blob/v1.36.0/docs/http/http-metrics.md[OpenTelemetry Semantic Conventions for HTTP Metrics (v1.36.0)] +and the https://github.com/open-telemetry/semantic-conventions/blob/v1.36.0/docs/http/http-spans.md[OpenTelemetry Semantic Conventions for HTTP Spans (v1.36.0)]. + + [[observability.http-server.reactive]] === Reactive applications From 5d214c2624cbfec3e6326f8a3a8af270af54e785 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:30:44 +0200 Subject: [PATCH 117/591] Polishing --- .../testcontext-framework/application-events.adoc | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/application-events.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/application-events.adoc index 659cf33a4cf4..e36d7e7f995e 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/application-events.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/application-events.adoc @@ -2,10 +2,10 @@ = Application Events The TestContext framework provides support for recording -xref:core/beans/context-introduction.adoc#context-functionality-events[application events] published in the -`ApplicationContext` so that assertions can be performed against those events within -tests. All events published during the execution of a single test are made available via -the `ApplicationEvents` API which allows you to process the events as a +xref:core/beans/context-introduction.adoc#context-functionality-events[application events] +published in the `ApplicationContext` so that assertions can be performed against those +events within tests. All events published during the execution of a single test are made +available via the `ApplicationEvents` API which allows you to process the events as a `java.util.Stream`. To use `ApplicationEvents` in your tests, do the following. @@ -24,8 +24,8 @@ To use `ApplicationEvents` in your tests, do the following. to an `@Autowired` field in the test class. The following test class uses the `SpringExtension` for JUnit Jupiter and -{assertj-docs}[AssertJ] to assert the types of application events -published while invoking a method in a Spring-managed component: +{assertj-docs}[AssertJ] to assert the types of application events published while +invoking a method in a Spring-managed component: // Don't use "quotes" in the "subs" section because of the asterisks in /* ... */ [tabs] From c0b71f8999c8c171f26d3dbbba9668f3bef3ad3a Mon Sep 17 00:00:00 2001 From: khj68 Date: Sun, 17 Aug 2025 17:26:48 +0900 Subject: [PATCH 118/591] Improve Javadoc of ApplicationEvents to clarify preferred usage This commit reorders and clarifies the usage instructions for ApplicationEvents to: 1. Recommend method parameter injection as the primary approach, since ApplicationEvents has a per-method lifecycle 2. Clarify that ApplicationEvents is not a general Spring bean and cannot be constructor-injected 3. Explicitly state that field injection is an alternative approach This addresses confusion where developers expect ApplicationEvents to behave like a regular Spring bean eligible for constructor injection. See gh-35297 Closes gh-35335 Signed-off-by: khj68 --- .../test/context/event/ApplicationEvents.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/spring-test/src/main/java/org/springframework/test/context/event/ApplicationEvents.java b/spring-test/src/main/java/org/springframework/test/context/event/ApplicationEvents.java index 3653d4da957e..5bdc8b3c7973 100644 --- a/spring-test/src/main/java/org/springframework/test/context/event/ApplicationEvents.java +++ b/spring-test/src/main/java/org/springframework/test/context/event/ApplicationEvents.java @@ -33,12 +33,14 @@ * to be manually registered if you have custom configuration via * {@link org.springframework.test.context.TestExecutionListeners @TestExecutionListeners} * that does not include the default listeners. - *

  • Annotate a field of type {@code ApplicationEvents} with + *
  • With JUnit Jupiter, declare a parameter of type {@code ApplicationEvents} + * in a test or lifecycle method. Since {@code ApplicationEvents} is scoped to the + * lifecycle of the current test method, this is the recommended approach.
  • + *
  • Alternatively, you can annotate a field of type {@code ApplicationEvents} with * {@link org.springframework.beans.factory.annotation.Autowired @Autowired} and - * use that instance of {@code ApplicationEvents} in your test and lifecycle methods.
  • - *
  • With JUnit Jupiter, you may optionally declare a parameter of type - * {@code ApplicationEvents} in a test or lifecycle method as an alternative to - * an {@code @Autowired} field in the test class.
  • + * use that instance of {@code ApplicationEvents} in your test and lifecycle methods. + * Note that {@code ApplicationEvents} is not a general Spring bean and is specifically + * designed for use within test methods. * * * @author Sam Brannen From 19d5ec67811e2f74bf08b1c909a609ead4380acc Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:21:58 +0200 Subject: [PATCH 119/591] Improve documentation for ApplicationEvents to clarify recommended usage See gh-35335 --- .../application-events.adoc | 39 ++++++++----------- .../test/context/event/ApplicationEvents.java | 15 ++++--- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/application-events.adoc b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/application-events.adoc index e36d7e7f995e..54dc7c1262df 100644 --- a/framework-docs/modules/ROOT/pages/testing/testcontext-framework/application-events.adoc +++ b/framework-docs/modules/ROOT/pages/testing/testcontext-framework/application-events.adoc @@ -16,12 +16,19 @@ To use `ApplicationEvents` in your tests, do the following. that `ApplicationEventsTestExecutionListener` is registered by default and only needs to be manually registered if you have custom configuration via `@TestExecutionListeners` that does not include the default listeners. -* Annotate a field of type `ApplicationEvents` with `@Autowired` and use that instance of - `ApplicationEvents` in your test and lifecycle methods (such as `@BeforeEach` and - `@AfterEach` methods in JUnit Jupiter). -** When using the xref:testing/testcontext-framework/support-classes.adoc#testcontext-junit-jupiter-extension[SpringExtension for JUnit Jupiter], you may declare a method - parameter of type `ApplicationEvents` in a test or lifecycle method as an alternative - to an `@Autowired` field in the test class. +* When using the + xref:testing/testcontext-framework/support-classes.adoc#testcontext-junit-jupiter-extension[SpringExtension for JUnit Jupiter], + declare a method parameter of type `ApplicationEvents` in a `@Test`, `@BeforeEach`, or + `@AfterEach` method. +** Since `ApplicationEvents` is scoped to the lifecycle of the current test method, this + is the recommended approach. +* Alternatively, you can annotate a field of type `ApplicationEvents` with `@Autowired` + and use that instance of `ApplicationEvents` in your test and lifecycle methods. + +NOTE: `ApplicationEvents` is registered with the `ApplicationContext` as a _resolvable +dependency_ which is scoped to the lifecycle of the current test method. Consequently, +`ApplicationEvents` cannot be accessed outside the lifecycle of a test method and cannot be +`@Autowired` into the constructor of a test class. The following test class uses the `SpringExtension` for JUnit Jupiter and {assertj-docs}[AssertJ] to assert the types of application events published while @@ -38,16 +45,10 @@ Java:: @RecordApplicationEvents // <1> class OrderServiceTests { - @Autowired - OrderService orderService; - - @Autowired - ApplicationEvents events; // <2> - @Test - void submitOrder() { + void submitOrder(@Autowired OrderService service, ApplicationEvents events) { // <2> // Invoke method in OrderService that publishes an event - orderService.submitOrder(new Order(/* ... */)); + service.submitOrder(new Order(/* ... */)); // Verify that an OrderSubmitted event was published long numEvents = events.stream(OrderSubmitted.class).count(); // <3> assertThat(numEvents).isEqualTo(1); @@ -66,16 +67,10 @@ Kotlin:: @RecordApplicationEvents // <1> class OrderServiceTests { - @Autowired - lateinit var orderService: OrderService - - @Autowired - lateinit var events: ApplicationEvents // <2> - @Test - fun submitOrder() { + fun submitOrder(@Autowired service: OrderService, events: ApplicationEvents) { // <2> // Invoke method in OrderService that publishes an event - orderService.submitOrder(Order(/* ... */)) + service.submitOrder(Order(/* ... */)) // Verify that an OrderSubmitted event was published val numEvents = events.stream(OrderSubmitted::class).count() // <3> assertThat(numEvents).isEqualTo(1) diff --git a/spring-test/src/main/java/org/springframework/test/context/event/ApplicationEvents.java b/spring-test/src/main/java/org/springframework/test/context/event/ApplicationEvents.java index 5bdc8b3c7973..98d5f195b473 100644 --- a/spring-test/src/main/java/org/springframework/test/context/event/ApplicationEvents.java +++ b/spring-test/src/main/java/org/springframework/test/context/event/ApplicationEvents.java @@ -34,15 +34,20 @@ * {@link org.springframework.test.context.TestExecutionListeners @TestExecutionListeners} * that does not include the default listeners. *
  • With JUnit Jupiter, declare a parameter of type {@code ApplicationEvents} - * in a test or lifecycle method. Since {@code ApplicationEvents} is scoped to the - * lifecycle of the current test method, this is the recommended approach.
  • + * in a {@code @Test}, {@code @BeforeEach}, or {@code @AfterEach} method. Since + * {@code ApplicationEvents} is scoped to the lifecycle of the current test method, + * this is the recommended approach. *
  • Alternatively, you can annotate a field of type {@code ApplicationEvents} with * {@link org.springframework.beans.factory.annotation.Autowired @Autowired} and - * use that instance of {@code ApplicationEvents} in your test and lifecycle methods. - * Note that {@code ApplicationEvents} is not a general Spring bean and is specifically - * designed for use within test methods.
  • + * use that instance of {@code ApplicationEvents} in your test and lifecycle methods. * * + *

    NOTE: {@code ApplicationEvents} is registered with the {@code ApplicationContext} as a + * {@linkplain org.springframework.beans.factory.config.ConfigurableListableBeanFactory#registerResolvableDependency + * resolvable dependency} which is scoped to the lifecycle of the current test method. + * Consequently, {@code ApplicationEvents} cannot be accessed outside the lifecycle of a + * test method and cannot be {@code @Autowired} into the constructor of a test class. + * * @author Sam Brannen * @author Oliver Drotbohm * @since 5.3.3 From 2489cced0fba3f84d938eaf182dfe305cfea2e90 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Wed, 20 Aug 2025 23:15:40 +0200 Subject: [PATCH 120/591] Expose RetryException#getRetryCount() and accept maxAttempts(0) Closes gh-35351 Closes gh-35362 --- .../ReactiveRetryInterceptorTests.java | 20 +++++++ .../resilience/RetryInterceptorTests.java | 25 ++++++++ .../core/retry/DefaultRetryPolicy.java | 5 +- .../core/retry/RetryException.java | 11 +++- .../core/retry/RetryPolicy.java | 39 ++++++------ .../retry/MaxAttemptsRetryPolicyTests.java | 13 ++++ .../core/retry/RetryPolicyTests.java | 10 +--- .../core/retry/RetryTemplateTests.java | 60 +++++++++++-------- 8 files changed, 128 insertions(+), 55 deletions(-) diff --git a/spring-context/src/test/java/org/springframework/resilience/ReactiveRetryInterceptorTests.java b/spring-context/src/test/java/org/springframework/resilience/ReactiveRetryInterceptorTests.java index ee7e8f179306..ff81f8ca107e 100644 --- a/spring-context/src/test/java/org/springframework/resilience/ReactiveRetryInterceptorTests.java +++ b/spring-context/src/test/java/org/springframework/resilience/ReactiveRetryInterceptorTests.java @@ -189,6 +189,26 @@ void adaptReactiveResultWithMinimalRetrySpec() { assertThat(target.counter.get()).isEqualTo(2); } + @Test + void adaptReactiveResultWithZeroAttempts() { + // Test minimal retry configuration: maxAttempts=1, delay=0, jitter=0, multiplier=1.0, maxDelay=0 + MinimalRetryBean target = new MinimalRetryBean(); + ProxyFactory pf = new ProxyFactory(); + pf.setTarget(target); + pf.addAdvice(new SimpleRetryInterceptor( + new MethodRetrySpec((m, t) -> true, 0, Duration.ZERO, Duration.ZERO, 1.0, Duration.ZERO))); + MinimalRetryBean proxy = (MinimalRetryBean) pf.getProxy(); + + // Should execute only 1 time, because maxAttempts=0 means initial call only + assertThatIllegalStateException() + .isThrownBy(() -> proxy.retryOperation().block()) + .satisfies(isRetryExhaustedException()) + .havingCause() + .isInstanceOf(IOException.class) + .withMessage("1"); + assertThat(target.counter.get()).isEqualTo(1); + } + @Test void adaptReactiveResultWithZeroDelayAndJitter() { // Test case where delay=0 and jitter>0 diff --git a/spring-context/src/test/java/org/springframework/resilience/RetryInterceptorTests.java b/spring-context/src/test/java/org/springframework/resilience/RetryInterceptorTests.java index ab0a4feea7ee..f981bfae4ad6 100644 --- a/spring-context/src/test/java/org/springframework/resilience/RetryInterceptorTests.java +++ b/spring-context/src/test/java/org/springframework/resilience/RetryInterceptorTests.java @@ -194,6 +194,31 @@ void withPostProcessorForClassWithStrings() { assertThat(target.counter).isEqualTo(6); } + @Test + void withPostProcessorForClassWithZeroAttempts() { + Properties props = new Properties(); + props.setProperty("delay", "10"); + props.setProperty("jitter", "5"); + props.setProperty("multiplier", "2.0"); + props.setProperty("maxDelay", "40"); + props.setProperty("limitedAttempts", "0"); + + GenericApplicationContext ctx = new GenericApplicationContext(); + ctx.getEnvironment().getPropertySources().addFirst(new PropertiesPropertySource("props", props)); + ctx.registerBeanDefinition("bean", new RootBeanDefinition(AnnotatedClassBeanWithStrings.class)); + ctx.registerBeanDefinition("bpp", new RootBeanDefinition(RetryAnnotationBeanPostProcessor.class)); + ctx.refresh(); + AnnotatedClassBeanWithStrings proxy = ctx.getBean(AnnotatedClassBeanWithStrings.class); + AnnotatedClassBeanWithStrings target = (AnnotatedClassBeanWithStrings) AopProxyUtils.getSingletonTarget(proxy); + + assertThatIOException().isThrownBy(proxy::retryOperation).withMessage("3"); + assertThat(target.counter).isEqualTo(3); + assertThatIOException().isThrownBy(proxy::otherOperation); + assertThat(target.counter).isEqualTo(4); + assertThatIOException().isThrownBy(proxy::overrideOperation); + assertThat(target.counter).isEqualTo(5); + } + @Test void withEnableAnnotation() throws Exception { AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(); diff --git a/spring-core/src/main/java/org/springframework/core/retry/DefaultRetryPolicy.java b/spring-core/src/main/java/org/springframework/core/retry/DefaultRetryPolicy.java index c9f073a96e07..796718b34e76 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/DefaultRetryPolicy.java +++ b/spring-core/src/main/java/org/springframework/core/retry/DefaultRetryPolicy.java @@ -45,7 +45,6 @@ class DefaultRetryPolicy implements RetryPolicy { private final BackOff backOff; - DefaultRetryPolicy(Set> includes, Set> excludes, @Nullable Predicate predicate, BackOff backOff) { @@ -59,8 +58,8 @@ class DefaultRetryPolicy implements RetryPolicy { @Override public boolean shouldRetry(Throwable throwable) { - return this.exceptionFilter.match(throwable) && - (this.predicate == null || this.predicate.test(throwable)); + return (this.exceptionFilter.match(throwable) && + (this.predicate == null || this.predicate.test(throwable))); } @Override diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryException.java b/spring-core/src/main/java/org/springframework/core/retry/RetryException.java index 694cd04465b2..eef16f68ef13 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryException.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryException.java @@ -28,6 +28,7 @@ * exceptions}. * * @author Mahmoud Ben Hassine + * @author Juergen Hoeller * @since 7.0 * @see RetryOperations */ @@ -51,8 +52,16 @@ public RetryException(String message, Throwable cause) { * Get the last exception thrown by the {@link Retryable} operation. */ @Override - public final synchronized Throwable getCause() { + public final Throwable getCause() { return Objects.requireNonNull(super.getCause()); } + /** + * Return the number of retry attempts, or 0 if no retry has been attempted + * after the initial invocation at all. + */ + public int getRetryCount() { + return getSuppressed().length; + } + } diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryPolicy.java b/spring-core/src/main/java/org/springframework/core/retry/RetryPolicy.java index 16b6ee2473e8..3e6bd5cf27f5 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryPolicy.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryPolicy.java @@ -82,12 +82,13 @@ static RetryPolicy withDefaults() { * Create a {@link RetryPolicy} configured with a maximum number of retry attempts. *

    The returned policy uses a fixed backoff of {@value Builder#DEFAULT_DELAY} * milliseconds. - * @param maxAttempts the maximum number of retry attempts; must be greater than zero + * @param maxAttempts the maximum number of retry attempts; + * must be positive (or zero for no retry) * @see Builder#maxAttempts(long) * @see FixedBackOff */ static RetryPolicy withMaxAttempts(long maxAttempts) { - assertMaxAttemptsIsPositive(maxAttempts); + assertMaxAttemptsIsNotNegative(maxAttempts); return builder().backOff(new FixedBackOff(Builder.DEFAULT_DELAY, maxAttempts)).build(); } @@ -100,14 +101,9 @@ static Builder builder() { } - private static void assertMaxAttemptsIsPositive(long maxAttempts) { - Assert.isTrue(maxAttempts > 0, - () -> "Invalid maxAttempts (%d): must be greater than zero.".formatted(maxAttempts)); - } - - private static void assertIsPositive(String name, Duration duration) { - Assert.isTrue((!duration.isNegative() && !duration.isZero()), - () -> "Invalid %s (%dms): must be greater than zero.".formatted(name, duration.toMillis())); + private static void assertMaxAttemptsIsNotNegative(long maxAttempts) { + Assert.isTrue(maxAttempts >= 0, + () -> "Invalid maxAttempts (%d): must be positive or zero for no retry.".formatted(maxAttempts)); } private static void assertIsNotNegative(String name, Duration duration) { @@ -115,6 +111,11 @@ private static void assertIsNotNegative(String name, Duration duration) { () -> "Invalid %s (%dms): must be greater than or equal to zero.".formatted(name, duration.toMillis())); } + private static void assertIsPositive(String name, Duration duration) { + Assert.isTrue((!duration.isNegative() && !duration.isZero()), + () -> "Invalid %s (%dms): must be greater than zero.".formatted(name, duration.toMillis())); + } + /** * Fluent API for configuring a {@link RetryPolicy} with common configuration @@ -146,13 +147,13 @@ final class Builder { private @Nullable BackOff backOff; - private long maxAttempts; + private @Nullable Long maxAttempts; private @Nullable Duration delay; private @Nullable Duration jitter; - private double multiplier; + private @Nullable Double multiplier; private @Nullable Duration maxDelay; @@ -191,12 +192,12 @@ public Builder backOff(BackOff backOff) { *

    The supplied value will override any previously configured value. *

    You should not specify this configuration option if you have * configured a custom {@link #backOff(BackOff) BackOff} strategy. - * @param maxAttempts the maximum number of retry attempts; must be - * greater than zero + * @param maxAttempts the maximum number of retry attempts; + * must be positive (or zero for no retry) * @return this {@code Builder} instance for chained method invocations */ public Builder maxAttempts(long maxAttempts) { - assertMaxAttemptsIsPositive(maxAttempts); + assertMaxAttemptsIsNotNegative(maxAttempts); this.maxAttempts = maxAttempts; return this; } @@ -399,18 +400,18 @@ public Builder predicate(Predicate predicate) { public RetryPolicy build() { BackOff backOff = this.backOff; if (backOff != null) { - boolean misconfigured = (this.maxAttempts != 0) || (this.delay != null) || (this.jitter != null) || - (this.multiplier != 0) || (this.maxDelay != null); + boolean misconfigured = (this.maxAttempts != null || this.delay != null || this.jitter != null || + this.multiplier != null || this.maxDelay != null); Assert.state(!misconfigured, """ The following configuration options are not supported with a custom BackOff strategy: \ maxAttempts, delay, jitter, multiplier, or maxDelay."""); } else { ExponentialBackOff exponentialBackOff = new ExponentialBackOff(); - exponentialBackOff.setMaxAttempts(this.maxAttempts > 0 ? this.maxAttempts : DEFAULT_MAX_ATTEMPTS); + exponentialBackOff.setMaxAttempts(this.maxAttempts != null ? this.maxAttempts : DEFAULT_MAX_ATTEMPTS); exponentialBackOff.setInitialInterval(this.delay != null ? this.delay.toMillis() : DEFAULT_DELAY); exponentialBackOff.setMaxInterval(this.maxDelay != null ? this.maxDelay.toMillis() : DEFAULT_MAX_DELAY); - exponentialBackOff.setMultiplier(this.multiplier > 1 ? this.multiplier : DEFAULT_MULTIPLIER); + exponentialBackOff.setMultiplier(this.multiplier != null ? this.multiplier : DEFAULT_MULTIPLIER); if (this.jitter != null) { exponentialBackOff.setJitter(this.jitter.toMillis()); } diff --git a/spring-core/src/test/java/org/springframework/core/retry/MaxAttemptsRetryPolicyTests.java b/spring-core/src/test/java/org/springframework/core/retry/MaxAttemptsRetryPolicyTests.java index d7e559e9592f..202e38494929 100644 --- a/spring-core/src/test/java/org/springframework/core/retry/MaxAttemptsRetryPolicyTests.java +++ b/spring-core/src/test/java/org/springframework/core/retry/MaxAttemptsRetryPolicyTests.java @@ -54,6 +54,18 @@ void maxAttempts() { assertThat(backOffExecution.nextBackOff()).isEqualTo(STOP); } + @Test + void maxAttemptsZero() { + var retryPolicy = RetryPolicy.builder().maxAttempts(0).delay(Duration.ZERO).build(); + var backOffExecution = retryPolicy.getBackOff().start(); + var throwable = mock(Throwable.class); + + assertThat(retryPolicy.shouldRetry(throwable)).isTrue(); + assertThat(backOffExecution.nextBackOff()).isEqualTo(STOP); + assertThat(retryPolicy.shouldRetry(throwable)).isTrue(); + assertThat(backOffExecution.nextBackOff()).isEqualTo(STOP); + } + @Test void maxAttemptsAndPredicate() { var retryPolicy = RetryPolicy.builder() @@ -115,6 +127,7 @@ void maxAttemptsWithIncludesAndExcludes() { private static class CustomNumberFormatException extends NumberFormatException { } + @SuppressWarnings("serial") private static class CustomFileSystemException extends FileSystemException { diff --git a/spring-core/src/test/java/org/springframework/core/retry/RetryPolicyTests.java b/spring-core/src/test/java/org/springframework/core/retry/RetryPolicyTests.java index 8f86350fc087..fac425dbf335 100644 --- a/spring-core/src/test/java/org/springframework/core/retry/RetryPolicyTests.java +++ b/spring-core/src/test/java/org/springframework/core/retry/RetryPolicyTests.java @@ -65,12 +65,9 @@ void withDefaults() { @Test void withMaxAttemptsPreconditions() { - assertThatIllegalArgumentException() - .isThrownBy(() -> RetryPolicy.withMaxAttempts(0)) - .withMessage("Invalid maxAttempts (0): must be greater than zero."); assertThatIllegalArgumentException() .isThrownBy(() -> RetryPolicy.withMaxAttempts(-1)) - .withMessage("Invalid maxAttempts (-1): must be greater than zero."); + .withMessageStartingWith("Invalid maxAttempts (-1)"); } @Test @@ -115,12 +112,9 @@ void backOff() { @Test void maxAttemptsPreconditions() { - assertThatIllegalArgumentException() - .isThrownBy(() -> RetryPolicy.builder().maxAttempts(0)) - .withMessage("Invalid maxAttempts (0): must be greater than zero."); assertThatIllegalArgumentException() .isThrownBy(() -> RetryPolicy.builder().maxAttempts(-1)) - .withMessage("Invalid maxAttempts (-1): must be greater than zero."); + .withMessageStartingWith("Invalid maxAttempts (-1)"); } @Test diff --git a/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java b/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java index c3259e108717..4e3ec1c11e18 100644 --- a/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java +++ b/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java @@ -32,9 +32,6 @@ import org.junit.jupiter.params.provider.FieldSource; import org.mockito.InOrder; -import org.springframework.util.backoff.BackOff; -import org.springframework.util.backoff.FixedBackOff; - import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.junit.jupiter.params.provider.Arguments.argumentSet; @@ -51,16 +48,13 @@ * * @author Mahmoud Ben Hassine * @author Sam Brannen + * @author Juergen Hoeller * @since 7.0 * @see RetryPolicyTests */ class RetryTemplateTests { - private final RetryPolicy retryPolicy = - RetryPolicy.builder() - .maxAttempts(3) - .delay(Duration.ZERO) - .build(); + private final RetryPolicy retryPolicy = RetryPolicy.builder().maxAttempts(3).delay(Duration.ZERO).build(); private final RetryTemplate retryTemplate = new RetryTemplate(retryPolicy); @@ -104,7 +98,8 @@ void retryWithInitialFailureAndZeroRetriesRetryPolicy() { .isThrownBy(() -> retryTemplate.execute(retryable)) .withMessageMatching("Retry policy for operation '.+?' exhausted; aborting execution") .withCause(exception) - .satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty()); + .satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty()) + .satisfies(throwable -> assertThat(throwable.getRetryCount()).isZero()); // RetryListener interactions: inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, exception); @@ -112,20 +107,32 @@ void retryWithInitialFailureAndZeroRetriesRetryPolicy() { } @Test - void retryWithInitialFailureAndZeroRetriesBackOffPolicy() { - RetryPolicy retryPolicy = new RetryPolicy() { + void retryWithInitialFailureAndZeroRetriesFixedBackOffPolicy() { + RetryPolicy retryPolicy = RetryPolicy.withMaxAttempts(0); - @Override - public boolean shouldRetry(Throwable throwable) { - return true; - } - - @Override - public BackOff getBackOff() { - return new FixedBackOff(10, 0); // Zero retries - } + RetryTemplate retryTemplate = new RetryTemplate(retryPolicy); + retryTemplate.setRetryListener(retryListener); + Exception exception = new RuntimeException("Boom!"); + Retryable retryable = () -> { + throw exception; }; + assertThatExceptionOfType(RetryException.class) + .isThrownBy(() -> retryTemplate.execute(retryable)) + .withMessageMatching("Retry policy for operation '.+?' exhausted; aborting execution") + .withCause(exception) + .satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty()) + .satisfies(throwable -> assertThat(throwable.getRetryCount()).isZero()); + + // RetryListener interactions: + inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, exception); + verifyNoMoreInteractions(retryListener); + } + + @Test + void retryWithInitialFailureAndZeroRetriesBackOffPolicyFromBuilder() { + RetryPolicy retryPolicy = RetryPolicy.builder().maxAttempts(0).build(); + RetryTemplate retryTemplate = new RetryTemplate(retryPolicy); retryTemplate.setRetryListener(retryListener); Exception exception = new RuntimeException("Boom!"); @@ -137,7 +144,8 @@ public BackOff getBackOff() { .isThrownBy(() -> retryTemplate.execute(retryable)) .withMessageMatching("Retry policy for operation '.+?' exhausted; aborting execution") .withCause(exception) - .satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty()); + .satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty()) + .satisfies(throwable -> assertThat(throwable.getRetryCount()).isZero()); // RetryListener interactions: inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, exception); @@ -282,7 +290,8 @@ public String getName() { .satisfies(hasSuppressedExceptionsSatisfyingExactly( suppressed1 -> assertThat(suppressed1).isExactlyInstanceOf(FileNotFoundException.class), suppressed2 -> assertThat(suppressed2).isExactlyInstanceOf(IOException.class) - )); + )) + .satisfies(throwable -> assertThat(throwable.getRetryCount()).isEqualTo(2)); // 3 = 1 initial invocation + 2 retry attempts assertThat(invocationCount).hasValue(3); @@ -344,7 +353,8 @@ public String getName() { .satisfies(hasSuppressedExceptionsSatisfyingExactly( suppressed1 -> assertThat(suppressed1).isExactlyInstanceOf(IOException.class), suppressed2 -> assertThat(suppressed2).isExactlyInstanceOf(IOException.class) - )); + )) + .satisfies(throwable -> assertThat(throwable.getRetryCount()).isEqualTo(2)); // 3 = 1 initial invocation + 2 retry attempts assertThat(invocationCount).hasValue(3); @@ -366,8 +376,9 @@ private static void repeat(int times, Runnable runnable) { } @SafeVarargs - private static final Consumer hasSuppressedExceptionsSatisfyingExactly( + private static Consumer hasSuppressedExceptionsSatisfyingExactly( ThrowingConsumer... requirements) { + return throwable -> assertThat(throwable.getSuppressed()).satisfiesExactly(requirements); } @@ -376,6 +387,7 @@ private static final Consumer hasSuppressedExceptionsSatisfyingExactl private static class CustomFileNotFoundException extends FileNotFoundException { } + /** * Custom {@link RuntimeException} that implements {@link #equals(Object)} * and {@link #hashCode()} for use in assertions that check for equality. From f64ff2866a672475b309cb484c28edb21329b3e1 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Thu, 21 Aug 2025 11:54:27 +0200 Subject: [PATCH 121/591] Expose RetryException to onRetryPolicyExhaustion (also in the signature) Includes getRetryPolicy and getRetryListener accessors in RetryTemplate. Closes gh-35334 --- .../core/retry/RetryListener.java | 9 +- .../core/retry/RetryTemplate.java | 18 +++- .../retry/support/CompositeRetryListener.java | 6 +- .../core/retry/RetryTemplateTests.java | 87 ++++++++++--------- .../support/CompositeRetryListenerTests.java | 3 +- 5 files changed, 76 insertions(+), 47 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryListener.java b/spring-core/src/main/java/org/springframework/core/retry/RetryListener.java index 7b0946122c30..2dd1c1b38a47 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryListener.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryListener.java @@ -29,6 +29,7 @@ * * @author Mahmoud Ben Hassine * @author Sam Brannen + * @author Juergen Hoeller * @since 7.0 * @see CompositeRetryListener */ @@ -64,9 +65,13 @@ default void onRetryFailure(RetryPolicy retryPolicy, Retryable retryable, Thr * Called if the {@link RetryPolicy} is exhausted. * @param retryPolicy the {@code RetryPolicy} * @param retryable the {@code Retryable} operation - * @param throwable the last exception thrown by the {@link Retryable} operation + * @param exception the resulting {@link RetryException}, including the last operation + * exception as a cause and all earlier operation exceptions as suppressed exceptions + * @see RetryException#getCause() + * @see RetryException#getSuppressed() + * @see RetryException#getRetryCount() */ - default void onRetryPolicyExhaustion(RetryPolicy retryPolicy, Retryable retryable, Throwable throwable) { + default void onRetryPolicyExhaustion(RetryPolicy retryPolicy, Retryable retryable, RetryException exception) { } } diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java b/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java index 352278df0c82..faa6b242cd50 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java @@ -90,6 +90,14 @@ public void setRetryPolicy(RetryPolicy retryPolicy) { this.retryPolicy = retryPolicy; } + /** + * Return the current {@link RetryPolicy} that is in use + * with this template. + */ + public RetryPolicy getRetryPolicy() { + return this.retryPolicy; + } + /** * Set the {@link RetryListener} to use. *

    If multiple listeners are needed, use a @@ -102,6 +110,14 @@ public void setRetryListener(RetryListener retryListener) { this.retryListener = retryListener; } + /** + * Return the current {@link RetryListener} that is in use + * with this template. + */ + public RetryListener getRetryListener() { + return this.retryListener; + } + /** * Execute the supplied {@link Retryable} operation according to the configured @@ -176,7 +192,7 @@ public void setRetryListener(RetryListener retryListener) { "Retry policy for operation '%s' exhausted; aborting execution".formatted(retryableName), exceptions.removeLast()); exceptions.forEach(retryException::addSuppressed); - this.retryListener.onRetryPolicyExhaustion(this.retryPolicy, retryable, lastException); + this.retryListener.onRetryPolicyExhaustion(this.retryPolicy, retryable, retryException); throw retryException; } } diff --git a/spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java b/spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java index f71d94d5f8f6..219ab7b605ce 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java +++ b/spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java @@ -21,6 +21,7 @@ import org.jspecify.annotations.Nullable; +import org.springframework.core.retry.RetryException; import org.springframework.core.retry.RetryListener; import org.springframework.core.retry.RetryPolicy; import org.springframework.core.retry.RetryTemplate; @@ -34,6 +35,7 @@ *

    This class is used to compose multiple listeners within a {@link RetryTemplate}. * * @author Mahmoud Ben Hassine + * @author Juergen Hoeller * @since 7.0 */ public class CompositeRetryListener implements RetryListener { @@ -82,8 +84,8 @@ public void onRetryFailure(RetryPolicy retryPolicy, Retryable retryable, Thro } @Override - public void onRetryPolicyExhaustion(RetryPolicy retryPolicy, Retryable retryable, Throwable throwable) { - this.listeners.forEach(listener -> listener.onRetryPolicyExhaustion(retryPolicy, retryable, throwable)); + public void onRetryPolicyExhaustion(RetryPolicy retryPolicy, Retryable retryable, RetryException exception) { + this.listeners.forEach(listener -> listener.onRetryPolicyExhaustion(retryPolicy, retryable, exception)); } } diff --git a/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java b/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java index 4e3ec1c11e18..d2e32f6c441a 100644 --- a/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java +++ b/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java @@ -68,6 +68,12 @@ void configureRetryTemplate() { retryTemplate.setRetryListener(retryListener); } + @Test + void checkRetryTemplateConfiguration() { + assertThat(retryTemplate.getRetryPolicy()).isSameAs(retryPolicy); + assertThat(retryTemplate.getRetryListener()).isSameAs(retryListener); + } + @Test void retryWithImmediateSuccess() throws Exception { AtomicInteger invocationCount = new AtomicInteger(); @@ -99,10 +105,9 @@ void retryWithInitialFailureAndZeroRetriesRetryPolicy() { .withMessageMatching("Retry policy for operation '.+?' exhausted; aborting execution") .withCause(exception) .satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty()) - .satisfies(throwable -> assertThat(throwable.getRetryCount()).isZero()); + .satisfies(throwable -> assertThat(throwable.getRetryCount()).isZero()) + .satisfies(throwable -> inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, throwable)); - // RetryListener interactions: - inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, exception); verifyNoMoreInteractions(retryListener); } @@ -122,10 +127,9 @@ void retryWithInitialFailureAndZeroRetriesFixedBackOffPolicy() { .withMessageMatching("Retry policy for operation '.+?' exhausted; aborting execution") .withCause(exception) .satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty()) - .satisfies(throwable -> assertThat(throwable.getRetryCount()).isZero()); + .satisfies(throwable -> assertThat(throwable.getRetryCount()).isZero()) + .satisfies(throwable -> inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, throwable)); - // RetryListener interactions: - inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, exception); verifyNoMoreInteractions(retryListener); } @@ -145,10 +149,9 @@ void retryWithInitialFailureAndZeroRetriesBackOffPolicyFromBuilder() { .withMessageMatching("Retry policy for operation '.+?' exhausted; aborting execution") .withCause(exception) .satisfies(throwable -> assertThat(throwable.getSuppressed()).isEmpty()) - .satisfies(throwable -> assertThat(throwable.getRetryCount()).isZero()); + .satisfies(throwable -> assertThat(throwable.getRetryCount()).isZero()) + .satisfies(throwable -> inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, throwable)); - // RetryListener interactions: - inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, exception); verifyNoMoreInteractions(retryListener); } @@ -194,18 +197,19 @@ public String getName() { assertThatExceptionOfType(RetryException.class) .isThrownBy(() -> retryTemplate.execute(retryable)) .withMessage("Retry policy for operation 'test' exhausted; aborting execution") - .withCause(new CustomException("Boom 4")); + .withCause(new CustomException("Boom 4")) + .satisfies(throwable -> { + invocationCount.set(1); + repeat(3, () -> { + inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); + inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable, + new CustomException("Boom " + invocationCount.incrementAndGet())); + }); + inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, throwable); + }); // 4 = 1 initial invocation + 3 retry attempts assertThat(invocationCount).hasValue(4); - // RetryListener interactions: - invocationCount.set(1); - repeat(3, () -> { - inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); - inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable, - new CustomException("Boom " + invocationCount.incrementAndGet())); - }); - inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, new CustomException("Boom 4")); verifyNoMoreInteractions(retryListener); } @@ -240,16 +244,17 @@ public String getName() { assertThatExceptionOfType(RetryException.class) .isThrownBy(() -> retryTemplate.execute(retryable)) .withMessage("Retry policy for operation 'always fails' exhausted; aborting execution") - .withCause(exception); + .withCause(exception) + .satisfies(throwable -> { + repeat(5, () -> { + inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); + inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable, exception); + }); + inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, throwable); + }); // 6 = 1 initial invocation + 5 retry attempts assertThat(invocationCount).hasValue(6); - // RetryListener interactions: - repeat(5, () -> { - inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); - inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable, exception); - }); - inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, exception); verifyNoMoreInteractions(retryListener); } @@ -291,17 +296,17 @@ public String getName() { suppressed1 -> assertThat(suppressed1).isExactlyInstanceOf(FileNotFoundException.class), suppressed2 -> assertThat(suppressed2).isExactlyInstanceOf(IOException.class) )) - .satisfies(throwable -> assertThat(throwable.getRetryCount()).isEqualTo(2)); + .satisfies(throwable -> assertThat(throwable.getRetryCount()).isEqualTo(2)) + .satisfies(throwable -> { + repeat(2, () -> { + inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); + inOrder.verify(retryListener).onRetryFailure(eq(retryPolicy), eq(retryable), any(Exception.class)); + }); + inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, throwable); + }); // 3 = 1 initial invocation + 2 retry attempts assertThat(invocationCount).hasValue(3); - // RetryListener interactions: - repeat(2, () -> { - inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); - inOrder.verify(retryListener).onRetryFailure(eq(retryPolicy), eq(retryable), any(Exception.class)); - }); - inOrder.verify(retryListener).onRetryPolicyExhaustion( - eq(retryPolicy), eq(retryable), any(IllegalStateException.class)); verifyNoMoreInteractions(retryListener); } @@ -354,17 +359,17 @@ public String getName() { suppressed1 -> assertThat(suppressed1).isExactlyInstanceOf(IOException.class), suppressed2 -> assertThat(suppressed2).isExactlyInstanceOf(IOException.class) )) - .satisfies(throwable -> assertThat(throwable.getRetryCount()).isEqualTo(2)); + .satisfies(throwable -> assertThat(throwable.getRetryCount()).isEqualTo(2)) + .satisfies(throwable -> { + repeat(2, () -> { + inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); + inOrder.verify(retryListener).onRetryFailure(eq(retryPolicy), eq(retryable), any(IOException.class)); + }); + inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, throwable); + }); // 3 = 1 initial invocation + 2 retry attempts assertThat(invocationCount).hasValue(3); - // RetryListener interactions: - repeat(2, () -> { - inOrder.verify(retryListener).beforeRetry(retryPolicy, retryable); - inOrder.verify(retryListener).onRetryFailure(eq(retryPolicy), eq(retryable), any(IOException.class)); - }); - inOrder.verify(retryListener).onRetryPolicyExhaustion( - eq(retryPolicy), eq(retryable), any(CustomFileNotFoundException.class)); verifyNoMoreInteractions(retryListener); } diff --git a/spring-core/src/test/java/org/springframework/core/retry/support/CompositeRetryListenerTests.java b/spring-core/src/test/java/org/springframework/core/retry/support/CompositeRetryListenerTests.java index 8fac26872f9e..10bb628f2570 100644 --- a/spring-core/src/test/java/org/springframework/core/retry/support/CompositeRetryListenerTests.java +++ b/spring-core/src/test/java/org/springframework/core/retry/support/CompositeRetryListenerTests.java @@ -21,6 +21,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.core.retry.RetryException; import org.springframework.core.retry.RetryListener; import org.springframework.core.retry.RetryPolicy; import org.springframework.core.retry.Retryable; @@ -83,7 +84,7 @@ void onRetryFailure() { @Test void onRetryPolicyExhaustion() { - Exception exception = new Exception(); + RetryException exception = new RetryException("", new Exception()); compositeRetryListener.onRetryPolicyExhaustion(retryPolicy, retryable, exception); verify(listener1).onRetryPolicyExhaustion(retryPolicy, retryable, exception); From 57fa52262e7ada6833f7640d91fef1fe673cdff3 Mon Sep 17 00:00:00 2001 From: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com> Date: Thu, 21 Aug 2025 18:09:34 +0800 Subject: [PATCH 122/591] =?UTF-8?q?Fix=20@=E2=81=A0HttpServiceClient=20exa?= =?UTF-8?q?mple=20in=20reference=20manual?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes gh-35363 Signed-off-by: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com> --- .../modules/ROOT/pages/integration/rest-clients.adoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc index d81436315a1a..21a9552395d7 100644 --- a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc +++ b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc @@ -1201,12 +1201,12 @@ annotate HTTP interfaces as follows: [source,java,indent=0,subs="verbatim,quotes"] ---- @HttpServiceClient("echo") - public class EchoServiceA { + public interface EchoServiceA { // ... } @HttpServiceClient("echo") - public class EchoServiceB { + public interface EchoServiceB { // ... } ---- From 8f4107953d7ff09f20fb4285c22b33f2ac6e37e0 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Thu, 21 Aug 2025 17:38:07 +0200 Subject: [PATCH 123/591] Perform retryable proceed() call on invocableClone() Closes gh-35353 --- .../retry/AbstractRetryInterceptor.java | 4 ++- .../resilience/RetryInterceptorTests.java | 36 +++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/spring-context/src/main/java/org/springframework/resilience/retry/AbstractRetryInterceptor.java b/spring-context/src/main/java/org/springframework/resilience/retry/AbstractRetryInterceptor.java index 762c5612f425..a74385a87ccb 100644 --- a/spring-context/src/main/java/org/springframework/resilience/retry/AbstractRetryInterceptor.java +++ b/spring-context/src/main/java/org/springframework/resilience/retry/AbstractRetryInterceptor.java @@ -26,6 +26,7 @@ import reactor.core.publisher.Mono; import reactor.util.retry.Retry; +import org.springframework.aop.ProxyMethodInvocation; import org.springframework.core.ReactiveAdapter; import org.springframework.core.ReactiveAdapterRegistry; import org.springframework.core.retry.RetryException; @@ -103,7 +104,8 @@ public AbstractRetryInterceptor() { return retryTemplate.execute(new Retryable<>() { @Override public @Nullable Object execute() throws Throwable { - return invocation.proceed(); + return (invocation instanceof ProxyMethodInvocation pmi ? + pmi.invocableClone().proceed() : invocation.proceed()); } @Override public String getName() { diff --git a/spring-context/src/test/java/org/springframework/resilience/RetryInterceptorTests.java b/spring-context/src/test/java/org/springframework/resilience/RetryInterceptorTests.java index f981bfae4ad6..e637c297fa5f 100644 --- a/spring-context/src/test/java/org/springframework/resilience/RetryInterceptorTests.java +++ b/spring-context/src/test/java/org/springframework/resilience/RetryInterceptorTests.java @@ -17,18 +17,21 @@ package org.springframework.resilience; import java.io.IOException; +import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.nio.file.AccessDeniedException; import java.time.Duration; import java.util.Properties; import java.util.concurrent.atomic.AtomicInteger; +import org.aopalliance.intercept.MethodInterceptor; import org.junit.jupiter.api.Test; import org.springframework.aop.framework.AopProxyUtils; import org.springframework.aop.framework.ProxyConfig; import org.springframework.aop.framework.ProxyFactory; import org.springframework.aop.framework.autoproxy.AutoProxyUtils; +import org.springframework.aop.interceptor.SimpleTraceInterceptor; import org.springframework.aop.support.AopUtils; import org.springframework.beans.factory.support.DefaultListableBeanFactory; import org.springframework.beans.factory.support.RootBeanDefinition; @@ -59,7 +62,30 @@ void withSimpleInterceptor() { pf.setTarget(target); pf.addAdvice(new SimpleRetryInterceptor( new MethodRetrySpec((m, t) -> true, 5, Duration.ofMillis(10)))); - NonAnnotatedBean proxy = (NonAnnotatedBean) pf.getProxy(); + pf.addAdvice(new SimpleTraceInterceptor()); + PlainInterface proxy = (PlainInterface) pf.getProxy(); + + assertThatIOException().isThrownBy(proxy::retryOperation).withMessage("6"); + assertThat(target.counter).isEqualTo(6); + } + + @Test + void withSimpleInterceptorAndNoTarget() { + NonAnnotatedBean target = new NonAnnotatedBean(); + ProxyFactory pf = new ProxyFactory(); + pf.addAdvice(new SimpleRetryInterceptor( + new MethodRetrySpec((m, t) -> true, 5, Duration.ofMillis(10)))); + pf.addAdvice(new SimpleTraceInterceptor()); + pf.addAdvice((MethodInterceptor) invocation -> { + try { + return invocation.getMethod().invoke(target, invocation.getArguments()); + } + catch (InvocationTargetException ex) { + throw ex.getTargetException(); + } + }); + pf.addInterface(PlainInterface.class); + PlainInterface proxy = (PlainInterface) pf.getProxy(); assertThatIOException().isThrownBy(proxy::retryOperation).withMessage("6"); assertThat(target.counter).isEqualTo(6); @@ -237,7 +263,7 @@ void withEnableAnnotation() throws Exception { } - static class NonAnnotatedBean { + static class NonAnnotatedBean implements PlainInterface { int counter = 0; @@ -248,6 +274,12 @@ public void retryOperation() throws IOException { } + public interface PlainInterface { + + void retryOperation() throws IOException; + } + + static class AnnotatedMethodBean { int counter = 0; From dc26aaa0ecd483ff22b5127c44a134f66024dcd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Tue, 12 Aug 2025 10:18:49 +0200 Subject: [PATCH 124/591] Use JsonMapper instead of ObjectMapper when relevant This commit updates Jackson 3 JSON support to use JsonMapper instead of ObjectMapper in converters, codecs and view constructors. As a consequence, AbstractJacksonDecoder, AbstractJacksonEncoder, AbstractJacksonHttpMessageConverter and JacksonCodecSupport are now parameterized with . Closes gh-35282 --- .../JacksonJsonMessageConverter.java | 33 ++++--- .../JacksonJsonMessageConverter.java | 45 +++++---- .../json/AbstractJsonContentAssertTests.java | 4 +- .../test/json/JsonPathValueAssertTests.java | 4 +- .../util/JsonPathExpectationsHelperTests.java | 16 ++-- .../EncoderDecoderMappingProviderTests.java | 6 +- .../server/JsonEncoderDecoderTests.java | 8 +- .../http/codec/AbstractJacksonDecoder.java | 18 ++-- .../http/codec/AbstractJacksonEncoder.java | 15 +-- .../http/codec/JacksonCodecSupport.java | 69 +++++++------- .../http/codec/cbor/JacksonCborDecoder.java | 2 +- .../http/codec/cbor/JacksonCborEncoder.java | 2 +- .../http/codec/json/JacksonJsonDecoder.java | 11 +-- .../http/codec/json/JacksonJsonEncoder.java | 11 +-- .../http/codec/smile/JacksonSmileDecoder.java | 2 +- .../http/codec/smile/JacksonSmileEncoder.java | 2 +- .../http/codec/support/BaseDefaultCodecs.java | 2 +- .../AbstractJacksonHttpMessageConverter.java | 93 ++++++++++--------- .../cbor/JacksonCborHttpMessageConverter.java | 2 +- .../json/JacksonJsonHttpMessageConverter.java | 9 +- .../JacksonSmileHttpMessageConverter.java | 2 +- .../xml/JacksonXmlHttpMessageConverter.java | 2 +- .../yaml/JacksonYamlHttpMessageConverter.java | 2 +- .../codec/json/JacksonJsonDecoderTests.java | 15 ++- .../codec/json/JacksonJsonEncoderTests.java | 7 +- .../JacksonJsonHttpMessageConverterTests.java | 16 ++-- .../ResponseEntityResultHandlerTests.java | 5 +- .../servlet/view/json/JacksonJsonView.java | 9 +- ...MvcConfigurationSupportExtensionTests.java | 10 +- .../WebMvcConfigurationSupportTests.java | 2 +- .../function/SseServerResponseTests.java | 5 +- ...questResponseBodyMethodProcessorTests.java | 2 +- .../view/json/JacksonJsonViewTests.java | 3 +- .../frame/JacksonJsonSockJsMessageCodec.java | 17 ++-- 34 files changed, 221 insertions(+), 230 deletions(-) diff --git a/spring-jms/src/main/java/org/springframework/jms/support/converter/JacksonJsonMessageConverter.java b/spring-jms/src/main/java/org/springframework/jms/support/converter/JacksonJsonMessageConverter.java index eabc2f4b49d2..e1d0d9f2dfaf 100644 --- a/spring-jms/src/main/java/org/springframework/jms/support/converter/JacksonJsonMessageConverter.java +++ b/spring-jms/src/main/java/org/springframework/jms/support/converter/JacksonJsonMessageConverter.java @@ -32,7 +32,6 @@ import jakarta.jms.TextMessage; import org.jspecify.annotations.Nullable; import tools.jackson.databind.JavaType; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectWriter; import tools.jackson.databind.cfg.MapperBuilder; import tools.jackson.databind.json.JsonMapper; @@ -63,7 +62,7 @@ public class JacksonJsonMessageConverter implements SmartMessageConverter, BeanC public static final String DEFAULT_ENCODING = "UTF-8"; - private final ObjectMapper objectMapper; + private final JsonMapper jsonMapper; private MessageType targetType = MessageType.BYTES; @@ -86,17 +85,17 @@ public class JacksonJsonMessageConverter implements SmartMessageConverter, BeanC * {@link MapperBuilder#findModules(ClassLoader)}. */ public JacksonJsonMessageConverter() { - this.objectMapper = JsonMapper.builder().findAndAddModules(JacksonJsonMessageConverter.class.getClassLoader()).build(); + this.jsonMapper = JsonMapper.builder().findAndAddModules(JacksonJsonMessageConverter.class.getClassLoader()).build(); } /** - * Construct a new instance with the provided {@link ObjectMapper}. + * Construct a new instance with the provided {@link JsonMapper}. * @see JsonMapper#builder() * @see MapperBuilder#findModules(ClassLoader) */ - public JacksonJsonMessageConverter(ObjectMapper objectMapper) { - Assert.notNull(objectMapper, "ObjectMapper must not be null"); - this.objectMapper = objectMapper; + public JacksonJsonMessageConverter(JsonMapper jsonMapper) { + Assert.notNull(jsonMapper, "JsonMapper must not be null"); + this.jsonMapper = jsonMapper; } /** @@ -173,9 +172,9 @@ public Message toMessage(Object object, Session session) throws JMSException, Me Message message; try { message = switch (this.targetType) { - case TEXT -> mapToTextMessage(object, session, this.objectMapper.writer()); - case BYTES -> mapToBytesMessage(object, session, this.objectMapper.writer()); - default -> mapToMessage(object, session, this.objectMapper.writer(), this.targetType); + case TEXT -> mapToTextMessage(object, session, this.jsonMapper.writer()); + case BYTES -> mapToBytesMessage(object, session, this.jsonMapper.writer()); + default -> mapToMessage(object, session, this.jsonMapper.writer(), this.targetType); }; } catch (IOException ex) { @@ -206,10 +205,10 @@ public Message toMessage(Object object, Session session, @Nullable Class json throws JMSException, MessageConversionException { if (jsonView != null) { - return toMessage(object, session, this.objectMapper.writerWithView(jsonView)); + return toMessage(object, session, this.jsonMapper.writerWithView(jsonView)); } else { - return toMessage(object, session, this.objectMapper.writer()); + return toMessage(object, session, this.jsonMapper.writer()); } } @@ -363,7 +362,7 @@ protected Object convertFromTextMessage(TextMessage message, JavaType targetJava throws JMSException, IOException { String body = message.getText(); - return this.objectMapper.readValue(body, targetJavaType); + return this.jsonMapper.readValue(body, targetJavaType); } /** @@ -386,7 +385,7 @@ protected Object convertFromBytesMessage(BytesMessage message, JavaType targetJa if (encoding != null) { try { String body = new String(bytes, encoding); - return this.objectMapper.readValue(body, targetJavaType); + return this.jsonMapper.readValue(body, targetJavaType); } catch (UnsupportedEncodingException ex) { throw new MessageConversionException("Cannot convert bytes to String", ex); @@ -394,7 +393,7 @@ protected Object convertFromBytesMessage(BytesMessage message, JavaType targetJa } else { // Jackson internally performs encoding detection, falling back to UTF-8. - return this.objectMapper.readValue(bytes, targetJavaType); + return this.jsonMapper.readValue(bytes, targetJavaType); } } @@ -437,11 +436,11 @@ protected JavaType getJavaTypeForMessage(Message message) throws JMSException { } Class mappedClass = this.idClassMappings.get(typeId); if (mappedClass != null) { - return this.objectMapper.constructType(mappedClass); + return this.jsonMapper.constructType(mappedClass); } try { Class typeClass = ClassUtils.forName(typeId, this.beanClassLoader); - return this.objectMapper.constructType(typeClass); + return this.jsonMapper.constructType(typeClass); } catch (Throwable ex) { throw new MessageConversionException("Failed to resolve type id [" + typeId + "]", ex); diff --git a/spring-messaging/src/main/java/org/springframework/messaging/converter/JacksonJsonMessageConverter.java b/spring-messaging/src/main/java/org/springframework/messaging/converter/JacksonJsonMessageConverter.java index 804f05b411ea..a2eb535e6d96 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/converter/JacksonJsonMessageConverter.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/converter/JacksonJsonMessageConverter.java @@ -27,7 +27,6 @@ import tools.jackson.core.JsonEncoding; import tools.jackson.core.JsonGenerator; import tools.jackson.databind.JavaType; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.cfg.MapperBuilder; import tools.jackson.databind.json.JsonMapper; @@ -52,7 +51,7 @@ public class JacksonJsonMessageConverter extends AbstractMessageConverter { private static final MimeType[] DEFAULT_MIME_TYPES = new MimeType[] { new MimeType("application", "json"), new MimeType("application", "*+json")}; - private final ObjectMapper objectMapper; + private final JsonMapper jsonMapper; /** @@ -73,35 +72,35 @@ public JacksonJsonMessageConverter() { */ public JacksonJsonMessageConverter(MimeType... supportedMimeTypes) { super(supportedMimeTypes); - this.objectMapper = JsonMapper.builder().findAndAddModules(JacksonJsonMessageConverter.class.getClassLoader()).build(); + this.jsonMapper = JsonMapper.builder().findAndAddModules(JacksonJsonMessageConverter.class.getClassLoader()).build(); } /** - * Construct a new instance with the provided {@link ObjectMapper}. + * Construct a new instance with the provided {@link JsonMapper}. * @see JsonMapper#builder() * @see MapperBuilder#findModules(ClassLoader) */ - public JacksonJsonMessageConverter(ObjectMapper objectMapper) { - this(objectMapper, DEFAULT_MIME_TYPES); + public JacksonJsonMessageConverter(JsonMapper jsonMapper) { + this(jsonMapper, DEFAULT_MIME_TYPES); } /** - * Construct a new instance with the provided {@link ObjectMapper} and the + * Construct a new instance with the provided {@link JsonMapper} and the * provided {@link MimeType}s. * @see JsonMapper#builder() * @see MapperBuilder#findModules(ClassLoader) */ - public JacksonJsonMessageConverter(ObjectMapper objectMapper, MimeType... supportedMimeTypes) { + public JacksonJsonMessageConverter(JsonMapper jsonMapper, MimeType... supportedMimeTypes) { super(supportedMimeTypes); - Assert.notNull(objectMapper, "ObjectMapper must not be null"); - this.objectMapper = objectMapper; + Assert.notNull(jsonMapper, "JsonMapper must not be null"); + this.jsonMapper = jsonMapper; } /** - * Return the underlying {@code ObjectMapper} for this converter. + * Return the underlying {@code JsonMapper} for this converter. */ - protected ObjectMapper getObjectMapper() { - return this.objectMapper; + protected JsonMapper getJsonMapper() { + return this.jsonMapper; } @Override @@ -122,7 +121,7 @@ protected boolean supports(Class clazz) { @Override protected @Nullable Object convertFromInternal(Message message, Class targetClass, @Nullable Object conversionHint) { - JavaType javaType = this.objectMapper.constructType(getResolvedType(targetClass, conversionHint)); + JavaType javaType = this.jsonMapper.constructType(getResolvedType(targetClass, conversionHint)); Object payload = message.getPayload(); Class view = getSerializationView(conversionHint); try { @@ -131,19 +130,19 @@ protected boolean supports(Class clazz) { } else if (payload instanceof byte[] bytes) { if (view != null) { - return this.objectMapper.readerWithView(view).forType(javaType).readValue(bytes); + return this.jsonMapper.readerWithView(view).forType(javaType).readValue(bytes); } else { - return this.objectMapper.readValue(bytes, javaType); + return this.jsonMapper.readValue(bytes, javaType); } } else { // Assuming a text-based source payload if (view != null) { - return this.objectMapper.readerWithView(view).forType(javaType).readValue(payload.toString()); + return this.jsonMapper.readerWithView(view).forType(javaType).readValue(payload.toString()); } else { - return this.objectMapper.readValue(payload.toString(), javaType); + return this.jsonMapper.readValue(payload.toString(), javaType); } } } @@ -161,12 +160,12 @@ else if (payload instanceof byte[] bytes) { if (byte[].class == getSerializedPayloadClass()) { ByteArrayOutputStream out = new ByteArrayOutputStream(1024); JsonEncoding encoding = getJsonEncoding(getMimeType(headers)); - try (JsonGenerator generator = this.objectMapper.createGenerator(out, encoding)) { + try (JsonGenerator generator = this.jsonMapper.createGenerator(out, encoding)) { if (view != null) { - this.objectMapper.writerWithView(view).writeValue(generator, payload); + this.jsonMapper.writerWithView(view).writeValue(generator, payload); } else { - this.objectMapper.writeValue(generator, payload); + this.jsonMapper.writeValue(generator, payload); } payload = out.toByteArray(); } @@ -175,10 +174,10 @@ else if (payload instanceof byte[] bytes) { // Assuming a text-based target payload Writer writer = new StringWriter(1024); if (view != null) { - this.objectMapper.writerWithView(view).writeValue(writer, payload); + this.jsonMapper.writerWithView(view).writeValue(writer, payload); } else { - this.objectMapper.writeValue(writer, payload); + this.jsonMapper.writeValue(writer, payload); } payload = writer.toString(); } diff --git a/spring-test/src/test/java/org/springframework/test/json/AbstractJsonContentAssertTests.java b/spring-test/src/test/java/org/springframework/test/json/AbstractJsonContentAssertTests.java index 92287b4d0177..c37ea1914d5e 100644 --- a/spring-test/src/test/java/org/springframework/test/json/AbstractJsonContentAssertTests.java +++ b/spring-test/src/test/java/org/springframework/test/json/AbstractJsonContentAssertTests.java @@ -45,7 +45,7 @@ import org.skyscreamer.jsonassert.JSONCompareMode; import org.skyscreamer.jsonassert.JSONCompareResult; import org.skyscreamer.jsonassert.comparator.JSONComparator; -import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.ClassPathResource; @@ -86,7 +86,7 @@ class AbstractJsonContentAssertTests { private static final String DIFFERENT = loadJson("different.json"); private static final HttpMessageContentConverter jsonContentConverter = HttpMessageContentConverter.of( - new JacksonJsonHttpMessageConverter(new ObjectMapper())); + new JacksonJsonHttpMessageConverter(new JsonMapper())); private static final JsonComparator comparator = JsonAssert.comparator(JsonCompareMode.LENIENT); diff --git a/spring-test/src/test/java/org/springframework/test/json/JsonPathValueAssertTests.java b/spring-test/src/test/java/org/springframework/test/json/JsonPathValueAssertTests.java index c0a3b832eb5b..1c26fa1865e5 100644 --- a/spring-test/src/test/java/org/springframework/test/json/JsonPathValueAssertTests.java +++ b/spring-test/src/test/java/org/springframework/test/json/JsonPathValueAssertTests.java @@ -27,7 +27,7 @@ import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; import org.springframework.test.http.HttpMessageContentConverter; @@ -206,7 +206,7 @@ void asMapWithNullFails() { class ConvertToTests { private static final HttpMessageContentConverter jsonContentConverter = HttpMessageContentConverter.of( - new JacksonJsonHttpMessageConverter(new ObjectMapper())); + new JacksonJsonHttpMessageConverter(new JsonMapper())); @Test void convertToWithoutHttpMessageConverter() { diff --git a/spring-test/src/test/java/org/springframework/test/util/JsonPathExpectationsHelperTests.java b/spring-test/src/test/java/org/springframework/test/util/JsonPathExpectationsHelperTests.java index 17c068dae759..f1c0f00e8d71 100644 --- a/spring-test/src/test/java/org/springframework/test/util/JsonPathExpectationsHelperTests.java +++ b/spring-test/src/test/java/org/springframework/test/util/JsonPathExpectationsHelperTests.java @@ -27,7 +27,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import tools.jackson.databind.JavaType; -import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; import org.springframework.core.ParameterizedTypeReference; @@ -385,14 +385,14 @@ public record Member(String name) {} */ private static class JacksonMappingProvider implements MappingProvider { - private final ObjectMapper objectMapper; + private final JsonMapper jsonMapper; public JacksonMappingProvider() { - this(new ObjectMapper()); + this(new JsonMapper()); } - public JacksonMappingProvider(ObjectMapper objectMapper) { - this.objectMapper = objectMapper; + public JacksonMappingProvider(JsonMapper jsonMapper) { + this.jsonMapper = jsonMapper; } @@ -402,7 +402,7 @@ public T map(Object source, Class targetType, Configuration configuration return null; } try { - return objectMapper.convertValue(source, targetType); + return jsonMapper.convertValue(source, targetType); } catch (Exception ex) { throw new MappingException(ex); @@ -416,10 +416,10 @@ public T map(Object source, final TypeRef targetType, Configuration confi if (source == null){ return null; } - JavaType type = objectMapper.getTypeFactory().constructType(targetType.getType()); + JavaType type = jsonMapper.getTypeFactory().constructType(targetType.getType()); try { - return (T) objectMapper.convertValue(source, type); + return (T) jsonMapper.convertValue(source, type); } catch (Exception ex) { throw new MappingException(ex); diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/EncoderDecoderMappingProviderTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/EncoderDecoderMappingProviderTests.java index 190a72b4866a..1051834999c3 100644 --- a/spring-test/src/test/java/org/springframework/test/web/reactive/server/EncoderDecoderMappingProviderTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/EncoderDecoderMappingProviderTests.java @@ -22,7 +22,7 @@ import com.jayway.jsonpath.Configuration; import com.jayway.jsonpath.TypeRef; import org.junit.jupiter.api.Test; -import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; import org.springframework.http.codec.json.JacksonJsonDecoder; import org.springframework.http.codec.json.JacksonJsonEncoder; @@ -36,10 +36,10 @@ */ class EncoderDecoderMappingProviderTests { - private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final JsonMapper jsonMapper = new JsonMapper(); private final EncoderDecoderMappingProvider mappingProvider = new EncoderDecoderMappingProvider( - new JacksonJsonEncoder(objectMapper), new JacksonJsonDecoder(objectMapper)); + new JacksonJsonEncoder(jsonMapper), new JacksonJsonDecoder(jsonMapper)); @Test diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/JsonEncoderDecoderTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/JsonEncoderDecoderTests.java index 2d5ee9beb8ae..62432a05a035 100644 --- a/spring-test/src/test/java/org/springframework/test/web/reactive/server/JsonEncoderDecoderTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/JsonEncoderDecoderTests.java @@ -19,7 +19,7 @@ import java.util.List; import org.junit.jupiter.api.Test; -import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; import org.springframework.http.codec.DecoderHttpMessageReader; import org.springframework.http.codec.EncoderHttpMessageWriter; @@ -39,13 +39,13 @@ */ class JsonEncoderDecoderTests { - private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final JsonMapper jsonMapper = new JsonMapper(); private static final HttpMessageWriter jacksonMessageWriter = new EncoderHttpMessageWriter<>( - new JacksonJsonEncoder(objectMapper)); + new JacksonJsonEncoder(jsonMapper)); private static final HttpMessageReader jacksonMessageReader = new DecoderHttpMessageReader<>( - new JacksonJsonDecoder(objectMapper)); + new JacksonJsonDecoder(jsonMapper)); @Test void fromWithEmptyWriters() { diff --git a/spring-web/src/main/java/org/springframework/http/codec/AbstractJacksonDecoder.java b/spring-web/src/main/java/org/springframework/http/codec/AbstractJacksonDecoder.java index 3b5248b4b61d..3a5270dfe5c0 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/AbstractJacksonDecoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/AbstractJacksonDecoder.java @@ -57,8 +57,9 @@ * * @author Sebastien Deleuze * @since 7.0 + * @param the type of {@link ObjectMapper} */ -public abstract class AbstractJacksonDecoder extends JacksonCodecSupport implements HttpMessageDecoder { +public abstract class AbstractJacksonDecoder extends JacksonCodecSupport implements HttpMessageDecoder { private int maxInMemorySize = 256 * 1024; @@ -68,14 +69,14 @@ public abstract class AbstractJacksonDecoder extends JacksonCodecSupport impleme * customized with the {@link tools.jackson.databind.JacksonModule}s found * by {@link MapperBuilder#findModules(ClassLoader)} and {@link MimeType}s. */ - protected AbstractJacksonDecoder(MapperBuilder builder, MimeType... mimeTypes) { + protected AbstractJacksonDecoder(MapperBuilder builder, MimeType... mimeTypes) { super(builder, mimeTypes); } /** * Construct a new instance with the provided {@link ObjectMapper} and {@link MimeType}s. */ - protected AbstractJacksonDecoder(ObjectMapper mapper, MimeType... mimeTypes) { + protected AbstractJacksonDecoder(T mapper, MimeType... mimeTypes) { super(mapper, mimeTypes); } @@ -104,7 +105,7 @@ public boolean canDecode(ResolvableType elementType, @Nullable MimeType mimeType if (!supportsMimeType(mimeType)) { return false; } - ObjectMapper mapper = selectObjectMapper(elementType, mimeType); + T mapper = selectMapper(elementType, mimeType); if (mapper == null) { return false; } @@ -115,7 +116,7 @@ public boolean canDecode(ResolvableType elementType, @Nullable MimeType mimeType public Flux decode(Publisher input, ResolvableType elementType, @Nullable MimeType mimeType, @Nullable Map hints) { - ObjectMapper mapper = selectObjectMapper(elementType, mimeType); + T mapper = selectMapper(elementType, mimeType); if (mapper == null) { return Flux.error(new IllegalStateException("No ObjectMapper for " + elementType)); } @@ -141,7 +142,7 @@ public Flux decode(Publisher input, ResolvableType elementTy return tokens.handle((tokenBuffer, sink) -> { try { - Object value = reader.readValue(tokenBuffer.asParser(getObjectMapper()._deserializationContext())); + Object value = reader.readValue(tokenBuffer.asParser(getMapper()._deserializationContext())); logValue(value, hints); if (value != null) { sink.next(value); @@ -189,7 +190,7 @@ public Mono decodeToMono(Publisher input, ResolvableType ele public Object decode(DataBuffer dataBuffer, ResolvableType targetType, @Nullable MimeType mimeType, @Nullable Map hints) throws DecodingException { - ObjectMapper mapper = selectObjectMapper(targetType, mimeType); + T mapper = selectMapper(targetType, mimeType); if (mapper == null) { throw new IllegalStateException("No ObjectMapper for " + targetType); } @@ -208,8 +209,7 @@ public Object decode(DataBuffer dataBuffer, ResolvableType targetType, } } - private ObjectReader createObjectReader( - ObjectMapper mapper, ResolvableType elementType, @Nullable Map hints) { + private ObjectReader createObjectReader(T mapper, ResolvableType elementType, @Nullable Map hints) { Assert.notNull(elementType, "'elementType' must not be null"); Class contextClass = getContextClass(elementType); diff --git a/spring-web/src/main/java/org/springframework/http/codec/AbstractJacksonEncoder.java b/spring-web/src/main/java/org/springframework/http/codec/AbstractJacksonEncoder.java index c9f67f43f5f0..75a2e47f7f95 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/AbstractJacksonEncoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/AbstractJacksonEncoder.java @@ -64,8 +64,9 @@ * * @author Sebastien Deleuze * @since 7.0 + * @param the type of {@link ObjectMapper} */ -public abstract class AbstractJacksonEncoder extends JacksonCodecSupport implements HttpMessageEncoder { +public abstract class AbstractJacksonEncoder extends JacksonCodecSupport implements HttpMessageEncoder { private static final byte[] NEWLINE_SEPARATOR = {'\n'}; @@ -90,14 +91,14 @@ public abstract class AbstractJacksonEncoder extends JacksonCodecSupport impleme * customized with the {@link tools.jackson.databind.JacksonModule}s found * by {@link MapperBuilder#findModules(ClassLoader)} and {@link MimeType}s. */ - protected AbstractJacksonEncoder(MapperBuilder builder, MimeType... mimeTypes) { + protected AbstractJacksonEncoder(MapperBuilder builder, MimeType... mimeTypes) { super(builder, mimeTypes); } /** * Construct a new instance with the provided {@link ObjectMapper} and {@link MimeType}s. */ - protected AbstractJacksonEncoder(ObjectMapper mapper, MimeType... mimeTypes) { + protected AbstractJacksonEncoder(T mapper, MimeType... mimeTypes) { super(mapper, mimeTypes); } @@ -122,7 +123,7 @@ public boolean canEncode(ResolvableType elementType, @Nullable MimeType mimeType return false; } } - if (this.objectMapperRegistrations != null && selectObjectMapper(elementType, mimeType) == null) { + if (this.mapperRegistrations != null && selectMapper(elementType, mimeType) == null) { return false; } Class clazz = elementType.resolve(); @@ -155,7 +156,7 @@ public Flux encode(Publisher inputStream, DataBufferFactory buffe } try { - ObjectMapper mapper = selectObjectMapper(elementType, mimeType); + T mapper = selectMapper(elementType, mimeType); if (mapper == null) { throw new IllegalStateException("No ObjectMapper for " + elementType); } @@ -225,7 +226,7 @@ public DataBuffer encodeValue(Object value, DataBufferFactory bufferFactory, filters = (FilterProvider) hints.get(FILTER_PROVIDER_HINT); } - ObjectMapper mapper = selectObjectMapper(valueType, mimeType); + T mapper = selectMapper(valueType, mimeType); if (mapper == null) { throw new IllegalStateException("No ObjectMapper for " + valueType); } @@ -319,7 +320,7 @@ private void logValue(@Nullable Map hints, Object value) { } private ObjectWriter createObjectWriter( - ObjectMapper mapper, ResolvableType valueType, @Nullable MimeType mimeType, + T mapper, ResolvableType valueType, @Nullable MimeType mimeType, @Nullable Class jsonView, @Nullable Map hints) { JavaType javaType = getJavaType(valueType.getType(), null); diff --git a/spring-web/src/main/java/org/springframework/http/codec/JacksonCodecSupport.java b/spring-web/src/main/java/org/springframework/http/codec/JacksonCodecSupport.java index ac52c2ca29ca..8dc62fe7e1a6 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/JacksonCodecSupport.java +++ b/spring-web/src/main/java/org/springframework/http/codec/JacksonCodecSupport.java @@ -50,12 +50,13 @@ import org.springframework.util.MimeType; /** - * Base class providing support methods for Jackson 2.x encoding and decoding. + * Base class providing support methods for Jackson 3.x encoding and decoding. * * @author Sebastien Deleuze * @since 7.0 + * @param the type of {@link ObjectMapper} */ -public abstract class JacksonCodecSupport { +public abstract class JacksonCodecSupport { /** * The key for the hint to specify a "JSON View" for encoding or decoding @@ -83,9 +84,9 @@ public abstract class JacksonCodecSupport { protected final Log logger = HttpLogging.forLogName(getClass()); - private final ObjectMapper defaultObjectMapper; + private final T defaultMapper; - protected @Nullable Map, Map> objectMapperRegistrations; + protected @Nullable Map, Map> mapperRegistrations; private final List mimeTypes; @@ -96,10 +97,10 @@ public abstract class JacksonCodecSupport { * customized with the {@link tools.jackson.databind.JacksonModule}s found * by {@link MapperBuilder#findModules(ClassLoader)} and {@link MimeType}s. */ - protected JacksonCodecSupport(MapperBuilder builder, MimeType... mimeTypes) { + protected JacksonCodecSupport(MapperBuilder builder, MimeType... mimeTypes) { Assert.notNull(builder, "MapperBuilder must not be null"); Assert.notEmpty(mimeTypes, "MimeTypes must not be empty"); - this.defaultObjectMapper = builder.addModules(initModules()).build(); + this.defaultMapper = builder.addModules(initModules()).build(); this.mimeTypes = List.of(mimeTypes); } @@ -108,10 +109,10 @@ protected JacksonCodecSupport(MapperBuilder builder, MimeType... mimeTypes * customized with the {@link tools.jackson.databind.JacksonModule}s found * by {@link MapperBuilder#findModules(ClassLoader)} and {@link MimeType}s. */ - protected JacksonCodecSupport(ObjectMapper objectMapper, MimeType... mimeTypes) { - Assert.notNull(objectMapper, "ObjectMapper must not be null"); + protected JacksonCodecSupport(T mapper, MimeType... mimeTypes) { + Assert.notNull(mapper, "ObjectMapper must not be null"); Assert.notEmpty(mimeTypes, "MimeTypes must not be empty"); - this.defaultObjectMapper = objectMapper; + this.defaultMapper = mapper; this.mimeTypes = List.of(mimeTypes); } @@ -124,19 +125,19 @@ private List initModules() { } /** - * Return the {@link ObjectMapper configured} default ObjectMapper. + * Return the {@link ObjectMapper configured} default mapper. */ - public ObjectMapper getObjectMapper() { - return this.defaultObjectMapper; + public T getMapper() { + return this.defaultMapper; } /** * Configure the {@link ObjectMapper} instances to use for the given * {@link Class}. This is useful when you want to deviate from the - * {@link #getObjectMapper() default} ObjectMapper or have the + * {@link #getMapper() default} ObjectMapper or have the * {@code ObjectMapper} vary by {@code MediaType}. *

    Note: Use of this method effectively turns off use of - * the default {@link #getObjectMapper() ObjectMapper} and supported + * the default {@link #getMapper() ObjectMapper} and supported * {@link #getMimeTypes() MimeTypes} for the given class. Therefore it is * important for the mappings configured here to * {@link MediaType#includes(MediaType) include} every MediaType that must @@ -145,12 +146,12 @@ public ObjectMapper getObjectMapper() { * @param registrar a consumer to populate or otherwise update the * MediaType-to-ObjectMapper associations for the given Class */ - public void registerObjectMappersForType(Class clazz, Consumer> registrar) { - if (this.objectMapperRegistrations == null) { - this.objectMapperRegistrations = new LinkedHashMap<>(); + public void registerMappersForType(Class clazz, Consumer> registrar) { + if (this.mapperRegistrations == null) { + this.mapperRegistrations = new LinkedHashMap<>(); } - Map registrations = - this.objectMapperRegistrations.computeIfAbsent(clazz, c -> new LinkedHashMap<>()); + Map registrations = + this.mapperRegistrations.computeIfAbsent(clazz, c -> new LinkedHashMap<>()); registrar.accept(registrations); } @@ -160,8 +161,8 @@ public void registerObjectMappersForType(Class clazz, Consumer getObjectMappersForType(Class clazz) { - for (Map.Entry, Map> entry : getObjectMapperRegistrations().entrySet()) { + public @Nullable Map getMappersForType(Class clazz) { + for (Map.Entry, Map> entry : getMapperRegistrations().entrySet()) { if (entry.getKey().isAssignableFrom(clazz)) { return entry.getValue(); } @@ -169,8 +170,8 @@ public void registerObjectMappersForType(Class clazz, Consumer, Map> getObjectMapperRegistrations() { - return (this.objectMapperRegistrations != null ? this.objectMapperRegistrations : Collections.emptyMap()); + protected Map, Map> getMapperRegistrations() { + return (this.mapperRegistrations != null ? this.mapperRegistrations : Collections.emptyMap()); } /** @@ -183,7 +184,7 @@ protected List getMimeTypes() { protected List getMimeTypes(ResolvableType elementType) { Class elementClass = elementType.toClass(); List result = null; - for (Map.Entry, Map> entry : getObjectMapperRegistrations().entrySet()) { + for (Map.Entry, Map> entry : getMapperRegistrations().entrySet()) { if (entry.getKey().isAssignableFrom(elementClass)) { result = (result != null ? result : new ArrayList<>(entry.getValue().size())); result.addAll(entry.getValue().keySet()); @@ -216,7 +217,7 @@ protected boolean supportsMimeType(@Nullable MimeType mimeType) { } protected JavaType getJavaType(Type type, @Nullable Class contextClass) { - return this.defaultObjectMapper.constructType(GenericTypeResolver.resolveType(type, contextClass)); + return this.defaultMapper.constructType(GenericTypeResolver.resolveType(type, contextClass)); } protected Map getHints(ResolvableType resolvableType) { @@ -250,18 +251,18 @@ protected Map getHints(ResolvableType resolvableType) { /** * Select an ObjectMapper to use, either the main ObjectMapper or another * if the handling for the given Class has been customized through - * {@link #registerObjectMappersForType(Class, Consumer)}. + * {@link #registerMappersForType(Class, Consumer)}. */ - protected @Nullable ObjectMapper selectObjectMapper(ResolvableType targetType, @Nullable MimeType targetMimeType) { - if (targetMimeType == null || CollectionUtils.isEmpty(this.objectMapperRegistrations)) { - return this.defaultObjectMapper; + protected @Nullable T selectMapper(ResolvableType targetType, @Nullable MimeType targetMimeType) { + if (targetMimeType == null || CollectionUtils.isEmpty(this.mapperRegistrations)) { + return this.defaultMapper; } Class targetClass = targetType.toClass(); - for (Map.Entry, Map> typeEntry : getObjectMapperRegistrations().entrySet()) { + for (Map.Entry, Map> typeEntry : getMapperRegistrations().entrySet()) { if (typeEntry.getKey().isAssignableFrom(targetClass)) { - for (Map.Entry objectMapperEntry : typeEntry.getValue().entrySet()) { - if (objectMapperEntry.getKey().includes(targetMimeType)) { - return objectMapperEntry.getValue(); + for (Map.Entry mapperEntry : typeEntry.getValue().entrySet()) { + if (mapperEntry.getKey().includes(targetMimeType)) { + return mapperEntry.getValue(); } } // No matching registrations @@ -269,7 +270,7 @@ protected Map getHints(ResolvableType resolvableType) { } } // No registrations - return this.defaultObjectMapper; + return this.defaultMapper; } } diff --git a/spring-web/src/main/java/org/springframework/http/codec/cbor/JacksonCborDecoder.java b/spring-web/src/main/java/org/springframework/http/codec/cbor/JacksonCborDecoder.java index 79a83187d6fa..c6669c048570 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/cbor/JacksonCborDecoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/cbor/JacksonCborDecoder.java @@ -40,7 +40,7 @@ * @see JacksonCborEncoder * @see Add CBOR support to WebFlux */ -public class JacksonCborDecoder extends AbstractJacksonDecoder { +public class JacksonCborDecoder extends AbstractJacksonDecoder { /** * Construct a new instance with a {@link CBORMapper} customized with the diff --git a/spring-web/src/main/java/org/springframework/http/codec/cbor/JacksonCborEncoder.java b/spring-web/src/main/java/org/springframework/http/codec/cbor/JacksonCborEncoder.java index 8d04b2be95e3..980b1299c20b 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/cbor/JacksonCborEncoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/cbor/JacksonCborEncoder.java @@ -41,7 +41,7 @@ * @see JacksonCborDecoder * @see Add CBOR support to WebFlux */ -public class JacksonCborEncoder extends AbstractJacksonEncoder { +public class JacksonCborEncoder extends AbstractJacksonEncoder { /** * Construct a new instance with a {@link CBORMapper} customized with the diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonDecoder.java b/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonDecoder.java index a4202c5c1a5f..096235fab800 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonDecoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonDecoder.java @@ -25,7 +25,6 @@ import org.jspecify.annotations.Nullable; import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.cfg.MapperBuilder; import tools.jackson.databind.json.JsonMapper; @@ -50,7 +49,7 @@ * @since 7.0 * @see JacksonJsonEncoder */ -public class JacksonJsonDecoder extends AbstractJacksonDecoder { +public class JacksonJsonDecoder extends AbstractJacksonDecoder { private static final CharBufferDecoder CHAR_BUFFER_DECODER = CharBufferDecoder.textPlainOnly(Arrays.asList(",", "\n"), false); @@ -73,20 +72,20 @@ public JacksonJsonDecoder() { } /** - * Construct a new instance with the provided {@link ObjectMapper}. + * Construct a new instance with the provided {@link JsonMapper}. * @see JsonMapper#builder() * @see MapperBuilder#findModules(ClassLoader) */ - public JacksonJsonDecoder(ObjectMapper mapper) { + public JacksonJsonDecoder(JsonMapper mapper) { this(mapper, DEFAULT_JSON_MIME_TYPES); } /** - * Construct a new instance with the provided {@link ObjectMapper} and {@link MimeType}s. + * Construct a new instance with the provided {@link JsonMapper} and {@link MimeType}s. * @see JsonMapper#builder() * @see MapperBuilder#findModules(ClassLoader) */ - public JacksonJsonDecoder(ObjectMapper mapper, MimeType... mimeTypes) { + public JacksonJsonDecoder(JsonMapper mapper, MimeType... mimeTypes) { super(mapper, mimeTypes); } diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonEncoder.java b/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonEncoder.java index f883727aa582..992f712d874c 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonEncoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonEncoder.java @@ -25,7 +25,6 @@ import tools.jackson.core.PrettyPrinter; import tools.jackson.core.util.DefaultIndenter; import tools.jackson.core.util.DefaultPrettyPrinter; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.ObjectWriter; import tools.jackson.databind.SerializationFeature; import tools.jackson.databind.cfg.MapperBuilder; @@ -51,7 +50,7 @@ * @since 7.0 * @see JacksonJsonDecoder */ -public class JacksonJsonEncoder extends AbstractJacksonEncoder { +public class JacksonJsonEncoder extends AbstractJacksonEncoder { private static final List problemDetailMimeTypes = Collections.singletonList(MediaType.APPLICATION_PROBLEM_JSON); @@ -80,21 +79,21 @@ public JacksonJsonEncoder() { } /** - * Construct a new instance with the provided {@link ObjectMapper}. + * Construct a new instance with the provided {@link JsonMapper}. * @see JsonMapper#builder() * @see MapperBuilder#findModules(ClassLoader) */ - public JacksonJsonEncoder(ObjectMapper mapper) { + public JacksonJsonEncoder(JsonMapper mapper) { this(mapper, DEFAULT_JSON_MIME_TYPES); } /** - * Construct a new instance with the provided {@link ObjectMapper} and + * Construct a new instance with the provided {@link JsonMapper} and * {@link MimeType}s. * @see JsonMapper#builder() * @see MapperBuilder#findModules(ClassLoader) */ - public JacksonJsonEncoder(ObjectMapper mapper, MimeType... mimeTypes) { + public JacksonJsonEncoder(JsonMapper mapper, MimeType... mimeTypes) { super(mapper, mimeTypes); setStreamingMediaTypes(List.of(MediaType.APPLICATION_NDJSON)); this.ssePrettyPrinter = initSsePrettyPrinter(); diff --git a/spring-web/src/main/java/org/springframework/http/codec/smile/JacksonSmileDecoder.java b/spring-web/src/main/java/org/springframework/http/codec/smile/JacksonSmileDecoder.java index aac01ee948a6..26b873614605 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/smile/JacksonSmileDecoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/smile/JacksonSmileDecoder.java @@ -33,7 +33,7 @@ * @since 7.0 * @see JacksonSmileEncoder */ -public class JacksonSmileDecoder extends AbstractJacksonDecoder { +public class JacksonSmileDecoder extends AbstractJacksonDecoder { private static final MimeType[] DEFAULT_SMILE_MIME_TYPES = new MimeType[] { new MimeType("application", "x-jackson-smile"), diff --git a/spring-web/src/main/java/org/springframework/http/codec/smile/JacksonSmileEncoder.java b/spring-web/src/main/java/org/springframework/http/codec/smile/JacksonSmileEncoder.java index cbdcbe38af6f..32eef1291d2a 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/smile/JacksonSmileEncoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/smile/JacksonSmileEncoder.java @@ -41,7 +41,7 @@ * @since 7.0 * @see JacksonSmileDecoder */ -public class JacksonSmileEncoder extends AbstractJacksonEncoder { +public class JacksonSmileEncoder extends AbstractJacksonEncoder { private static final MimeType[] DEFAULT_SMILE_MIME_TYPES = new MimeType[] { new MimeType("application", "x-jackson-smile"), diff --git a/spring-web/src/main/java/org/springframework/http/codec/support/BaseDefaultCodecs.java b/spring-web/src/main/java/org/springframework/http/codec/support/BaseDefaultCodecs.java index 38b08f4038fc..b8d3353da55b 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/support/BaseDefaultCodecs.java +++ b/spring-web/src/main/java/org/springframework/http/codec/support/BaseDefaultCodecs.java @@ -527,7 +527,7 @@ else if (codec instanceof EncoderHttpMessageWriter encoderHttpMessageWriter) } } if (jacksonPresent) { - if (codec instanceof AbstractJacksonDecoder abstractJacksonDecoder) { + if (codec instanceof AbstractJacksonDecoder abstractJacksonDecoder) { abstractJacksonDecoder.setMaxInMemorySize(size); } } diff --git a/spring-web/src/main/java/org/springframework/http/converter/AbstractJacksonHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/AbstractJacksonHttpMessageConverter.java index da328cbee4d3..938576e37ec6 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/AbstractJacksonHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/AbstractJacksonHttpMessageConverter.java @@ -82,9 +82,10 @@ * * @author Sebastien Deleuze * @since 7.0 + * @param the type of {@link ObjectMapper} * @see JacksonJsonHttpMessageConverter */ -public abstract class AbstractJacksonHttpMessageConverter extends AbstractSmartHttpMessageConverter { +public abstract class AbstractJacksonHttpMessageConverter extends AbstractSmartHttpMessageConverter { private static final String JSON_VIEW_HINT = JsonView.class.getName(); @@ -103,9 +104,9 @@ public abstract class AbstractJacksonHttpMessageConverter extends AbstractSmartH } - protected final ObjectMapper defaultObjectMapper; + protected final T defaultMapper; - private @Nullable Map, Map> objectMapperRegistrations; + private @Nullable Map, Map> mapperRegistrations; private final @Nullable PrettyPrinter ssePrettyPrinter; @@ -115,8 +116,8 @@ public abstract class AbstractJacksonHttpMessageConverter extends AbstractSmartH * customized with the {@link tools.jackson.databind.JacksonModule}s found * by {@link MapperBuilder#findModules(ClassLoader)}. */ - private AbstractJacksonHttpMessageConverter(MapperBuilder builder) { - this.defaultObjectMapper = builder.addModules(initModules()).build(); + private AbstractJacksonHttpMessageConverter(MapperBuilder builder) { + this.defaultMapper = builder.addModules(initModules()).build(); this.ssePrettyPrinter = initSsePrettyPrinter(); } @@ -125,7 +126,7 @@ private AbstractJacksonHttpMessageConverter(MapperBuilder builder) { * customized with the {@link tools.jackson.databind.JacksonModule}s found * by {@link MapperBuilder#findModules(ClassLoader)} and {@link MediaType}. */ - protected AbstractJacksonHttpMessageConverter(MapperBuilder builder, MediaType supportedMediaType) { + protected AbstractJacksonHttpMessageConverter(MapperBuilder builder, MediaType supportedMediaType) { this(builder); setSupportedMediaTypes(Collections.singletonList(supportedMediaType)); } @@ -135,7 +136,7 @@ protected AbstractJacksonHttpMessageConverter(MapperBuilder builder, Media * customized with the {@link tools.jackson.databind.JacksonModule}s found * by {@link MapperBuilder#findModules(ClassLoader)} and {@link MediaType}s. */ - protected AbstractJacksonHttpMessageConverter(MapperBuilder builder, MediaType... supportedMediaTypes) { + protected AbstractJacksonHttpMessageConverter(MapperBuilder builder, MediaType... supportedMediaTypes) { this(builder); setSupportedMediaTypes(Arrays.asList(supportedMediaTypes)); } @@ -143,24 +144,24 @@ protected AbstractJacksonHttpMessageConverter(MapperBuilder builder, Media /** * Construct a new instance with the provided {@link ObjectMapper}. */ - protected AbstractJacksonHttpMessageConverter(ObjectMapper objectMapper) { - this.defaultObjectMapper = objectMapper; + protected AbstractJacksonHttpMessageConverter(T mapper) { + this.defaultMapper = mapper; this.ssePrettyPrinter = initSsePrettyPrinter(); } /** * Construct a new instance with the provided {@link ObjectMapper} and {@link MediaType}. */ - protected AbstractJacksonHttpMessageConverter(ObjectMapper objectMapper, MediaType supportedMediaType) { - this(objectMapper); + protected AbstractJacksonHttpMessageConverter(T mapper, MediaType supportedMediaType) { + this(mapper); setSupportedMediaTypes(Collections.singletonList(supportedMediaType)); } /** * Construct a new instance with the provided {@link ObjectMapper} and {@link MediaType}s. */ - protected AbstractJacksonHttpMessageConverter(ObjectMapper objectMapper, MediaType... supportedMediaTypes) { - this(objectMapper); + protected AbstractJacksonHttpMessageConverter(T mapper, MediaType... supportedMediaTypes) { + this(mapper); setSupportedMediaTypes(Arrays.asList(supportedMediaTypes)); } @@ -184,19 +185,19 @@ public void setSupportedMediaTypes(List supportedMediaTypes) { } /** - * Return the main {@code ObjectMapper} in use. + * Return the main {@link ObjectMapper} in use. */ - public ObjectMapper getObjectMapper() { - return this.defaultObjectMapper; + public T getMapper() { + return this.defaultMapper; } /** * Configure the {@link ObjectMapper} instances to use for the given * {@link Class}. This is useful when you want to deviate from the - * {@link #getObjectMapper() default} ObjectMapper or have the + * {@link #getMapper() default} ObjectMapper or have the * {@code ObjectMapper} vary by {@code MediaType}. *

    Note: Use of this method effectively turns off use of - * the default {@link #getObjectMapper() ObjectMapper} and + * the default {@link #getMapper() ObjectMapper} and * {@link #setSupportedMediaTypes(List) supportedMediaTypes} for the given * class. Therefore it is important for the mappings configured here to * {@link MediaType#includes(MediaType) include} every MediaType that must @@ -205,12 +206,12 @@ public ObjectMapper getObjectMapper() { * @param registrar a consumer to populate or otherwise update the * MediaType-to-ObjectMapper associations for the given Class */ - public void registerObjectMappersForType(Class clazz, Consumer> registrar) { - if (this.objectMapperRegistrations == null) { - this.objectMapperRegistrations = new LinkedHashMap<>(); + public void registerMappersForType(Class clazz, Consumer> registrar) { + if (this.mapperRegistrations == null) { + this.mapperRegistrations = new LinkedHashMap<>(); } - Map registrations = - this.objectMapperRegistrations.computeIfAbsent(clazz, c -> new LinkedHashMap<>()); + Map registrations = + this.mapperRegistrations.computeIfAbsent(clazz, c -> new LinkedHashMap<>()); registrar.accept(registrations); } @@ -220,8 +221,8 @@ public void registerObjectMappersForType(Class clazz, Consumer getObjectMappersForType(Class clazz) { - for (Map.Entry, Map> entry : getObjectMapperRegistrations().entrySet()) { + public Map getMappersForType(Class clazz) { + for (Map.Entry, Map> entry : getMapperRegistrations().entrySet()) { if (entry.getKey().isAssignableFrom(clazz)) { return entry.getValue(); } @@ -232,7 +233,7 @@ public Map getObjectMappersForType(Class clazz) { @Override public List getSupportedMediaTypes(Class clazz) { List result = null; - for (Map.Entry, Map> entry : getObjectMapperRegistrations().entrySet()) { + for (Map.Entry, Map> entry : getMapperRegistrations().entrySet()) { if (entry.getKey().isAssignableFrom(clazz)) { result = (result != null ? result : new ArrayList<>(entry.getValue().size())); result.addAll(entry.getValue().keySet()); @@ -245,8 +246,8 @@ public List getSupportedMediaTypes(Class clazz) { getMediaTypesForProblemDetail() : getSupportedMediaTypes()); } - private Map, Map> getObjectMapperRegistrations() { - return (this.objectMapperRegistrations != null ? this.objectMapperRegistrations : Collections.emptyMap()); + private Map, Map> getMapperRegistrations() { + return (this.mapperRegistrations != null ? this.mapperRegistrations : Collections.emptyMap()); } /** @@ -267,7 +268,7 @@ public boolean canRead(ResolvableType type, @Nullable MediaType mediaType) { if (clazz == null) { return false; } - return this.objectMapperRegistrations == null || selectObjectMapper(clazz, mediaType) != null; + return this.mapperRegistrations == null || selectMapper(clazz, mediaType) != null; } @Override @@ -285,23 +286,23 @@ public boolean canWrite(Class clazz, @Nullable MediaType mediaType) { if (MappingJacksonValue.class.isAssignableFrom(clazz)) { throw new UnsupportedOperationException("MappingJacksonValue is not supported, use hints instead"); } - return this.objectMapperRegistrations == null || selectObjectMapper(clazz, mediaType) != null; + return this.mapperRegistrations == null || selectMapper(clazz, mediaType) != null; } /** * Select an ObjectMapper to use, either the main ObjectMapper or another * if the handling for the given Class has been customized through - * {@link #registerObjectMappersForType(Class, Consumer)}. + * {@link #registerMappersForType(Class, Consumer)}. */ - private @Nullable ObjectMapper selectObjectMapper(Class targetType, @Nullable MediaType targetMediaType) { - if (targetMediaType == null || CollectionUtils.isEmpty(this.objectMapperRegistrations)) { - return this.defaultObjectMapper; + private @Nullable T selectMapper(Class targetType, @Nullable MediaType targetMediaType) { + if (targetMediaType == null || CollectionUtils.isEmpty(this.mapperRegistrations)) { + return this.defaultMapper; } - for (Map.Entry, Map> typeEntry : getObjectMapperRegistrations().entrySet()) { + for (Map.Entry, Map> typeEntry : getMapperRegistrations().entrySet()) { if (typeEntry.getKey().isAssignableFrom(targetType)) { - for (Map.Entry objectMapperEntry : typeEntry.getValue().entrySet()) { - if (objectMapperEntry.getKey().includes(targetMediaType)) { - return objectMapperEntry.getValue(); + for (Map.Entry mapperEntry : typeEntry.getValue().entrySet()) { + if (mapperEntry.getKey().includes(targetMediaType)) { + return mapperEntry.getValue(); } } // No matching registrations @@ -309,7 +310,7 @@ public boolean canWrite(Class clazz, @Nullable MediaType mediaType) { } } // No registrations - return this.defaultObjectMapper; + return this.defaultMapper; } @Override @@ -334,8 +335,8 @@ private Object readJavaType(JavaType javaType, HttpInputMessage inputMessage, @N MediaType contentType = inputMessage.getHeaders().getContentType(); Charset charset = getCharset(contentType); - ObjectMapper objectMapper = selectObjectMapper(javaType.getRawClass(), contentType); - Assert.state(objectMapper != null, () -> "No ObjectMapper for " + javaType); + T mapper = selectMapper(javaType.getRawClass(), contentType); + Assert.state(mapper != null, () -> "No ObjectMapper for " + javaType); boolean isUnicode = ENCODINGS.containsKey(charset.name()) || "UTF-16".equals(charset.name()) || @@ -345,7 +346,7 @@ private Object readJavaType(JavaType javaType, HttpInputMessage inputMessage, @N if (inputMessage instanceof MappingJacksonInputMessage) { throw new UnsupportedOperationException("MappingJacksonInputMessage is not supported, use hints instead"); } - ObjectReader objectReader = objectMapper.readerFor(javaType); + ObjectReader objectReader = mapper.readerFor(javaType); if (hints != null && hints.containsKey(JSON_VIEW_HINT)) { objectReader = objectReader.withView((Class) hints.get(JSON_VIEW_HINT)); } @@ -401,8 +402,8 @@ protected void writeInternal(Object object, ResolvableType resolvableType, HttpO JsonEncoding encoding = getJsonEncoding(contentType); Class clazz = object.getClass(); - ObjectMapper objectMapper = selectObjectMapper(clazz, contentType); - Assert.state(objectMapper != null, () -> "No ObjectMapper for " + clazz.getName()); + T mapper = selectMapper(clazz, contentType); + Assert.state(mapper != null, () -> "No ObjectMapper for " + clazz.getName()); OutputStream outputStream = StreamUtils.nonClosing(outputMessage.getBody()); Class jsonView = null; @@ -419,7 +420,7 @@ protected void writeInternal(Object object, ResolvableType resolvableType, HttpO } ObjectWriter objectWriter = (jsonView != null ? - objectMapper.writerWithView(jsonView) : objectMapper.writer()); + mapper.writerWithView(jsonView) : mapper.writer()); if (filters != null) { objectWriter = objectWriter.with(filters); } @@ -485,7 +486,7 @@ protected void writeSuffix(JsonGenerator generator, Object object) { * @return the Jackson JavaType */ protected JavaType getJavaType(Type type, @Nullable Class contextClass) { - return this.defaultObjectMapper.constructType(GenericTypeResolver.resolveType(type, contextClass)); + return this.defaultMapper.constructType(GenericTypeResolver.resolveType(type, contextClass)); } /** diff --git a/spring-web/src/main/java/org/springframework/http/converter/cbor/JacksonCborHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/cbor/JacksonCborHttpMessageConverter.java index 348f32df7c77..cd5fcefea94f 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/cbor/JacksonCborHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/cbor/JacksonCborHttpMessageConverter.java @@ -38,7 +38,7 @@ * @author Sebastien Deleuze * @since 7.0 */ -public class JacksonCborHttpMessageConverter extends AbstractJacksonHttpMessageConverter { +public class JacksonCborHttpMessageConverter extends AbstractJacksonHttpMessageConverter { /** * Construct a new instance with a {@link CBORMapper} customized with the diff --git a/spring-web/src/main/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverter.java index f1902a8ad618..5862aa6e073c 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverter.java @@ -21,7 +21,6 @@ import org.jspecify.annotations.Nullable; import tools.jackson.core.JsonGenerator; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.cfg.MapperBuilder; import tools.jackson.databind.json.JsonMapper; @@ -32,7 +31,7 @@ /** * Implementation of {@link org.springframework.http.converter.HttpMessageConverter} * that can read and write JSON using Jackson 3.x's - * {@link ObjectMapper}. + * {@link JsonMapper}. * *

    This converter can be used to bind to typed beans, or untyped * {@code HashMap} instances. @@ -56,7 +55,7 @@ * @author Sebastien Deleuze * @since 7.0 */ -public class JacksonJsonHttpMessageConverter extends AbstractJacksonHttpMessageConverter { +public class JacksonJsonHttpMessageConverter extends AbstractJacksonHttpMessageConverter { private static final List problemDetailMediaTypes = Collections.singletonList(MediaType.APPLICATION_PROBLEM_JSON); @@ -79,11 +78,11 @@ public JacksonJsonHttpMessageConverter() { } /** - * Construct a new instance with the provided {@link ObjectMapper}. + * Construct a new instance with the provided {@link JsonMapper}. * @see JsonMapper#builder() * @see MapperBuilder#findModules(ClassLoader) */ - public JacksonJsonHttpMessageConverter(ObjectMapper objectMapper) { + public JacksonJsonHttpMessageConverter(JsonMapper objectMapper) { super(objectMapper, DEFAULT_JSON_MIME_TYPES); } diff --git a/spring-web/src/main/java/org/springframework/http/converter/smile/JacksonSmileHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/smile/JacksonSmileHttpMessageConverter.java index d3beaf31311c..a6f6c9a9686b 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/smile/JacksonSmileHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/smile/JacksonSmileHttpMessageConverter.java @@ -38,7 +38,7 @@ * @author Sebastien Deleuze * @since 7.0 */ -public class JacksonSmileHttpMessageConverter extends AbstractJacksonHttpMessageConverter { +public class JacksonSmileHttpMessageConverter extends AbstractJacksonHttpMessageConverter { private static final MediaType DEFAULT_SMILE_MIME_TYPES = new MediaType("application", "x-jackson-smile"); diff --git a/spring-web/src/main/java/org/springframework/http/converter/xml/JacksonXmlHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/xml/JacksonXmlHttpMessageConverter.java index c375fc7ea911..3213daded78d 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/xml/JacksonXmlHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/xml/JacksonXmlHttpMessageConverter.java @@ -53,7 +53,7 @@ * @author Sebastien Deleuze * @since 7.0 */ -public class JacksonXmlHttpMessageConverter extends AbstractJacksonHttpMessageConverter { +public class JacksonXmlHttpMessageConverter extends AbstractJacksonHttpMessageConverter { private static final List problemDetailMediaTypes = Collections.singletonList(MediaType.APPLICATION_PROBLEM_XML); diff --git a/spring-web/src/main/java/org/springframework/http/converter/yaml/JacksonYamlHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/yaml/JacksonYamlHttpMessageConverter.java index 9f602ae62d22..e4cb42245305 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/yaml/JacksonYamlHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/yaml/JacksonYamlHttpMessageConverter.java @@ -38,7 +38,7 @@ * @author Sebastien Deleuze * @since 7.0 */ -public class JacksonYamlHttpMessageConverter extends AbstractJacksonHttpMessageConverter { +public class JacksonYamlHttpMessageConverter extends AbstractJacksonHttpMessageConverter { /** * Construct a new instance with a {@link YAMLMapper} customized with the diff --git a/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonDecoderTests.java b/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonDecoderTests.java index 56a202d0f21a..6ae9bcffadb7 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonDecoderTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonDecoderTests.java @@ -31,7 +31,6 @@ import tools.jackson.core.JsonParser; import tools.jackson.databind.DeserializationContext; import tools.jackson.databind.JsonNode; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.annotation.JsonDeserialize; import tools.jackson.databind.deser.std.StdDeserializer; import tools.jackson.databind.json.JsonMapper; @@ -101,9 +100,9 @@ void canDecodeWithObjectMapperRegistrationForType() { assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), halFormsJsonMediaType)).isTrue(); assertThat(decoder.canDecode(ResolvableType.forClass(Map.class), MediaType.APPLICATION_JSON)).isTrue(); - decoder.registerObjectMappersForType(Pojo.class, map -> { - map.put(halJsonMediaType, new ObjectMapper()); - map.put(MediaType.APPLICATION_JSON, new ObjectMapper()); + decoder.registerMappersForType(Pojo.class, map -> { + map.put(halJsonMediaType, new JsonMapper()); + map.put(MediaType.APPLICATION_JSON, new JsonMapper()); }); assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), halJsonMediaType)).isTrue(); @@ -115,7 +114,7 @@ void canDecodeWithObjectMapperRegistrationForType() { @Test // SPR-15866 void canDecodeWithProvidedMimeType() { MimeType textJavascript = new MimeType("text", "javascript", StandardCharsets.UTF_8); - JacksonJsonDecoder decoder = new JacksonJsonDecoder(new ObjectMapper(), textJavascript); + JacksonJsonDecoder decoder = new JacksonJsonDecoder(new JsonMapper(), textJavascript); assertThat(decoder.getDecodableMimeTypes()).isEqualTo(Collections.singletonList(textJavascript)); } @@ -124,7 +123,7 @@ void canDecodeWithProvidedMimeType() { @SuppressWarnings("unchecked") void decodableMimeTypesIsImmutable() { MimeType textJavascript = new MimeType("text", "javascript", StandardCharsets.UTF_8); - JacksonJsonDecoder decoder = new JacksonJsonDecoder(new ObjectMapper(), textJavascript); + JacksonJsonDecoder decoder = new JacksonJsonDecoder(new JsonMapper(), textJavascript); assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> decoder.getDecodableMimeTypes().add(new MimeType("text", "ecmascript"))); @@ -135,8 +134,8 @@ void decodableMimeTypesWithObjectMapperRegistration() { MimeType mimeType1 = MediaType.parseMediaType("application/hal+json"); MimeType mimeType2 = new MimeType("text", "javascript", StandardCharsets.UTF_8); - JacksonJsonDecoder decoder = new JacksonJsonDecoder(new ObjectMapper(), mimeType2); - decoder.registerObjectMappersForType(Pojo.class, map -> map.put(mimeType1, new ObjectMapper())); + JacksonJsonDecoder decoder = new JacksonJsonDecoder(new JsonMapper(), mimeType2); + decoder.registerMappersForType(Pojo.class, map -> map.put(mimeType1, new JsonMapper())); assertThat(decoder.getDecodableMimeTypes(ResolvableType.forClass(Pojo.class))) .containsExactly(mimeType1); diff --git a/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonEncoderTests.java b/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonEncoderTests.java index d0a0ff530f43..229fb9fed27b 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonEncoderTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonEncoderTests.java @@ -28,7 +28,6 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.SerializationFeature; import tools.jackson.databind.json.JsonMapper; @@ -109,7 +108,7 @@ public void encode() throws Exception { @Test // SPR-15866 public void canEncodeWithCustomMimeType() { MimeType textJavascript = new MimeType("text", "javascript", StandardCharsets.UTF_8); - JacksonJsonEncoder encoder = new JacksonJsonEncoder(new ObjectMapper(), textJavascript); + JacksonJsonEncoder encoder = new JacksonJsonEncoder(new JsonMapper(), textJavascript); assertThat(encoder.getEncodableMimeTypes()).isEqualTo(Collections.singletonList(textJavascript)); } @@ -117,7 +116,7 @@ public void canEncodeWithCustomMimeType() { @Test void encodableMimeTypesIsImmutable() { MimeType textJavascript = new MimeType("text", "javascript", StandardCharsets.UTF_8); - JacksonJsonEncoder encoder = new JacksonJsonEncoder(new ObjectMapper(), textJavascript); + JacksonJsonEncoder encoder = new JacksonJsonEncoder(new JsonMapper(), textJavascript); assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> encoder.getEncodableMimeTypes().add(new MimeType("text", "ecmascript"))); @@ -231,7 +230,7 @@ void classLevelJsonView() { @Test // gh-22771 public void encodeWithFlushAfterWriteOff() { - ObjectMapper mapper = JsonMapper.builder().configure(SerializationFeature.FLUSH_AFTER_WRITE_VALUE, false).build(); + JsonMapper mapper = JsonMapper.builder().configure(SerializationFeature.FLUSH_AFTER_WRITE_VALUE, false).build(); JacksonJsonEncoder encoder = new JacksonJsonEncoder(mapper); Flux result = encoder.encode(Flux.just(new Pojo("foo", "bar")), this.bufferFactory, diff --git a/spring-web/src/test/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverterTests.java b/spring-web/src/test/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverterTests.java index b3f7e08f0b5e..556c506c0a0d 100644 --- a/spring-web/src/test/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverterTests.java +++ b/spring-web/src/test/java/org/springframework/http/converter/json/JacksonJsonHttpMessageConverterTests.java @@ -87,9 +87,9 @@ void canReadWithObjectMapperRegistrationForType() { assertThat(converter.canRead(MyBean.class, halFormsJsonMediaType)).isTrue(); assertThat(converter.canRead(Map.class, MediaType.APPLICATION_JSON)).isTrue(); - converter.registerObjectMappersForType(MyBean.class, map -> { - map.put(halJsonMediaType, new ObjectMapper()); - map.put(MediaType.APPLICATION_JSON, new ObjectMapper()); + converter.registerMappersForType(MyBean.class, map -> { + map.put(halJsonMediaType, new JsonMapper()); + map.put(MediaType.APPLICATION_JSON, new JsonMapper()); }); assertThat(converter.canRead(MyBean.class, halJsonMediaType)).isTrue(); @@ -121,9 +121,9 @@ void getSupportedMediaTypes() { assertThat(converter.getSupportedMediaTypes(MyBean.class)).containsExactly(defaultMediaTypes); MediaType halJson = MediaType.parseMediaType("application/hal+json"); - converter.registerObjectMappersForType(MyBean.class, map -> { - map.put(halJson, new ObjectMapper()); - map.put(MediaType.APPLICATION_JSON, new ObjectMapper()); + converter.registerMappersForType(MyBean.class, map -> { + map.put(halJson, new JsonMapper()); + map.put(MediaType.APPLICATION_JSON, new JsonMapper()); }); assertThat(converter.getSupportedMediaTypes(MyBean.class)).containsExactly(halJson, MediaType.APPLICATION_JSON); @@ -365,7 +365,7 @@ void prettyPrint() throws Exception { PrettyPrintBean bean = new PrettyPrintBean(); bean.setName("Jason"); - ObjectMapper mapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); + JsonMapper mapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); this.converter = new JacksonJsonHttpMessageConverter(mapper); this.converter.write(bean, ResolvableType.forType(PrettyPrintBean.class), MediaType.APPLICATION_JSON, outputMessage, null); @@ -384,7 +384,7 @@ void prettyPrintWithSse() throws Exception { PrettyPrintBean bean = new PrettyPrintBean(); bean.setName("Jason"); - ObjectMapper mapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); + JsonMapper mapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); this.converter = new JacksonJsonHttpMessageConverter(mapper); this.converter.write(bean, ResolvableType.forType(PrettyPrintBean.class), MediaType.APPLICATION_JSON, outputMessage, null); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java index 98a31e66a0a7..5be9f01d589a 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java @@ -33,7 +33,6 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.SerializationFeature; import tools.jackson.databind.json.JsonMapper; @@ -439,10 +438,10 @@ void handleWithObjectMapperByTypeRegistration() { MediaType halFormsMediaType = MediaType.parseMediaType("application/prs.hal-forms+json"); MediaType halMediaType = MediaType.parseMediaType("application/hal+json"); - ObjectMapper objectMapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); + JsonMapper jsonMapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); JacksonJsonEncoder encoder = new JacksonJsonEncoder(); - encoder.registerObjectMappersForType(Person.class, map -> map.put(halMediaType, objectMapper)); + encoder.registerMappersForType(Person.class, map -> map.put(halMediaType, jsonMapper)); EncoderHttpMessageWriter writer = new EncoderHttpMessageWriter<>(encoder); ResponseEntityResultHandler handler = new ResponseEntityResultHandler( diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/JacksonJsonView.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/JacksonJsonView.java index 6c2abcc89412..371b66825005 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/JacksonJsonView.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/JacksonJsonView.java @@ -24,7 +24,6 @@ import jakarta.servlet.http.HttpServletRequest; import org.jspecify.annotations.Nullable; import tools.jackson.core.JsonGenerator; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.cfg.MapperBuilder; import tools.jackson.databind.json.JsonMapper; @@ -35,7 +34,7 @@ /** * Spring MVC {@link View} that renders JSON content by serializing the model for the current request - * using Jackson 3's {@link ObjectMapper}. + * using Jackson 3's {@link JsonMapper}. * *

    By default, the entire contents of the model map (with the exception of framework-specific classes) * will be encoded as JSON. If the model contains only one key, you can have it extracted encoded as JSON @@ -79,11 +78,11 @@ public JacksonJsonView() { } /** - * Construct a new instance using the provided {@link ObjectMapper} + * Construct a new instance using the provided {@link JsonMapper} * and setting the content type to {@value #DEFAULT_CONTENT_TYPE}. */ - public JacksonJsonView(ObjectMapper objectMapper) { - super(objectMapper, DEFAULT_CONTENT_TYPE); + public JacksonJsonView(JsonMapper jsonMapper) { + super(jsonMapper, DEFAULT_CONTENT_TYPE); } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportExtensionTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportExtensionTests.java index 75d2426aa88b..18db77722bdf 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportExtensionTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportExtensionTests.java @@ -27,7 +27,7 @@ import org.junit.jupiter.api.Test; import tools.jackson.databind.DeserializationFeature; import tools.jackson.databind.MapperFeature; -import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; import org.springframework.beans.DirectFieldAccessor; import org.springframework.beans.testfixture.beans.TestBean; @@ -214,10 +214,10 @@ void requestMappingHandlerAdapter() { assertThat(converters.get(0).getClass()).isEqualTo(StringHttpMessageConverter.class); assertThat(converters.get(1).getClass()).isEqualTo(AllEncompassingFormHttpMessageConverter.class); assertThat(converters.get(2).getClass()).isEqualTo(JacksonJsonHttpMessageConverter.class); - ObjectMapper objectMapper = ((JacksonJsonHttpMessageConverter) converters.get(2)).getObjectMapper(); - assertThat(objectMapper.deserializationConfig().isEnabled(MapperFeature.DEFAULT_VIEW_INCLUSION)).isFalse(); - assertThat(objectMapper.deserializationConfig().isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)).isFalse(); - assertThat(objectMapper.serializationConfig().isEnabled(MapperFeature.DEFAULT_VIEW_INCLUSION)).isFalse(); + JsonMapper jsonMapper = ((JacksonJsonHttpMessageConverter) converters.get(2)).getMapper(); + assertThat(jsonMapper.deserializationConfig().isEnabled(MapperFeature.DEFAULT_VIEW_INCLUSION)).isFalse(); + assertThat(jsonMapper.deserializationConfig().isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)).isFalse(); + assertThat(jsonMapper.serializationConfig().isEnabled(MapperFeature.DEFAULT_VIEW_INCLUSION)).isFalse(); DirectFieldAccessor fieldAccessor = new DirectFieldAccessor(adapter); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportTests.java index c6514aa03b6f..2d4e17a1f314 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportTests.java @@ -181,7 +181,7 @@ void requestMappingHandlerAdapter() { .filter(AbstractJacksonHttpMessageConverter.class::isInstance) .map(AbstractJacksonHttpMessageConverter.class::cast) .forEach(converter -> { - ObjectMapper mapper = converter.getObjectMapper(); + ObjectMapper mapper = converter.getMapper(); assertThat(mapper.deserializationConfig().isEnabled(MapperFeature.DEFAULT_VIEW_INCLUSION)).isFalse(); assertThat(mapper.deserializationConfig().isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)).isFalse(); assertThat(mapper.serializationConfig().isEnabled(MapperFeature.DEFAULT_VIEW_INCLUSION)).isFalse(); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/SseServerResponseTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/SseServerResponseTests.java index ef13f5626ca8..a23f0fdcc0bb 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/SseServerResponseTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/SseServerResponseTests.java @@ -24,7 +24,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.SerializationFeature; import tools.jackson.databind.json.JsonMapper; @@ -109,8 +108,8 @@ void sendObjectWithPrettyPrint() throws Exception { } }); - ObjectMapper objectMapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); - JacksonJsonHttpMessageConverter converter = new JacksonJsonHttpMessageConverter(objectMapper); + JsonMapper jsonMapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); + JacksonJsonHttpMessageConverter converter = new JacksonJsonHttpMessageConverter(jsonMapper); ServerResponse.Context context = () -> List.of(converter); ModelAndView mav = response.writeTo(this.mockRequest, this.mockResponse, context); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorTests.java index 4f1eecd9a839..81bdb288d2a2 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorTests.java @@ -338,7 +338,7 @@ void handleReturnValueWithObjectMapperByTypeRegistration() throws Exception { simpleBean.setName("Jason"); JacksonJsonHttpMessageConverter converter = new JacksonJsonHttpMessageConverter(); - converter.registerObjectMappersForType(SimpleBean.class, map -> map.put(halMediaType, mapper)); + converter.registerMappersForType(SimpleBean.class, map -> map.put(halMediaType, mapper)); RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor(List.of(converter)); MethodParameter returnType = new MethodParameter(getClass().getDeclaredMethod("getSimpleBean"), -1); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/view/json/JacksonJsonViewTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/view/json/JacksonJsonViewTests.java index 86a1212d718a..2186de361e75 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/view/json/JacksonJsonViewTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/view/json/JacksonJsonViewTests.java @@ -33,7 +33,6 @@ import tools.jackson.core.JsonGenerator; import tools.jackson.databind.BeanDescription; import tools.jackson.databind.JavaType; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.SerializationContext; import tools.jackson.databind.SerializationFeature; import tools.jackson.databind.ValueSerializer; @@ -181,7 +180,7 @@ void renderSimpleBeanNotPrefixed() throws Exception { @Test void renderWithCustomSerializerLocatedByFactory() throws Exception { SerializerFactory factory = new DelegatingSerializerFactory(null); - ObjectMapper mapper = JsonMapper.builder().serializerFactory(factory).build(); + JsonMapper mapper = JsonMapper.builder().serializerFactory(factory).build(); view = new JacksonJsonView(mapper); Object bean = new TestBeanSimple(); diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/frame/JacksonJsonSockJsMessageCodec.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/frame/JacksonJsonSockJsMessageCodec.java index 70a18aa8cf52..8bbedfb14962 100644 --- a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/frame/JacksonJsonSockJsMessageCodec.java +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/frame/JacksonJsonSockJsMessageCodec.java @@ -20,7 +20,6 @@ import org.jspecify.annotations.Nullable; import tools.jackson.core.io.JsonStringEncoder; -import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.cfg.MapperBuilder; import tools.jackson.databind.json.JsonMapper; @@ -37,7 +36,7 @@ */ public class JacksonJsonSockJsMessageCodec extends AbstractSockJsMessageCodec { - private final ObjectMapper objectMapper; + private final JsonMapper jsonMapper; /** @@ -46,28 +45,28 @@ public class JacksonJsonSockJsMessageCodec extends AbstractSockJsMessageCodec { * {@link MapperBuilder#findModules(ClassLoader)}. */ public JacksonJsonSockJsMessageCodec() { - this.objectMapper = JsonMapper.builder().findAndAddModules(JacksonJsonSockJsMessageCodec.class.getClassLoader()).build(); + this.jsonMapper = JsonMapper.builder().findAndAddModules(JacksonJsonSockJsMessageCodec.class.getClassLoader()).build(); } /** - * Construct a new instance with the provided {@link ObjectMapper}. + * Construct a new instance with the provided {@link JsonMapper}. * @see JsonMapper#builder() * @see MapperBuilder#findAndAddModules(ClassLoader) */ - public JacksonJsonSockJsMessageCodec(ObjectMapper objectMapper) { - Assert.notNull(objectMapper, "ObjectMapper must not be null"); - this.objectMapper = objectMapper; + public JacksonJsonSockJsMessageCodec(JsonMapper jsonMapper) { + Assert.notNull(jsonMapper, "JsonMapper must not be null"); + this.jsonMapper = jsonMapper; } @Override public String @Nullable [] decode(String content) { - return this.objectMapper.readValue(content, String[].class); + return this.jsonMapper.readValue(content, String[].class); } @Override public String @Nullable [] decodeInputStream(InputStream content) { - return this.objectMapper.readValue(content, String[].class); + return this.jsonMapper.readValue(content, String[].class); } @Override From d128dd26163cf6c0cf703d257a62a0fd84dd1a8e Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Fri, 22 Aug 2025 19:24:11 +0200 Subject: [PATCH 125/591] Make StartupStep AutoCloseable This commit mames `StartupStep` extend `AutoCloseable` in order to allow the try/with resources syntax and making the `step.end()` call transparent. Closes gh-35277 --- .../core/beans/context-introduction.adoc | 26 +++++++++---------- .../core/metrics/StartupStep.java | 6 ++++- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/core/beans/context-introduction.adoc b/framework-docs/modules/ROOT/pages/core/beans/context-introduction.adoc index 7f6f19572af8..4bafff8d45f3 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/context-introduction.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/context-introduction.adoc @@ -933,13 +933,12 @@ Java:: [source,java,indent=0,subs="verbatim,quotes"] ---- // create a startup step and start recording - StartupStep scanPackages = getApplicationStartup().start("spring.context.base-packages.scan"); - // add tagging information to the current step - scanPackages.tag("packages", () -> Arrays.toString(basePackages)); - // perform the actual phase we're instrumenting - this.scanner.scan(basePackages); - // end the current step - scanPackages.end(); + try (StartupStep scanPackages = getApplicationStartup().start("spring.context.base-packages.scan")) { + // add tagging information to the current step + scanPackages.tag("packages", () -> Arrays.toString(basePackages)); + // perform the actual phase we're instrumenting + this.scanner.scan(basePackages); + } ---- Kotlin:: @@ -947,13 +946,12 @@ Kotlin:: [source,kotlin,indent=0,subs="verbatim,quotes"] ---- // create a startup step and start recording - val scanPackages = getApplicationStartup().start("spring.context.base-packages.scan") - // add tagging information to the current step - scanPackages.tag("packages", () -> Arrays.toString(basePackages)) - // perform the actual phase we're instrumenting - this.scanner.scan(basePackages) - // end the current step - scanPackages.end() + try (val scanPackages = getApplicationStartup().start("spring.context.base-packages.scan")) { + // add tagging information to the current step + scanPackages.tag("packages", () -> Arrays.toString(basePackages)); + // perform the actual phase we're instrumenting + this.scanner.scan(basePackages); + } ---- ====== diff --git a/spring-core/src/main/java/org/springframework/core/metrics/StartupStep.java b/spring-core/src/main/java/org/springframework/core/metrics/StartupStep.java index 11dd6e8c43fe..f3f4a4a90dc0 100644 --- a/spring-core/src/main/java/org/springframework/core/metrics/StartupStep.java +++ b/spring-core/src/main/java/org/springframework/core/metrics/StartupStep.java @@ -36,7 +36,7 @@ * @author Brian Clozel * @since 5.3 */ -public interface StartupStep { +public interface StartupStep extends AutoCloseable { /** * Return the name of the startup step. @@ -83,6 +83,10 @@ public interface StartupStep { */ void end(); + @Override + default void close() { + this.end(); + } /** * Immutable collection of {@link Tag}. From a46023134ab2bd11451a32cb09bd356edf40f008 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Fri, 22 Aug 2025 21:05:16 +0200 Subject: [PATCH 126/591] Polishing --- .../web/service/invoker/HttpServiceProxyFactoryTests.java | 3 +-- .../web/service/registry/ClientHttpServiceRegistrarTests.java | 2 +- .../org/springframework/web/service/registry/TestGroup.java | 2 +- .../web/service/registry/TestGroupRegistry.java | 2 +- .../web/service/registry/basic/BasicClient.java | 1 - .../org/springframework/web/service/registry/echo/EchoA.java | 1 - .../org/springframework/web/service/registry/echo/EchoB.java | 1 - .../springframework/web/service/registry/echo/EchoClientA.java | 1 - .../springframework/web/service/registry/echo/EchoClientB.java | 1 - .../web/service/registry/greeting/GreetingA.java | 1 - .../web/service/registry/greeting/GreetingB.java | 1 - 11 files changed, 4 insertions(+), 12 deletions(-) diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/HttpServiceProxyFactoryTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/HttpServiceProxyFactoryTests.java index 708945b71d7e..167698514ada 100644 --- a/spring-web/src/test/java/org/springframework/web/service/invoker/HttpServiceProxyFactoryTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/HttpServiceProxyFactoryTests.java @@ -27,11 +27,11 @@ /** * Unit tests for {@link HttpServiceProxyFactory}. + * * @author Rossen Stoyanchev */ public class HttpServiceProxyFactoryTests { - @Test void httpExchangeAdapterDecorator() { @@ -44,7 +44,6 @@ void httpExchangeAdapterDecorator() { } - private interface Service { @GetExchange diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/ClientHttpServiceRegistrarTests.java b/spring-web/src/test/java/org/springframework/web/service/registry/ClientHttpServiceRegistrarTests.java index 3373945b12f9..7f737c925013 100644 --- a/spring-web/src/test/java/org/springframework/web/service/registry/ClientHttpServiceRegistrarTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/registry/ClientHttpServiceRegistrarTests.java @@ -16,7 +16,6 @@ package org.springframework.web.service.registry; - import java.util.List; import java.util.Map; @@ -37,6 +36,7 @@ /** * Unit tests for {@link AbstractClientHttpServiceRegistrar}. + * * @author Rossen Stoyanchev */ public class ClientHttpServiceRegistrarTests { diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/TestGroup.java b/spring-web/src/test/java/org/springframework/web/service/registry/TestGroup.java index d4ed645a0b1e..97f14e463e98 100644 --- a/spring-web/src/test/java/org/springframework/web/service/registry/TestGroup.java +++ b/spring-web/src/test/java/org/springframework/web/service/registry/TestGroup.java @@ -16,7 +16,6 @@ package org.springframework.web.service.registry; - import java.util.Arrays; import java.util.LinkedHashSet; import java.util.Set; @@ -53,4 +52,5 @@ public static TestGroup ofPackageClasses(String name, ClientType clientType, Cla group.packageClasses().addAll(Arrays.asList(packageClasses)); return group; } + } diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/TestGroupRegistry.java b/spring-web/src/test/java/org/springframework/web/service/registry/TestGroupRegistry.java index 2f059d0674a5..3a3ec15bc073 100644 --- a/spring-web/src/test/java/org/springframework/web/service/registry/TestGroupRegistry.java +++ b/spring-web/src/test/java/org/springframework/web/service/registry/TestGroupRegistry.java @@ -16,7 +16,6 @@ package org.springframework.web.service.registry; - import java.util.Arrays; import java.util.LinkedHashMap; import java.util.Map; @@ -76,4 +75,5 @@ private TestGroup getOrCreateGroup() { return this.groupMap.computeIfAbsent(this.groupName, name -> new TestGroup(name, this.clientType)); } } + } diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/basic/BasicClient.java b/spring-web/src/test/java/org/springframework/web/service/registry/basic/BasicClient.java index 6b81e0e7796b..86f49a958f80 100644 --- a/spring-web/src/test/java/org/springframework/web/service/registry/basic/BasicClient.java +++ b/spring-web/src/test/java/org/springframework/web/service/registry/basic/BasicClient.java @@ -16,7 +16,6 @@ package org.springframework.web.service.registry.basic; - import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.service.annotation.GetExchange; import org.springframework.web.service.registry.HttpServiceClient; diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoA.java b/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoA.java index cfd064d9e93d..202abfb03e9b 100644 --- a/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoA.java +++ b/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoA.java @@ -16,7 +16,6 @@ package org.springframework.web.service.registry.echo; - import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.service.annotation.GetExchange; diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoB.java b/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoB.java index a32163ddf3c1..44227ca0409e 100644 --- a/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoB.java +++ b/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoB.java @@ -16,7 +16,6 @@ package org.springframework.web.service.registry.echo; - import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.service.annotation.GetExchange; diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoClientA.java b/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoClientA.java index 7ab473f58447..9c25fc5e662b 100644 --- a/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoClientA.java +++ b/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoClientA.java @@ -16,7 +16,6 @@ package org.springframework.web.service.registry.echo; - import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.service.annotation.GetExchange; import org.springframework.web.service.registry.HttpServiceClient; diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoClientB.java b/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoClientB.java index 88368f0c931c..e5b858409590 100644 --- a/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoClientB.java +++ b/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoClientB.java @@ -16,7 +16,6 @@ package org.springframework.web.service.registry.echo; - import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.service.annotation.GetExchange; import org.springframework.web.service.registry.HttpServiceClient; diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/greeting/GreetingA.java b/spring-web/src/test/java/org/springframework/web/service/registry/greeting/GreetingA.java index f0993ffe04de..dd56c254ba6b 100644 --- a/spring-web/src/test/java/org/springframework/web/service/registry/greeting/GreetingA.java +++ b/spring-web/src/test/java/org/springframework/web/service/registry/greeting/GreetingA.java @@ -16,7 +16,6 @@ package org.springframework.web.service.registry.greeting; - import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.service.annotation.GetExchange; diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/greeting/GreetingB.java b/spring-web/src/test/java/org/springframework/web/service/registry/greeting/GreetingB.java index dfd6e8ee64ec..c4ee2148d5c5 100644 --- a/spring-web/src/test/java/org/springframework/web/service/registry/greeting/GreetingB.java +++ b/spring-web/src/test/java/org/springframework/web/service/registry/greeting/GreetingB.java @@ -16,7 +16,6 @@ package org.springframework.web.service.registry.greeting; - import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.service.annotation.GetExchange; From c248f94e5a6fb7cfc085ee9a9e39ad9293be9ae0 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Fri, 22 Aug 2025 21:59:38 +0200 Subject: [PATCH 127/591] Cache bean type next to primary bean names (on singleton creation) This avoids singleton access for type checks in hasPrimaryConflict. Closes gh-35330 --- .../support/DefaultListableBeanFactory.java | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java index cbe60f9a525c..b8ee9acb4b1e 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java @@ -58,6 +58,7 @@ import org.springframework.beans.factory.BeanFactoryUtils; import org.springframework.beans.factory.BeanNotOfRequiredTypeException; import org.springframework.beans.factory.CannotLoadBeanClassException; +import org.springframework.beans.factory.FactoryBean; import org.springframework.beans.factory.InjectionPoint; import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.beans.factory.NoUniqueBeanDefinitionException; @@ -196,8 +197,8 @@ public class DefaultListableBeanFactory extends AbstractAutowireCapableBeanFacto /** Map from bean name to merged BeanDefinitionHolder. */ private final Map mergedBeanDefinitionHolders = new ConcurrentHashMap<>(256); - /** Set of bean definition names with a primary marker. */ - private final Set primaryBeanNames = ConcurrentHashMap.newKeySet(16); + /** Map of bean definition names with a primary marker plus corresponding type. */ + private final Map> primaryBeanNamesWithType = new ConcurrentHashMap<>(16); /** Map of singleton and non-singleton bean names, keyed by dependency type. */ private final Map, String[]> allBeanNamesByType = new ConcurrentHashMap<>(64); @@ -1037,7 +1038,7 @@ protected Object obtainInstanceFromSupplier(Supplier supplier, String beanNam protected void cacheMergedBeanDefinition(RootBeanDefinition mbd, String beanName) { super.cacheMergedBeanDefinition(mbd, beanName); if (mbd.isPrimary()) { - this.primaryBeanNames.add(beanName); + this.primaryBeanNamesWithType.put(beanName, Void.class); } } @@ -1313,7 +1314,7 @@ else if (isConfigurationFrozen()) { // Cache a primary marker for the given bean. if (beanDefinition.isPrimary()) { - this.primaryBeanNames.add(beanName); + this.primaryBeanNamesWithType.put(beanName, Void.class); } } @@ -1405,7 +1406,7 @@ protected void resetBeanDefinition(String beanName) { destroySingleton(beanName); // Remove a cached primary marker for the given bean. - this.primaryBeanNames.remove(beanName); + this.primaryBeanNamesWithType.remove(beanName); // Notify all post-processors that the specified bean definition has been reset. for (MergedBeanDefinitionPostProcessor processor : getBeanPostProcessorCache().mergedDefinition) { @@ -1458,9 +1459,18 @@ protected void checkForAliasCircle(String name, String alias) { @Override protected void addSingleton(String beanName, Object singletonObject) { super.addSingleton(beanName, singletonObject); + Predicate> filter = (beanType -> beanType != Object.class && beanType.isInstance(singletonObject)); this.allBeanNamesByType.keySet().removeIf(filter); this.singletonBeanNamesByType.keySet().removeIf(filter); + + if (this.primaryBeanNamesWithType.containsKey(beanName) && singletonObject.getClass() != NullBean.class) { + Class beanType = (singletonObject instanceof FactoryBean fb ? + getTypeForFactoryBean(fb) : singletonObject.getClass()); + if (beanType != null) { + this.primaryBeanNamesWithType.put(beanName, beanType); + } + } } @Override @@ -2268,8 +2278,12 @@ private boolean isSelfReference(@Nullable String beanName, @Nullable String cand * not matching the given bean name. */ private boolean hasPrimaryConflict(String beanName, Class dependencyType) { - for (String candidate : this.primaryBeanNames) { - if (isTypeMatch(candidate, dependencyType) && !candidate.equals(beanName)) { + for (Map.Entry> candidate : this.primaryBeanNamesWithType.entrySet()) { + String candidateName = candidate.getKey(); + Class candidateType = candidate.getValue(); + if (!candidateName.equals(beanName) && (candidateType != Void.class ? + dependencyType.isAssignableFrom(candidateType) : // cached singleton class for primary bean + isTypeMatch(candidateName, dependencyType))) { // not instantiated yet or not a singleton return true; } } From 300ae841ce2db60c3addeed49c832fd56b7ee900 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Fri, 22 Aug 2025 22:00:22 +0200 Subject: [PATCH 128/591] Align setBeanResolver nullability with getBeanResolver Includes consistent javadoc for all applicable methods. Closes gh-35371 --- .../support/StandardEvaluationContext.java | 134 ++++++++++++++++-- 1 file changed, 121 insertions(+), 13 deletions(-) diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/support/StandardEvaluationContext.java b/spring-expression/src/main/java/org/springframework/expression/spel/support/StandardEvaluationContext.java index 1682c6b48f72..dcc9104a93ec 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/support/StandardEvaluationContext.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/support/StandardEvaluationContext.java @@ -129,34 +129,72 @@ public StandardEvaluationContext(@Nullable Object rootObject) { } + /** + * Specify the default root context object (including a type descriptor) + * against which unqualified properties, methods, etc. should be resolved. + * @param rootObject the root object to use + * @param typeDescriptor a corresponding type descriptor + */ public void setRootObject(@Nullable Object rootObject, TypeDescriptor typeDescriptor) { this.rootObject = new TypedValue(rootObject, typeDescriptor); } + /** + * Specify the default root context object against which unqualified + * properties, methods, etc. should be resolved. + * @param rootObject the root object to use + */ public void setRootObject(@Nullable Object rootObject) { this.rootObject = (rootObject != null ? new TypedValue(rootObject) : TypedValue.NULL); } + /** + * Return the configured default root context object against which unqualified + * properties, methods, etc. should be resolved (can be {@link TypedValue#NULL}). + */ @Override public TypedValue getRootObject() { return this.rootObject; } + /** + * Set the list of property accessors to use in this evaluation context. + *

    Replaces any previously configured property accessors. + */ public void setPropertyAccessors(List propertyAccessors) { this.propertyAccessors = propertyAccessors; } + /** + * Get the list of property accessors configured in this evaluation context. + */ @Override public List getPropertyAccessors() { return initPropertyAccessors(); } - public void addPropertyAccessor(PropertyAccessor accessor) { - addBeforeDefault(initPropertyAccessors(), accessor); + /** + * Add the supplied property accessor to this evaluation context. + * @param propertyAccessor the property accessor to add + * @see #getPropertyAccessors() + * @see #setPropertyAccessors(List) + * @see #removePropertyAccessor(PropertyAccessor) + */ + public void addPropertyAccessor(PropertyAccessor propertyAccessor) { + addBeforeDefault(initPropertyAccessors(), propertyAccessor); } - public boolean removePropertyAccessor(PropertyAccessor accessor) { - return initPropertyAccessors().remove(accessor); + /** + * Remove the supplied property accessor from this evaluation context. + * @param propertyAccessor the property accessor to remove + * @return {@code true} if the property accessor was removed, {@code false} + * if the property accessor was not configured in this evaluation context + * @see #getPropertyAccessors() + * @see #setPropertyAccessors(List) + * @see #addPropertyAccessor(PropertyAccessor) + */ + public boolean removePropertyAccessor(PropertyAccessor propertyAccessor) { + return initPropertyAccessors().remove(propertyAccessor); } /** @@ -198,8 +236,8 @@ public void addIndexAccessor(IndexAccessor indexAccessor) { /** * Remove the supplied index accessor from this evaluation context. * @param indexAccessor the index accessor to remove - * @return {@code true} if the index accessor was removed, {@code false} if - * the index accessor was not configured in this evaluation context + * @return {@code true} if the index accessor was removed, {@code false} + * if the index accessor was not configured in this evaluation context * @since 6.2 * @see #getIndexAccessors() * @see #setIndexAccessors(List) @@ -209,44 +247,96 @@ public boolean removeIndexAccessor(IndexAccessor indexAccessor) { return initIndexAccessors().remove(indexAccessor); } + /** + * Set the list of constructor resolvers to use in this evaluation context. + *

    Replaces any previously configured constructor resolvers. + */ public void setConstructorResolvers(List constructorResolvers) { this.constructorResolvers = constructorResolvers; } + /** + * Get the list of constructor resolvers to use in this evaluation context. + */ @Override public List getConstructorResolvers() { return initConstructorResolvers(); } - public void addConstructorResolver(ConstructorResolver resolver) { - addBeforeDefault(initConstructorResolvers(), resolver); + /** + * Add the supplied constructor resolver to this evaluation context. + * @param constructorResolver the constructor resolver to add + * @see #getConstructorResolvers() + * @see #setConstructorResolvers(List) + * @see #removeConstructorResolver(ConstructorResolver) + */ + public void addConstructorResolver(ConstructorResolver constructorResolver) { + addBeforeDefault(initConstructorResolvers(), constructorResolver); } - public boolean removeConstructorResolver(ConstructorResolver resolver) { - return initConstructorResolvers().remove(resolver); + /** + * Remove the supplied constructor resolver from this evaluation context. + * @param constructorResolver the constructor resolver to remove + * @return {@code true} if the constructor resolver was removed, {@code false} + * if the constructor resolver was not configured in this evaluation context +< * @see #getConstructorResolvers() + * @see #setConstructorResolvers(List) + * @see #addConstructorResolver(ConstructorResolver) + */ + public boolean removeConstructorResolver(ConstructorResolver constructorResolver) { + return initConstructorResolvers().remove(constructorResolver); } + /** + * Set the list of method resolvers to use in this evaluation context. + *

    Replaces any previously configured method resolvers. + */ public void setMethodResolvers(List methodResolvers) { this.methodResolvers = methodResolvers; } + /** + * Get the list of method resolvers to use in this evaluation context. + */ @Override public List getMethodResolvers() { return initMethodResolvers(); } - public void addMethodResolver(MethodResolver resolver) { - addBeforeDefault(initMethodResolvers(), resolver); + /** + * Add the supplied method resolver to this evaluation context. + * @param methodResolver the method resolver to add + * @see #getMethodResolvers() + * @see #setMethodResolvers(List) + * @see #removeMethodResolver(MethodResolver) + */ + public void addMethodResolver(MethodResolver methodResolver) { + addBeforeDefault(initMethodResolvers(), methodResolver); } + /** + * Remove the supplied method resolver from this evaluation context. + * @param methodResolver the method resolver to remove + * @return {@code true} if the method resolver was removed, {@code false} + * if the method resolver was not configured in this evaluation context + * @see #getMethodResolvers() + * @see #setMethodResolvers(List) + * @see #addMethodResolver(MethodResolver) + */ public boolean removeMethodResolver(MethodResolver methodResolver) { return initMethodResolvers().remove(methodResolver); } - public void setBeanResolver(BeanResolver beanResolver) { + /** + * Set the {@link BeanResolver} to use for looking up beans, if any. + */ + public void setBeanResolver(@Nullable BeanResolver beanResolver) { this.beanResolver = beanResolver; } + /** + * Get the configured {@link BeanResolver} for looking up beans, if any. + */ @Override @Nullable public BeanResolver getBeanResolver() { @@ -284,11 +374,17 @@ public TypeLocator getTypeLocator() { return this.typeLocator; } + /** + * Set the {@link TypeConverter} for value conversion. + */ public void setTypeConverter(TypeConverter typeConverter) { Assert.notNull(typeConverter, "TypeConverter must not be null"); this.typeConverter = typeConverter; } + /** + * Get the configured {@link TypeConverter} for value conversion. + */ @Override public TypeConverter getTypeConverter() { if (this.typeConverter == null) { @@ -297,21 +393,33 @@ public TypeConverter getTypeConverter() { return this.typeConverter; } + /** + * Set the {@link TypeComparator} for comparing pairs of objects. + */ public void setTypeComparator(TypeComparator typeComparator) { Assert.notNull(typeComparator, "TypeComparator must not be null"); this.typeComparator = typeComparator; } + /** + * Get the configured {@link TypeComparator} for comparing pairs of objects. + */ @Override public TypeComparator getTypeComparator() { return this.typeComparator; } + /** + * Set the {@link OperatorOverloader} for mathematical operations. + */ public void setOperatorOverloader(OperatorOverloader operatorOverloader) { Assert.notNull(operatorOverloader, "OperatorOverloader must not be null"); this.operatorOverloader = operatorOverloader; } + /** + * Get the configured {@link OperatorOverloader} for mathematical operations. + */ @Override public OperatorOverloader getOperatorOverloader() { return this.operatorOverloader; From 55181fa1c9657a03c4a5c1debd4f0d088b89f856 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Fri, 22 Aug 2025 22:00:52 +0200 Subject: [PATCH 129/591] Polishing --- .../beans/factory/support/AbstractBeanFactory.java | 3 +-- .../CacheOperationExpressionEvaluatorTests.java | 12 ++++++------ .../cache/interceptor/CachePutEvaluationTests.java | 4 ++++ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/AbstractBeanFactory.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/AbstractBeanFactory.java index 45119c10bd2b..26e7553370fa 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/AbstractBeanFactory.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/AbstractBeanFactory.java @@ -520,8 +520,7 @@ public boolean isTypeMatch(String name, ResolvableType typeToMatch) throws NoSuc * to check whether the bean with the given name matches the specified type. Allow * additional constraints to be applied to ensure that beans are not created early. * @param name the name of the bean to query - * @param typeToMatch the type to match against (as a - * {@code ResolvableType}) + * @param typeToMatch the type to match against (as a {@code ResolvableType}) * @return {@code true} if the bean type matches, {@code false} if it * doesn't match or cannot be determined yet * @throws NoSuchBeanDefinitionException if there is no bean with the given name diff --git a/spring-context/src/test/java/org/springframework/cache/interceptor/CacheOperationExpressionEvaluatorTests.java b/spring-context/src/test/java/org/springframework/cache/interceptor/CacheOperationExpressionEvaluatorTests.java index cde8716ec3b9..d5fba6ac6134 100644 --- a/spring-context/src/test/java/org/springframework/cache/interceptor/CacheOperationExpressionEvaluatorTests.java +++ b/spring-context/src/test/java/org/springframework/cache/interceptor/CacheOperationExpressionEvaluatorTests.java @@ -60,12 +60,6 @@ class CacheOperationExpressionEvaluatorTests { private final AnnotationCacheOperationSource source = new AnnotationCacheOperationSource(); - private Collection getOps(String name) { - Method method = ReflectionUtils.findMethod(AnnotatedClass.class, name, Object.class, Object.class); - return this.source.getCacheOperations(method, AnnotatedClass.class); - } - - @Test void testMultipleCachingSource() { Collection ops = getOps("multipleCaching"); @@ -144,6 +138,12 @@ void resolveBeanReference() { assertThat(value).isEqualTo(String.class.getName()); } + + private Collection getOps(String name) { + Method method = ReflectionUtils.findMethod(AnnotatedClass.class, name, Object.class, Object.class); + return this.source.getCacheOperations(method, AnnotatedClass.class); + } + private EvaluationContext createEvaluationContext(Object result) { return createEvaluationContext(result, null); } diff --git a/spring-context/src/test/java/org/springframework/cache/interceptor/CachePutEvaluationTests.java b/spring-context/src/test/java/org/springframework/cache/interceptor/CachePutEvaluationTests.java index 50ad24d6f5b8..31811b0cd403 100644 --- a/spring-context/src/test/java/org/springframework/cache/interceptor/CachePutEvaluationTests.java +++ b/spring-context/src/test/java/org/springframework/cache/interceptor/CachePutEvaluationTests.java @@ -104,6 +104,7 @@ void getAndPut() { assertThat(this.cache.get(anotherValue + 100).get()).as("Wrong value for @CachePut key").isEqualTo(anotherValue); } + @Configuration @EnableCaching static class Config implements CachingConfigurer { @@ -121,8 +122,10 @@ public SimpleService simpleService() { } + @CacheConfig("test") public static class SimpleService { + private AtomicLong counter = new AtomicLong(); /** @@ -144,4 +147,5 @@ public Long getAndPut(long id) { return this.counter.getAndIncrement(); } } + } From 4a4cf8a787066c0b3c2600ed210c2ed9b36d4e58 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Fri, 22 Aug 2025 22:38:34 +0200 Subject: [PATCH 130/591] Remove erroneous javadoc symbol --- .../expression/spel/support/StandardEvaluationContext.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/support/StandardEvaluationContext.java b/spring-expression/src/main/java/org/springframework/expression/spel/support/StandardEvaluationContext.java index dcc9104a93ec..4d272fc9f699 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/support/StandardEvaluationContext.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/support/StandardEvaluationContext.java @@ -279,7 +279,7 @@ public void addConstructorResolver(ConstructorResolver constructorResolver) { * @param constructorResolver the constructor resolver to remove * @return {@code true} if the constructor resolver was removed, {@code false} * if the constructor resolver was not configured in this evaluation context -< * @see #getConstructorResolvers() + * @see #getConstructorResolvers() * @see #setConstructorResolvers(List) * @see #addConstructorResolver(ConstructorResolver) */ From 01b24f2644306514a48606d6c1ba728dfd1b9678 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Fri, 22 Aug 2025 23:05:13 +0200 Subject: [PATCH 131/591] Upgrade to Protobuf 4.32, HtmlUnit 4.15, Mockito 5.19 --- framework-platform/framework-platform.gradle | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index 96b106f330ff..47de7e2da7e8 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -21,7 +21,7 @@ dependencies { api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.1")) api(platform("org.jetbrains.kotlinx:kotlinx-serialization-bom:1.6.3")) api(platform("org.junit:junit-bom:5.13.4")) - api(platform("org.mockito:mockito-bom:5.18.0")) + api(platform("org.mockito:mockito-bom:5.19.0")) constraints { api("com.fasterxml:aalto-xml:1.3.2") @@ -31,7 +31,7 @@ dependencies { api("com.google.code.findbugs:findbugs:3.0.1") api("com.google.code.findbugs:jsr305:3.0.2") api("com.google.code.gson:gson:2.13.1") - api("com.google.protobuf:protobuf-java-util:4.31.1") + api("com.google.protobuf:protobuf-java-util:4.32.0") api("com.h2database:h2:2.3.232") api("com.jayway.jsonpath:json-path:2.9.0") api("com.oracle.database.jdbc:ojdbc11:21.9.0.0") @@ -129,7 +129,7 @@ dependencies { api("org.hibernate:hibernate-core-jakarta:5.6.15.Final") api("org.hibernate:hibernate-validator:7.0.5.Final") api("org.hsqldb:hsqldb:2.7.4") - api("org.htmlunit:htmlunit:4.14.0") + api("org.htmlunit:htmlunit:4.15.0") api("org.javamoney:moneta:1.4.4") api("org.jruby:jruby:9.4.13.0") api("org.junit.support:testng-engine:1.0.5") @@ -137,7 +137,7 @@ dependencies { api("org.ogce:xpp3:1.1.6") api("org.python:jython-standalone:2.7.4") api("org.quartz-scheduler:quartz:2.3.2") - api("org.seleniumhq.selenium:htmlunit3-driver:4.34.0") + api("org.seleniumhq.selenium:htmlunit3-driver:4.35.0") api("org.seleniumhq.selenium:selenium-java:4.35.0") api("org.skyscreamer:jsonassert:1.5.3") api("org.slf4j:slf4j-api:2.0.17") From e6f2b6a2a3b49a25833cd1c5ec763b236de96ee0 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Fri, 22 Aug 2025 23:21:59 +0200 Subject: [PATCH 132/591] Upgrade to Netty 4.2.4 --- framework-platform/framework-platform.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index 1e6def45465c..7aadf832e7b7 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -9,7 +9,7 @@ javaPlatform { dependencies { api(platform("com.fasterxml.jackson:jackson-bom:2.20.0-rc1")) api(platform("io.micrometer:micrometer-bom:1.16.0-M2")) - api(platform("io.netty:netty-bom:4.2.3.Final")) + api(platform("io.netty:netty-bom:4.2.4.Final")) api(platform("io.projectreactor:reactor-bom:2025.0.0-M6")) api(platform("io.rsocket:rsocket-bom:1.1.5")) api(platform("org.apache.groovy:groovy-bom:5.0.0-rc-1")) From ed7c3d737caca9a23d23294436b482cbab03d992 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Fri, 22 Aug 2025 21:30:18 +0200 Subject: [PATCH 133/591] Fix broken link in WebDriver docs Closes gh-35374 Signed-off-by: Daniel Garnier-Moiroux --- .../modules/ROOT/pages/testing/mockmvc/htmlunit/webdriver.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework-docs/modules/ROOT/pages/testing/mockmvc/htmlunit/webdriver.adoc b/framework-docs/modules/ROOT/pages/testing/mockmvc/htmlunit/webdriver.adoc index d3a8df109d38..a9e3533cac50 100644 --- a/framework-docs/modules/ROOT/pages/testing/mockmvc/htmlunit/webdriver.adoc +++ b/framework-docs/modules/ROOT/pages/testing/mockmvc/htmlunit/webdriver.adoc @@ -261,7 +261,7 @@ Kotlin:: This improves on the design of our xref:testing/mockmvc/htmlunit/mah.adoc#spring-mvc-test-server-htmlunit-mah-usage[HtmlUnit test] by leveraging the Page Object Pattern. As we mentioned in -xref:testing/mockmvc/htmlunit/webdriver.adoc#spring-mvc-test-server-htmlunit-webdriver-why[Why WebDriver and MockMvc?], we can use the Page Object Pattern +xref:testing/mockmvc/htmlunit/webdriver.adoc#mockmvc-server-htmlunit-webdriver-why[Why WebDriver and MockMvc?], we can use the Page Object Pattern with HtmlUnit, but it is much easier with WebDriver. Consider the following `CreateMessagePage` implementation: From 0e2af5d113cacc837717f969ef46b3a4f8795946 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Sun, 24 Aug 2025 10:30:48 +0200 Subject: [PATCH 134/591] Avoid default AutoCloseable implementation in ExecutorService on JDK 19+ Consistently calls shutdown() unless a specific close() method has been provided in a subclass. Closes gh-35316 --- .../support/DisposableBeanAdapter.java | 22 +++++++-- .../support/RootBeanDefinitionTests.java | 47 ++++++++++++++++++- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/DisposableBeanAdapter.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/DisposableBeanAdapter.java index e74e5f23f299..46f5b08907dd 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/DisposableBeanAdapter.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/DisposableBeanAdapter.java @@ -23,6 +23,7 @@ import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import org.apache.commons.logging.Log; @@ -418,14 +419,29 @@ static String[] inferDestroyMethodsIfNecessary(Class target, RootBeanDefiniti String destroyMethodName = beanDefinition.resolvedDestroyMethodName; if (destroyMethodName == null) { destroyMethodName = beanDefinition.getDestroyMethodName(); - boolean autoCloseable = (AutoCloseable.class.isAssignableFrom(target)); + boolean autoCloseable = AutoCloseable.class.isAssignableFrom(target); + boolean executorService = ExecutorService.class.isAssignableFrom(target); if (AbstractBeanDefinition.INFER_METHOD.equals(destroyMethodName) || - (destroyMethodName == null && autoCloseable)) { + (destroyMethodName == null && (autoCloseable || executorService))) { // Only perform destroy method inference in case of the bean // not explicitly implementing the DisposableBean interface destroyMethodName = null; if (!(DisposableBean.class.isAssignableFrom(target))) { - if (autoCloseable) { + if (executorService) { + destroyMethodName = SHUTDOWN_METHOD_NAME; + try { + // On JDK 19+, avoid the ExecutorService-level AutoCloseable default implementation + // which awaits task termination for 1 day, even for delayed tasks such as cron jobs. + // Custom close() implementations in ExecutorService subclasses are still accepted. + if (target.getMethod(CLOSE_METHOD_NAME).getDeclaringClass() != ExecutorService.class) { + destroyMethodName = CLOSE_METHOD_NAME; + } + } + catch (NoSuchMethodException ex) { + // Ignore - stick with shutdown() + } + } + else if (autoCloseable) { destroyMethodName = CLOSE_METHOD_NAME; } else { diff --git a/spring-beans/src/test/java/org/springframework/beans/factory/support/RootBeanDefinitionTests.java b/spring-beans/src/test/java/org/springframework/beans/factory/support/RootBeanDefinitionTests.java index 056597442ba4..3d71b6d2420e 100644 --- a/spring-beans/src/test/java/org/springframework/beans/factory/support/RootBeanDefinitionTests.java +++ b/spring-beans/src/test/java/org/springframework/beans/factory/support/RootBeanDefinitionTests.java @@ -17,6 +17,7 @@ package org.springframework.beans.factory.support; import java.lang.reflect.Method; +import java.util.concurrent.ExecutorService; import org.junit.jupiter.api.Test; @@ -59,13 +60,38 @@ void setInstanceDoesNotOverrideResolvedFactoryMethodWithNull() { } @Test - void resolveDestroyMethodWithMatchingCandidateReplacedInferredVaue() { + void resolveDestroyMethodWithMatchingCandidateReplacedForCloseMethod() { RootBeanDefinition beanDefinition = new RootBeanDefinition(BeanWithCloseMethod.class); beanDefinition.setDestroyMethodName(AbstractBeanDefinition.INFER_METHOD); beanDefinition.resolveDestroyMethodIfNecessary(); assertThat(beanDefinition.getDestroyMethodNames()).containsExactly("close"); } + @Test + void resolveDestroyMethodWithMatchingCandidateReplacedForShutdownMethod() { + RootBeanDefinition beanDefinition = new RootBeanDefinition(BeanWithShutdownMethod.class); + beanDefinition.setDestroyMethodName(AbstractBeanDefinition.INFER_METHOD); + beanDefinition.resolveDestroyMethodIfNecessary(); + assertThat(beanDefinition.getDestroyMethodNames()).containsExactly("shutdown"); + } + + @Test + void resolveDestroyMethodWithMatchingCandidateReplacedForExecutorService() { + RootBeanDefinition beanDefinition = new RootBeanDefinition(BeanImplementingExecutorService.class); + beanDefinition.setDestroyMethodName(AbstractBeanDefinition.INFER_METHOD); + beanDefinition.resolveDestroyMethodIfNecessary(); + assertThat(beanDefinition.getDestroyMethodNames()).containsExactly("shutdown"); + // even on JDK 19+ where the ExecutorService interface declares a default AutoCloseable implementation + } + + @Test + void resolveDestroyMethodWithMatchingCandidateReplacedForAutoCloseableExecutorService() { + RootBeanDefinition beanDefinition = new RootBeanDefinition(BeanImplementingExecutorServiceAndAutoCloseable.class); + beanDefinition.setDestroyMethodName(AbstractBeanDefinition.INFER_METHOD); + beanDefinition.resolveDestroyMethodIfNecessary(); + assertThat(beanDefinition.getDestroyMethodNames()).containsExactly("close"); + } + @Test void resolveDestroyMethodWithNoCandidateSetDestroyMethodNameToNull() { RootBeanDefinition beanDefinition = new RootBeanDefinition(BeanWithNoDestroyMethod.class); @@ -90,6 +116,25 @@ public void close() { } + static class BeanWithShutdownMethod { + + public void shutdown() { + } + } + + + abstract static class BeanImplementingExecutorService implements ExecutorService { + } + + + abstract static class BeanImplementingExecutorServiceAndAutoCloseable implements ExecutorService, AutoCloseable { + + @Override + public void close() { + } + } + + static class BeanWithNoDestroyMethod { } From f62519bb55d039e7c491e739757c3830ea735bdf Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Sun, 24 Aug 2025 10:31:01 +0200 Subject: [PATCH 135/591] Add cancelRemainingTasksOnClose flag for enforcing early interruption Closes gh-35372 --- .../core/task/SimpleAsyncTaskExecutor.java | 59 ++++++++++++++++--- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/core/task/SimpleAsyncTaskExecutor.java b/spring-core/src/main/java/org/springframework/core/task/SimpleAsyncTaskExecutor.java index 33b35c4b37d3..bde5c547180c 100644 --- a/spring-core/src/main/java/org/springframework/core/task/SimpleAsyncTaskExecutor.java +++ b/spring-core/src/main/java/org/springframework/core/task/SimpleAsyncTaskExecutor.java @@ -92,6 +92,8 @@ public class SimpleAsyncTaskExecutor extends CustomizableThreadCreator @Nullable private Set activeThreads; + private boolean cancelRemainingTasksOnClose = false; + private boolean rejectTasksWhenLimitReached = false; private volatile boolean active = true; @@ -184,12 +186,33 @@ public void setTaskDecorator(TaskDecorator taskDecorator) { * @param timeout the timeout in milliseconds * @since 6.1 * @see #close() + * @see #setCancelRemainingTasksOnClose * @see org.springframework.scheduling.concurrent.ExecutorConfigurationSupport#setAwaitTerminationMillis */ public void setTaskTerminationTimeout(long timeout) { Assert.isTrue(timeout >= 0, "Timeout value must be >=0"); this.taskTerminationTimeout = timeout; - this.activeThreads = (timeout > 0 ? ConcurrentHashMap.newKeySet() : null); + trackActiveThreadsIfNecessary(); + } + + /** + * Specify whether to cancel remaining tasks on close: that is, whether to + * interrupt any active threads at the time of the {@link #close()} call. + *

    The default is {@code false}, not tracking active threads at all or + * just interrupting any remaining threads that still have not finished after + * the specified {@link #setTaskTerminationTimeout taskTerminationTimeout}. + * Switch this to {@code true} for immediate interruption on close, either in + * combination with a subsequent termination timeout or without any waiting + * at all, depending on whether a {@code taskTerminationTimeout} has been + * specified as well. + * @since 6.2.11 + * @see #close() + * @see #setTaskTerminationTimeout + * @see org.springframework.scheduling.concurrent.ExecutorConfigurationSupport#setWaitForTasksToCompleteOnShutdown + */ + public void setCancelRemainingTasksOnClose(boolean cancelRemainingTasksOnClose) { + this.cancelRemainingTasksOnClose = cancelRemainingTasksOnClose; + trackActiveThreadsIfNecessary(); } /** @@ -249,6 +272,15 @@ public boolean isActive() { return this.active; } + /** + * Track active threads only when a task termination timeout has been + * specified or interruption of remaining threads has been requested. + */ + private void trackActiveThreadsIfNecessary() { + this.activeThreads = (this.taskTerminationTimeout > 0 || this.cancelRemainingTasksOnClose ? + ConcurrentHashMap.newKeySet() : null); + } + /** * Executes the given task, within a concurrency throttle @@ -353,7 +385,7 @@ protected Thread newThread(Runnable task) { } /** - * This close methods tracks the termination of active threads if a concrete + * This close method tracks the termination of active threads if a concrete * {@link #setTaskTerminationTimeout task termination timeout} has been set. * Otherwise, it is not necessary to close this executor. * @since 6.1 @@ -364,17 +396,26 @@ public void close() { this.active = false; Set threads = this.activeThreads; if (threads != null) { - synchronized (threads) { - try { - if (!threads.isEmpty()) { - threads.wait(this.taskTerminationTimeout); + if (this.cancelRemainingTasksOnClose) { + // Early interrupt for remaining tasks on close + threads.forEach(Thread::interrupt); + } + if (this.taskTerminationTimeout > 0) { + synchronized (threads) { + try { + if (!threads.isEmpty()) { + threads.wait(this.taskTerminationTimeout); + } + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); } } - catch (InterruptedException ex) { - Thread.currentThread().interrupt(); + if (!this.cancelRemainingTasksOnClose) { + // Late interrupt for remaining tasks after timeout + threads.forEach(Thread::interrupt); } } - threads.forEach(Thread::interrupt); } } } From 6978f0a398203b85132162abd12bacecd6205791 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Sun, 24 Aug 2025 14:02:04 +0200 Subject: [PATCH 136/591] Document terms and units in DataSize.parse(...) methods Closes gh-35298 --- .../springframework/util/unit/DataSize.java | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/util/unit/DataSize.java b/spring-core/src/main/java/org/springframework/util/unit/DataSize.java index 002c52413338..35b85821efed 100644 --- a/spring-core/src/main/java/org/springframework/util/unit/DataSize.java +++ b/spring-core/src/main/java/org/springframework/util/unit/DataSize.java @@ -137,14 +137,19 @@ public static DataSize of(long amount, DataUnit unit) { } /** - * Obtain a {@link DataSize} from a text string such as {@code 12MB} using + * Obtain a {@link DataSize} from a text string such as {@code "5MB"} using * {@link DataUnit#BYTES} if no unit is specified. - *

    Examples: - *

    -	 * "12KB" -- parses as "12 kilobytes"
    -	 * "5MB"  -- parses as "5 megabytes"
    -	 * "20"   -- parses as "20 bytes"
    -	 * 
    + *

    Examples

    + * + * + * + * + * + * + *
    TextParsed AsSize in Bytes
    "20"20 bytes20
    "20B"20 bytes20
    "12KB"12 kilobytes12,288
    "5MB"5 megabytes5,242,880
    + *

    Note that the terms and units used in the above examples are based on + * binary prefixes. + * Consult the {@linkplain DataSize class-level Javadoc} for details. * @param text the text to parse * @return the parsed {@code DataSize} * @see #parse(CharSequence, DataUnit) @@ -154,19 +159,24 @@ public static DataSize parse(CharSequence text) { } /** - * Obtain a {@link DataSize} from a text string such as {@code 12MB} using + * Obtain a {@link DataSize} from a text string such as {@code "5MB"} using * the specified default {@link DataUnit} if no unit is specified. *

    The string starts with a number followed optionally by a unit matching * one of the supported {@linkplain DataUnit suffixes}. *

    If neither a unit nor a default {@code DataUnit} is specified, * {@link DataUnit#BYTES} will be inferred. - *

    Examples: - *

    -	 * "12KB" -- parses as "12 kilobytes"
    -	 * "5MB"  -- parses as "5 megabytes"
    -	 * "20"   -- parses as "20 kilobytes" (where the {@code defaultUnit} is {@link DataUnit#KILOBYTES})
    -	 * "20"   -- parses as "20 bytes" (if the {@code defaultUnit} is {@code null})
    -	 * 
    + *

    Examples

    + * + * + * + * + * + * + * + *
    TextDefault UnitParsed AsSize in Bytes
    "20"{@code null}20 bytes20
    "20"{@link DataUnit#KILOBYTES KILOBYTES}20 kilobytes20,480
    "20B"N/A20 bytes20
    "12KB"N/A12 kilobytes12,288
    "5MB"N/A5 megabytes5,242,880
    + *

    Note that the terms and units used in the above examples are based on + * binary prefixes. + * Consult the {@linkplain DataSize class-level Javadoc} for details. * @param text the text to parse * @param defaultUnit the default {@code DataUnit} to use * @return the parsed {@code DataSize} From 7112efee1bef87d401be3a90335c790fb96f6612 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Mon, 25 Aug 2025 14:23:13 +0200 Subject: [PATCH 137/591] Align HttpStatus with RFC9110 This commit updates the `HttpStatus` enum with the latest changes in RFC9110: * deprecate "413 Payload Too Large" in favor of "413 Content Too Large" * deprecate "418 I'm a teapot" as it was meant as a joke and is now marked as unused * Introduce new "421 Misdirected Request" * deprecate "422 Unprocessable Entity" in favor of "422 Unprocessable Content" * deprecate "509 Bandwidth Limit Exceeded" as it's now unassigned * deprecate "510 Not Extended" as it's now marked as "historic" The relevant exceptions, test matchers and more have been updated as a result. Closes gh-32870 --- .../servlet/result/StatusResultMatchers.java | 40 ++++- .../servlet/result/StatusResultMatchersDsl.kt | 35 +++++ .../org/springframework/http/HttpStatus.java | 127 +++++++++------ .../springframework/http/ResponseEntity.java | 12 ++ .../web/client/HttpClientErrorException.java | 23 +++ .../MaxUploadSizeExceededException.java | 4 +- .../web/server/ContentTooLargeException.java | 37 +++++ .../web/server/PayloadTooLargeException.java | 2 + .../springframework/http/HttpStatusTests.java | 146 +++++++++--------- .../http/ResponseEntityTests.java | 10 ++ .../client/WebClientResponseException.java | 18 +++ .../function/server/ServerResponse.java | 11 ++ ...AbstractMessageReaderArgumentResolver.java | 4 +- .../function/server/CoRouterFunctionDsl.kt | 7 + .../function/server/RouterFunctionDsl.kt | 11 ++ .../MessageReaderArgumentResolverTests.java | 4 +- .../ResponseEntityExceptionHandlerTests.java | 1 + .../web/servlet/function/ServerResponse.java | 11 ++ .../web/servlet/function/RouterFunctionDsl.kt | 7 + 19 files changed, 385 insertions(+), 125 deletions(-) create mode 100644 spring-web/src/main/java/org/springframework/web/server/ContentTooLargeException.java diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/result/StatusResultMatchers.java b/spring-test/src/main/java/org/springframework/test/web/servlet/result/StatusResultMatchers.java index caa05a88c765..8aa53769ea5d 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/result/StatusResultMatchers.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/result/StatusResultMatchers.java @@ -141,7 +141,9 @@ public ResultMatcher isSwitchingProtocols() { /** * Assert the response status code is {@code HttpStatus.PROCESSING} (102). + * @deprecated since 7.0, removed from WebDAV specification */ + @Deprecated(since = "7.0") public ResultMatcher isProcessing() { return matcher(HttpStatus.PROCESSING); } @@ -364,10 +366,20 @@ public ResultMatcher isPreconditionFailed() { return matcher(HttpStatus.PRECONDITION_FAILED); } + /** + * Assert the response status code is {@code HttpStatus.CONTENT_TOO_LARGE} (413). + * @since 7.0 + */ + public ResultMatcher isContentTooLarge() { + return matcher(HttpStatus.CONTENT_TOO_LARGE); + } + /** * Assert the response status code is {@code HttpStatus.PAYLOAD_TOO_LARGE} (413). * @since 4.1 + * @deprecated since 7.0 in favor of {@link #isContentTooLarge()} */ + @Deprecated(since = "7.0") public ResultMatcher isPayloadTooLarge() { return matcher(HttpStatus.PAYLOAD_TOO_LARGE); } @@ -403,14 +415,34 @@ public ResultMatcher isExpectationFailed() { /** * Assert the response status code is {@code HttpStatus.I_AM_A_TEAPOT} (418). + * @deprecated since 7.0, this was marked as unused in RFC 9110 */ + @Deprecated(since = "7.0") public ResultMatcher isIAmATeapot() { - return matcher(HttpStatus.valueOf(418)); + return matcher(HttpStatus.I_AM_A_TEAPOT); + } + + /** + * Assert the response status code is {@code HttpStatus.MISDIRECTED_REQUEST} (421). + * @since 7.0 + */ + public ResultMatcher isMisdirectedRequest() { + return matcher(HttpStatus.MISDIRECTED_REQUEST); + } + + /** + * Assert the response status code is {@code HttpStatus.UNPROCESSABLE_CONTENT} (422). + * @since 7.0 + */ + public ResultMatcher isUnprocessableContent() { + return matcher(HttpStatus.UNPROCESSABLE_CONTENT); } /** * Assert the response status code is {@code HttpStatus.UNPROCESSABLE_ENTITY} (422). + * @deprecated since 7.0 in favor of {@link #isUnprocessableContent()} */ + @Deprecated(since = "7.0") public ResultMatcher isUnprocessableEntity() { return matcher(HttpStatus.UNPROCESSABLE_ENTITY); } @@ -538,14 +570,18 @@ public ResultMatcher isLoopDetected() { /** * Assert the response status code is {@code HttpStatus.BANDWIDTH_LIMIT_EXCEEDED} (509). + * @deprecated since 7.0, since this is now unassigned */ + @Deprecated(since = "7.0") public ResultMatcher isBandwidthLimitExceeded() { - return matcher(HttpStatus.valueOf(509)); + return matcher(HttpStatus.BANDWIDTH_LIMIT_EXCEEDED); } /** * Assert the response status code is {@code HttpStatus.NOT_EXTENDED} (510). + * @deprecated since 7.0, this is now marked as "historic" and not endorsed by a standards body. */ + @Deprecated(since = "7.0") public ResultMatcher isNotExtended() { return matcher(HttpStatus.NOT_EXTENDED); } diff --git a/spring-test/src/main/kotlin/org/springframework/test/web/servlet/result/StatusResultMatchersDsl.kt b/spring-test/src/main/kotlin/org/springframework/test/web/servlet/result/StatusResultMatchersDsl.kt index f92f8571180a..0233fbf8077f 100644 --- a/spring-test/src/main/kotlin/org/springframework/test/web/servlet/result/StatusResultMatchersDsl.kt +++ b/spring-test/src/main/kotlin/org/springframework/test/web/servlet/result/StatusResultMatchersDsl.kt @@ -110,6 +110,8 @@ class StatusResultMatchersDsl internal constructor (private val actions: ResultA /** * @see StatusResultMatchers.isProcessing */ + @Deprecated("Removed from WebDAV specification RFC 4918") + @Suppress("DEPRECATION") fun isProcessing() { actions.andExpect(matchers.isProcessing()) } @@ -325,9 +327,18 @@ class StatusResultMatchersDsl internal constructor (private val actions: ResultA actions.andExpect(matchers.isPreconditionFailed()) } + /** + * @see StatusResultMatchers.isContentTooLarge + */ + fun isContentTooLarge() { + actions.andExpect(matchers.isContentTooLarge()) + } + /** * @see StatusResultMatchers.isPayloadTooLarge */ + @Deprecated("Use Content Too Large instead", replaceWith = ReplaceWith("isContentTooLarge()")) + @Suppress("DEPRECATION") fun isPayloadTooLarge() { actions.andExpect(matchers.isPayloadTooLarge()) } @@ -363,13 +374,33 @@ class StatusResultMatchersDsl internal constructor (private val actions: ResultA /** * @see StatusResultMatchers.isIAmATeapot */ + @Deprecated("Marked as unused in RFC 9110") + @Suppress("DEPRECATION") fun isIAmATeapot() { actions.andExpect(matchers.isIAmATeapot()) } + /** + * @see StatusResultMatchers.isMisdirectedRequest + * @since 7.0 + */ + fun isMisdirectedRequest() { + actions.andExpect(matchers.isMisdirectedRequest()) + } + + /** + * @see StatusResultMatchers.isUnprocessableContent + * @since 7.0 + */ + fun isUnprocessableContent() { + actions.andExpect(matchers.isUnprocessableContent()) + } + /** * @see StatusResultMatchers.isUnprocessableEntity */ + @Deprecated("Use UnprocessableContent instead.", ReplaceWith("isUnprocessableContent()")) + @Suppress("DEPRECATION") fun isUnprocessableEntity() { actions.andExpect(matchers.isUnprocessableEntity()) } @@ -496,6 +527,8 @@ class StatusResultMatchersDsl internal constructor (private val actions: ResultA /** * @see StatusResultMatchers.isBandwidthLimitExceeded */ + @Deprecated("This is now unassigned") + @Suppress("DEPRECATION") fun isBandwidthLimitExceeded() { actions.andExpect(matchers.isBandwidthLimitExceeded()) } @@ -503,6 +536,8 @@ class StatusResultMatchersDsl internal constructor (private val actions: ResultA /** * @see StatusResultMatchers.isNotExtended */ + @Deprecated("This is marked as 'historic' and is not endorsed by a standards body") + @Suppress("DEPRECATION") fun isNotExtended() { actions.andExpect(matchers.isNotExtended()) } diff --git a/spring-web/src/main/java/org/springframework/http/HttpStatus.java b/spring-web/src/main/java/org/springframework/http/HttpStatus.java index 796a01020f83..c5f42dabd9f0 100644 --- a/spring-web/src/main/java/org/springframework/http/HttpStatus.java +++ b/spring-web/src/main/java/org/springframework/http/HttpStatus.java @@ -37,18 +37,20 @@ public enum HttpStatus implements HttpStatusCode { /** * {@code 100 Continue}. - * @see HTTP/1.1: Semantics and Content, section 6.2.1 + * @see HTTP Semantics, section 15.2.1 */ CONTINUE(100, Series.INFORMATIONAL, "Continue"), /** * {@code 101 Switching Protocols}. - * @see HTTP/1.1: Semantics and Content, section 6.2.2 + * @see HTTP Semantics, section 15.2.2 */ SWITCHING_PROTOCOLS(101, Series.INFORMATIONAL, "Switching Protocols"), /** * {@code 102 Processing}. * @see WebDAV + * @deprecated since 7.0, removed from WebDAV specification */ + @Deprecated(since = "7.0") PROCESSING(102, Series.INFORMATIONAL, "Processing"), /** * {@code 103 Early Hints}. @@ -61,42 +63,42 @@ public enum HttpStatus implements HttpStatusCode { /** * {@code 200 OK}. - * @see HTTP/1.1: Semantics and Content, section 6.3.1 + * @see HTTP Semantics, section 15.3.1 */ OK(200, Series.SUCCESSFUL, "OK"), /** * {@code 201 Created}. - * @see HTTP/1.1: Semantics and Content, section 6.3.2 + * @see HTTP Semantics, section 15.3.2 */ CREATED(201, Series.SUCCESSFUL, "Created"), /** * {@code 202 Accepted}. - * @see HTTP/1.1: Semantics and Content, section 6.3.3 + * @see HTTP Semantics, section 15.3.3 */ ACCEPTED(202, Series.SUCCESSFUL, "Accepted"), /** * {@code 203 Non-Authoritative Information}. - * @see HTTP/1.1: Semantics and Content, section 6.3.4 + * @see HTTP Semantics, section 15.3.4 */ NON_AUTHORITATIVE_INFORMATION(203, Series.SUCCESSFUL, "Non-Authoritative Information"), /** * {@code 204 No Content}. - * @see HTTP/1.1: Semantics and Content, section 6.3.5 + * @see HTTP Semantics, section 15.3.5 */ NO_CONTENT(204, Series.SUCCESSFUL, "No Content"), /** * {@code 205 Reset Content}. - * @see HTTP/1.1: Semantics and Content, section 6.3.6 + * @see HTTP Semantics, section 15.3.6 */ RESET_CONTENT(205, Series.SUCCESSFUL, "Reset Content"), /** * {@code 206 Partial Content}. - * @see HTTP/1.1: Range Requests, section 4.1 + * @see HTTP/1.1: Range Requests, section 4.1 */ PARTIAL_CONTENT(206, Series.SUCCESSFUL, "Partial Content"), /** * {@code 207 Multi-Status}. - * @see WebDAV + * @see WebDAV */ MULTI_STATUS(207, Series.SUCCESSFUL, "Multi-Status"), /** @@ -114,37 +116,37 @@ public enum HttpStatus implements HttpStatusCode { /** * {@code 300 Multiple Choices}. - * @see HTTP/1.1: Semantics and Content, section 6.4.1 + * @see HTTP Semantics, section 15.4.1 */ MULTIPLE_CHOICES(300, Series.REDIRECTION, "Multiple Choices"), /** * {@code 301 Moved Permanently}. - * @see HTTP/1.1: Semantics and Content, section 6.4.2 + * @see HTTP Semantics, section 15.4.2 */ MOVED_PERMANENTLY(301, Series.REDIRECTION, "Moved Permanently"), /** * {@code 302 Found}. - * @see HTTP/1.1: Semantics and Content, section 6.4.3 + * @see HTTP Semantics, section 15.4.3 */ FOUND(302, Series.REDIRECTION, "Found"), /** * {@code 303 See Other}. - * @see HTTP/1.1: Semantics and Content, section 6.4.4 + * @see HTTP Semantics, section 15.4.4 */ SEE_OTHER(303, Series.REDIRECTION, "See Other"), /** * {@code 304 Not Modified}. - * @see HTTP/1.1: Conditional Requests, section 4.1 + * @see HTTP Semantics, section 15.4.5 */ NOT_MODIFIED(304, Series.REDIRECTION, "Not Modified"), /** * {@code 307 Temporary Redirect}. - * @see HTTP/1.1: Semantics and Content, section 6.4.7 + * @see HTTP Semantics, section 15.4.8 */ TEMPORARY_REDIRECT(307, Series.REDIRECTION, "Temporary Redirect"), /** * {@code 308 Permanent Redirect}. - * @see RFC 7238 + * @see HTTP Semantics, section 15.4.9 */ PERMANENT_REDIRECT(308, Series.REDIRECTION, "Permanent Redirect"), @@ -152,70 +154,70 @@ public enum HttpStatus implements HttpStatusCode { /** * {@code 400 Bad Request}. - * @see HTTP/1.1: Semantics and Content, section 6.5.1 + * @see HTTP Semantics, section 15.5.1 */ BAD_REQUEST(400, Series.CLIENT_ERROR, "Bad Request"), /** * {@code 401 Unauthorized}. - * @see HTTP/1.1: Authentication, section 3.1 + * @see HTTP Semantics, section 15.5.2 */ UNAUTHORIZED(401, Series.CLIENT_ERROR, "Unauthorized"), /** * {@code 402 Payment Required}. - * @see HTTP/1.1: Semantics and Content, section 6.5.2 + * @see HTTP Semantics, section 15.5.3 */ PAYMENT_REQUIRED(402, Series.CLIENT_ERROR, "Payment Required"), /** * {@code 403 Forbidden}. - * @see HTTP/1.1: Semantics and Content, section 6.5.3 + * @see HTTP Semantics, section 15.5.4 */ FORBIDDEN(403, Series.CLIENT_ERROR, "Forbidden"), /** * {@code 404 Not Found}. - * @see HTTP/1.1: Semantics and Content, section 6.5.4 + * @see HTTP Semantics, section 15.5.5 */ NOT_FOUND(404, Series.CLIENT_ERROR, "Not Found"), /** * {@code 405 Method Not Allowed}. - * @see HTTP/1.1: Semantics and Content, section 6.5.5 + * @see HTTP Semantics, section 15.5.6 */ METHOD_NOT_ALLOWED(405, Series.CLIENT_ERROR, "Method Not Allowed"), /** * {@code 406 Not Acceptable}. - * @see HTTP/1.1: Semantics and Content, section 6.5.6 + * @see HTTP Semantics, section 15.5.7 */ NOT_ACCEPTABLE(406, Series.CLIENT_ERROR, "Not Acceptable"), /** * {@code 407 Proxy Authentication Required}. - * @see HTTP/1.1: Authentication, section 3.2 + * @see HTTP Semantics, section 15.5.8 */ PROXY_AUTHENTICATION_REQUIRED(407, Series.CLIENT_ERROR, "Proxy Authentication Required"), /** * {@code 408 Request Timeout}. - * @see HTTP/1.1: Semantics and Content, section 6.5.7 + * @see HTTP Semantics, section 15.5.9 */ REQUEST_TIMEOUT(408, Series.CLIENT_ERROR, "Request Timeout"), /** * {@code 409 Conflict}. - * @see HTTP/1.1: Semantics and Content, section 6.5.8 + * @see HTTP Semantics, section 15.5.10 */ CONFLICT(409, Series.CLIENT_ERROR, "Conflict"), /** * {@code 410 Gone}. - * @see - * HTTP/1.1: Semantics and Content, section 6.5.9 + * @see + * HTTP Semantics, section 15.5.11 */ GONE(410, Series.CLIENT_ERROR, "Gone"), /** * {@code 411 Length Required}. - * @see - * HTTP/1.1: Semantics and Content, section 6.5.10 + * @see + * HTTP Semantics, section 15.5.12 */ LENGTH_REQUIRED(411, Series.CLIENT_ERROR, "Length Required"), /** * {@code 412 Precondition failed}. - * @see - * HTTP/1.1: Conditional Requests, section 4.2 + * @see + * HTTP Semantics, section 15.5.13 */ PRECONDITION_FAILED(412, Series.CLIENT_ERROR, "Precondition Failed"), /** @@ -223,41 +225,67 @@ public enum HttpStatus implements HttpStatusCode { * @since 4.1 * @see * HTTP/1.1: Semantics and Content, section 6.5.11 + * @deprecated since 7.0 in favor of {@link #CONTENT_TOO_LARGE} */ + @Deprecated(since = "7.0") PAYLOAD_TOO_LARGE(413, Series.CLIENT_ERROR, "Payload Too Large"), + /** + * {@code 413 Content Too Large}. + * @since 7.0 + * @see + * HTTP Semantics, section 15.5.14 + */ + CONTENT_TOO_LARGE(413, Series.CLIENT_ERROR, "Content Too Large"), /** * {@code 414 URI Too Long}. * @since 4.1 - * @see - * HTTP/1.1: Semantics and Content, section 6.5.12 + * @see + * HTTP Semantics, section 15.5.15 */ URI_TOO_LONG(414, Series.CLIENT_ERROR, "URI Too Long"), /** * {@code 415 Unsupported Media Type}. - * @see - * HTTP/1.1: Semantics and Content, section 6.5.13 + * @see + * HTTP Semantics, section 15.5.16 */ UNSUPPORTED_MEDIA_TYPE(415, Series.CLIENT_ERROR, "Unsupported Media Type"), /** * {@code 416 Requested Range Not Satisfiable}. - * @see HTTP/1.1: Range Requests, section 4.4 + * @see + * HTTP Semantics, section 15.5.17 */ REQUESTED_RANGE_NOT_SATISFIABLE(416, Series.CLIENT_ERROR, "Requested range not satisfiable"), /** * {@code 417 Expectation Failed}. - * @see - * HTTP/1.1: Semantics and Content, section 6.5.14 + * @see + * HTTP Semantics, section 15.5.18 */ EXPECTATION_FAILED(417, Series.CLIENT_ERROR, "Expectation Failed"), /** * {@code 418 I'm a teapot}. * @see HTCPCP/1.0 + * @deprecated since 7.0, marked as unused in RFC 9110 */ + @Deprecated(since = "7.0") I_AM_A_TEAPOT(418, Series.CLIENT_ERROR, "I'm a teapot"), + /** + * {@code 421 Misdirected Request}. + * @see + * HTTP Semantics, section 15.5.20 + */ + MISDIRECTED_REQUEST(421, Series.CLIENT_ERROR, "Misdirected Request"), + /** + * {@code 422 Unprocessable Content}. + * @see + * HTTP Semantics, section 15.5.21 + */ + UNPROCESSABLE_CONTENT(422, Series.CLIENT_ERROR, "Unprocessable Content"), /** * {@code 422 Unprocessable Entity}. * @see WebDAV + * @deprecated since 7.0 in favor of {@link #UNPROCESSABLE_CONTENT} */ + @Deprecated(since = "7.0") UNPROCESSABLE_ENTITY(422, Series.CLIENT_ERROR, "Unprocessable Entity"), /** * {@code 423 Locked}. @@ -277,7 +305,8 @@ public enum HttpStatus implements HttpStatusCode { TOO_EARLY(425, Series.CLIENT_ERROR, "Too Early"), /** * {@code 426 Upgrade Required}. - * @see Upgrading to TLS Within HTTP/1.1 + * @see + * HTTP Semantics, section 15.5.22 */ UPGRADE_REQUIRED(426, Series.CLIENT_ERROR, "Upgrade Required"), /** @@ -307,32 +336,32 @@ public enum HttpStatus implements HttpStatusCode { /** * {@code 500 Internal Server Error}. - * @see HTTP/1.1: Semantics and Content, section 6.6.1 + * @see HTTP Semantics, section 15.6.1 */ INTERNAL_SERVER_ERROR(500, Series.SERVER_ERROR, "Internal Server Error"), /** * {@code 501 Not Implemented}. - * @see HTTP/1.1: Semantics and Content, section 6.6.2 + * @see HTTP Semantics, section 15.6.2 */ NOT_IMPLEMENTED(501, Series.SERVER_ERROR, "Not Implemented"), /** * {@code 502 Bad Gateway}. - * @see HTTP/1.1: Semantics and Content, section 6.6.3 + * @see HTTP Semantics, section 15.6.3 */ BAD_GATEWAY(502, Series.SERVER_ERROR, "Bad Gateway"), /** * {@code 503 Service Unavailable}. - * @see HTTP/1.1: Semantics and Content, section 6.6.4 + * @see HTTP Semantics, section 15.6.4 */ SERVICE_UNAVAILABLE(503, Series.SERVER_ERROR, "Service Unavailable"), /** * {@code 504 Gateway Timeout}. - * @see HTTP/1.1: Semantics and Content, section 6.6.5 + * @see HTTP Semantics, section 15.6.5 */ GATEWAY_TIMEOUT(504, Series.SERVER_ERROR, "Gateway Timeout"), /** * {@code 505 HTTP Version Not Supported}. - * @see HTTP/1.1: Semantics and Content, section 6.6.6 + * @see HTTP Semantics, section 15.6.6 */ HTTP_VERSION_NOT_SUPPORTED(505, Series.SERVER_ERROR, "HTTP Version not supported"), /** @@ -352,12 +381,16 @@ public enum HttpStatus implements HttpStatusCode { LOOP_DETECTED(508, Series.SERVER_ERROR, "Loop Detected"), /** * {@code 509 Bandwidth Limit Exceeded} + * @deprecated since 7.0, this is now unassigned */ + @Deprecated(since = "7.0") BANDWIDTH_LIMIT_EXCEEDED(509, Series.SERVER_ERROR, "Bandwidth Limit Exceeded"), /** * {@code 510 Not Extended} * @see HTTP Extension Framework + * @deprecated since 7.0, this is now marked as "historic" and not endorsed by a standards body. */ + @Deprecated(since = "7.0") NOT_EXTENDED(510, Series.SERVER_ERROR, "Not Extended"), /** * {@code 511 Network Authentication Required}. diff --git a/spring-web/src/main/java/org/springframework/http/ResponseEntity.java b/spring-web/src/main/java/org/springframework/http/ResponseEntity.java index 2b63f835603b..9d879cf55263 100644 --- a/spring-web/src/main/java/org/springframework/http/ResponseEntity.java +++ b/spring-web/src/main/java/org/springframework/http/ResponseEntity.java @@ -362,12 +362,24 @@ public static HeadersBuilder notFound() { return status(HttpStatus.NOT_FOUND); } + /** + * Create a builder with an + * {@linkplain HttpStatus#UNPROCESSABLE_CONTENT UNPROCESSABLE_CONTENT} status. + * @return the created builder + * @since 7.0 + */ + public static BodyBuilder unprocessableContent() { + return status(HttpStatus.UNPROCESSABLE_CONTENT); + } + /** * Create a builder with an * {@linkplain HttpStatus#UNPROCESSABLE_ENTITY UNPROCESSABLE_ENTITY} status. * @return the created builder * @since 4.1.3 + * @deprecated since 7.0 in favor of {@link #unprocessableContent()} */ + @Deprecated(since = "7.0") public static BodyBuilder unprocessableEntity() { return status(HttpStatus.UNPROCESSABLE_ENTITY); } diff --git a/spring-web/src/main/java/org/springframework/web/client/HttpClientErrorException.java b/spring-web/src/main/java/org/springframework/web/client/HttpClientErrorException.java index 139592df1cba..56a794d8e371 100644 --- a/spring-web/src/main/java/org/springframework/web/client/HttpClientErrorException.java +++ b/spring-web/src/main/java/org/springframework/web/client/HttpClientErrorException.java @@ -133,6 +133,9 @@ public static HttpClientErrorException create(@Nullable String message, HttpStat case UNPROCESSABLE_ENTITY -> message != null ? new UnprocessableEntity(message, statusText, headers, body, charset) : new UnprocessableEntity(statusText, headers, body, charset); + case UNPROCESSABLE_CONTENT -> message != null ? + new UnprocessableContent(message, statusText, headers, body, charset) : + new UnprocessableContent(statusText, headers, body, charset); default -> message != null ? new HttpClientErrorException(message, statusCode, statusText, headers, body, charset) : new HttpClientErrorException(statusCode, statusText, headers, body, charset); @@ -307,10 +310,30 @@ private UnsupportedMediaType(String message, String statusText, } } + /** + * {@link HttpClientErrorException} for status HTTP 422 Unprocessable Content. + * @since 7.0 + */ + @SuppressWarnings("serial") + public static final class UnprocessableContent extends HttpClientErrorException { + + private UnprocessableContent(String statusText, HttpHeaders headers, byte[] body, @Nullable Charset charset) { + super(HttpStatus.UNPROCESSABLE_CONTENT, statusText, headers, body, charset); + } + + private UnprocessableContent(String message, String statusText, + HttpHeaders headers, byte[] body, @Nullable Charset charset) { + + super(message, HttpStatus.UNPROCESSABLE_CONTENT, statusText, headers, body, charset); + } + } + /** * {@link HttpClientErrorException} for status HTTP 422 Unprocessable Entity. * @since 5.1 + * @deprecated since 7.0 in favor of {@link UnprocessableContent} */ + @Deprecated(since = "7.0") @SuppressWarnings("serial") public static final class UnprocessableEntity extends HttpClientErrorException { diff --git a/spring-web/src/main/java/org/springframework/web/multipart/MaxUploadSizeExceededException.java b/spring-web/src/main/java/org/springframework/web/multipart/MaxUploadSizeExceededException.java index 90a7368db034..9532943e760e 100644 --- a/spring-web/src/main/java/org/springframework/web/multipart/MaxUploadSizeExceededException.java +++ b/spring-web/src/main/java/org/springframework/web/multipart/MaxUploadSizeExceededException.java @@ -35,7 +35,7 @@ public class MaxUploadSizeExceededException extends MultipartException implements ErrorResponse { private final ProblemDetail body = - ProblemDetail.forStatusAndDetail(HttpStatus.PAYLOAD_TOO_LARGE, "Maximum upload size exceeded"); + ProblemDetail.forStatusAndDetail(HttpStatus.CONTENT_TOO_LARGE, "Maximum upload size exceeded"); private final long maxUploadSize; @@ -71,7 +71,7 @@ public long getMaxUploadSize() { @Override public HttpStatusCode getStatusCode() { - return HttpStatus.PAYLOAD_TOO_LARGE; + return HttpStatus.CONTENT_TOO_LARGE; } @Override diff --git a/spring-web/src/main/java/org/springframework/web/server/ContentTooLargeException.java b/spring-web/src/main/java/org/springframework/web/server/ContentTooLargeException.java new file mode 100644 index 000000000000..744d01406b0f --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/server/ContentTooLargeException.java @@ -0,0 +1,37 @@ +/* + * Copyright 2002-present 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.server; + +import org.jspecify.annotations.Nullable; + +import org.springframework.http.HttpStatus; + +/** + * Exception for errors that fit response status 413 (Content too large) for use in + * Spring Web applications. + * + * @author Brian Clozel + * @since 7.0 + */ +@SuppressWarnings("serial") +public class ContentTooLargeException extends ResponseStatusException { + + public ContentTooLargeException(@Nullable Throwable cause) { + super(HttpStatus.CONTENT_TOO_LARGE, null, cause); + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/server/PayloadTooLargeException.java b/spring-web/src/main/java/org/springframework/web/server/PayloadTooLargeException.java index 228f7726b5b1..b0bf9c054d46 100644 --- a/spring-web/src/main/java/org/springframework/web/server/PayloadTooLargeException.java +++ b/spring-web/src/main/java/org/springframework/web/server/PayloadTooLargeException.java @@ -27,8 +27,10 @@ * * @author Kim Bosung * @since 6.2 + * @deprecated since 7.0 in favor of {@link ContentTooLargeException} */ @SuppressWarnings("serial") +@Deprecated(since = "7.0") public class PayloadTooLargeException extends ResponseStatusException { public PayloadTooLargeException(@Nullable Throwable cause) { diff --git a/spring-web/src/test/java/org/springframework/http/HttpStatusTests.java b/spring-web/src/test/java/org/springframework/http/HttpStatusTests.java index ee43756b3dbd..5eabdde97a05 100644 --- a/spring-web/src/test/java/org/springframework/http/HttpStatusTests.java +++ b/spring-web/src/test/java/org/springframework/http/HttpStatusTests.java @@ -16,12 +16,15 @@ package org.springframework.http; -import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + import static org.assertj.core.api.Assertions.assertThat; /** @@ -29,86 +32,89 @@ */ class HttpStatusTests { - private final Map statusCodes = new LinkedHashMap<>(); + private final MultiValueMap statusCodes = new LinkedMultiValueMap<>(); @BeforeEach void createStatusCodes() { - statusCodes.put(100, "CONTINUE"); - statusCodes.put(101, "SWITCHING_PROTOCOLS"); - statusCodes.put(102, "PROCESSING"); - statusCodes.put(103, "EARLY_HINTS"); - - statusCodes.put(200, "OK"); - statusCodes.put(201, "CREATED"); - statusCodes.put(202, "ACCEPTED"); - statusCodes.put(203, "NON_AUTHORITATIVE_INFORMATION"); - statusCodes.put(204, "NO_CONTENT"); - statusCodes.put(205, "RESET_CONTENT"); - statusCodes.put(206, "PARTIAL_CONTENT"); - statusCodes.put(207, "MULTI_STATUS"); - statusCodes.put(208, "ALREADY_REPORTED"); - statusCodes.put(226, "IM_USED"); - - statusCodes.put(300, "MULTIPLE_CHOICES"); - statusCodes.put(301, "MOVED_PERMANENTLY"); - statusCodes.put(302, "FOUND"); - statusCodes.put(303, "SEE_OTHER"); - statusCodes.put(304, "NOT_MODIFIED"); - statusCodes.put(307, "TEMPORARY_REDIRECT"); - statusCodes.put(308, "PERMANENT_REDIRECT"); - - statusCodes.put(400, "BAD_REQUEST"); - statusCodes.put(401, "UNAUTHORIZED"); - statusCodes.put(402, "PAYMENT_REQUIRED"); - statusCodes.put(403, "FORBIDDEN"); - statusCodes.put(404, "NOT_FOUND"); - statusCodes.put(405, "METHOD_NOT_ALLOWED"); - statusCodes.put(406, "NOT_ACCEPTABLE"); - statusCodes.put(407, "PROXY_AUTHENTICATION_REQUIRED"); - statusCodes.put(408, "REQUEST_TIMEOUT"); - statusCodes.put(409, "CONFLICT"); - statusCodes.put(410, "GONE"); - statusCodes.put(411, "LENGTH_REQUIRED"); - statusCodes.put(412, "PRECONDITION_FAILED"); - statusCodes.put(413, "PAYLOAD_TOO_LARGE"); - statusCodes.put(414, "URI_TOO_LONG"); - statusCodes.put(415, "UNSUPPORTED_MEDIA_TYPE"); - statusCodes.put(416, "REQUESTED_RANGE_NOT_SATISFIABLE"); - statusCodes.put(417, "EXPECTATION_FAILED"); - statusCodes.put(418, "I_AM_A_TEAPOT"); - statusCodes.put(422, "UNPROCESSABLE_ENTITY"); - statusCodes.put(423, "LOCKED"); - statusCodes.put(424, "FAILED_DEPENDENCY"); - statusCodes.put(425, "TOO_EARLY"); - statusCodes.put(426, "UPGRADE_REQUIRED"); - statusCodes.put(428, "PRECONDITION_REQUIRED"); - statusCodes.put(429, "TOO_MANY_REQUESTS"); - statusCodes.put(431, "REQUEST_HEADER_FIELDS_TOO_LARGE"); - statusCodes.put(451, "UNAVAILABLE_FOR_LEGAL_REASONS"); - - statusCodes.put(500, "INTERNAL_SERVER_ERROR"); - statusCodes.put(501, "NOT_IMPLEMENTED"); - statusCodes.put(502, "BAD_GATEWAY"); - statusCodes.put(503, "SERVICE_UNAVAILABLE"); - statusCodes.put(504, "GATEWAY_TIMEOUT"); - statusCodes.put(505, "HTTP_VERSION_NOT_SUPPORTED"); - statusCodes.put(506, "VARIANT_ALSO_NEGOTIATES"); - statusCodes.put(507, "INSUFFICIENT_STORAGE"); - statusCodes.put(508, "LOOP_DETECTED"); - statusCodes.put(509, "BANDWIDTH_LIMIT_EXCEEDED"); - statusCodes.put(510, "NOT_EXTENDED"); - statusCodes.put(511, "NETWORK_AUTHENTICATION_REQUIRED"); + statusCodes.add(100, "CONTINUE"); + statusCodes.add(101, "SWITCHING_PROTOCOLS"); + statusCodes.add(102, "PROCESSING"); + statusCodes.add(103, "EARLY_HINTS"); + + statusCodes.add(200, "OK"); + statusCodes.add(201, "CREATED"); + statusCodes.add(202, "ACCEPTED"); + statusCodes.add(203, "NON_AUTHORITATIVE_INFORMATION"); + statusCodes.add(204, "NO_CONTENT"); + statusCodes.add(205, "RESET_CONTENT"); + statusCodes.add(206, "PARTIAL_CONTENT"); + statusCodes.add(207, "MULTI_STATUS"); + statusCodes.add(208, "ALREADY_REPORTED"); + statusCodes.add(226, "IM_USED"); + + statusCodes.add(300, "MULTIPLE_CHOICES"); + statusCodes.add(301, "MOVED_PERMANENTLY"); + statusCodes.add(302, "FOUND"); + statusCodes.add(303, "SEE_OTHER"); + statusCodes.add(304, "NOT_MODIFIED"); + statusCodes.add(307, "TEMPORARY_REDIRECT"); + statusCodes.add(308, "PERMANENT_REDIRECT"); + + statusCodes.add(400, "BAD_REQUEST"); + statusCodes.add(401, "UNAUTHORIZED"); + statusCodes.add(402, "PAYMENT_REQUIRED"); + statusCodes.add(403, "FORBIDDEN"); + statusCodes.add(404, "NOT_FOUND"); + statusCodes.add(405, "METHOD_NOT_ALLOWED"); + statusCodes.add(406, "NOT_ACCEPTABLE"); + statusCodes.add(407, "PROXY_AUTHENTICATION_REQUIRED"); + statusCodes.add(408, "REQUEST_TIMEOUT"); + statusCodes.add(409, "CONFLICT"); + statusCodes.add(410, "GONE"); + statusCodes.add(411, "LENGTH_REQUIRED"); + statusCodes.add(412, "PRECONDITION_FAILED"); + statusCodes.add(413, "PAYLOAD_TOO_LARGE"); + statusCodes.add(413, "CONTENT_TOO_LARGE"); + statusCodes.add(414, "URI_TOO_LONG"); + statusCodes.add(415, "UNSUPPORTED_MEDIA_TYPE"); + statusCodes.add(416, "REQUESTED_RANGE_NOT_SATISFIABLE"); + statusCodes.add(417, "EXPECTATION_FAILED"); + statusCodes.add(418, "I_AM_A_TEAPOT"); + statusCodes.add(421, "MISDIRECTED_REQUEST"); + statusCodes.add(422, "UNPROCESSABLE_CONTENT"); + statusCodes.add(422, "UNPROCESSABLE_ENTITY"); + statusCodes.add(423, "LOCKED"); + statusCodes.add(424, "FAILED_DEPENDENCY"); + statusCodes.add(425, "TOO_EARLY"); + statusCodes.add(426, "UPGRADE_REQUIRED"); + statusCodes.add(428, "PRECONDITION_REQUIRED"); + statusCodes.add(429, "TOO_MANY_REQUESTS"); + statusCodes.add(431, "REQUEST_HEADER_FIELDS_TOO_LARGE"); + statusCodes.add(451, "UNAVAILABLE_FOR_LEGAL_REASONS"); + + statusCodes.add(500, "INTERNAL_SERVER_ERROR"); + statusCodes.add(501, "NOT_IMPLEMENTED"); + statusCodes.add(502, "BAD_GATEWAY"); + statusCodes.add(503, "SERVICE_UNAVAILABLE"); + statusCodes.add(504, "GATEWAY_TIMEOUT"); + statusCodes.add(505, "HTTP_VERSION_NOT_SUPPORTED"); + statusCodes.add(506, "VARIANT_ALSO_NEGOTIATES"); + statusCodes.add(507, "INSUFFICIENT_STORAGE"); + statusCodes.add(508, "LOOP_DETECTED"); + statusCodes.add(509, "BANDWIDTH_LIMIT_EXCEEDED"); + statusCodes.add(510, "NOT_EXTENDED"); + statusCodes.add(511, "NETWORK_AUTHENTICATION_REQUIRED"); } @Test void fromMapToEnum() { - for (Map.Entry entry : statusCodes.entrySet()) { + for (Map.Entry> entry : statusCodes.entrySet()) { int value = entry.getKey(); HttpStatus status = HttpStatus.valueOf(value); assertThat(status.value()).as("Invalid value").isEqualTo(value); - assertThat(status.name()).as("Invalid name for [" + value + "]").isEqualTo(entry.getValue()); + assertThat(entry.getValue()).as("Invalid name for [" + value + "]").contains(status.name()); } } @@ -117,7 +123,7 @@ void fromEnumToMap() { for (HttpStatus status : HttpStatus.values()) { int code = status.value(); assertThat(statusCodes).as("Map has no value for [" + code + "]").containsKey(code); - assertThat(status.name()).as("Invalid name for [" + code + "]").isEqualTo(statusCodes.get(code)); + assertThat(statusCodes.get(code)).as("Invalid name for [" + code + "]").contains(status.name()); } } diff --git a/spring-web/src/test/java/org/springframework/http/ResponseEntityTests.java b/spring-web/src/test/java/org/springframework/http/ResponseEntityTests.java index dd9141ed38cf..e3f27b0b0a60 100644 --- a/spring-web/src/test/java/org/springframework/http/ResponseEntityTests.java +++ b/spring-web/src/test/java/org/springframework/http/ResponseEntityTests.java @@ -166,6 +166,16 @@ void notFound() { } @Test + void unprocessableContent() { + ResponseEntity responseEntity = ResponseEntity.unprocessableContent().body("error"); + + assertThat(responseEntity).isNotNull(); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT); + assertThat(responseEntity.getBody()).isEqualTo("error"); + } + + @Test + @SuppressWarnings("deprecate") void unprocessableEntity() { ResponseEntity responseEntity = ResponseEntity.unprocessableEntity().body("error"); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java index f66bc104b638..c8948475231e 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java @@ -464,11 +464,29 @@ public static class UnsupportedMediaType extends WebClientResponseException { } } + /** + * {@link WebClientResponseException} for status HTTP 422 Unprocessable Content. + * @since 7.0 + */ + @SuppressWarnings("serial") + public static class UnprocessableContent extends WebClientResponseException { + + UnprocessableContent( + String statusText, HttpHeaders headers, byte[] body, @Nullable Charset charset, + @Nullable HttpRequest request) { + + super(HttpStatus.UNPROCESSABLE_CONTENT.value(), statusText, headers, body, charset, request); + } + } + + /** * {@link WebClientResponseException} for status HTTP 422 Unprocessable Entity. * @since 5.1 + * @deprecated since 7.0 in favor of {@link UnprocessableContent} */ @SuppressWarnings("serial") + @Deprecated(since = "7.0") public static class UnprocessableEntity extends WebClientResponseException { UnprocessableEntity( diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerResponse.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerResponse.java index b95725059815..01ab3fa211e0 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerResponse.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/ServerResponse.java @@ -212,11 +212,22 @@ static HeadersBuilder notFound() { return status(HttpStatus.NOT_FOUND); } + /** + * Create a builder with an + * {@linkplain HttpStatus#UNPROCESSABLE_CONTENT 422 Unprocessable Content} status. + * @return the created builder + */ + static BodyBuilder unprocessableContent() { + return status(HttpStatus.UNPROCESSABLE_CONTENT); + } + /** * Create a builder with an * {@linkplain HttpStatus#UNPROCESSABLE_ENTITY 422 Unprocessable Entity} status. * @return the created builder + * @deprecated since 7.0 in favor of {@link #unprocessableContent()} */ + @Deprecated(since = "7.0") static BodyBuilder unprocessableEntity() { return status(HttpStatus.UNPROCESSABLE_ENTITY); } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageReaderArgumentResolver.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageReaderArgumentResolver.java index 1247d3bd685a..7d8114d50f78 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageReaderArgumentResolver.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageReaderArgumentResolver.java @@ -52,7 +52,7 @@ import org.springframework.web.bind.support.WebExchangeDataBinder; import org.springframework.web.reactive.BindingContext; import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolverSupport; -import org.springframework.web.server.PayloadTooLargeException; +import org.springframework.web.server.ContentTooLargeException; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.ServerWebInputException; @@ -236,7 +236,7 @@ protected Mono readBody(MethodParameter bodyParam, @Nullable MethodParam private Throwable handleReadError(MethodParameter parameter, Throwable ex) { if (ex instanceof DataBufferLimitException) { - return new PayloadTooLargeException(ex); + return new ContentTooLargeException(ex); } if (ex instanceof DecodingException) { return new ServerWebInputException("Failed to read HTTP message", parameter, ex); diff --git a/spring-webflux/src/main/kotlin/org/springframework/web/reactive/function/server/CoRouterFunctionDsl.kt b/spring-webflux/src/main/kotlin/org/springframework/web/reactive/function/server/CoRouterFunctionDsl.kt index 18053e4f4140..f022b2b3daf3 100644 --- a/spring-webflux/src/main/kotlin/org/springframework/web/reactive/function/server/CoRouterFunctionDsl.kt +++ b/spring-webflux/src/main/kotlin/org/springframework/web/reactive/function/server/CoRouterFunctionDsl.kt @@ -807,9 +807,16 @@ class CoRouterFunctionDsl internal constructor (private val init: (CoRouterFunct */ fun notFound() = ServerResponse.notFound() + /** + * @see ServerResponse.unprocessableContent + */ + fun unprocessableContent() = ServerResponse.unprocessableContent() + /** * @see ServerResponse.unprocessableEntity */ + @Deprecated("Use unprocessable content instead.", ReplaceWith("unprocessableContent()")) + @Suppress("DEPRECATION") fun unprocessableEntity() = ServerResponse.unprocessableEntity() /** diff --git a/spring-webflux/src/main/kotlin/org/springframework/web/reactive/function/server/RouterFunctionDsl.kt b/spring-webflux/src/main/kotlin/org/springframework/web/reactive/function/server/RouterFunctionDsl.kt index c05ef8728217..35c77331e9e8 100644 --- a/spring-webflux/src/main/kotlin/org/springframework/web/reactive/function/server/RouterFunctionDsl.kt +++ b/spring-webflux/src/main/kotlin/org/springframework/web/reactive/function/server/RouterFunctionDsl.kt @@ -870,12 +870,23 @@ class RouterFunctionDsl internal constructor (private val init: RouterFunctionDs fun notFound(): ServerResponse.HeadersBuilder<*> = ServerResponse.notFound() + /** + * Create a builder with an + * [422 Unprocessable Content][HttpStatus.UNPROCESSABLE_CONTENT] status. + * @return the created builder + * @since 7.0 + */ + fun unprocessableContent(): ServerResponse.BodyBuilder = + ServerResponse.unprocessableContent() + /** * Create a builder with an * [422 Unprocessable Entity][HttpStatus.UNPROCESSABLE_ENTITY] status. * @return the created builder * @since 5.1 */ + @Deprecated("Use unprocessable content instead.", ReplaceWith("unprocessableContent()")) + @Suppress("DEPRECATION") fun unprocessableEntity(): ServerResponse.BodyBuilder = ServerResponse.unprocessableEntity() diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/MessageReaderArgumentResolverTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/MessageReaderArgumentResolverTests.java index e7d833f604f7..8ce9bff70123 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/MessageReaderArgumentResolverTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/MessageReaderArgumentResolverTests.java @@ -52,7 +52,7 @@ import org.springframework.web.bind.support.ConfigurableWebBindingInitializer; import org.springframework.web.method.HandlerMethod; import org.springframework.web.reactive.BindingContext; -import org.springframework.web.server.PayloadTooLargeException; +import org.springframework.web.server.ContentTooLargeException; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.ServerWebInputException; import org.springframework.web.server.UnsupportedMediaTypeStatusException; @@ -128,7 +128,7 @@ public void tooLargeBody() { Mono result = (Mono) this.resolver.readBody( param, true, this.bindingContext, exchange).block(); - StepVerifier.create(result).expectError(PayloadTooLargeException.class).verify(); + StepVerifier.create(result).expectError(ContentTooLargeException.class).verify(); } @Test 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 aa4ade257f8f..fc64b0e69d79 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 @@ -135,6 +135,7 @@ void handleServerErrorException() { } @Test + @SuppressWarnings("deprecation") void handleResponseStatusException() { testException(new ResponseStatusException(HttpStatus.BANDWIDTH_LIMIT_EXCEEDED)); } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerResponse.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerResponse.java index b07176d55185..016ef572c63a 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerResponse.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/ServerResponse.java @@ -211,11 +211,22 @@ static HeadersBuilder notFound() { return status(HttpStatus.NOT_FOUND); } + /** + * Create a builder with a + * {@linkplain HttpStatus#UNPROCESSABLE_CONTENT 422 Unprocessable Content} status. + * @return the created builder + */ + static BodyBuilder unprocessableContent() { + return status(HttpStatus.UNPROCESSABLE_CONTENT); + } + /** * Create a builder with a * {@linkplain HttpStatus#UNPROCESSABLE_ENTITY 422 Unprocessable Entity} status. * @return the created builder + * @deprecated since 7.0 in favor of {@link #unprocessableContent()} */ + @Deprecated(since = "7.0") static BodyBuilder unprocessableEntity() { return status(HttpStatus.UNPROCESSABLE_ENTITY); } diff --git a/spring-webmvc/src/main/kotlin/org/springframework/web/servlet/function/RouterFunctionDsl.kt b/spring-webmvc/src/main/kotlin/org/springframework/web/servlet/function/RouterFunctionDsl.kt index 96f08df4db4c..6d1474b4a01b 100644 --- a/spring-webmvc/src/main/kotlin/org/springframework/web/servlet/function/RouterFunctionDsl.kt +++ b/spring-webmvc/src/main/kotlin/org/springframework/web/servlet/function/RouterFunctionDsl.kt @@ -812,9 +812,16 @@ class RouterFunctionDsl internal constructor (private val init: (RouterFunctionD */ fun notFound() = ServerResponse.notFound() + /** + * @see ServerResponse.unprocessableContent + */ + fun unprocessableContent() = ServerResponse.unprocessableContent() + /** * @see ServerResponse.unprocessableEntity */ + @Deprecated("Use Unprocessable Content instead", ReplaceWith("unprocessableContent()")) + @Suppress("DEPRECATION") fun unprocessableEntity() = ServerResponse.unprocessableEntity() /** From fdfd15b9b3c3f02a0801821d601430395cfc2e7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Mon, 25 Aug 2025 14:38:04 +0200 Subject: [PATCH 138/591] Refine null-safety tooling introduction Closes gh-35383 --- framework-docs/modules/ROOT/pages/core/null-safety.adoc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/core/null-safety.adoc b/framework-docs/modules/ROOT/pages/core/null-safety.adoc index 108259e3730f..2460bbe292f4 100644 --- a/framework-docs/modules/ROOT/pages/core/null-safety.adoc +++ b/framework-docs/modules/ROOT/pages/core/null-safety.adoc @@ -8,9 +8,9 @@ recommended in order to get familiar with those annotations and semantics. The primary goal of this null-safety arrangement is to prevent a `NullPointerException` from being thrown at runtime via build time checks and to use explicit nullability as a way to express the possible absence of value. -It is useful in both Java by leveraging some tooling (https://github.com/uber/NullAway[NullAway] or IDEs supporting -JSpecify annotations such as IntelliJ IDEA) and Kotlin where JSpecify annotations are automatically translated to -{kotlin-docs}/null-safety.html[Kotlin's null safety]. +It is useful in Java by leveraging nullability checkers such as https://github.com/uber/NullAway[NullAway] or IDEs +supporting JSpecify annotations such as IntelliJ IDEA and Eclipse (the latter requiring manual configuration). In Kotlin, +JSpecify annotations are automatically translated to {kotlin-docs}/null-safety.html[Kotlin's null safety]. The {spring-framework-api}/core/Nullness.html[`Nullness` Spring API] can be used at runtime to detect the nullness of a type usage, a field, a method return type, or a parameter. It provides full support for From 41017148304e45819c007713c5514f58334a3eb6 Mon Sep 17 00:00:00 2001 From: spicydev Date: Sun, 20 Jul 2025 12:05:03 -0400 Subject: [PATCH 139/591] Add compression support in JdkClientHttpRequestFactory This commit ensures that the "Accept-Encoding" header is present for HTTP requests sent by the `JdkClientHttpRequestFactory`. Only "gzip" and "deflate" encodings are supported. This also adds a custom `BodyHandler` that decompresses HTTP response bodies if the "Content-Encoding" header lists a supported variant. This feature is enabled by default and can be disabled on the request factory. See gh-35225 Signed-off-by: spicydev [brian.clozel@broadcom.com: squash commits] Signed-off-by: Brian Clozel --- .../http/client/JdkClientHttpRequest.java | 62 ++++++++++++++++++- .../client/JdkClientHttpRequestFactory.java | 12 +++- .../client/AbstractMockWebServerTests.java | 26 ++++++++ .../JdkClientHttpRequestFactoryTests.java | 38 ++++++++++++ 4 files changed, 134 insertions(+), 4 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java b/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java index 5eba436ffcb8..930d5bdbfdd7 100644 --- a/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java @@ -24,10 +24,15 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandler; +import java.net.http.HttpResponse.BodySubscriber; +import java.net.http.HttpResponse.BodySubscribers; +import java.net.http.HttpResponse.ResponseInfo; import java.net.http.HttpTimeoutException; import java.nio.ByteBuffer; import java.time.Duration; import java.util.Collections; +import java.util.List; import java.util.Locale; import java.util.Set; import java.util.TreeSet; @@ -38,6 +43,8 @@ import java.util.concurrent.Flow; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.zip.GZIPInputStream; +import java.util.zip.InflaterInputStream; import org.jspecify.annotations.Nullable; @@ -60,6 +67,8 @@ class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest { private static final Set DISALLOWED_HEADERS = disallowedHeaders(); + private static final List ALLOWED_ENCODINGS = List.of("gzip", "deflate"); + private final HttpClient httpClient; @@ -71,15 +80,18 @@ class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest { private final @Nullable Duration timeout; + private final boolean compressionEnabled; + public JdkClientHttpRequest(HttpClient httpClient, URI uri, HttpMethod method, Executor executor, - @Nullable Duration readTimeout) { + @Nullable Duration readTimeout, boolean compressionEnabled) { this.httpClient = httpClient; this.uri = uri; this.method = method; this.executor = executor; this.timeout = readTimeout; + this.compressionEnabled = compressionEnabled; } @@ -100,7 +112,7 @@ protected ClientHttpResponse executeInternal(HttpHeaders headers, @Nullable Body TimeoutHandler timeoutHandler = null; try { HttpRequest request = buildRequest(headers, body); - responseFuture = this.httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()); + responseFuture = this.httpClient.sendAsync(request, new DecompressingBodyHandler()); if (this.timeout != null) { timeoutHandler = new TimeoutHandler(responseFuture, this.timeout); @@ -152,6 +164,15 @@ else if (cause instanceof IOException ioEx) { private HttpRequest buildRequest(HttpHeaders headers, @Nullable Body body) { HttpRequest.Builder builder = HttpRequest.newBuilder().uri(this.uri); + // When compression is enabled and valid encoding is absent, we add gzip as standard encoding + if (this.compressionEnabled) { + if (headers.containsHeader(HttpHeaders.ACCEPT_ENCODING) && + !ALLOWED_ENCODINGS.contains(headers.getFirst(HttpHeaders.ACCEPT_ENCODING))) { + headers.remove(HttpHeaders.ACCEPT_ENCODING); + } + headers.add(HttpHeaders.ACCEPT_ENCODING, "gzip"); + } + headers.forEach((headerName, headerValues) -> { if (!DISALLOWED_HEADERS.contains(headerName.toLowerCase(Locale.ROOT))) { for (String headerValue : headerValues) { @@ -237,7 +258,7 @@ public ByteBuffer map(byte[] b, int off, int len) { /** * Temporary workaround to use instead of {@link HttpRequest.Builder#timeout(Duration)} * until JDK-8258397 - * is fixed. Essentially, create a future wiht a timeout handler, and use it + * is fixed. Essentially, create a future with a timeout handler, and use it * to close the response. * @see OpenJDK discussion thread */ @@ -288,4 +309,39 @@ public void handleCancellationException(CancellationException ex) throws HttpTim } } + /** + * Custom BodyHandler that checks the Content-Encoding header and applies the appropriate decompression algorithm. + * Supports Gzip and Deflate encoded responses. + */ + public static final class DecompressingBodyHandler implements BodyHandler { + + @Override + public BodySubscriber apply(ResponseInfo responseInfo) { + String contentEncoding = responseInfo.headers().firstValue(HttpHeaders.CONTENT_ENCODING).orElse(""); + if (contentEncoding.equalsIgnoreCase("gzip")) { + // If the content is gzipped, wrap the InputStream with a GZIPInputStream + return BodySubscribers.mapping( + BodySubscribers.ofInputStream(), + (InputStream is) -> { + try { + return new GZIPInputStream(is); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); // Propagate IOExceptions + } + }); + } + else if (contentEncoding.equalsIgnoreCase("deflate")) { + // If the content is encoded using deflate, wrap the InputStream with a InflaterInputStream + return BodySubscribers.mapping( + BodySubscribers.ofInputStream(), + InflaterInputStream::new); + } + else { + // Otherwise, return a standard InputStream BodySubscriber + return BodySubscribers.ofInputStream(); + } + } + } + } diff --git a/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequestFactory.java b/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequestFactory.java index 886a64e2a773..01cce828239b 100644 --- a/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequestFactory.java +++ b/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequestFactory.java @@ -43,6 +43,8 @@ public class JdkClientHttpRequestFactory implements ClientHttpRequestFactory { private @Nullable Duration readTimeout; + private boolean compressionEnabled; + /** * Create a new instance of the {@code JdkClientHttpRequestFactory} @@ -96,10 +98,18 @@ public void setReadTimeout(Duration readTimeout) { this.readTimeout = readTimeout; } + /** + * Sets custom {@link BodyHandler} that can handle gzip encoded {@link HttpClient}'s response. + * @param compressionEnabled to enable compression by default for all {@link HttpClient}'s requests. + */ + public void setCompressionEnabled(boolean compressionEnabled) { + this.compressionEnabled = compressionEnabled; + } + @Override public ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod) throws IOException { - return new JdkClientHttpRequest(this.httpClient, uri, httpMethod, this.executor, this.readTimeout); + return new JdkClientHttpRequest(this.httpClient, uri, httpMethod, this.executor, this.readTimeout, this.compressionEnabled); } } diff --git a/spring-web/src/test/java/org/springframework/http/client/AbstractMockWebServerTests.java b/spring-web/src/test/java/org/springframework/http/client/AbstractMockWebServerTests.java index 28e83978e15a..922b8c00ba7e 100644 --- a/spring-web/src/test/java/org/springframework/http/client/AbstractMockWebServerTests.java +++ b/spring-web/src/test/java/org/springframework/http/client/AbstractMockWebServerTests.java @@ -23,8 +23,14 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.springframework.http.HttpHeaders; import org.springframework.util.StringUtils; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.zip.DeflaterOutputStream; +import java.util.zip.GZIPOutputStream; + import static org.assertj.core.api.Assertions.assertThat; /** @@ -106,6 +112,26 @@ else if(request.getTarget().startsWith("/header/")) { String headerName = request.getTarget().replace("/header/",""); return new MockResponse.Builder().body(headerName + ":" + request.getHeaders().get(headerName)).code(200).build(); } + else if(request.getTarget().startsWith("/compress/")) { + String encoding = request.getTarget().replace("/compress/",""); + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + if (encoding.equals("gzip")) { + try(GZIPOutputStream gzipOutputStream = new GZIPOutputStream(byteArrayOutputStream)) { + gzipOutputStream.write("Test Payload".getBytes()); + gzipOutputStream.flush(); + } + } + else if(encoding.equals("deflate")) { + try(DeflaterOutputStream deflaterOutputStream = new DeflaterOutputStream(byteArrayOutputStream)) { + deflaterOutputStream.write("Test Payload".getBytes()); + deflaterOutputStream.flush(); + } + } else { + byteArrayOutputStream.write("Test Payload".getBytes()); + } + return new MockResponse.Builder().body(byteArrayOutputStream.toString(StandardCharsets.ISO_8859_1)) + .code(200).setHeader(HttpHeaders.CONTENT_ENCODING, encoding).build(); + } return new MockResponse.Builder().code(404).build(); } catch (Throwable ex) { diff --git a/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestFactoryTests.java b/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestFactoryTests.java index 025f47e0c44f..8f380754c9ac 100644 --- a/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestFactoryTests.java +++ b/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestFactoryTests.java @@ -108,6 +108,44 @@ void deleteRequestWithBody() throws Exception { } } + @Test + void compressionDisabled() throws IOException { + URI uri = URI.create(baseUrl + "/compress/"); + ClientHttpRequest request = this.factory.createRequest(uri, HttpMethod.GET); + try (ClientHttpResponse response = request.execute()) { + assertThat(response.getStatusCode()).as("Invalid response status").isEqualTo(HttpStatus.OK); + assertThat(StreamUtils.copyToString(response.getBody(), StandardCharsets.ISO_8859_1)) + .as("Invalid request body").isEqualTo("Test Payload"); + } + } + + @Test + void compressionGzip() throws IOException { + URI uri = URI.create(baseUrl + "/compress/gzip"); + JdkClientHttpRequestFactory requestFactory = (JdkClientHttpRequestFactory) this.factory; + requestFactory.setCompressionEnabled(true); + ClientHttpRequest request = requestFactory.createRequest(uri, HttpMethod.GET); + + try (ClientHttpResponse response = request.execute()) { + assertThat(response.getStatusCode()).as("Invalid response status").isEqualTo(HttpStatus.OK); + assertThat(StreamUtils.copyToString(response.getBody(), StandardCharsets.ISO_8859_1)) + .as("Invalid request body").isEqualTo("Test Payload"); + } + } + + @Test + void compressionDeflate() throws IOException { + URI uri = URI.create(baseUrl + "/compress/deflate"); + JdkClientHttpRequestFactory requestFactory = (JdkClientHttpRequestFactory) this.factory; + requestFactory.setCompressionEnabled(true); + ClientHttpRequest request = requestFactory.createRequest(uri, HttpMethod.GET); + try (ClientHttpResponse response = request.execute()) { + assertThat(response.getStatusCode()).as("Invalid response status").isEqualTo(HttpStatus.OK); + assertThat(StreamUtils.copyToString(response.getBody(), StandardCharsets.ISO_8859_1)) + .as("Invalid request body").isEqualTo("Test Payload"); + } + } + @Test // gh-34971 @EnabledForJreRange(min = JRE.JAVA_19) // behavior fixed in Java 19 void requestContentLengthHeaderWhenNoBody() throws Exception { From 18eb2a607386460f44c98dae008e58d47213dbad Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Mon, 25 Aug 2025 19:06:25 +0200 Subject: [PATCH 140/591] Polishing contribution Closes gh-35225 --- .../http/client/JdkClientHttpRequest.java | 28 ++++++------- .../client/JdkClientHttpRequestFactory.java | 12 +++--- .../client/AbstractMockWebServerTests.java | 39 ++++++++++++------- .../JdkClientHttpRequestFactoryTests.java | 33 +++++++++------- .../client/JdkClientHttpRequestTests.java | 2 +- 5 files changed, 60 insertions(+), 54 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java b/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java index 930d5bdbfdd7..4c9bef04a6f6 100644 --- a/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java @@ -67,7 +67,7 @@ class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest { private static final Set DISALLOWED_HEADERS = disallowedHeaders(); - private static final List ALLOWED_ENCODINGS = List.of("gzip", "deflate"); + private static final List SUPPORTED_ENCODINGS = List.of("gzip", "deflate"); private final HttpClient httpClient; @@ -80,18 +80,18 @@ class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest { private final @Nullable Duration timeout; - private final boolean compressionEnabled; + private final boolean compression; - public JdkClientHttpRequest(HttpClient httpClient, URI uri, HttpMethod method, Executor executor, - @Nullable Duration readTimeout, boolean compressionEnabled) { + JdkClientHttpRequest(HttpClient httpClient, URI uri, HttpMethod method, Executor executor, + @Nullable Duration readTimeout, boolean compression) { this.httpClient = httpClient; this.uri = uri; this.method = method; this.executor = executor; this.timeout = readTimeout; - this.compressionEnabled = compressionEnabled; + this.compression = compression; } @@ -164,13 +164,10 @@ else if (cause instanceof IOException ioEx) { private HttpRequest buildRequest(HttpHeaders headers, @Nullable Body body) { HttpRequest.Builder builder = HttpRequest.newBuilder().uri(this.uri); - // When compression is enabled and valid encoding is absent, we add gzip as standard encoding - if (this.compressionEnabled) { - if (headers.containsHeader(HttpHeaders.ACCEPT_ENCODING) && - !ALLOWED_ENCODINGS.contains(headers.getFirst(HttpHeaders.ACCEPT_ENCODING))) { - headers.remove(HttpHeaders.ACCEPT_ENCODING); + if (this.compression) { + if (!headers.containsHeader(HttpHeaders.ACCEPT_ENCODING)) { + headers.addAll(HttpHeaders.ACCEPT_ENCODING, SUPPORTED_ENCODINGS); } - headers.add(HttpHeaders.ACCEPT_ENCODING, "gzip"); } headers.forEach((headerName, headerValues) -> { @@ -310,16 +307,15 @@ public void handleCancellationException(CancellationException ex) throws HttpTim } /** - * Custom BodyHandler that checks the Content-Encoding header and applies the appropriate decompression algorithm. + * BodyHandler that checks the Content-Encoding header and applies the appropriate decompression algorithm. * Supports Gzip and Deflate encoded responses. */ - public static final class DecompressingBodyHandler implements BodyHandler { + private static final class DecompressingBodyHandler implements BodyHandler { @Override public BodySubscriber apply(ResponseInfo responseInfo) { String contentEncoding = responseInfo.headers().firstValue(HttpHeaders.CONTENT_ENCODING).orElse(""); if (contentEncoding.equalsIgnoreCase("gzip")) { - // If the content is gzipped, wrap the InputStream with a GZIPInputStream return BodySubscribers.mapping( BodySubscribers.ofInputStream(), (InputStream is) -> { @@ -327,18 +323,16 @@ public BodySubscriber apply(ResponseInfo responseInfo) { return new GZIPInputStream(is); } catch (IOException ex) { - throw new UncheckedIOException(ex); // Propagate IOExceptions + throw new UncheckedIOException(ex); } }); } else if (contentEncoding.equalsIgnoreCase("deflate")) { - // If the content is encoded using deflate, wrap the InputStream with a InflaterInputStream return BodySubscribers.mapping( BodySubscribers.ofInputStream(), InflaterInputStream::new); } else { - // Otherwise, return a standard InputStream BodySubscriber return BodySubscribers.ofInputStream(); } } diff --git a/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequestFactory.java b/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequestFactory.java index 01cce828239b..14dec7ffa51e 100644 --- a/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequestFactory.java +++ b/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequestFactory.java @@ -43,7 +43,7 @@ public class JdkClientHttpRequestFactory implements ClientHttpRequestFactory { private @Nullable Duration readTimeout; - private boolean compressionEnabled; + private boolean compression = true; /** @@ -99,17 +99,17 @@ public void setReadTimeout(Duration readTimeout) { } /** - * Sets custom {@link BodyHandler} that can handle gzip encoded {@link HttpClient}'s response. - * @param compressionEnabled to enable compression by default for all {@link HttpClient}'s requests. + * Set whether support for uncompressing "gzip" and "deflate" HTTP responses is enabled. + * @since 7.0 */ - public void setCompressionEnabled(boolean compressionEnabled) { - this.compressionEnabled = compressionEnabled; + public void enableCompression(boolean enable) { + this.compression = enable; } @Override public ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod) throws IOException { - return new JdkClientHttpRequest(this.httpClient, uri, httpMethod, this.executor, this.readTimeout, this.compressionEnabled); + return new JdkClientHttpRequest(this.httpClient, uri, httpMethod, this.executor, this.readTimeout, this.compression); } } diff --git a/spring-web/src/test/java/org/springframework/http/client/AbstractMockWebServerTests.java b/spring-web/src/test/java/org/springframework/http/client/AbstractMockWebServerTests.java index 922b8c00ba7e..7730e95c2122 100644 --- a/spring-web/src/test/java/org/springframework/http/client/AbstractMockWebServerTests.java +++ b/spring-web/src/test/java/org/springframework/http/client/AbstractMockWebServerTests.java @@ -16,21 +16,21 @@ package org.springframework.http.client; +import java.io.ByteArrayOutputStream; +import java.util.zip.DeflaterOutputStream; +import java.util.zip.GZIPOutputStream; + import mockwebserver3.Dispatcher; import mockwebserver3.MockResponse; import mockwebserver3.MockWebServer; import mockwebserver3.RecordedRequest; +import okio.Buffer; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.springframework.http.HttpHeaders; import org.springframework.util.StringUtils; -import java.io.ByteArrayOutputStream; -import java.nio.charset.StandardCharsets; -import java.util.zip.DeflaterOutputStream; -import java.util.zip.GZIPOutputStream; - import static org.assertj.core.api.Assertions.assertThat; /** @@ -112,25 +112,34 @@ else if(request.getTarget().startsWith("/header/")) { String headerName = request.getTarget().replace("/header/",""); return new MockResponse.Builder().body(headerName + ":" + request.getHeaders().get(headerName)).code(200).build(); } - else if(request.getTarget().startsWith("/compress/")) { + else if(request.getTarget().startsWith("/compress/") && request.getBody() != null) { String encoding = request.getTarget().replace("/compress/",""); - ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + String requestBody = request.getBody().utf8(); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); if (encoding.equals("gzip")) { - try(GZIPOutputStream gzipOutputStream = new GZIPOutputStream(byteArrayOutputStream)) { - gzipOutputStream.write("Test Payload".getBytes()); + try(GZIPOutputStream gzipOutputStream = new GZIPOutputStream(outputStream)) { + gzipOutputStream.write(requestBody.getBytes()); gzipOutputStream.flush(); } } else if(encoding.equals("deflate")) { - try(DeflaterOutputStream deflaterOutputStream = new DeflaterOutputStream(byteArrayOutputStream)) { - deflaterOutputStream.write("Test Payload".getBytes()); + try(DeflaterOutputStream deflaterOutputStream = new DeflaterOutputStream(outputStream)) { + deflaterOutputStream.write(requestBody.getBytes()); deflaterOutputStream.flush(); } - } else { - byteArrayOutputStream.write("Test Payload".getBytes()); } - return new MockResponse.Builder().body(byteArrayOutputStream.toString(StandardCharsets.ISO_8859_1)) - .code(200).setHeader(HttpHeaders.CONTENT_ENCODING, encoding).build(); + else { + outputStream.write(requestBody.getBytes()); + } + Buffer buffer = new Buffer(); + buffer.write(outputStream.toByteArray()); + MockResponse.Builder builder = new MockResponse.Builder() + .body(buffer) + .code(200); + if (!encoding.isEmpty()) { + builder.setHeader(HttpHeaders.CONTENT_ENCODING, encoding); + } + return builder.build(); } return new MockResponse.Builder().code(404).build(); } diff --git a/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestFactoryTests.java b/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestFactoryTests.java index 8f380754c9ac..2d9a9c32a118 100644 --- a/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestFactoryTests.java +++ b/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestFactoryTests.java @@ -100,22 +100,22 @@ public void contentLength0() throws IOException { void deleteRequestWithBody() throws Exception { URI uri = URI.create(baseUrl + "/echo"); ClientHttpRequest request = this.factory.createRequest(uri, HttpMethod.DELETE); - StreamUtils.copy("body", StandardCharsets.ISO_8859_1, request.getBody()); + StreamUtils.copy("body", StandardCharsets.UTF_8, request.getBody()); try (ClientHttpResponse response = request.execute()) { assertThat(response.getStatusCode()).as("Invalid response status").isEqualTo(HttpStatus.OK); - assertThat(StreamUtils.copyToString(response.getBody(), StandardCharsets.ISO_8859_1)) - .as("Invalid request body").isEqualTo("body"); + assertThat(response.getBody()).as("Invalid request body").hasContent("body"); } } @Test void compressionDisabled() throws IOException { URI uri = URI.create(baseUrl + "/compress/"); - ClientHttpRequest request = this.factory.createRequest(uri, HttpMethod.GET); + ClientHttpRequest request = this.factory.createRequest(uri, HttpMethod.POST); + StreamUtils.copy("Payload to compress", StandardCharsets.UTF_8, request.getBody()); try (ClientHttpResponse response = request.execute()) { assertThat(response.getStatusCode()).as("Invalid response status").isEqualTo(HttpStatus.OK); - assertThat(StreamUtils.copyToString(response.getBody(), StandardCharsets.ISO_8859_1)) - .as("Invalid request body").isEqualTo("Test Payload"); + assertThat(response.getHeaders().containsHeader("Content-Encoding")).isFalse(); + assertThat(response.getBody()).as("Invalid request body").hasContent("Payload to compress"); } } @@ -123,13 +123,14 @@ void compressionDisabled() throws IOException { void compressionGzip() throws IOException { URI uri = URI.create(baseUrl + "/compress/gzip"); JdkClientHttpRequestFactory requestFactory = (JdkClientHttpRequestFactory) this.factory; - requestFactory.setCompressionEnabled(true); - ClientHttpRequest request = requestFactory.createRequest(uri, HttpMethod.GET); - + requestFactory.enableCompression(true); + ClientHttpRequest request = requestFactory.createRequest(uri, HttpMethod.POST); + StreamUtils.copy("Payload to compress", StandardCharsets.UTF_8, request.getBody()); try (ClientHttpResponse response = request.execute()) { assertThat(response.getStatusCode()).as("Invalid response status").isEqualTo(HttpStatus.OK); - assertThat(StreamUtils.copyToString(response.getBody(), StandardCharsets.ISO_8859_1)) - .as("Invalid request body").isEqualTo("Test Payload"); + assertThat(response.getHeaders().getFirst("Content-Encoding")) + .as("Invalid content encoding").isEqualTo("gzip"); + assertThat(response.getBody()).as("Invalid request body").hasContent("Payload to compress"); } } @@ -137,12 +138,14 @@ void compressionGzip() throws IOException { void compressionDeflate() throws IOException { URI uri = URI.create(baseUrl + "/compress/deflate"); JdkClientHttpRequestFactory requestFactory = (JdkClientHttpRequestFactory) this.factory; - requestFactory.setCompressionEnabled(true); - ClientHttpRequest request = requestFactory.createRequest(uri, HttpMethod.GET); + requestFactory.enableCompression(true); + ClientHttpRequest request = requestFactory.createRequest(uri, HttpMethod.POST); + StreamUtils.copy("Payload to compress", StandardCharsets.UTF_8, request.getBody()); try (ClientHttpResponse response = request.execute()) { assertThat(response.getStatusCode()).as("Invalid response status").isEqualTo(HttpStatus.OK); - assertThat(StreamUtils.copyToString(response.getBody(), StandardCharsets.ISO_8859_1)) - .as("Invalid request body").isEqualTo("Test Payload"); + assertThat(response.getHeaders().getFirst("Content-Encoding")) + .as("Invalid content encoding").isEqualTo("deflate"); + assertThat(response.getBody()).as("Invalid request body").hasContent("Payload to compress"); } } diff --git a/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTests.java b/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTests.java index 300af1ea221c..5b2f0bdc42b8 100644 --- a/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTests.java +++ b/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestTests.java @@ -72,7 +72,7 @@ void futureCancelled() { } private JdkClientHttpRequest createRequest(Duration timeout) { - return new JdkClientHttpRequest(client, URI.create("https://abc.com"), HttpMethod.GET, executor, timeout); + return new JdkClientHttpRequest(client, URI.create("https://abc.com"), HttpMethod.GET, executor, timeout, false); } } From 4903fee9392d50c18a1421e6e768210750d22011 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Tue, 26 Aug 2025 11:01:29 +0200 Subject: [PATCH 141/591] =?UTF-8?q?Permit=20@=E2=81=A0Nullable=20value=20i?= =?UTF-8?q?n=20ResponseCookie=20from*()=20factory=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes gh-35377 --- .../java/org/springframework/http/ResponseCookie.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/http/ResponseCookie.java b/spring-web/src/main/java/org/springframework/http/ResponseCookie.java index 44b0eaa7eac2..5cdc8e1eed77 100644 --- a/spring-web/src/main/java/org/springframework/http/ResponseCookie.java +++ b/spring-web/src/main/java/org/springframework/http/ResponseCookie.java @@ -211,7 +211,7 @@ public String toString() { * @return a builder to create the cookie with * @since 6.0 */ - public static ResponseCookieBuilder from(final String name) { + public static ResponseCookieBuilder from(String name) { return new DefaultResponseCookieBuilder(name, null, false); } @@ -222,7 +222,7 @@ public static ResponseCookieBuilder from(final String name) { * @param value the cookie value * @return a builder to create the cookie with */ - public static ResponseCookieBuilder from(final String name, final String value) { + public static ResponseCookieBuilder from(String name, @Nullable String value) { return new DefaultResponseCookieBuilder(name, value, false); } @@ -236,7 +236,7 @@ public static ResponseCookieBuilder from(final String name, final String value) * @return a builder to create the cookie with * @since 5.2.5 */ - public static ResponseCookieBuilder fromClientResponse(final String name, final String value) { + public static ResponseCookieBuilder fromClientResponse(String name, @Nullable String value) { return new DefaultResponseCookieBuilder(name, value, true); } @@ -425,7 +425,7 @@ private static class DefaultResponseCookieBuilder implements ResponseCookieBuild @Nullable private String sameSite; - public DefaultResponseCookieBuilder(String name, @Nullable String value, boolean lenient) { + DefaultResponseCookieBuilder(String name, @Nullable String value, boolean lenient) { this.name = name; this.value = value; this.lenient = lenient; From d8804c798b821cca14810e2c602f24de16c8d642 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Tue, 26 Aug 2025 18:21:56 +0300 Subject: [PATCH 142/591] RestTestClient correctly exposes the response body Closes gh-35385 --- .../web/servlet/client/DefaultRestTestClient.java | 12 ++++++------ .../test/web/servlet/client/ExchangeResult.java | 14 +++++--------- .../client/samples/bind/RouterFunctionTests.java | 15 +++++++++++++++ 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java index c46bdc39b5d0..c6ee841720ea 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java @@ -286,33 +286,33 @@ public CookieAssertions expectCookie() { @Override public BodySpec expectBody(Class bodyType) { - B body = this.exchangeResult.getBody(bodyType); + B body = this.exchangeResult.getClientResponse().bodyTo(bodyType); EntityExchangeResult result = new EntityExchangeResult<>(this.exchangeResult, body); return new DefaultBodySpec<>(result); } @Override public BodySpec expectBody(ParameterizedTypeReference bodyType) { - B body = this.exchangeResult.getBody(bodyType); + B body = this.exchangeResult.getClientResponse().bodyTo(bodyType); EntityExchangeResult result = initExchangeResult(body); return new DefaultBodySpec<>(result); } @Override public BodyContentSpec expectBody() { - byte[] body = this.exchangeResult.getBody(byte[].class); + byte[] body = this.exchangeResult.getClientResponse().bodyTo(byte[].class); EntityExchangeResult result = initExchangeResult(body); return new DefaultBodyContentSpec(result); } @Override public EntityExchangeResult returnResult(Class elementClass) { - return initExchangeResult(this.exchangeResult.getBody(elementClass)); + return initExchangeResult(this.exchangeResult.getClientResponse().bodyTo(elementClass)); } @Override public EntityExchangeResult returnResult(ParameterizedTypeReference elementTypeRef) { - return initExchangeResult(this.exchangeResult.getBody(elementTypeRef)); + return initExchangeResult(this.exchangeResult.getClientResponse().bodyTo(elementTypeRef)); } private EntityExchangeResult initExchangeResult(@Nullable B body) { @@ -412,7 +412,7 @@ private static class DefaultBodyContentSpec implements BodyContentSpec { @Override public EntityExchangeResult isEmpty() { this.result.assertWithDiagnostics(() -> - AssertionErrors.assertTrue("Expected empty body", this.result.getBody(byte[].class) == null)); + AssertionErrors.assertTrue("Expected empty body", this.result.getResponseBody() == null)); return new EntityExchangeResult<>(this.result, null); } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/ExchangeResult.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/ExchangeResult.java index def8eee72b6f..771a9eb1e0a4 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/ExchangeResult.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/ExchangeResult.java @@ -29,7 +29,6 @@ import org.apache.commons.logging.LogFactory; import org.jspecify.annotations.Nullable; -import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpRequest; @@ -160,14 +159,11 @@ private static ResponseCookie toResponseCookie(HttpCookie cookie, @Nullable Stri .build(); } - @Nullable - public T getBody(Class bodyType) { - return this.clientResponse.bodyTo(bodyType); - } - - @Nullable - public T getBody(ParameterizedTypeReference bodyType) { - return this.clientResponse.bodyTo(bodyType); + /** + * Provide access to the response. For internal use to decode the body. + */ + ConvertibleClientHttpResponse getClientResponse() { + return this.clientResponse; } /** diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/RouterFunctionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/RouterFunctionTests.java index ab0a4ab01339..eb0ddc5208f2 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/RouterFunctionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/RouterFunctionTests.java @@ -16,13 +16,17 @@ package org.springframework.test.web.servlet.client.samples.bind; +import java.nio.charset.StandardCharsets; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.client.EntityExchangeResult; import org.springframework.test.web.servlet.client.RestTestClient; import org.springframework.web.servlet.function.RouterFunction; import org.springframework.web.servlet.function.ServerResponse; +import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.web.servlet.function.RequestPredicates.GET; import static org.springframework.web.servlet.function.RouterFunctions.route; @@ -50,4 +54,15 @@ void test() { .expectBody(String.class).isEqualTo("It works!"); } + @Test + void testEntityExchangeResult() { + EntityExchangeResult result = this.testClient.get().uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody() + .returnResult(); + + assertThat(result.getResponseBody()).isEqualTo("It works!".getBytes(StandardCharsets.UTF_8)); + } + } From 2b7f88ee449fb37c81f2e3649beaa643f9537357 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 26 Aug 2025 18:40:35 +0200 Subject: [PATCH 143/591] Fix annotation arrays support in ClassFile metadata As of gh-33616, Spring now supports metadata reading with the ClassFile API on JDK 24+ runtimes. This commit fixes a bug where `ArrayStoreException` were thrown when reading annotation attribute values for arrays. Fixes gh-35252 --- .../ClassFileAnnotationMetadata.java | 39 +++++++++++++++++-- .../type/AbstractAnnotationMetadataTests.java | 30 ++++++++------ 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/spring-core/src/main/java24/org/springframework/core/type/classreading/ClassFileAnnotationMetadata.java b/spring-core/src/main/java24/org/springframework/core/type/classreading/ClassFileAnnotationMetadata.java index 248600ec6cdd..5cfc0ed49b52 100644 --- a/spring-core/src/main/java24/org/springframework/core/type/classreading/ClassFileAnnotationMetadata.java +++ b/spring-core/src/main/java24/org/springframework/core/type/classreading/ClassFileAnnotationMetadata.java @@ -25,6 +25,7 @@ import java.lang.reflect.Array; import java.util.Collections; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -86,7 +87,7 @@ static MergedAnnotations createMergedAnnotations(String className, RuntimeVisibl return createMergedAnnotation(className, annotationValue.annotation(), classLoader); } case AnnotationValue.OfClass classValue -> { - return fromTypeDescriptor(classValue.className().stringValue()); + return loadClass(classValue.className().stringValue(), classLoader); } case AnnotationValue.OfEnum enumValue -> { return parseEnum(enumValue, classLoader); @@ -103,6 +104,16 @@ private static String fromTypeDescriptor(String descriptor) { classDesc.packageName() + "." + classDesc.displayName(); } + private static Class loadClass(String className, @Nullable ClassLoader classLoader) { + try { + String name = fromTypeDescriptor(className); + return ClassUtils.forName(name, classLoader); + } + catch (ClassNotFoundException ex) { + return Object.class; + } + } + private static Object parseArrayValue(String className, @Nullable ClassLoader classLoader, AnnotationValue.OfArray arrayValue) { if (arrayValue.values().isEmpty()) { return new Object[0]; @@ -119,10 +130,10 @@ private static Object parseArrayValue(String className, @Nullable ClassLoader cl return stream.map(AnnotationValue.OfLong.class::cast).mapToLong(AnnotationValue.OfLong::longValue).toArray(); } default -> { - Object firstResolvedValue = readAnnotationValue(className, arrayValue.values().getFirst(), classLoader); + Class arrayElementType = resolveArrayElementType(arrayValue.values(), classLoader); return stream .map(rawValue -> readAnnotationValue(className, rawValue, classLoader)) - .toArray(s -> (Object[]) Array.newInstance(firstResolvedValue.getClass(), s)); + .toArray(s -> (Object[]) Array.newInstance(arrayElementType, s)); } } } @@ -139,6 +150,28 @@ private static Object parseArrayValue(String className, @Nullable ClassLoader cl } } + private static Class resolveArrayElementType(List values, @Nullable ClassLoader classLoader) { + AnnotationValue firstValue = values.getFirst(); + switch (firstValue) { + case AnnotationValue.OfConstant constantValue -> { + return constantValue.resolvedValue().getClass(); + } + case AnnotationValue.OfAnnotation _ -> { + return MergedAnnotation.class; + } + case AnnotationValue.OfClass _ -> { + return Class.class; + } + case AnnotationValue.OfEnum enumValue -> { + return loadClass(enumValue.className().stringValue(), classLoader); + } + default -> { + return Object.class; + } + } + } + + record Source(Annotation entryName) { } diff --git a/spring-core/src/test/java/org/springframework/core/type/AbstractAnnotationMetadataTests.java b/spring-core/src/test/java/org/springframework/core/type/AbstractAnnotationMetadataTests.java index 2a99548154fe..3b4b1fcf007c 100644 --- a/spring-core/src/test/java/org/springframework/core/type/AbstractAnnotationMetadataTests.java +++ b/spring-core/src/test/java/org/springframework/core/type/AbstractAnnotationMetadataTests.java @@ -290,11 +290,11 @@ void getAllAnnotationAttributesReturnsAllAttributes() { void getComplexAttributeTypesReturnsAll() { MultiValueMap attributes = get(WithComplexAttributeTypes.class).getAllAnnotationAttributes(ComplexAttributes.class.getName()); - assertThat(attributes).containsOnlyKeys("names", "count", "type", "subAnnotation"); + assertThat(attributes).containsOnlyKeys("names", "count", "types", "subAnnotation"); assertThat(attributes.get("names")).hasSize(1); assertThat(attributes.get("names").get(0)).isEqualTo(new String[]{"first", "second"}); - assertThat(attributes.get("count")).containsExactlyInAnyOrder(TestEnum.ONE); - assertThat(attributes.get("type")).containsExactlyInAnyOrder(TestEnum.class); + assertThat(attributes.get("count").get(0)).isEqualTo(new TestEnum[]{TestEnum.ONE, TestEnum.TWO}); + assertThat(attributes.get("types").get(0)).isEqualTo(new Class[]{TestEnum.class}); assertThat(attributes.get("subAnnotation")).hasSize(1); } @@ -312,8 +312,8 @@ void getComplexAttributeTypesReturnsAllWithKotlinMetadata() { void getAnnotationAttributeIntType() { MultiValueMap attributes = get(WithIntType.class).getAllAnnotationAttributes(ComplexAttributes.class.getName()); - assertThat(attributes).containsOnlyKeys("names", "count", "type", "subAnnotation"); - assertThat(attributes.get("type")).contains(int.class); + assertThat(attributes).containsOnlyKeys("names", "count", "types", "subAnnotation"); + assertThat(attributes.get("types").get(0)).isEqualTo(new Class[]{int.class}); } @Test @@ -454,13 +454,13 @@ public static class WithMetaAnnotationAttributes { } - @ComplexAttributes(names = {"first", "second"}, count = TestEnum.ONE, - type = TestEnum.class, subAnnotation = @SubAnnotation(name="spring")) + @ComplexAttributes(names = {"first", "second"}, count = {TestEnum.ONE, TestEnum.TWO}, + types = {TestEnum.class}, subAnnotation = @SubAnnotation(name="spring")) @Metadata(mv = {42}) public static class WithComplexAttributeTypes { } - @ComplexAttributes(names = "void", count = TestEnum.ONE, type = int.class, + @ComplexAttributes(names = "void", count = TestEnum.ONE, types = int.class, subAnnotation = @SubAnnotation(name="spring")) public static class WithIntType { @@ -471,9 +471,9 @@ public static class WithIntType { String[] names(); - TestEnum count(); + TestEnum[] count(); - Class type(); + Class[] types(); SubAnnotation subAnnotation(); } @@ -484,7 +484,15 @@ public static class WithIntType { } public enum TestEnum { - ONE, TWO, THREE + ONE { + + }, + TWO { + + }, + THREE { + + } } @RepeatableAnnotation(name = "first") From 81b4020fc6392025e5e55b2d26b7cd5bac1ca7fe Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 26 Aug 2025 21:58:15 +0200 Subject: [PATCH 144/591] Do not load concrete types in annotation metadata This change fixes a regression introduced in the previous commit. Closes gh-35252 --- spring-core/spring-core.gradle | 1 + .../ClassFileAnnotationMetadata.java | 13 +++----- .../DefaultAnnotationMetadataTests.java | 29 +++++++++++++++++- .../SimpleAnnotationMetadataTests.java | 30 ++++++++++++++++++- 4 files changed, 62 insertions(+), 11 deletions(-) diff --git a/spring-core/spring-core.gradle b/spring-core/spring-core.gradle index 24649ec50de5..a6445f5438cf 100644 --- a/spring-core/spring-core.gradle +++ b/spring-core/spring-core.gradle @@ -88,6 +88,7 @@ dependencies { optional("org.jetbrains.kotlin:kotlin-stdlib") optional("org.jetbrains.kotlinx:kotlinx-coroutines-core") optional("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") + testCompileOnly("com.github.ben-manes.caffeine:caffeine") testFixturesImplementation("io.projectreactor:reactor-test") testFixturesImplementation("org.assertj:assertj-core") testFixturesImplementation("org.junit.jupiter:junit-jupiter") diff --git a/spring-core/src/main/java24/org/springframework/core/type/classreading/ClassFileAnnotationMetadata.java b/spring-core/src/main/java24/org/springframework/core/type/classreading/ClassFileAnnotationMetadata.java index 5cfc0ed49b52..20cd3a8591de 100644 --- a/spring-core/src/main/java24/org/springframework/core/type/classreading/ClassFileAnnotationMetadata.java +++ b/spring-core/src/main/java24/org/springframework/core/type/classreading/ClassFileAnnotationMetadata.java @@ -87,7 +87,7 @@ static MergedAnnotations createMergedAnnotations(String className, RuntimeVisibl return createMergedAnnotation(className, annotationValue.annotation(), classLoader); } case AnnotationValue.OfClass classValue -> { - return loadClass(classValue.className().stringValue(), classLoader); + return fromTypeDescriptor(classValue.className().stringValue()); } case AnnotationValue.OfEnum enumValue -> { return parseEnum(enumValue, classLoader); @@ -105,13 +105,8 @@ private static String fromTypeDescriptor(String descriptor) { } private static Class loadClass(String className, @Nullable ClassLoader classLoader) { - try { - String name = fromTypeDescriptor(className); - return ClassUtils.forName(name, classLoader); - } - catch (ClassNotFoundException ex) { - return Object.class; - } + String name = fromTypeDescriptor(className); + return ClassUtils.resolveClassName(name, classLoader); } private static Object parseArrayValue(String className, @Nullable ClassLoader classLoader, AnnotationValue.OfArray arrayValue) { @@ -160,7 +155,7 @@ private static Class resolveArrayElementType(List values, @N return MergedAnnotation.class; } case AnnotationValue.OfClass _ -> { - return Class.class; + return String.class; } case AnnotationValue.OfEnum enumValue -> { return loadClass(enumValue.className().stringValue(), classLoader); diff --git a/spring-core/src/test/java/org/springframework/core/type/classreading/DefaultAnnotationMetadataTests.java b/spring-core/src/test/java/org/springframework/core/type/classreading/DefaultAnnotationMetadataTests.java index 301c8e274604..a296ecf41e8a 100644 --- a/spring-core/src/test/java/org/springframework/core/type/classreading/DefaultAnnotationMetadataTests.java +++ b/spring-core/src/test/java/org/springframework/core/type/classreading/DefaultAnnotationMetadataTests.java @@ -16,15 +16,26 @@ package org.springframework.core.type.classreading; +import java.io.IOException; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import com.github.benmanes.caffeine.cache.Caffeine; +import org.junit.jupiter.api.Test; + import org.springframework.core.type.AbstractAnnotationMetadataTests; import org.springframework.core.type.AnnotationMetadata; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + /** * Tests for {@link SimpleAnnotationMetadata} and * {@link SimpleAnnotationMetadataReadingVisitor} on Java < 24, * and for the ClassFile API variant on Java >= 24. * * @author Phillip Webb + * @author Brian Clozel */ class DefaultAnnotationMetadataTests extends AbstractAnnotationMetadataTests { @@ -34,9 +45,25 @@ protected AnnotationMetadata get(Class source) { return MetadataReaderFactory.create(source.getClassLoader()) .getMetadataReader(source.getName()).getAnnotationMetadata(); } - catch (Exception ex) { + catch (IOException ex) { throw new IllegalStateException(ex); } } + @Test + void getClassAttributeWhenUnknownClass() { + var annotation = get(WithClassMissingFromClasspath.class).getAnnotations().get(ClassAttributes.class); + assertThat(annotation.getStringArray("types")).contains("com.github.benmanes.caffeine.cache.Caffeine"); + assertThatIllegalArgumentException().isThrownBy(() -> annotation.getClassArray("types")); + } + + @ClassAttributes(types = {Caffeine.class}) + public static class WithClassMissingFromClasspath { + } + + @Retention(RetentionPolicy.RUNTIME) + public @interface ClassAttributes { + Class[] types(); + } + } diff --git a/spring-core/src/test/java/org/springframework/core/type/classreading/SimpleAnnotationMetadataTests.java b/spring-core/src/test/java/org/springframework/core/type/classreading/SimpleAnnotationMetadataTests.java index 868efdb4bd5a..562754328d5a 100644 --- a/spring-core/src/test/java/org/springframework/core/type/classreading/SimpleAnnotationMetadataTests.java +++ b/spring-core/src/test/java/org/springframework/core/type/classreading/SimpleAnnotationMetadataTests.java @@ -16,14 +16,25 @@ package org.springframework.core.type.classreading; +import java.io.IOException; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import com.github.benmanes.caffeine.cache.Caffeine; +import org.junit.jupiter.api.Test; + import org.springframework.core.type.AbstractAnnotationMetadataTests; import org.springframework.core.type.AnnotationMetadata; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + /** * Tests for {@link SimpleAnnotationMetadata} and * {@link SimpleAnnotationMetadataReadingVisitor}. * * @author Phillip Webb + * @author Brian Clozel */ class SimpleAnnotationMetadataTests extends AbstractAnnotationMetadataTests { @@ -34,9 +45,26 @@ protected AnnotationMetadata get(Class source) { source.getClassLoader()).getMetadataReader( source.getName()).getAnnotationMetadata(); } - catch (Exception ex) { + catch (IOException ex) { throw new IllegalStateException(ex); } } + @Test + void getClassAttributeWhenUnknownClass() { + var annotation = get(WithClassMissingFromClasspath.class).getAnnotations().get(ClassAttributes.class); + assertThat(annotation.getStringArray("types")).contains("com.github.benmanes.caffeine.cache.Caffeine"); + assertThatIllegalArgumentException().isThrownBy(() -> annotation.getClassArray("types")); + } + + @ClassAttributes(types = {Caffeine.class}) + public static class WithClassMissingFromClasspath { + } + + + @Retention(RetentionPolicy.RUNTIME) + public @interface ClassAttributes { + Class[] types(); + } + } From 764336f0f201c34ba0e636324e4b31da922c81c1 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Wed, 27 Aug 2025 13:36:34 +0200 Subject: [PATCH 145/591] Fix Jetty DataBufferFactory memory leak Prior to this commit, gh-32097 added native support for Jetty for both client and server integrations. The `JettyDataBufferFactory` was promoted as a first class citizen, extracted from a private class in the client support. To accomodate with server-side requirements, an extra `buffer.retain()` call was performed. While this is useful for server-side support, this introduced a bug in the data buffer factory, as wrapping an existing chunk means that this chunk is already retained. This commit fixes the buffer factory implementation and moved existing tests from mocks to actual pooled buffer implementations from Jetty. The extra `buffer.retain()` is now done from the server support, right before wrapping the buffer. Fixes gh-35319 --- .../core/io/buffer/JettyDataBuffer.java | 1 - .../core/io/buffer/JettyDataBufferTests.java | 31 +++++++++++-------- .../reactive/JettyCoreServerHttpRequest.java | 5 ++- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/core/io/buffer/JettyDataBuffer.java b/spring-core/src/main/java/org/springframework/core/io/buffer/JettyDataBuffer.java index 748c88c1aaad..e10cd38b7f9a 100644 --- a/spring-core/src/main/java/org/springframework/core/io/buffer/JettyDataBuffer.java +++ b/spring-core/src/main/java/org/springframework/core/io/buffer/JettyDataBuffer.java @@ -55,7 +55,6 @@ public final class JettyDataBuffer implements PooledDataBuffer { this.bufferFactory = bufferFactory; this.delegate = delegate; this.chunk = chunk; - this.chunk.retain(); } JettyDataBuffer(JettyDataBufferFactory bufferFactory, DefaultDataBuffer delegate) { diff --git a/spring-core/src/test/java/org/springframework/core/io/buffer/JettyDataBufferTests.java b/spring-core/src/test/java/org/springframework/core/io/buffer/JettyDataBufferTests.java index fd6d4e15fdcc..2b14ba462798 100644 --- a/spring-core/src/test/java/org/springframework/core/io/buffer/JettyDataBufferTests.java +++ b/spring-core/src/test/java/org/springframework/core/io/buffer/JettyDataBufferTests.java @@ -18,33 +18,34 @@ import java.nio.ByteBuffer; +import org.eclipse.jetty.io.ArrayByteBufferPool; import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.io.RetainableByteBuffer; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalStateException; -import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.then; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; /** + * Tests for {@link JettyDataBuffer} * @author Arjen Poutsma + * @author Brian Clozel */ public class JettyDataBufferTests { private final JettyDataBufferFactory dataBufferFactory = new JettyDataBufferFactory(); + private ArrayByteBufferPool.Tracking byteBufferPool = new ArrayByteBufferPool.Tracking(); + @Test void releaseRetainChunk() { - ByteBuffer buffer = ByteBuffer.allocate(3); - Content.Chunk mockChunk = mock(); - given(mockChunk.getByteBuffer()).willReturn(buffer); - given(mockChunk.release()).willReturn(false, false, true); - + RetainableByteBuffer retainableBuffer = byteBufferPool.acquire(3, false); + ByteBuffer buffer = retainableBuffer.getByteBuffer(); + buffer.position(0).limit(1); + Content.Chunk chunk = Content.Chunk.asChunk(buffer, false, retainableBuffer); - - JettyDataBuffer dataBuffer = this.dataBufferFactory.wrap(mockChunk); + JettyDataBuffer dataBuffer = this.dataBufferFactory.wrap(chunk); dataBuffer.retain(); dataBuffer.retain(); assertThat(dataBuffer.release()).isFalse(); @@ -52,8 +53,12 @@ void releaseRetainChunk() { assertThat(dataBuffer.release()).isTrue(); assertThatIllegalStateException().isThrownBy(dataBuffer::release); + assertThat(retainableBuffer.isRetained()).isFalse(); + assertThat(byteBufferPool.getLeaks()).isEmpty(); + } - then(mockChunk).should(times(3)).retain(); - then(mockChunk).should(times(3)).release(); + @AfterEach + public void tearDown() throws Exception { + this.byteBufferPool.clear(); } } diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/JettyCoreServerHttpRequest.java b/spring-web/src/main/java/org/springframework/http/server/reactive/JettyCoreServerHttpRequest.java index 817164480158..9e25df9460de 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/JettyCoreServerHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/JettyCoreServerHttpRequest.java @@ -115,7 +115,10 @@ public Flux getBody() { // We access the request body as a Flow.Publisher, which is wrapped as an org.reactivestreams.Publisher and // then wrapped as a Flux. return Flux.from(FlowAdapters.toPublisher(Content.Source.asPublisher(this.request))) - .map(this.dataBufferFactory::wrap); + .map(chunk -> { + chunk.retain(); + return this.dataBufferFactory.wrap(chunk); + }); } } From a7e3a438c9d7d6dd5820fd7439f4fbade32f39c5 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Thu, 28 Aug 2025 15:14:29 +0300 Subject: [PATCH 146/591] Correctly apply required API version validation Closes gh-35386 --- .../web/accept/ApiVersionStrategy.java | 19 +++++++++++-------- .../DefaultApiVersionStrategiesTests.java | 9 +++++---- .../reactive/accept/ApiVersionStrategy.java | 19 +++++++++++-------- .../DefaultApiVersionStrategiesTests.java | 9 +++++---- 4 files changed, 32 insertions(+), 24 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/web/accept/ApiVersionStrategy.java b/spring-web/src/main/java/org/springframework/web/accept/ApiVersionStrategy.java index 9418ab7be05c..8b4d700f0f2c 100644 --- a/spring-web/src/main/java/org/springframework/web/accept/ApiVersionStrategy.java +++ b/spring-web/src/main/java/org/springframework/web/accept/ApiVersionStrategy.java @@ -70,17 +70,20 @@ void validateVersion(@Nullable Comparable requestVersion, HttpServletRequest */ default @Nullable Comparable resolveParseAndValidateVersion(HttpServletRequest request) { String value = resolveVersion(request); + Comparable version; if (value == null) { - return getDefaultVersion(); + version = getDefaultVersion(); } - try { - Comparable version = parseVersion(value); - validateVersion(version, request); - return version; - } - catch (Exception ex) { - throw new InvalidApiVersionException(value, null, ex); + else { + try { + version = parseVersion(value); + } + catch (Exception ex) { + throw new InvalidApiVersionException(value, null, ex); + } } + validateVersion(version, request); + return version; } /** diff --git a/spring-web/src/test/java/org/springframework/web/accept/DefaultApiVersionStrategiesTests.java b/spring-web/src/test/java/org/springframework/web/accept/DefaultApiVersionStrategiesTests.java index 222798a3bc88..5edea422003e 100644 --- a/spring-web/src/test/java/org/springframework/web/accept/DefaultApiVersionStrategiesTests.java +++ b/spring-web/src/test/java/org/springframework/web/accept/DefaultApiVersionStrategiesTests.java @@ -35,8 +35,6 @@ public class DefaultApiVersionStrategiesTests { private static final SemanticApiVersionParser parser = new SemanticApiVersionParser(); - private final MockHttpServletRequest request = new MockHttpServletRequest(); - @Test void defaultVersionIsParsed() { @@ -113,8 +111,11 @@ private static DefaultApiVersionStrategy apiVersionStrategy( } private void validateVersion(@Nullable String version, DefaultApiVersionStrategy strategy) { - Comparable parsedVersion = (version != null ? parser.parseVersion(version) : null); - strategy.validateVersion(parsedVersion, request); + MockHttpServletRequest request = new MockHttpServletRequest(); + if (version != null) { + request.setParameter("api-version", version); + } + strategy.resolveParseAndValidateVersion(request); } } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/ApiVersionStrategy.java b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/ApiVersionStrategy.java index fc7cca7af51a..762c7cc623db 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/ApiVersionStrategy.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/ApiVersionStrategy.java @@ -72,17 +72,20 @@ void validateVersion(@Nullable Comparable requestVersion, ServerWebExchange e */ default @Nullable Comparable resolveParseAndValidateVersion(ServerWebExchange exchange) { String value = resolveVersion(exchange); + Comparable version; if (value == null) { - return getDefaultVersion(); + version = getDefaultVersion(); } - try { - Comparable version = parseVersion(value); - validateVersion(version, exchange); - return version; - } - catch (Exception ex) { - throw new InvalidApiVersionException(value, null, ex); + else { + try { + version = parseVersion(value); + } + catch (Exception ex) { + throw new InvalidApiVersionException(value, null, ex); + } } + validateVersion(version, exchange); + return version; } /** diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java index 367587dd50cd..f826dccf311c 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java @@ -25,7 +25,6 @@ import org.springframework.web.accept.InvalidApiVersionException; import org.springframework.web.accept.MissingApiVersionException; import org.springframework.web.accept.SemanticApiVersionParser; -import org.springframework.web.server.ServerWebExchange; import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest; import org.springframework.web.testfixture.server.MockServerWebExchange; @@ -40,8 +39,6 @@ public class DefaultApiVersionStrategiesTests { private static final SemanticApiVersionParser parser = new SemanticApiVersionParser(); - private final ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/")); - @Test void defaultVersionIsParsed() { @@ -114,8 +111,12 @@ private static DefaultApiVersionStrategy apiVersionStrategy( } private void validateVersion(@Nullable String version, DefaultApiVersionStrategy strategy) { + MockServerHttpRequest.BaseBuilder requestBuilder = MockServerHttpRequest.get("/"); + if (version != null) { + requestBuilder.queryParam("api-version", version); + } Comparable parsedVersion = (version != null ? parser.parseVersion(version) : null); - strategy.validateVersion(parsedVersion, exchange); + strategy.resolveParseAndValidateVersion(MockServerWebExchange.builder(requestBuilder).build()); } } From 11cb06235706cd826c2351b56697cb2e82d68e16 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Thu, 28 Aug 2025 15:49:37 +0300 Subject: [PATCH 147/591] Assert versionRequired and defaultVersion Closes gh-35387 --- .../web/accept/DefaultApiVersionStrategy.java | 12 +++++++----- .../accept/DefaultApiVersionStrategiesTests.java | 13 ++++++++++++- .../reactive/accept/DefaultApiVersionStrategy.java | 12 +++++++----- .../web/reactive/config/ApiVersionConfigurer.java | 2 +- .../accept/DefaultApiVersionStrategiesTests.java | 14 ++++++++++++-- .../condition/VersionRequestConditionTests.java | 2 +- .../config/annotation/ApiVersionConfigurer.java | 2 +- .../condition/VersionRequestConditionTests.java | 2 +- 8 files changed, 42 insertions(+), 17 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java b/spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java index b9c41fb31bb5..bb65d4c0a317 100644 --- a/spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java +++ b/spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java @@ -61,9 +61,9 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy { * @param versionResolvers one or more resolvers to try; the first non-null * value returned by any resolver becomes the value used * @param versionParser parser for raw version values - * @param versionRequired whether a version is required; if a request - * does not have a version, and a {@code defaultVersion} is not specified, - * validation fails with {@link MissingApiVersionException} + * @param versionRequired whether a version is required leading to + * {@link MissingApiVersionException} for requests that don't have one; + * by default set to true unless there is a defaultVersion * @param defaultVersion a default version to assign to requests that * don't specify one * @param detectSupportedVersions whether to use API versions that appear in @@ -74,16 +74,18 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy { */ public DefaultApiVersionStrategy( List versionResolvers, ApiVersionParser versionParser, - boolean versionRequired, @Nullable String defaultVersion, + @Nullable Boolean versionRequired, @Nullable String defaultVersion, boolean detectSupportedVersions, @Nullable Predicate> supportedVersionPredicate, @Nullable ApiVersionDeprecationHandler deprecationHandler) { Assert.notEmpty(versionResolvers, "At least one ApiVersionResolver is required"); Assert.notNull(versionParser, "ApiVersionParser is required"); + Assert.isTrue(defaultVersion == null || versionRequired == null || !versionRequired, + "versionRequired cannot be set to true if a defaultVersion is also configured"); this.versionResolvers = new ArrayList<>(versionResolvers); this.versionParser = versionParser; - this.versionRequired = (versionRequired && defaultVersion == null); + this.versionRequired = (versionRequired != null ? versionRequired : defaultVersion == null); this.defaultVersion = (defaultVersion != null ? versionParser.parseVersion(defaultVersion) : null); this.detectSupportedVersions = detectSupportedVersions; this.supportedVersionPredicate = initSupportedVersionPredicate(supportedVersionPredicate); diff --git a/spring-web/src/test/java/org/springframework/web/accept/DefaultApiVersionStrategiesTests.java b/spring-web/src/test/java/org/springframework/web/accept/DefaultApiVersionStrategiesTests.java index 5edea422003e..be1597479f90 100644 --- a/spring-web/src/test/java/org/springframework/web/accept/DefaultApiVersionStrategiesTests.java +++ b/spring-web/src/test/java/org/springframework/web/accept/DefaultApiVersionStrategiesTests.java @@ -25,6 +25,7 @@ import org.springframework.web.testfixture.servlet.MockHttpServletRequest; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.assertj.core.api.Assertions.assertThatThrownBy; /** @@ -93,6 +94,16 @@ void validateUnsupportedWithPredicate() { assertThatThrownBy(() -> validateVersion("1.2", strategy)).isInstanceOf(InvalidApiVersionException.class); } + @Test + void versionRequiredAndDefaultVersionSet() { + assertThatIllegalArgumentException() + .isThrownBy(() -> + new DefaultApiVersionStrategy( + List.of(request -> request.getParameter("api-version")), new SemanticApiVersionParser(), + true, "1.2", true, version -> true, null)) + .withMessage("versionRequired cannot be set to true if a defaultVersion is also configured"); + } + private static DefaultApiVersionStrategy apiVersionStrategy() { return apiVersionStrategy(null, false, null); } @@ -107,7 +118,7 @@ private static DefaultApiVersionStrategy apiVersionStrategy( return new DefaultApiVersionStrategy( List.of(request -> request.getParameter("api-version")), new SemanticApiVersionParser(), - true, defaultVersion, detectSupportedVersions, supportedVersionPredicate, null); + null, defaultVersion, detectSupportedVersions, supportedVersionPredicate, null); } private void validateVersion(@Nullable String version, DefaultApiVersionStrategy strategy) { diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java index 114f0c95dd57..da7ce06b2ddf 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java @@ -63,9 +63,9 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy { * @param versionResolvers one or more resolvers to try; the first non-null * value returned by any resolver becomes the resolved used * @param versionParser parser for to raw version values - * @param versionRequired whether a version is required; if a request - * does not have a version, and a {@code defaultVersion} is not specified, - * validation fails with {@link MissingApiVersionException} + * @param versionRequired whether a version is required leading to + * {@link MissingApiVersionException} for requests that don't have one; + * by default set to true unless there is a defaultVersion * @param defaultVersion a default version to assign to requests that * don't specify one * @param detectSupportedVersions whether to use API versions that appear in @@ -76,16 +76,18 @@ public class DefaultApiVersionStrategy implements ApiVersionStrategy { */ public DefaultApiVersionStrategy( List versionResolvers, ApiVersionParser versionParser, - boolean versionRequired, @Nullable String defaultVersion, + @Nullable Boolean versionRequired, @Nullable String defaultVersion, boolean detectSupportedVersions, @Nullable Predicate> supportedVersionPredicate, @Nullable ApiVersionDeprecationHandler deprecationHandler) { Assert.notEmpty(versionResolvers, "At least one ApiVersionResolver is required"); Assert.notNull(versionParser, "ApiVersionParser is required"); + Assert.isTrue(defaultVersion == null || versionRequired == null || !versionRequired, + "versionRequired cannot be set to true if a defaultVersion is also configured"); this.versionResolvers = new ArrayList<>(versionResolvers); this.versionParser = versionParser; - this.versionRequired = (versionRequired && defaultVersion == null); + this.versionRequired = (versionRequired != null ? versionRequired : defaultVersion == null); this.defaultVersion = (defaultVersion != null ? versionParser.parseVersion(defaultVersion) : null); this.detectSupportedVersions = detectSupportedVersions; this.supportedVersionPredicate = initSupportedVersionPredicate(supportedVersionPredicate); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java b/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java index 2fe21fc5393d..549120c5857e 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java @@ -212,7 +212,7 @@ public ApiVersionConfigurer setDeprecationHandler(ApiVersionDeprecationHandler h DefaultApiVersionStrategy strategy = new DefaultApiVersionStrategy(this.versionResolvers, (this.versionParser != null ? this.versionParser : new SemanticApiVersionParser()), - (this.versionRequired != null ? this.versionRequired : true), this.defaultVersion, + this.versionRequired, this.defaultVersion, this.detectSupportedVersions, this.supportedVersionPredicate, this.deprecationHandler); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java index f826dccf311c..377cc2f8704d 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategiesTests.java @@ -29,6 +29,7 @@ import org.springframework.web.testfixture.server.MockServerWebExchange; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.assertj.core.api.Assertions.assertThatThrownBy; /** @@ -97,6 +98,16 @@ void validateUnsupportedWithPredicate() { assertThatThrownBy(() -> validateVersion("1.2", strategy)).isInstanceOf(InvalidApiVersionException.class); } + @Test + void versionRequiredAndDefaultVersionSet() { + assertThatIllegalArgumentException() + .isThrownBy(() -> + new org.springframework.web.accept.DefaultApiVersionStrategy( + List.of(request -> request.getParameter("api-version")), new SemanticApiVersionParser(), + true, "1.2", true, version -> true, null)) + .withMessage("versionRequired cannot be set to true if a defaultVersion is also configured"); + } + private static DefaultApiVersionStrategy apiVersionStrategy() { return apiVersionStrategy(null, false, null); } @@ -107,7 +118,7 @@ private static DefaultApiVersionStrategy apiVersionStrategy( return new DefaultApiVersionStrategy( List.of(exchange -> exchange.getRequest().getQueryParams().getFirst("api-version")), - parser, true, defaultVersion, detectSupportedVersions, supportedVersionPredicate, null); + parser, null, defaultVersion, detectSupportedVersions, supportedVersionPredicate, null); } private void validateVersion(@Nullable String version, DefaultApiVersionStrategy strategy) { @@ -115,7 +126,6 @@ private void validateVersion(@Nullable String version, DefaultApiVersionStrategy if (version != null) { requestBuilder.queryParam("api-version", version); } - Comparable parsedVersion = (version != null ? parser.parseVersion(version) : null); strategy.resolveParseAndValidateVersion(MockServerWebExchange.builder(requestBuilder).build()); } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java index b96de727c5a4..e658ec41da01 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/VersionRequestConditionTests.java @@ -52,7 +52,7 @@ void setUp() { private static DefaultApiVersionStrategy initVersionStrategy(@Nullable String defaultVersion) { return new DefaultApiVersionStrategy( List.of(exchange -> exchange.getRequest().getQueryParams().getFirst("api-version")), - new SemanticApiVersionParser(), true, defaultVersion, false, null, null); + new SemanticApiVersionParser(), null, defaultVersion, false, null, null); } @Test diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java index e08e17081637..f3db4095d4ad 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java @@ -213,7 +213,7 @@ public ApiVersionConfigurer setDeprecationHandler(ApiVersionDeprecationHandler h DefaultApiVersionStrategy strategy = new DefaultApiVersionStrategy(this.versionResolvers, (this.versionParser != null ? this.versionParser : new SemanticApiVersionParser()), - (this.versionRequired != null ? this.versionRequired : true), this.defaultVersion, + this.versionRequired, this.defaultVersion, this.detectSupportedVersions, this.supportedVersionPredicate, this.deprecationHandler); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java index 47b41ba4563a..5fbb0769826e 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/condition/VersionRequestConditionTests.java @@ -50,7 +50,7 @@ void setUp() { private static DefaultApiVersionStrategy initVersionStrategy(@Nullable String defaultVersion) { return new DefaultApiVersionStrategy( List.of(request -> request.getParameter("api-version")), - new SemanticApiVersionParser(), true, defaultVersion, false, null, null); + new SemanticApiVersionParser(), null, defaultVersion, false, null, null); } @Test From 737f66d92206fb05796d4f45cc7280875a5ce700 Mon Sep 17 00:00:00 2001 From: Johnny Lim Date: Thu, 14 Aug 2025 21:56:51 +0900 Subject: [PATCH 148/591] Restore mixed use of uri() and queryParam() for query parameters in AbstractMockHttpServletRequestBuilder. See gh-35329 Signed-off-by: Johnny Lim --- .../request/AbstractMockHttpServletRequestBuilder.java | 2 +- .../request/MockHttpServletRequestBuilderTests.java | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilder.java index bcc47e2b491c..8d688e7bfd48 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilder.java @@ -826,7 +826,7 @@ public final MockHttpServletRequest buildRequest(ServletContext servletContext) addRequestParams(request, UriComponentsBuilder.fromUri(uri).build().getQueryParams()); this.parameters.forEach((name, values) -> - request.setParameter(name, values.toArray(new String[0]))); + request.addParameter(name, values.toArray(new String[0]))); if (!this.formFields.isEmpty()) { if (this.content != null && this.content.length > 0) { diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilderTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilderTests.java index f62cce7c551c..6ba183d9aa42 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilderTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilderTests.java @@ -273,6 +273,15 @@ void queryParameterWithoutValues() { assertThat(request.getParameterMap().get("foo")).containsExactly(); } + @Test + void queryParametersWithUriAndQueryParam() { + this.builder = new MockHttpServletRequestBuilder(GET).uri("/path?param1=value1"); + this.builder.queryParam("param1", "value2"); + MockHttpServletRequest request = this.builder.buildRequest(this.servletContext); + + assertThat(request.getParameterMap().get("param1")).containsExactly("value1", "value2"); + } + @Test void queryParameterMap() { this.builder = new MockHttpServletRequestBuilder(GET).uri("/"); From 268706abd2fb38179e5e52137c07acf7f905b2ec Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Fri, 29 Aug 2025 10:22:43 +0300 Subject: [PATCH 149/591] Polishing in AbstractMockHttpServletRequestBuilder See gh-35329 --- ...AbstractMockHttpServletRequestBuilder.java | 22 ++-------- .../MockHttpServletRequestBuilderTests.java | 44 +++++++++---------- 2 files changed, 26 insertions(+), 40 deletions(-) diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilder.java index 8d688e7bfd48..a1b34f49a724 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilder.java @@ -342,7 +342,7 @@ public B accept(String... mediaTypes) { * @param values one or more header values */ public B header(String name, Object... values) { - addToMultiValueMap(this.headers, name, values); + this.headers.addAll(name, Arrays.asList(values)); return self(); } @@ -372,11 +372,7 @@ public B headers(HttpHeaders httpHeaders) { * @param values one or more values */ public B param(String name, String... values) { - if (values.length == 0) { - this.parameters.computeIfAbsent(name, k -> new ArrayList<>()); - return self(); - } - addToMultiValueMap(this.parameters, name, values); + this.parameters.addAll(name, Arrays.asList(values)); return self(); } @@ -823,10 +819,9 @@ public final MockHttpServletRequest buildRequest(ServletContext servletContext) if (query != null) { request.setQueryString(query); } - addRequestParams(request, UriComponentsBuilder.fromUri(uri).build().getQueryParams()); - this.parameters.forEach((name, values) -> - request.addParameter(name, values.toArray(new String[0]))); + addRequestParams(request, UriComponentsBuilder.fromUri(uri).build().getQueryParams()); + this.parameters.forEach((name, values) -> request.addParameter(name, values.toArray(new String[0]))); if (!this.formFields.isEmpty()) { if (this.content != null && this.content.length > 0) { @@ -993,19 +988,10 @@ public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) return request; } - private static void addToMap(Map map, String name, Object value) { Assert.hasLength(name, "'name' must not be empty"); Assert.notNull(value, "'value' must not be null"); map.put(name, value); } - private static void addToMultiValueMap(MultiValueMap map, String name, T[] values) { - Assert.hasLength(name, "'name' must not be empty"); - Assert.notEmpty(values, "'values' must not be empty"); - for (T value : values) { - map.add(name, value); - } - } - } diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilderTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilderTests.java index 6ba183d9aa42..9225771224b0 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilderTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilderTests.java @@ -230,7 +230,7 @@ void requestParameter() { } @Test - void requestParameterFromQuery() { + void requestParameterFromQueryString() { this.builder = new MockHttpServletRequestBuilder(GET).uri("/?foo=bar&foo=baz"); MockHttpServletRequest request = this.builder.buildRequest(this.servletContext); @@ -241,7 +241,7 @@ void requestParameterFromQuery() { } @Test - void requestParameterFromQueryList() { + void requestParameterFromQueryStringWithListValues() { this.builder = new MockHttpServletRequestBuilder(GET).uri("/?foo[0]=bar&foo[1]=baz"); MockHttpServletRequest request = this.builder.buildRequest(this.servletContext); @@ -263,25 +263,6 @@ void queryParameter() { assertThat(request.getQueryString()).isEqualTo("foo=bar&foo=baz"); } - @Test // gh-35210 - void queryParameterWithoutValues() { - this.builder = new MockHttpServletRequestBuilder(GET).uri("/"); - this.builder.queryParam("foo"); - MockHttpServletRequest request = this.builder.buildRequest(this.servletContext); - - assertThat(request.getQueryString()).isEqualTo("foo"); - assertThat(request.getParameterMap().get("foo")).containsExactly(); - } - - @Test - void queryParametersWithUriAndQueryParam() { - this.builder = new MockHttpServletRequestBuilder(GET).uri("/path?param1=value1"); - this.builder.queryParam("param1", "value2"); - MockHttpServletRequest request = this.builder.buildRequest(this.servletContext); - - assertThat(request.getParameterMap().get("param1")).containsExactly("value1", "value2"); - } - @Test void queryParameterMap() { this.builder = new MockHttpServletRequestBuilder(GET).uri("/"); @@ -299,7 +280,7 @@ void queryParameterMap() { } @Test - void queryParameterList() { + void queryParameterWithListValues() { this.builder = new MockHttpServletRequestBuilder(GET).uri("/"); this.builder.queryParam("foo[0]", "bar"); this.builder.queryParam("foo[1]", "baz"); @@ -311,6 +292,25 @@ void queryParameterList() { assertThat(request.getParameter("foo[1]")).isEqualTo("baz"); } + @Test // gh-35329 + void queryParameterAndQueryString() { + this.builder = new MockHttpServletRequestBuilder(GET).uri("/path?param1=value1"); + this.builder.queryParam("param1", "value2"); + MockHttpServletRequest request = this.builder.buildRequest(this.servletContext); + + assertThat(request.getParameterMap().get("param1")).containsExactly("value1", "value2"); + } + + @Test // gh-35210 + void queryParameterWithoutValues() { + this.builder = new MockHttpServletRequestBuilder(GET).uri("/"); + this.builder.queryParam("foo"); + MockHttpServletRequest request = this.builder.buildRequest(this.servletContext); + + assertThat(request.getQueryString()).isEqualTo("foo"); + assertThat(request.getParameterMap().get("foo")).containsExactly(); + } + @Test void formField() { this.builder = new MockHttpServletRequestBuilder(POST).uri("/"); From 0c024439654e64939b5d1a9ecff145ed9eaf6c1f Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Fri, 29 Aug 2025 10:25:37 +0300 Subject: [PATCH 150/591] Consistently support no value params in query string Closes gh-35329 --- .../request/AbstractMockHttpServletRequestBuilder.java | 10 ++++++---- .../request/MockHttpServletRequestBuilderTests.java | 9 +++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilder.java index a1b34f49a724..503f48f2ca13 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilder.java @@ -911,10 +911,12 @@ private void updatePathRequestProperties(MockHttpServletRequest request, String } private void addRequestParams(MockHttpServletRequest request, MultiValueMap map) { - map.forEach((key, values) -> values.forEach(value -> { - value = (value != null ? UriUtils.decode(value, StandardCharsets.UTF_8) : null); - request.addParameter(UriUtils.decode(key, StandardCharsets.UTF_8), value); - })); + map.forEach((key, values) -> + request.addParameter( + UriUtils.decode(key, StandardCharsets.UTF_8), + values.stream() + .map(value -> value != null ? UriUtils.decode(value, StandardCharsets.UTF_8) : null) + .toArray(String[]::new))); } private byte[] writeFormData(MediaType mediaType, Charset charset) { diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilderTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilderTests.java index 9225771224b0..60f25994c3bb 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilderTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilderTests.java @@ -311,6 +311,15 @@ void queryParameterWithoutValues() { assertThat(request.getParameterMap().get("foo")).containsExactly(); } + @Test // gh-35210 + void queryStringWithoutValues() { + this.builder = new MockHttpServletRequestBuilder(GET).uri("/path?foo"); + MockHttpServletRequest request = this.builder.buildRequest(this.servletContext); + + assertThat(request.getQueryString()).isEqualTo("foo"); + assertThat(request.getParameterMap().get("foo")).containsExactly((String) null); + } + @Test void formField() { this.builder = new MockHttpServletRequestBuilder(POST).uri("/"); From a585beac498f9abb86843a1ab578a377ea7d2230 Mon Sep 17 00:00:00 2001 From: Gustav <69737612+gustaavv@users.noreply.github.com> Date: Fri, 29 Aug 2025 19:17:18 +0800 Subject: [PATCH 151/591] Fix typo in websocket doc (#35393) Signed-off-by: Gustav <69737612+gustaavv@users.noreply.github.com> --- .../pages/web/websocket/stomp/configuration-performance.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework-docs/modules/ROOT/pages/web/websocket/stomp/configuration-performance.adoc b/framework-docs/modules/ROOT/pages/web/websocket/stomp/configuration-performance.adoc index 1456a7e07703..c1706a321d38 100644 --- a/framework-docs/modules/ROOT/pages/web/websocket/stomp/configuration-performance.adoc +++ b/framework-docs/modules/ROOT/pages/web/websocket/stomp/configuration-performance.adoc @@ -72,7 +72,7 @@ such as https://github.com/stomp-js/stompjs[`stomp-js/stompjs`] and others split STOMP messages at 16K boundaries and send them as multiple WebSocket messages, which requires the server to buffer and re-assemble. -Spring's STOMP-over-WebSocket support does this ,so applications can configure the +Spring's STOMP-over-WebSocket support does this, so applications can configure the maximum size for STOMP messages irrespective of WebSocket server-specific message sizes. Keep in mind that the WebSocket message size is automatically adjusted, if necessary, to ensure they can carry 16K WebSocket messages at a From 21e6d7392d28c07bd3269348b68e5df1700e6404 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Fri, 29 Aug 2025 14:50:52 +0300 Subject: [PATCH 152/591] Re-order methods and polishing In StatusHandler and DefaultConvertibleClientHttpResponse. See gh-35391 --- .../web/client/DefaultRestClient.java | 39 ++--- .../web/client/StatusHandler.java | 140 +++++++++++------- 2 files changed, 104 insertions(+), 75 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java b/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java index 5f7e04535eaa..d5d1fe3876d0 100644 --- a/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java +++ b/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java @@ -775,7 +775,7 @@ private class DefaultResponseSpec implements ResponseSpec { DefaultResponseSpec(RequestHeadersSpec requestHeadersSpec) { this.requestHeadersSpec = requestHeadersSpec; this.statusHandlers.addAll(DefaultRestClient.this.defaultStatusHandlers); - this.statusHandlers.add(StatusHandler.defaultHandler(DefaultRestClient.this.messageConverters)); + this.statusHandlers.add(StatusHandler.createDefaultStatusHandler(DefaultRestClient.this.messageConverters)); this.defaultStatusHandlerCount = this.statusHandlers.size(); } @@ -886,7 +886,10 @@ private Map getHints() { return this.requestHeadersSpec.exchange(exchangeFunction); } - private @Nullable T readBody(HttpRequest request, ClientHttpResponse response, Type bodyType, Class bodyClass, @Nullable Map hints) { + private @Nullable T readBody( + HttpRequest request, ClientHttpResponse response, Type bodyType, Class bodyClass, + @Nullable Map hints) { + return DefaultRestClient.this.readWithMessageConverters( response, () -> applyStatusHandlers(request, response), bodyType, bodyClass, hints); @@ -923,40 +926,40 @@ public DefaultConvertibleClientHttpResponse(ClientHttpResponse delegate, @Nullab } @Override - public @Nullable T bodyTo(Class bodyType) { - return readWithMessageConverters(this.delegate, () -> {} , bodyType, bodyType, this.hints); + public HttpStatusCode getStatusCode() throws IOException { + return this.delegate.getStatusCode(); } @Override - public @Nullable T bodyTo(ParameterizedTypeReference bodyType) { - Type type = bodyType.getType(); - Class bodyClass = bodyClass(type); - return readWithMessageConverters(this.delegate, () -> {}, type, bodyClass, this.hints); + public String getStatusText() throws IOException { + return this.delegate.getStatusText(); } @Override - public InputStream getBody() throws IOException { - return this.delegate.getBody(); + public HttpHeaders getHeaders() { + return this.delegate.getHeaders(); } @Override - public HttpHeaders getHeaders() { - return this.delegate.getHeaders(); + public InputStream getBody() throws IOException { + return this.delegate.getBody(); } @Override - public HttpStatusCode getStatusCode() throws IOException { - return this.delegate.getStatusCode(); + public void close() { + this.delegate.close(); } @Override - public String getStatusText() throws IOException { - return this.delegate.getStatusText(); + public @Nullable T bodyTo(Class bodyType) { + return readWithMessageConverters(this.delegate, () -> {} , bodyType, bodyType, this.hints); } @Override - public void close() { - this.delegate.close(); + public @Nullable T bodyTo(ParameterizedTypeReference bodyType) { + Type type = bodyType.getType(); + Class bodyClass = bodyClass(type); + return readWithMessageConverters(this.delegate, () -> {}, type, bodyClass, this.hints); } } diff --git a/spring-web/src/main/java/org/springframework/web/client/StatusHandler.java b/spring-web/src/main/java/org/springframework/web/client/StatusHandler.java index b94293775f31..96a7a5eb3d89 100644 --- a/spring-web/src/main/java/org/springframework/web/client/StatusHandler.java +++ b/spring-web/src/main/java/org/springframework/web/client/StatusHandler.java @@ -39,90 +39,103 @@ import org.springframework.util.ObjectUtils; /** - * Used by {@link DefaultRestClient} and {@link DefaultRestClientBuilder}. + * Simple container for an error response Predicate and an error response handler + * to support the status handling mechanism of {@link RestClient.ResponseSpec}. * * @author Arjen Poutsma + * @author Rossen Stoyanchev * @since 6.1 */ final class StatusHandler { private final ResponsePredicate predicate; - private final RestClient.ResponseSpec.ErrorHandler errorHandler; + private final RestClient.ResponseSpec.ErrorHandler handler; - private StatusHandler(ResponsePredicate predicate, RestClient.ResponseSpec.ErrorHandler errorHandler) { + private StatusHandler(ResponsePredicate predicate, RestClient.ResponseSpec.ErrorHandler handler) { this.predicate = predicate; - this.errorHandler = errorHandler; + this.handler = handler; } - public static StatusHandler of(Predicate predicate, - RestClient.ResponseSpec.ErrorHandler errorHandler) { + /** + * Test whether the response has any errors. + */ + public boolean test(ClientHttpResponse response) throws IOException { + return this.predicate.test(response); + } + + /** + * Handle the error in the given response. + *

    This method is only called when {@link #test(ClientHttpResponse)} + * has returned {@code true}. +ß */ + public void handle(HttpRequest request, ClientHttpResponse response) throws IOException { + this.handler.handle(request, response); + } + + + /** + * Create a StatusHandler from a RestClient {@link RestClient.ResponseSpec.ErrorHandler}. + */ + public static StatusHandler of( + Predicate predicate, RestClient.ResponseSpec.ErrorHandler errorHandler) { + Assert.notNull(predicate, "Predicate must not be null"); Assert.notNull(errorHandler, "ErrorHandler must not be null"); return new StatusHandler(response -> predicate.test(response.getStatusCode()), errorHandler); } + /** + * Create a StatusHandler from a {@link ResponseErrorHandler}. + */ public static StatusHandler fromErrorHandler(ResponseErrorHandler errorHandler) { Assert.notNull(errorHandler, "ResponseErrorHandler must not be null"); - return new StatusHandler(errorHandler::hasError, (request, response) -> - errorHandler.handleError(request.getURI(), request.getMethod(), response)); + return new StatusHandler(errorHandler::hasError, + (request, response) -> errorHandler.handleError(request.getURI(), request.getMethod(), response)); } - public static StatusHandler defaultHandler(List> messageConverters) { + /** + * Create a StatusHandler for default error response handling. + */ + public static StatusHandler createDefaultStatusHandler(List> converters) { return new StatusHandler(response -> response.getStatusCode().isError(), (request, response) -> { - HttpStatusCode statusCode = response.getStatusCode(); - String statusText = response.getStatusText(); - HttpHeaders headers = response.getHeaders(); - byte[] body = RestClientUtils.getBody(response); - Charset charset = RestClientUtils.getCharset(response); - String message = getErrorMessage(statusCode.value(), statusText, body, charset); - RestClientResponseException ex; - - if (statusCode.is4xxClientError()) { - ex = HttpClientErrorException.create(message, statusCode, statusText, headers, body, charset); - } - else if (statusCode.is5xxServerError()) { - ex = HttpServerErrorException.create(message, statusCode, statusText, headers, body, charset); - } - else { - ex = new UnknownHttpStatusCodeException(message, statusCode.value(), statusText, headers, body, charset); - } - if (!CollectionUtils.isEmpty(messageConverters)) { - ex.setBodyConvertFunction(initBodyConvertFunction(response, body, messageConverters)); - } - throw ex; + throw createException(response, converters); }); } - @SuppressWarnings("NullAway") - private static Function initBodyConvertFunction(ClientHttpResponse response, byte[] body, List> messageConverters) { - Assert.state(!CollectionUtils.isEmpty(messageConverters), "Expected message converters"); - return resolvableType -> { - try { - HttpMessageConverterExtractor extractor = - new HttpMessageConverterExtractor<>(resolvableType.getType(), messageConverters); + private static RestClientResponseException createException( + ClientHttpResponse response, List> converters) throws IOException { - return extractor.extractData(new ClientHttpResponseDecorator(response) { - @Override - public InputStream getBody() { - return new ByteArrayInputStream(body); - } - }); - } - catch (IOException ex) { - throw new RestClientException("Error while extracting response for type [" + resolvableType + "]", ex); - } - }; - } + HttpStatusCode statusCode = response.getStatusCode(); + String statusText = response.getStatusText(); + HttpHeaders headers = response.getHeaders(); + byte[] body = RestClientUtils.getBody(response); + Charset charset = RestClientUtils.getCharset(response); + String message = getErrorMessage(statusCode.value(), statusText, body, charset); + RestClientResponseException ex; + if (statusCode.is4xxClientError()) { + ex = HttpClientErrorException.create(message, statusCode, statusText, headers, body, charset); + } + else if (statusCode.is5xxServerError()) { + ex = HttpServerErrorException.create(message, statusCode, statusText, headers, body, charset); + } + else { + ex = new UnknownHttpStatusCodeException(message, statusCode.value(), statusText, headers, body, charset); + } + if (!CollectionUtils.isEmpty(converters)) { + ex.setBodyConvertFunction(initBodyConvertFunction(response, body, converters)); + } + return ex; + } - private static String getErrorMessage(int rawStatusCode, String statusText, byte @Nullable [] responseBody, - @Nullable Charset charset) { + private static String getErrorMessage( + int rawStatusCode, String statusText, byte @Nullable [] responseBody, @Nullable Charset charset) { String preface = rawStatusCode + " " + statusText + ": "; @@ -138,14 +151,27 @@ private static String getErrorMessage(int rawStatusCode, String statusText, byte return preface + bodyText; } + @SuppressWarnings("NullAway") + private static Function initBodyConvertFunction( + ClientHttpResponse response, byte[] body, List> messageConverters) { + Assert.state(!CollectionUtils.isEmpty(messageConverters), "Expected message converters"); + return resolvableType -> { + try { + HttpMessageConverterExtractor extractor = + new HttpMessageConverterExtractor<>(resolvableType.getType(), messageConverters); - public boolean test(ClientHttpResponse response) throws IOException { - return this.predicate.test(response); - } - - public void handle(HttpRequest request, ClientHttpResponse response) throws IOException { - this.errorHandler.handle(request, response); + return extractor.extractData(new ClientHttpResponseDecorator(response) { + @Override + public InputStream getBody() { + return new ByteArrayInputStream(body); + } + }); + } + catch (IOException ex) { + throw new RestClientException("Error while extracting response for type [" + resolvableType + "]", ex); + } + }; } From f22d1eab231f06c4f964de72fe1633318d540a9f Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Fri, 29 Aug 2025 15:12:14 +0300 Subject: [PATCH 153/591] Add createException to ConvertibleClientHttpResponse Closes gh-35391 --- .../springframework/web/client/DefaultRestClient.java | 5 +++++ .../org/springframework/web/client/RestClient.java | 9 +++++++++ .../org/springframework/web/client/StatusHandler.java | 11 ++++++++++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java b/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java index d5d1fe3876d0..600bafe9aa09 100644 --- a/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java +++ b/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java @@ -961,6 +961,11 @@ public void close() { Class bodyClass = bodyClass(type); return readWithMessageConverters(this.delegate, () -> {}, type, bodyClass, this.hints); } + + @Override + public RestClientResponseException createException() throws IOException { + return StatusHandler.createException(this, DefaultRestClient.this.messageConverters); + } } } diff --git a/spring-web/src/main/java/org/springframework/web/client/RestClient.java b/spring-web/src/main/java/org/springframework/web/client/RestClient.java index bb7e6180b2a8..61652c6cf292 100644 --- a/spring-web/src/main/java/org/springframework/web/client/RestClient.java +++ b/spring-web/src/main/java/org/springframework/web/client/RestClient.java @@ -878,6 +878,15 @@ interface ConvertibleClientHttpResponse extends ClientHttpResponse { * @return the body, or {@code null} if no response body was available */ @Nullable T bodyTo(ParameterizedTypeReference bodyType); + + /** + * Create a {@link RestClientResponseException} of the appropriate + * subtype depending on the response status code. The exception contains + * the status, headers, and body of the response. + * @throws IOException in case of a response failure (e.g. to obtain the status) + * @since 7.0 + */ + RestClientResponseException createException() throws IOException; } } diff --git a/spring-web/src/main/java/org/springframework/web/client/StatusHandler.java b/spring-web/src/main/java/org/springframework/web/client/StatusHandler.java index 96a7a5eb3d89..d943d9809379 100644 --- a/spring-web/src/main/java/org/springframework/web/client/StatusHandler.java +++ b/spring-web/src/main/java/org/springframework/web/client/StatusHandler.java @@ -108,7 +108,16 @@ public static StatusHandler createDefaultStatusHandler(List> converters) throws IOException { HttpStatusCode statusCode = response.getStatusCode(); From 441b14b0c1405688a9f3101cf039a352cc9ec147 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Fri, 29 Aug 2025 16:02:14 +0300 Subject: [PATCH 154/591] Handle error responses in RestClientAdapter Closes gh-35375 --- .../web/client/support/RestClientAdapter.java | 41 +++++++++++++------ .../support/RestClientAdapterTests.java | 11 +++++ 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java b/spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java index af979f08eeb1..d58fac3a0a56 100644 --- a/spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java +++ b/spring-web/src/main/java/org/springframework/web/client/support/RestClientAdapter.java @@ -16,6 +16,7 @@ package org.springframework.web.client.support; +import java.io.IOException; import java.io.InputStream; import java.net.URI; import java.util.ArrayList; @@ -72,13 +73,10 @@ public HttpHeaders exchangeForHeaders(HttpRequestValues values) { return newRequest(values).retrieve().toBodilessEntity().getHeaders(); } - @SuppressWarnings("unchecked") @Override public @Nullable T exchangeForBody(HttpRequestValues values, ParameterizedTypeReference bodyType) { - if (bodyType.getType().equals(InputStream.class)) { - return (T) newRequest(values).exchange((request, response) -> response.getBody(), false); - } - return newRequest(values).retrieve().body(bodyType); + return (bodyType.getType().equals(InputStream.class) ? + exchangeForInputStream(values) : newRequest(values).retrieve().body(bodyType)); } @Override @@ -86,16 +84,23 @@ public ResponseEntity exchangeForBodilessEntity(HttpRequestValues values) return newRequest(values).retrieve().toBodilessEntity(); } - @SuppressWarnings("unchecked") @Override public ResponseEntity exchangeForEntity(HttpRequestValues values, ParameterizedTypeReference bodyType) { - if (bodyType.getType().equals(InputStream.class)) { - return (ResponseEntity) newRequest(values).exchangeForRequiredValue((request, response) -> - ResponseEntity.status(response.getStatusCode()) - .headers(response.getHeaders()) - .body(response.getBody()), false); - } - return newRequest(values).retrieve().toEntity(bodyType); + return (bodyType.getType().equals(InputStream.class) ? + exchangeForEntityInputStream(values) : newRequest(values).retrieve().toEntity(bodyType)); + } + + @SuppressWarnings("unchecked") + private T exchangeForInputStream(HttpRequestValues values) { + return (T) newRequest(values).exchange((request, response) -> getInputStream(response), false); + } + + @SuppressWarnings("unchecked") + private ResponseEntity exchangeForEntityInputStream(HttpRequestValues values) { + return (ResponseEntity) newRequest(values).exchangeForRequiredValue((request, response) -> + ResponseEntity.status(response.getStatusCode()) + .headers(response.getHeaders()) + .body(getInputStream(response)), false); } @SuppressWarnings("unchecked") @@ -157,6 +162,16 @@ else if (values.getBodyValueType() != null) { return bodySpec; } + private static InputStream getInputStream( + RestClient.RequestHeadersSpec.ConvertibleClientHttpResponse response) throws IOException { + + if (response.getStatusCode().isError()) { + throw response.createException(); + } + return response.getBody(); + } + + /** * Create a {@link RestClientAdapter} for the given {@link RestClient}. diff --git a/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java b/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java index b288a6e65912..648accfd3f9a 100644 --- a/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java @@ -56,6 +56,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.client.ApiVersionInserter; +import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestTemplate; import org.springframework.web.multipart.MultipartFile; @@ -69,6 +70,7 @@ import org.springframework.web.util.UriBuilderFactory; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * Integration tests for {@link HttpServiceProxyFactory} with {@link RestClient} @@ -335,6 +337,15 @@ void getInputStream() throws Exception { assertThat(StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8)).isEqualTo("Hello Spring 2!"); } + @Test // gh-35375 + void getInputStreamWithError() { + prepareResponse(builder -> builder.code(400).body("rejected")); + + assertThatThrownBy(() -> initService().getInputStream()) + .isExactlyInstanceOf(HttpClientErrorException.BadRequest.class) + .hasMessage("400 Client Error: \"rejected\""); + } + @Test void postOutputStream() throws Exception { prepareResponse(builder -> From 9979de99fd4ab91a16aaf08c28c3b949f417a31b Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Fri, 29 Aug 2025 16:47:58 +0200 Subject: [PATCH 155/591] Do not decompress HTTP responses when compression disabled This commit refines changes made in gh-35225 so as to not decompress HTTP responses if decompression support is not enabled. Closes gh-35225 --- .../http/client/JdkClientHttpRequest.java | 2 +- .../client/AbstractMockWebServerTests.java | 19 ++++++++----------- .../JdkClientHttpRequestFactoryTests.java | 10 ++++++++-- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java b/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java index 4c9bef04a6f6..5ae62cbadaaa 100644 --- a/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/client/JdkClientHttpRequest.java @@ -112,7 +112,7 @@ protected ClientHttpResponse executeInternal(HttpHeaders headers, @Nullable Body TimeoutHandler timeoutHandler = null; try { HttpRequest request = buildRequest(headers, body); - responseFuture = this.httpClient.sendAsync(request, new DecompressingBodyHandler()); + responseFuture = this.httpClient.sendAsync(request, this.compression ? new DecompressingBodyHandler() : HttpResponse.BodyHandlers.ofInputStream()); if (this.timeout != null) { timeoutHandler = new TimeoutHandler(responseFuture, this.timeout); diff --git a/spring-web/src/test/java/org/springframework/http/client/AbstractMockWebServerTests.java b/spring-web/src/test/java/org/springframework/http/client/AbstractMockWebServerTests.java index 7730e95c2122..9fbef5328d48 100644 --- a/spring-web/src/test/java/org/springframework/http/client/AbstractMockWebServerTests.java +++ b/spring-web/src/test/java/org/springframework/http/client/AbstractMockWebServerTests.java @@ -116,29 +116,26 @@ else if(request.getTarget().startsWith("/compress/") && request.getBody() != nul String encoding = request.getTarget().replace("/compress/",""); String requestBody = request.getBody().utf8(); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - if (encoding.equals("gzip")) { - try(GZIPOutputStream gzipOutputStream = new GZIPOutputStream(outputStream)) { - gzipOutputStream.write(requestBody.getBytes()); - gzipOutputStream.flush(); - } - } - else if(encoding.equals("deflate")) { + if(encoding.equals("deflate")) { try(DeflaterOutputStream deflaterOutputStream = new DeflaterOutputStream(outputStream)) { deflaterOutputStream.write(requestBody.getBytes()); deflaterOutputStream.flush(); } } + // compress anyway with gzip else { - outputStream.write(requestBody.getBytes()); + encoding = "gzip"; + try(GZIPOutputStream gzipOutputStream = new GZIPOutputStream(outputStream)) { + gzipOutputStream.write(requestBody.getBytes()); + gzipOutputStream.flush(); + } } Buffer buffer = new Buffer(); buffer.write(outputStream.toByteArray()); MockResponse.Builder builder = new MockResponse.Builder() .body(buffer) .code(200); - if (!encoding.isEmpty()) { - builder.setHeader(HttpHeaders.CONTENT_ENCODING, encoding); - } + builder.setHeader(HttpHeaders.CONTENT_ENCODING, encoding); return builder.build(); } return new MockResponse.Builder().code(404).build(); diff --git a/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestFactoryTests.java b/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestFactoryTests.java index 2d9a9c32a118..c64782f9f646 100644 --- a/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestFactoryTests.java +++ b/spring-web/src/test/java/org/springframework/http/client/JdkClientHttpRequestFactoryTests.java @@ -110,12 +110,18 @@ void deleteRequestWithBody() throws Exception { @Test void compressionDisabled() throws IOException { URI uri = URI.create(baseUrl + "/compress/"); + if (this.factory instanceof JdkClientHttpRequestFactory jdkClientHttpRequestFactory) { + jdkClientHttpRequestFactory.enableCompression(false); + } ClientHttpRequest request = this.factory.createRequest(uri, HttpMethod.POST); StreamUtils.copy("Payload to compress", StandardCharsets.UTF_8, request.getBody()); try (ClientHttpResponse response = request.execute()) { + assertThat(request.getHeaders().containsHeader("Accept-Encoding")).isFalse(); assertThat(response.getStatusCode()).as("Invalid response status").isEqualTo(HttpStatus.OK); - assertThat(response.getHeaders().containsHeader("Content-Encoding")).isFalse(); - assertThat(response.getBody()).as("Invalid request body").hasContent("Payload to compress"); + assertThat(response.getHeaders().containsHeader("Content-Encoding")).isTrue(); + assertThat(StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8)) + .as("Body should not be decompressed") + .doesNotContain("Payload to compress"); } } From cd208797e2db429062d9b938e3d8e357508c7d77 Mon Sep 17 00:00:00 2001 From: Tran Ngoc Nhan Date: Thu, 28 Aug 2025 22:24:08 +0700 Subject: [PATCH 156/591] Fix links to Reactive Libraries and RestTemplate Closes gh-35392 Signed-off-by: Tran Ngoc Nhan --- framework-docs/modules/ROOT/pages/web/webflux-webclient.adoc | 2 +- framework-docs/modules/ROOT/pages/web/webmvc-client.adoc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/web/webflux-webclient.adoc b/framework-docs/modules/ROOT/pages/web/webflux-webclient.adoc index 01e2cb144a50..37b40afa32db 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux-webclient.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux-webclient.adoc @@ -3,7 +3,7 @@ :page-section-summary-toc: 1 Spring WebFlux includes a client to perform HTTP requests with. `WebClient` has a -functional, fluent API based on Reactor, see xref:web-reactive.adoc#webflux-reactive-libraries[Reactive Libraries], +functional, fluent API based on Reactor, see xref:web/webflux-reactive-libraries.adoc[Reactive Libraries], which enables declarative composition of asynchronous logic without the need to deal with threads or concurrency. It is fully non-blocking, it supports streaming, and relies on the same xref:web/webflux/reactive-spring.adoc#webflux-codecs[codecs] that are also used to encode and diff --git a/framework-docs/modules/ROOT/pages/web/webmvc-client.adoc b/framework-docs/modules/ROOT/pages/web/webmvc-client.adoc index 17bd4bcd567b..7a2a284343fd 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc-client.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc-client.adoc @@ -27,7 +27,7 @@ See xref:web/webflux-webclient.adoc[WebClient] for more details. Spring REST client and exposes a simple, template-method API over underlying HTTP client libraries. -See xref:integration/rest-clients.adoc[REST Endpoints] for details. +See xref:integration/rest-clients.adoc#rest-resttemplate[REST Endpoints] for details. [[webmvc-http-interface]] From b741632e99666f0469d64a7bf6dabd19368e709a Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Fri, 29 Aug 2025 17:25:15 +0200 Subject: [PATCH 157/591] Polish wording in web sections --- .../modules/ROOT/pages/web/webflux-webclient.adoc | 10 +++++----- .../modules/ROOT/pages/web/webmvc-client.adoc | 14 +++++++------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/web/webflux-webclient.adoc b/framework-docs/modules/ROOT/pages/web/webflux-webclient.adoc index 37b40afa32db..a8b3b595ed3e 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux-webclient.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux-webclient.adoc @@ -2,18 +2,18 @@ = WebClient :page-section-summary-toc: 1 -Spring WebFlux includes a client to perform HTTP requests with. `WebClient` has a -functional, fluent API based on Reactor, see xref:web/webflux-reactive-libraries.adoc[Reactive Libraries], +Spring WebFlux includes a client to perform HTTP requests. `WebClient` has a +functional, fluent API based on Reactor (see xref:web/webflux-reactive-libraries.adoc[Reactive Libraries]) which enables declarative composition of asynchronous logic without the need to deal with -threads or concurrency. It is fully non-blocking, it supports streaming, and relies on +threads or concurrency. It is fully non-blocking, supports streaming, and relies on the same xref:web/webflux/reactive-spring.adoc#webflux-codecs[codecs] that are also used to encode and decode request and response content on the server side. -`WebClient` needs an HTTP client library to perform requests with. There is built-in +`WebClient` needs an HTTP client library to perform requests. There is built-in support for the following: * {reactor-github-org}/reactor-netty[Reactor Netty] * {java-api}/java.net.http/java/net/http/HttpClient.html[JDK HttpClient] * https://github.com/jetty-project/jetty-reactive-httpclient[Jetty Reactive HttpClient] * https://hc.apache.org/index.html[Apache HttpComponents] -* Others can be plugged via `ClientHttpConnector`. +* Others can be plugged in via `ClientHttpConnector`. diff --git a/framework-docs/modules/ROOT/pages/web/webmvc-client.adoc b/framework-docs/modules/ROOT/pages/web/webmvc-client.adoc index 7a2a284343fd..e22a36120212 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc-client.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc-client.adoc @@ -15,27 +15,27 @@ See xref:integration/rest-clients.adoc#rest-restclient[`RestClient`] for more de [[webmvc-webclient]] == `WebClient` -`WebClient` is a reactive client to perform HTTP requests with a fluent API. +`WebClient` is a reactive client for making HTTP requests with a fluent API. -See xref:web/webflux-webclient.adoc[WebClient] for more details. +See xref:web/webflux-webclient.adoc[`WebClient`] for more details. [[webmvc-resttemplate]] == `RestTemplate` -`RestTemplate` is a synchronous client to perform HTTP requests. It is the original +`RestTemplate` is a synchronous client for making HTTP requests. It is the original Spring REST client and exposes a simple, template-method API over underlying HTTP client libraries. -See xref:integration/rest-clients.adoc#rest-resttemplate[REST Endpoints] for details. +See xref:integration/rest-clients.adoc#rest-resttemplate[`RestTemplate`] for details. [[webmvc-http-interface]] == HTTP Interface -The Spring Frameworks lets you define an HTTP service as a Java interface with HTTP +The Spring Framework lets you define an HTTP service as a Java interface with HTTP exchange methods. You can then generate a proxy that implements this interface and performs the exchanges. This helps to simplify HTTP remote access and provides additional -flexibility for to choose an API style such as synchronous or reactive. +flexibility for choosing an API style such as synchronous or reactive. -See xref:integration/rest-clients.adoc#rest-http-interface[REST Endpoints] for details. +See xref:integration/rest-clients.adoc#rest-http-interface[HTTP Interface] for details. From 1653ec3b449ac81360dae9f916cc9aa18badea4c Mon Sep 17 00:00:00 2001 From: Park Sung Jun Date: Mon, 1 Sep 2025 00:06:56 +0900 Subject: [PATCH 158/591] Add tests for applyRelativePath method in StringUtils Closes gh-35397 Signed-off-by: Park Sung Jun --- .../util/StringUtilsTests.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/spring-core/src/test/java/org/springframework/util/StringUtilsTests.java b/spring-core/src/test/java/org/springframework/util/StringUtilsTests.java index bdfd1ca82b52..0d9376bf74f4 100644 --- a/spring-core/src/test/java/org/springframework/util/StringUtilsTests.java +++ b/spring-core/src/test/java/org/springframework/util/StringUtilsTests.java @@ -794,6 +794,25 @@ void collectionToDelimitedStringWithNullValuesShouldNotFail() { assertThat(StringUtils.collectionToCommaDelimitedString(Collections.singletonList(null))).isEqualTo("null"); } + @Test + void applyRelativePath() { + // Basic combination + assertThat(StringUtils.applyRelativePath("mypath/myfile", "otherfile")).isEqualTo("mypath/otherfile"); + // Relative path starts with slash + assertThat(StringUtils.applyRelativePath("mypath/myfile", "/otherfile")).isEqualTo("mypath/otherfile"); + // Includes root path + assertThat(StringUtils.applyRelativePath("/mypath/myfile", "otherfile")).isEqualTo("/mypath/otherfile"); + assertThat(StringUtils.applyRelativePath("/mypath/myfile", "/otherfile")).isEqualTo("/mypath/otherfile"); + // When base path has no slash + assertThat(StringUtils.applyRelativePath("myfile", "otherfile")).isEqualTo("otherfile"); + // Keep parent directory token as-is + assertThat(StringUtils.applyRelativePath("mypath/myfile", "../otherfile")).isEqualTo("mypath/../otherfile"); + // Base path ends with slash + assertThat(StringUtils.applyRelativePath("mypath/", "otherfile")).isEqualTo("mypath/otherfile"); + // Empty relative path + assertThat(StringUtils.applyRelativePath("mypath/myfile", "")).isEqualTo("mypath/"); + } + @Test void truncatePreconditions() { assertThatIllegalArgumentException() From 7b3c4e589301d45bf8f7b731192d2423671e71f4 Mon Sep 17 00:00:00 2001 From: Mengqi Xu <2663479778@qq.com> Date: Sat, 29 Mar 2025 23:05:58 +0800 Subject: [PATCH 159/591] Add support for Forwarded By HTTP headers See gh-34683 Signed-off-by: Mengqi Xu <2663479778@qq.com> --- .../DefaultServerHttpRequestBuilder.java | 17 +++- .../server/reactive/ServerHttpRequest.java | 6 ++ .../web/filter/ForwardedHeaderFilter.java | 15 ++++ .../adapter/ForwardedHeaderTransformer.java | 7 ++ .../web/util/ForwardedHeaderUtils.java | 54 ++++++++++++ .../filter/ForwardedHeaderFilterTests.java | 87 +++++++++++++++++++ .../ForwardedHeaderTransformerTests.java | 36 ++++++++ 7 files changed, 219 insertions(+), 3 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/DefaultServerHttpRequestBuilder.java b/spring-web/src/main/java/org/springframework/http/server/reactive/DefaultServerHttpRequestBuilder.java index b97c338b6fd2..a09bc19e2d22 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/DefaultServerHttpRequestBuilder.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/DefaultServerHttpRequestBuilder.java @@ -57,6 +57,8 @@ class DefaultServerHttpRequestBuilder implements ServerHttpRequest.Builder { private @Nullable InetSocketAddress remoteAddress; + private @Nullable InetSocketAddress localAddress; + private final Flux body; private final ServerHttpRequest originalRequest; @@ -131,10 +133,16 @@ public ServerHttpRequest.Builder remoteAddress(InetSocketAddress remoteAddress) return this; } + @Override + public ServerHttpRequest.Builder localAddress(InetSocketAddress localAddress) { + this.localAddress = localAddress; + return this; + } + @Override public ServerHttpRequest build() { return new MutatedServerHttpRequest(getUriToUse(), this.contextPath, - this.httpMethod, this.sslInfo, this.remoteAddress, this.headers, this.body, this.originalRequest); + this.httpMethod, this.sslInfo, this.remoteAddress, this.localAddress, this.headers, this.body, this.originalRequest); } private URI getUriToUse() { @@ -182,16 +190,19 @@ private static class MutatedServerHttpRequest extends AbstractServerHttpRequest private final @Nullable InetSocketAddress remoteAddress; + private final @Nullable InetSocketAddress localAddress; + private final Flux body; private final ServerHttpRequest originalRequest; public MutatedServerHttpRequest(URI uri, @Nullable String contextPath, - HttpMethod method, @Nullable SslInfo sslInfo, @Nullable InetSocketAddress remoteAddress, + HttpMethod method, @Nullable SslInfo sslInfo, @Nullable InetSocketAddress remoteAddress, @Nullable InetSocketAddress localAddress, HttpHeaders headers, Flux body, ServerHttpRequest originalRequest) { super(method, uri, contextPath, headers); this.remoteAddress = (remoteAddress != null ? remoteAddress : originalRequest.getRemoteAddress()); + this.localAddress = (localAddress != null ? localAddress : originalRequest.getLocalAddress()); this.sslInfo = (sslInfo != null ? sslInfo : originalRequest.getSslInfo()); this.body = body; this.originalRequest = originalRequest; @@ -204,7 +215,7 @@ protected MultiValueMap initCookies() { @Override public @Nullable InetSocketAddress getLocalAddress() { - return this.originalRequest.getLocalAddress(); + return this.localAddress; } @Override diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/ServerHttpRequest.java b/spring-web/src/main/java/org/springframework/http/server/reactive/ServerHttpRequest.java index 7bfef574254e..f25c908eb3a2 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/ServerHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/ServerHttpRequest.java @@ -184,6 +184,12 @@ interface Builder { */ Builder remoteAddress(InetSocketAddress remoteAddress); + /** + * Set the address of the local client. + * @since 7.x + */ + Builder localAddress(InetSocketAddress localAddress); + /** * Build a {@link ServerHttpRequest} decorator with the mutated properties. */ diff --git a/spring-web/src/main/java/org/springframework/web/filter/ForwardedHeaderFilter.java b/spring-web/src/main/java/org/springframework/web/filter/ForwardedHeaderFilter.java index f99b62602c61..2bf8fc1bcbb0 100644 --- a/spring-web/src/main/java/org/springframework/web/filter/ForwardedHeaderFilter.java +++ b/spring-web/src/main/java/org/springframework/web/filter/ForwardedHeaderFilter.java @@ -73,6 +73,7 @@ * @author Eddú Meléndez * @author Rob Winch * @author Brian Clozel + * @author Mengqi Xu * @since 4.3 * @see https://tools.ietf.org/html/rfc7239 * @see Forwarded Headers @@ -92,6 +93,7 @@ public class ForwardedHeaderFilter extends OncePerRequestFilter { FORWARDED_HEADER_NAMES.add("X-Forwarded-Prefix"); FORWARDED_HEADER_NAMES.add("X-Forwarded-Ssl"); FORWARDED_HEADER_NAMES.add("X-Forwarded-For"); + FORWARDED_HEADER_NAMES.add("X-Forwarded-By"); } @@ -255,6 +257,8 @@ private static class ForwardedHeaderExtractingRequest extends ForwardedHeaderRem private final @Nullable InetSocketAddress remoteAddress; + private final @Nullable InetSocketAddress localAddress; + private final ForwardedPrefixExtractor forwardedPrefixExtractor; ForwardedHeaderExtractingRequest(HttpServletRequest servletRequest) { @@ -272,6 +276,7 @@ private static class ForwardedHeaderExtractingRequest extends ForwardedHeaderRem this.port = (port == -1 ? (this.secure ? 443 : 80) : port); this.remoteAddress = ForwardedHeaderUtils.parseForwardedFor(uri, headers, request.getRemoteAddress()); + this.localAddress = ForwardedHeaderUtils.parseForwardedBy(uri, headers, request.getLocalAddress()); // Use Supplier as Tomcat updates delegate request on FORWARD Supplier requestSupplier = () -> (HttpServletRequest) getRequest(); @@ -330,6 +335,16 @@ public int getRemotePort() { return (this.remoteAddress != null ? this.remoteAddress.getPort() : super.getRemotePort()); } + @Override + public @Nullable String getLocalAddr() { + return (this.localAddress != null ? this.localAddress.getHostString() : super.getLocalAddr()); + } + + @Override + public int getLocalPort() { + return (this.localAddress != null ? this.localAddress.getPort() : super.getLocalPort()); + } + @SuppressWarnings("DataFlowIssue") @Override public @Nullable Object getAttribute(String name) { diff --git a/spring-web/src/main/java/org/springframework/web/server/adapter/ForwardedHeaderTransformer.java b/spring-web/src/main/java/org/springframework/web/server/adapter/ForwardedHeaderTransformer.java index 2db8bbd05db4..d5ea6af968a8 100644 --- a/spring-web/src/main/java/org/springframework/web/server/adapter/ForwardedHeaderTransformer.java +++ b/spring-web/src/main/java/org/springframework/web/server/adapter/ForwardedHeaderTransformer.java @@ -55,6 +55,7 @@ * * @author Rossen Stoyanchev * @author Sebastien Deleuze + * @author Mengqi Xu * @since 5.1 * @see https://tools.ietf.org/html/rfc7239 * @see Forwarded Headers @@ -72,6 +73,7 @@ public class ForwardedHeaderTransformer implements FunctionRFC 7239, Section 5.1 + */ + public static @Nullable InetSocketAddress parseForwardedBy( + URI uri, HttpHeaders headers, @Nullable InetSocketAddress localAddress) { + + int port = (localAddress != null ? + localAddress.getPort() : "https".equals(uri.getScheme()) ? 443 : 80); + + String forwardedHeader = headers.getFirst("Forwarded"); + if (StringUtils.hasText(forwardedHeader)) { + String forwardedToUse = StringUtils.tokenizeToStringArray(forwardedHeader, ",")[0]; + Matcher matcher = FORWARDED_BY_PATTERN.matcher(forwardedToUse); + if (matcher.find()) { + String value = matcher.group(1).trim(); + String host = value; + int portSeparatorIdx = value.lastIndexOf(':'); + int squareBracketIdx = value.lastIndexOf(']'); + if (portSeparatorIdx > squareBracketIdx) { + if (squareBracketIdx == -1 && value.indexOf(':') != portSeparatorIdx) { + throw new IllegalArgumentException("Invalid IPv4 address: " + value); + } + host = value.substring(0, portSeparatorIdx); + try { + port = Integer.parseInt(value, portSeparatorIdx + 1, value.length(), 10); + } + catch (NumberFormatException ex) { + throw new IllegalArgumentException( + "Failed to parse a port from \"forwarded\"-type header value: " + value); + } + } + return InetSocketAddress.createUnresolved(host, port); + } + } + + String byHeader = headers.getFirst("X-Forwarded-By"); + if (StringUtils.hasText(byHeader)) { + String host = StringUtils.tokenizeToStringArray(byHeader, ",")[0]; + boolean ipv6 = (host.indexOf(':') != -1); + host = (ipv6 && !host.startsWith("[") && !host.endsWith("]") ? "[" + host + "]" : host); + return InetSocketAddress.createUnresolved(host, port); + } + + return null; + } + } diff --git a/spring-web/src/test/java/org/springframework/web/filter/ForwardedHeaderFilterTests.java b/spring-web/src/test/java/org/springframework/web/filter/ForwardedHeaderFilterTests.java index 841efefbae9b..9db168eefcf5 100644 --- a/spring-web/src/test/java/org/springframework/web/filter/ForwardedHeaderFilterTests.java +++ b/spring-web/src/test/java/org/springframework/web/filter/ForwardedHeaderFilterTests.java @@ -49,6 +49,7 @@ * @author Rob Winch * @author Brian Clozel * @author Sebastien Deleuze + * @author Mengqi Xu */ class ForwardedHeaderFilterTests { @@ -66,6 +67,8 @@ class ForwardedHeaderFilterTests { private static final String X_FORWARDED_FOR = "x-forwarded-for"; + private static final String X_FORWARDED_BY = "x-forwarded-by"; + private final ForwardedHeaderFilter filter = new ForwardedHeaderFilter(); @@ -93,6 +96,7 @@ void shouldFilter() { testShouldFilter(X_FORWARDED_SSL); testShouldFilter(X_FORWARDED_PREFIX); testShouldFilter(X_FORWARDED_FOR); + testShouldFilter(X_FORWARDED_BY); } private void testShouldFilter(String headerName) { @@ -115,6 +119,7 @@ void forwardedRequest(String protocol) throws Exception { this.request.addHeader(X_FORWARDED_PORT, "443"); this.request.addHeader("foo", "bar"); this.request.addHeader(X_FORWARDED_FOR, "[203.0.113.195]"); + this.request.addHeader(X_FORWARDED_BY, "[203.0.113.196]"); this.filter.doFilter(this.request, new MockHttpServletResponse(), this.filterChain); HttpServletRequest actual = (HttpServletRequest) this.filterChain.getRequest(); @@ -126,11 +131,13 @@ void forwardedRequest(String protocol) throws Exception { assertThat(actual.getServerPort()).isEqualTo(443); assertThat(actual.isSecure()).isTrue(); assertThat(actual.getRemoteAddr()).isEqualTo(actual.getRemoteHost()).isEqualTo("[203.0.113.195]"); + assertThat(actual.getLocalAddr()).isEqualTo(actual.getLocalAddr()).isEqualTo("[203.0.113.196]"); assertThat(actual.getHeader(X_FORWARDED_PROTO)).isNull(); assertThat(actual.getHeader(X_FORWARDED_HOST)).isNull(); assertThat(actual.getHeader(X_FORWARDED_PORT)).isNull(); assertThat(actual.getHeader(X_FORWARDED_FOR)).isNull(); + assertThat(actual.getHeader(X_FORWARDED_BY)).isNull(); assertThat(actual.getHeader("foo")).isEqualTo("bar"); } @@ -143,6 +150,7 @@ void forwardedRequestInRemoveOnlyMode() throws Exception { this.request.addHeader(X_FORWARDED_SSL, "on"); this.request.addHeader("foo", "bar"); this.request.addHeader(X_FORWARDED_FOR, "203.0.113.195"); + this.request.addHeader(X_FORWARDED_BY, "203.0.113.196"); this.filter.setRemoveOnly(true); this.filter.doFilter(this.request, new MockHttpServletResponse(), this.filterChain); @@ -156,12 +164,14 @@ void forwardedRequestInRemoveOnlyMode() throws Exception { assertThat(actual.isSecure()).isFalse(); assertThat(actual.getRemoteAddr()).isEqualTo(MockHttpServletRequest.DEFAULT_REMOTE_ADDR); assertThat(actual.getRemoteHost()).isEqualTo(MockHttpServletRequest.DEFAULT_REMOTE_HOST); + assertThat(actual.getLocalAddr()).isEqualTo(MockHttpServletRequest.DEFAULT_SERVER_ADDR); assertThat(actual.getHeader(X_FORWARDED_PROTO)).isNull(); assertThat(actual.getHeader(X_FORWARDED_HOST)).isNull(); assertThat(actual.getHeader(X_FORWARDED_PORT)).isNull(); assertThat(actual.getHeader(X_FORWARDED_SSL)).isNull(); assertThat(actual.getHeader(X_FORWARDED_FOR)).isNull(); + assertThat(actual.getHeader(X_FORWARDED_BY)).isNull(); assertThat(actual.getHeader("foo")).isEqualTo("bar"); } @@ -541,6 +551,83 @@ void forwardedForMultipleIdentifiers() throws Exception { } + @Nested + class ForwardedBy { + + @Test + void xForwardedForEmpty() throws Exception { + request.addHeader(X_FORWARDED_BY, ""); + HttpServletRequest actual = filterAndGetWrappedRequest(); + + assertThat(actual.getLocalAddr()).isEqualTo(MockHttpServletRequest.DEFAULT_SERVER_ADDR); + assertThat(actual.getLocalPort()).isEqualTo(MockHttpServletRequest.DEFAULT_SERVER_PORT); + } + + @Test + void xForwardedForSingleIdentifier() throws Exception { + request.addHeader(X_FORWARDED_BY, "203.0.113.195"); + HttpServletRequest actual = filterAndGetWrappedRequest(); + + assertThat(actual.getLocalAddr()).isEqualTo(actual.getLocalAddr()).isEqualTo("203.0.113.195"); + assertThat(actual.getLocalPort()).isEqualTo(MockHttpServletRequest.DEFAULT_SERVER_PORT); + } + + @Test + void xForwardedForMultipleIdentifiers() throws Exception { + request.addHeader(X_FORWARDED_BY, "203.0.113.195, 70.41.3.18, 150.172.238.178"); + HttpServletRequest actual = filterAndGetWrappedRequest(); + + assertThat(actual.getLocalAddr()).isEqualTo(actual.getLocalAddr()).isEqualTo("203.0.113.195"); + assertThat(actual.getLocalPort()).isEqualTo(MockHttpServletRequest.DEFAULT_SERVER_PORT); + } + + @Test + void forwardedForIpV4Identifier() throws Exception { + request.addHeader(FORWARDED, "By=203.0.113.195"); + HttpServletRequest actual = filterAndGetWrappedRequest(); + + assertThat(actual.getLocalAddr()).isEqualTo(actual.getLocalAddr()).isEqualTo("203.0.113.195"); + assertThat(actual.getLocalPort()).isEqualTo(MockHttpServletRequest.DEFAULT_SERVER_PORT); + } + + @Test + void forwardedForIpV6Identifier() throws Exception { + request.addHeader(FORWARDED, "By=\"[2001:db8:cafe::17]\""); + HttpServletRequest actual = filterAndGetWrappedRequest(); + + assertThat(actual.getLocalAddr()).isEqualTo(actual.getLocalAddr()).isEqualTo("[2001:db8:cafe::17]"); + assertThat(actual.getLocalPort()).isEqualTo(MockHttpServletRequest.DEFAULT_SERVER_PORT); + } + + @Test + void forwardedForIpV4IdentifierWithPort() throws Exception { + request.addHeader(FORWARDED, "By=\"203.0.113.195:47011\""); + HttpServletRequest actual = filterAndGetWrappedRequest(); + + assertThat(actual.getLocalAddr()).isEqualTo(actual.getLocalAddr()).isEqualTo("203.0.113.195"); + assertThat(actual.getLocalPort()).isEqualTo(47011); + } + + @Test + void forwardedForIpV6IdentifierWithPort() throws Exception { + request.addHeader(FORWARDED, "By=\"[2001:db8:cafe::17]:47011\""); + HttpServletRequest actual = filterAndGetWrappedRequest(); + + assertThat(actual.getLocalAddr()).isEqualTo(actual.getLocalAddr()).isEqualTo("[2001:db8:cafe::17]"); + assertThat(actual.getLocalPort()).isEqualTo(47011); + } + + @Test + void forwardedForMultipleIdentifiers() throws Exception { + request.addHeader(FORWARDED, "by=203.0.113.195;proto=http, by=\"[2001:db8:cafe::17]\", by=unknown"); + HttpServletRequest actual = filterAndGetWrappedRequest(); + + assertThat(actual.getLocalAddr()).isEqualTo(actual.getLocalAddr()).isEqualTo("203.0.113.195"); + assertThat(actual.getLocalPort()).isEqualTo(MockHttpServletRequest.DEFAULT_SERVER_PORT); + } + + } + @Nested class SendRedirect { diff --git a/spring-web/src/test/java/org/springframework/web/server/adapter/ForwardedHeaderTransformerTests.java b/spring-web/src/test/java/org/springframework/web/server/adapter/ForwardedHeaderTransformerTests.java index fdb1ae12c369..edcb12223325 100644 --- a/spring-web/src/test/java/org/springframework/web/server/adapter/ForwardedHeaderTransformerTests.java +++ b/spring-web/src/test/java/org/springframework/web/server/adapter/ForwardedHeaderTransformerTests.java @@ -33,6 +33,7 @@ * * @author Rossen Stoyanchev * @author Sebastien Deleuze + * @author Mengqi Xu */ class ForwardedHeaderTransformerTests { @@ -52,6 +53,7 @@ void removeOnly() { headers.add("X-Forwarded-Prefix", "prefix"); headers.add("X-Forwarded-Ssl", "on"); headers.add("X-Forwarded-For", "203.0.113.195"); + headers.add("X-Forwarded-By", "203.0.113.196"); ServerHttpRequest request = this.requestMutator.apply(getRequest(headers)); assertForwardedHeadersRemoved(request); @@ -233,6 +235,40 @@ void xForwardedFor() { assertThat(request.getRemoteAddress().getHostName()).isEqualTo("203.0.113.195"); } + @Test + void forwardedBy() { + HttpHeaders headers = new HttpHeaders(); + headers.add("Forwarded", "by=\"203.0.113.195:4711\";host=84.198.58.199;proto=https"); + + InetSocketAddress localAddress = new InetSocketAddress("example.client", 47011); + + ServerHttpRequest request = MockServerHttpRequest + .method(HttpMethod.GET, URI.create("https://example.com/a%20b?q=a%2Bb")) + .localAddress(localAddress) + .headers(headers) + .build(); + + request = this.requestMutator.apply(request); + assertThat(request.getLocalAddress()).isNotNull(); + assertThat(request.getLocalAddress().getHostName()).isEqualTo("203.0.113.195"); + assertThat(request.getLocalAddress().getPort()).isEqualTo(4711); + } + + @Test + void xForwardedBy() { + HttpHeaders headers = new HttpHeaders(); + headers.add("x-forwarded-by", "203.0.113.195, 70.41.3.18, 150.172.238.178"); + + ServerHttpRequest request = MockServerHttpRequest + .method(HttpMethod.GET, URI.create("https://example.com/a%20b?q=a%2Bb")) + .headers(headers) + .build(); + + request = this.requestMutator.apply(request); + assertThat(request.getLocalAddress()).isNotNull(); + assertThat(request.getLocalAddress().getHostName()).isEqualTo("203.0.113.195"); + } + private MockServerHttpRequest getRequest(HttpHeaders headers) { return MockServerHttpRequest.get(BASE_URL).headers(headers).build(); From 942fbf30320a52f2f28e46961176e13cf8576a4d Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Mon, 1 Sep 2025 11:49:31 +0200 Subject: [PATCH 160/591] Polishing contribution Closes gh-34683 --- .../pages/web/webflux/reactive-spring.adoc | 4 -- .../ROOT/partials/web/forwarded-headers.adoc | 11 ++- .../server/reactive/ServerHttpRequest.java | 2 +- .../web/filter/ForwardedHeaderFilter.java | 1 - .../adapter/ForwardedHeaderTransformer.java | 1 - .../web/util/ForwardedHeaderUtils.java | 67 +++++++------------ .../filter/ForwardedHeaderFilterTests.java | 45 ++----------- .../ForwardedHeaderTransformerTests.java | 16 ----- .../web/util/ForwardedHeaderUtilsTests.java | 11 +++ 9 files changed, 51 insertions(+), 107 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/web/webflux/reactive-spring.adoc b/framework-docs/modules/ROOT/pages/web/webflux/reactive-spring.adoc index d869b1fb2aa7..8de72eeaad03 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/reactive-spring.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/reactive-spring.adoc @@ -343,10 +343,6 @@ the request, based on forwarded headers, and then removes those headers. If you it as a bean with the name `forwardedHeaderTransformer`, it will be xref:web/webflux/reactive-spring.adoc#webflux-web-handler-api-special-beans[detected] and used. -NOTE: In 5.1 `ForwardedHeaderFilter` was deprecated and superseded by -`ForwardedHeaderTransformer` so forwarded headers can be processed earlier, before the -exchange is created. If the filter is configured anyway, it is taken out of the list of -filters, and `ForwardedHeaderTransformer` is used instead. [[webflux-forwarded-headers-security]] === Security Considerations diff --git a/framework-docs/modules/ROOT/partials/web/forwarded-headers.adoc b/framework-docs/modules/ROOT/partials/web/forwarded-headers.adoc index 45e48114a618..c1f63fad0de0 100644 --- a/framework-docs/modules/ROOT/partials/web/forwarded-headers.adoc +++ b/framework-docs/modules/ROOT/partials/web/forwarded-headers.adoc @@ -9,7 +9,7 @@ that proxies can use to provide information about the original request. === Non-standard Headers There are other non-standard headers, too, including `X-Forwarded-Host`, `X-Forwarded-Port`, -`X-Forwarded-Proto`, `X-Forwarded-Ssl`, and `X-Forwarded-Prefix`. +`X-Forwarded-Proto`, `X-Forwarded-Ssl`, `X-Forwarded-Prefix`, and `X-Forwarded-For`. [[x-forwarded-host]] ==== X-Forwarded-Host @@ -113,3 +113,12 @@ https://example.com/api/app1/{path} -> http://localhost:8080/app1/{path} In this case, the proxy has a prefix of `/api/app1` and the server has a prefix of `/app1`. The proxy can send `X-Forwarded-Prefix: /api/app1` to have the original prefix `/api/app1` override the server prefix `/app1`. + +[[x-forwarded-for]] +==== X-Forwarded-For + +https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-For[`X-Forwarded-For:

    `] +is a de-facto standard header that is used to communicate the original `InetSocketAddress` of the client to a +downstream server. For example, if a request is sent by a client at `[fd00:fefe:1::4]` to a proxy at +`192.168.0.1`, the "remote address" information contained in the HTTP request will reflect the actual address of the +client, not the proxy. diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/ServerHttpRequest.java b/spring-web/src/main/java/org/springframework/http/server/reactive/ServerHttpRequest.java index f25c908eb3a2..420087a439fe 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/ServerHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/ServerHttpRequest.java @@ -186,7 +186,7 @@ interface Builder { /** * Set the address of the local client. - * @since 7.x + * @since 7.0 */ Builder localAddress(InetSocketAddress localAddress); diff --git a/spring-web/src/main/java/org/springframework/web/filter/ForwardedHeaderFilter.java b/spring-web/src/main/java/org/springframework/web/filter/ForwardedHeaderFilter.java index 2bf8fc1bcbb0..b71217dcdc02 100644 --- a/spring-web/src/main/java/org/springframework/web/filter/ForwardedHeaderFilter.java +++ b/spring-web/src/main/java/org/springframework/web/filter/ForwardedHeaderFilter.java @@ -93,7 +93,6 @@ public class ForwardedHeaderFilter extends OncePerRequestFilter { FORWARDED_HEADER_NAMES.add("X-Forwarded-Prefix"); FORWARDED_HEADER_NAMES.add("X-Forwarded-Ssl"); FORWARDED_HEADER_NAMES.add("X-Forwarded-For"); - FORWARDED_HEADER_NAMES.add("X-Forwarded-By"); } diff --git a/spring-web/src/main/java/org/springframework/web/server/adapter/ForwardedHeaderTransformer.java b/spring-web/src/main/java/org/springframework/web/server/adapter/ForwardedHeaderTransformer.java index d5ea6af968a8..ca28bb5be838 100644 --- a/spring-web/src/main/java/org/springframework/web/server/adapter/ForwardedHeaderTransformer.java +++ b/spring-web/src/main/java/org/springframework/web/server/adapter/ForwardedHeaderTransformer.java @@ -73,7 +73,6 @@ public class ForwardedHeaderTransformer implements Function squareBracketIdx) { - if (squareBracketIdx == -1 && value.indexOf(':') != portSeparatorIdx) { - throw new IllegalArgumentException("Invalid IPv4 address: " + value); - } - host = value.substring(0, portSeparatorIdx); - try { - port = Integer.parseInt(value, portSeparatorIdx + 1, value.length(), 10); - } - catch (NumberFormatException ex) { - throw new IllegalArgumentException( - "Failed to parse a port from \"forwarded\"-type header value: " + value); - } - } - return InetSocketAddress.createUnresolved(host, port); + return parseInetSocketAddress(value, port); } } @@ -191,13 +175,14 @@ private static void adaptForwardedHost(UriComponentsBuilder uriComponentsBuilder } /** - * Parse the first "Forwarded: by=..." or "X-Forwarded-By" header value to + * Parse the first "Forwarded: by=..." header value to * an {@code InetSocketAddress} representing the address of the server. * @param uri the request {@code URI} * @param headers the request headers that may contain forwarded headers * @param localAddress the current local address * @return an {@code InetSocketAddress} with the extracted host and port, or * {@code null} if the headers are not present + * @since 7.0 * @see RFC 7239, Section 5.1 */ public static @Nullable InetSocketAddress parseForwardedBy( @@ -212,35 +197,31 @@ private static void adaptForwardedHost(UriComponentsBuilder uriComponentsBuilder Matcher matcher = FORWARDED_BY_PATTERN.matcher(forwardedToUse); if (matcher.find()) { String value = matcher.group(1).trim(); - String host = value; - int portSeparatorIdx = value.lastIndexOf(':'); - int squareBracketIdx = value.lastIndexOf(']'); - if (portSeparatorIdx > squareBracketIdx) { - if (squareBracketIdx == -1 && value.indexOf(':') != portSeparatorIdx) { - throw new IllegalArgumentException("Invalid IPv4 address: " + value); - } - host = value.substring(0, portSeparatorIdx); - try { - port = Integer.parseInt(value, portSeparatorIdx + 1, value.length(), 10); - } - catch (NumberFormatException ex) { - throw new IllegalArgumentException( - "Failed to parse a port from \"forwarded\"-type header value: " + value); - } - } - return InetSocketAddress.createUnresolved(host, port); + return parseInetSocketAddress(value, port); } } - String byHeader = headers.getFirst("X-Forwarded-By"); - if (StringUtils.hasText(byHeader)) { - String host = StringUtils.tokenizeToStringArray(byHeader, ",")[0]; - boolean ipv6 = (host.indexOf(':') != -1); - host = (ipv6 && !host.startsWith("[") && !host.endsWith("]") ? "[" + host + "]" : host); - return InetSocketAddress.createUnresolved(host, port); - } - return null; } + private static InetSocketAddress parseInetSocketAddress(String value, int port) { + String host = value; + int portSeparatorIdx = value.lastIndexOf(':'); + int squareBracketIdx = value.lastIndexOf(']'); + if (portSeparatorIdx > squareBracketIdx) { + if (squareBracketIdx == -1 && value.indexOf(':') != portSeparatorIdx) { + throw new IllegalArgumentException("Invalid IPv4 address: " + value); + } + host = value.substring(0, portSeparatorIdx); + try { + port = Integer.parseInt(value, portSeparatorIdx + 1, value.length(), 10); + } + catch (NumberFormatException ex) { + throw new IllegalArgumentException( + "Failed to parse a port from \"forwarded\"-type header value: " + value); + } + } + return InetSocketAddress.createUnresolved(host, port); + } + } diff --git a/spring-web/src/test/java/org/springframework/web/filter/ForwardedHeaderFilterTests.java b/spring-web/src/test/java/org/springframework/web/filter/ForwardedHeaderFilterTests.java index 9db168eefcf5..0ed3d01661ec 100644 --- a/spring-web/src/test/java/org/springframework/web/filter/ForwardedHeaderFilterTests.java +++ b/spring-web/src/test/java/org/springframework/web/filter/ForwardedHeaderFilterTests.java @@ -67,8 +67,6 @@ class ForwardedHeaderFilterTests { private static final String X_FORWARDED_FOR = "x-forwarded-for"; - private static final String X_FORWARDED_BY = "x-forwarded-by"; - private final ForwardedHeaderFilter filter = new ForwardedHeaderFilter(); @@ -96,7 +94,6 @@ void shouldFilter() { testShouldFilter(X_FORWARDED_SSL); testShouldFilter(X_FORWARDED_PREFIX); testShouldFilter(X_FORWARDED_FOR); - testShouldFilter(X_FORWARDED_BY); } private void testShouldFilter(String headerName) { @@ -119,7 +116,6 @@ void forwardedRequest(String protocol) throws Exception { this.request.addHeader(X_FORWARDED_PORT, "443"); this.request.addHeader("foo", "bar"); this.request.addHeader(X_FORWARDED_FOR, "[203.0.113.195]"); - this.request.addHeader(X_FORWARDED_BY, "[203.0.113.196]"); this.filter.doFilter(this.request, new MockHttpServletResponse(), this.filterChain); HttpServletRequest actual = (HttpServletRequest) this.filterChain.getRequest(); @@ -131,13 +127,11 @@ void forwardedRequest(String protocol) throws Exception { assertThat(actual.getServerPort()).isEqualTo(443); assertThat(actual.isSecure()).isTrue(); assertThat(actual.getRemoteAddr()).isEqualTo(actual.getRemoteHost()).isEqualTo("[203.0.113.195]"); - assertThat(actual.getLocalAddr()).isEqualTo(actual.getLocalAddr()).isEqualTo("[203.0.113.196]"); assertThat(actual.getHeader(X_FORWARDED_PROTO)).isNull(); assertThat(actual.getHeader(X_FORWARDED_HOST)).isNull(); assertThat(actual.getHeader(X_FORWARDED_PORT)).isNull(); assertThat(actual.getHeader(X_FORWARDED_FOR)).isNull(); - assertThat(actual.getHeader(X_FORWARDED_BY)).isNull(); assertThat(actual.getHeader("foo")).isEqualTo("bar"); } @@ -150,7 +144,6 @@ void forwardedRequestInRemoveOnlyMode() throws Exception { this.request.addHeader(X_FORWARDED_SSL, "on"); this.request.addHeader("foo", "bar"); this.request.addHeader(X_FORWARDED_FOR, "203.0.113.195"); - this.request.addHeader(X_FORWARDED_BY, "203.0.113.196"); this.filter.setRemoveOnly(true); this.filter.doFilter(this.request, new MockHttpServletResponse(), this.filterChain); @@ -171,7 +164,6 @@ void forwardedRequestInRemoveOnlyMode() throws Exception { assertThat(actual.getHeader(X_FORWARDED_PORT)).isNull(); assertThat(actual.getHeader(X_FORWARDED_SSL)).isNull(); assertThat(actual.getHeader(X_FORWARDED_FOR)).isNull(); - assertThat(actual.getHeader(X_FORWARDED_BY)).isNull(); assertThat(actual.getHeader("foo")).isEqualTo("bar"); } @@ -555,34 +547,7 @@ void forwardedForMultipleIdentifiers() throws Exception { class ForwardedBy { @Test - void xForwardedForEmpty() throws Exception { - request.addHeader(X_FORWARDED_BY, ""); - HttpServletRequest actual = filterAndGetWrappedRequest(); - - assertThat(actual.getLocalAddr()).isEqualTo(MockHttpServletRequest.DEFAULT_SERVER_ADDR); - assertThat(actual.getLocalPort()).isEqualTo(MockHttpServletRequest.DEFAULT_SERVER_PORT); - } - - @Test - void xForwardedForSingleIdentifier() throws Exception { - request.addHeader(X_FORWARDED_BY, "203.0.113.195"); - HttpServletRequest actual = filterAndGetWrappedRequest(); - - assertThat(actual.getLocalAddr()).isEqualTo(actual.getLocalAddr()).isEqualTo("203.0.113.195"); - assertThat(actual.getLocalPort()).isEqualTo(MockHttpServletRequest.DEFAULT_SERVER_PORT); - } - - @Test - void xForwardedForMultipleIdentifiers() throws Exception { - request.addHeader(X_FORWARDED_BY, "203.0.113.195, 70.41.3.18, 150.172.238.178"); - HttpServletRequest actual = filterAndGetWrappedRequest(); - - assertThat(actual.getLocalAddr()).isEqualTo(actual.getLocalAddr()).isEqualTo("203.0.113.195"); - assertThat(actual.getLocalPort()).isEqualTo(MockHttpServletRequest.DEFAULT_SERVER_PORT); - } - - @Test - void forwardedForIpV4Identifier() throws Exception { + void forwardedByIpV4Identifier() throws Exception { request.addHeader(FORWARDED, "By=203.0.113.195"); HttpServletRequest actual = filterAndGetWrappedRequest(); @@ -591,7 +556,7 @@ void forwardedForIpV4Identifier() throws Exception { } @Test - void forwardedForIpV6Identifier() throws Exception { + void forwardedByIpV6Identifier() throws Exception { request.addHeader(FORWARDED, "By=\"[2001:db8:cafe::17]\""); HttpServletRequest actual = filterAndGetWrappedRequest(); @@ -600,7 +565,7 @@ void forwardedForIpV6Identifier() throws Exception { } @Test - void forwardedForIpV4IdentifierWithPort() throws Exception { + void forwardedByIpV4IdentifierWithPort() throws Exception { request.addHeader(FORWARDED, "By=\"203.0.113.195:47011\""); HttpServletRequest actual = filterAndGetWrappedRequest(); @@ -609,7 +574,7 @@ void forwardedForIpV4IdentifierWithPort() throws Exception { } @Test - void forwardedForIpV6IdentifierWithPort() throws Exception { + void forwardedByIpV6IdentifierWithPort() throws Exception { request.addHeader(FORWARDED, "By=\"[2001:db8:cafe::17]:47011\""); HttpServletRequest actual = filterAndGetWrappedRequest(); @@ -618,7 +583,7 @@ void forwardedForIpV6IdentifierWithPort() throws Exception { } @Test - void forwardedForMultipleIdentifiers() throws Exception { + void forwardedByMultipleIdentifiers() throws Exception { request.addHeader(FORWARDED, "by=203.0.113.195;proto=http, by=\"[2001:db8:cafe::17]\", by=unknown"); HttpServletRequest actual = filterAndGetWrappedRequest(); diff --git a/spring-web/src/test/java/org/springframework/web/server/adapter/ForwardedHeaderTransformerTests.java b/spring-web/src/test/java/org/springframework/web/server/adapter/ForwardedHeaderTransformerTests.java index edcb12223325..0381cbac5f50 100644 --- a/spring-web/src/test/java/org/springframework/web/server/adapter/ForwardedHeaderTransformerTests.java +++ b/spring-web/src/test/java/org/springframework/web/server/adapter/ForwardedHeaderTransformerTests.java @@ -53,7 +53,6 @@ void removeOnly() { headers.add("X-Forwarded-Prefix", "prefix"); headers.add("X-Forwarded-Ssl", "on"); headers.add("X-Forwarded-For", "203.0.113.195"); - headers.add("X-Forwarded-By", "203.0.113.196"); ServerHttpRequest request = this.requestMutator.apply(getRequest(headers)); assertForwardedHeadersRemoved(request); @@ -254,21 +253,6 @@ void forwardedBy() { assertThat(request.getLocalAddress().getPort()).isEqualTo(4711); } - @Test - void xForwardedBy() { - HttpHeaders headers = new HttpHeaders(); - headers.add("x-forwarded-by", "203.0.113.195, 70.41.3.18, 150.172.238.178"); - - ServerHttpRequest request = MockServerHttpRequest - .method(HttpMethod.GET, URI.create("https://example.com/a%20b?q=a%2Bb")) - .headers(headers) - .build(); - - request = this.requestMutator.apply(request); - assertThat(request.getLocalAddress()).isNotNull(); - assertThat(request.getLocalAddress().getHostName()).isEqualTo("203.0.113.195"); - } - private MockServerHttpRequest getRequest(HttpHeaders headers) { return MockServerHttpRequest.get(BASE_URL).headers(headers).build(); diff --git a/spring-web/src/test/java/org/springframework/web/util/ForwardedHeaderUtilsTests.java b/spring-web/src/test/java/org/springframework/web/util/ForwardedHeaderUtilsTests.java index 12e777a3e92c..40fdd7a5ca47 100644 --- a/spring-web/src/test/java/org/springframework/web/util/ForwardedHeaderUtilsTests.java +++ b/spring-web/src/test/java/org/springframework/web/util/ForwardedHeaderUtilsTests.java @@ -551,4 +551,15 @@ void fromHttpRequestXForwardedHeaderForIpv6Formatting() { assertThat(address.getHostName()).isEqualTo("[fd00:fefe:1::4]"); } + @Test + void parseForwardedByHeader() { + HttpHeaders headers = new HttpHeaders(); + headers.add("Forwarded", "by=[fd00:fefe:1::4], 192.168.0.1"); + + InetSocketAddress address = + ForwardedHeaderUtils.parseForwardedBy(URI.create("https://example.com"), headers, null); + + assertThat(address.getHostName()).isEqualTo("[fd00:fefe:1::4]"); + } + } From 521764e68b7ab425ff7964e0b3d9d303c80adf7b Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Tue, 2 Sep 2025 12:31:17 +0200 Subject: [PATCH 161/591] =?UTF-8?q?Fix=20links,=20@=E2=81=A0since=20tags,?= =?UTF-8?q?=20formatting,=20etc.=20in=20RestTestClient?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/reactive/server/WebTestClient.java | 43 +++++---- .../web/servlet/client/RestTestClient.java | 95 ++++++++++--------- 2 files changed, 71 insertions(+), 67 deletions(-) diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java index 9fe4a75edde4..17a98ebed231 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java @@ -601,7 +601,7 @@ interface RequestHeadersSpec> { * Set the list of acceptable {@linkplain MediaType media types}, as * specified by the {@code Accept} header. * @param acceptableMediaTypes the acceptable media types - * @return the same instance + * @return this spec for further declaration of the request */ S accept(MediaType... acceptableMediaTypes); @@ -609,7 +609,7 @@ interface RequestHeadersSpec> { * Set the list of acceptable {@linkplain Charset charsets}, as specified * by the {@code Accept-Charset} header. * @param acceptableCharsets the acceptable charsets - * @return the same instance + * @return this spec for further declaration of the request */ S acceptCharset(Charset... acceptableCharsets); @@ -617,7 +617,7 @@ interface RequestHeadersSpec> { * Add a cookie with the given name and value. * @param name the cookie name * @param value the cookie value - * @return the same instance + * @return this spec for further declaration of the request */ S cookie(String name, String value); @@ -628,7 +628,7 @@ interface RequestHeadersSpec> { * {@linkplain MultiValueMap#remove(Object) remove} values, or use any of the other * {@link MultiValueMap} methods. * @param cookiesConsumer a function that consumes the cookies map - * @return this builder + * @return this spec for further declaration of the request */ S cookies(Consumer> cookiesConsumer); @@ -637,14 +637,14 @@ interface RequestHeadersSpec> { *

    The date should be specified as the number of milliseconds since * January 1, 1970 GMT. * @param ifModifiedSince the new value of the header - * @return the same instance + * @return this spec for further declaration of the request */ S ifModifiedSince(ZonedDateTime ifModifiedSince); /** * Set the values of the {@code If-None-Match} header. * @param ifNoneMatches the new value of the header - * @return the same instance + * @return this spec for further declaration of the request */ S ifNoneMatch(String... ifNoneMatches); @@ -652,7 +652,7 @@ interface RequestHeadersSpec> { * Add the given, single header value under the given name. * @param headerName the header name * @param headerValues the header value(s) - * @return the same instance + * @return this spec for further declaration of the request */ S header(String headerName, String... headerValues); @@ -663,7 +663,7 @@ interface RequestHeadersSpec> { * {@linkplain HttpHeaders#remove(String) remove} values, or use any of the other * {@link HttpHeaders} methods. * @param headersConsumer a function that consumes the {@code HttpHeaders} - * @return this builder + * @return this spec for further declaration of the request */ S headers(Consumer headersConsumer); @@ -674,6 +674,7 @@ interface RequestHeadersSpec> { * @param version the API version of the request; this can be a String or * some Object that can be formatted by the inserter — for example, * through an {@link ApiVersionFormatter} + * @return this spec for further declaration of the request * @since 7.0 */ S apiVersion(Object version); @@ -682,7 +683,7 @@ interface RequestHeadersSpec> { * Set the attribute with the given name to the given value. * @param name the name of the attribute to add * @param value the value of the attribute to add - * @return this builder + * @return this spec for further declaration of the request */ S attribute(String name, Object value); @@ -691,20 +692,20 @@ interface RequestHeadersSpec> { * the consumer are "live", so that the consumer can be used to inspect attributes, * remove attributes, or use any of the other map-provided methods. * @param attributesConsumer a function that consumes the attributes - * @return this builder + * @return this spec for further declaration of the request */ S attributes(Consumer> attributesConsumer); /** * Perform the exchange without a request body. - * @return spec for decoding the response + * @return a spec for decoding the response */ ResponseSpec exchange(); } /** - * Specification for providing body of a request. + * Specification for providing the body of a request. */ interface RequestBodySpec extends RequestHeadersSpec { @@ -712,7 +713,7 @@ interface RequestBodySpec extends RequestHeadersSpec { * Set the length of the body in bytes, as specified by the * {@code Content-Length} header. * @param contentLength the content length - * @return the same instance + * @return this spec for further declaration of the request * @see HttpHeaders#setContentLength(long) */ RequestBodySpec contentLength(long contentLength); @@ -721,7 +722,7 @@ interface RequestBodySpec extends RequestHeadersSpec { * Set the {@linkplain MediaType media type} of the body, as specified * by the {@code Content-Type} header. * @param contentType the content type - * @return the same instance + * @return this spec for further declaration of the request * @see HttpHeaders#setContentType(MediaType) */ RequestBodySpec contentType(MediaType contentType); @@ -731,7 +732,7 @@ interface RequestBodySpec extends RequestHeadersSpec { * {@link WebClient.RequestBodySpec#bodyValue(Object) * bodyValue} method on the underlying {@code WebClient}. * @param body the value to write to the request body - * @return spec for further declaration of the request + * @return this spec for further declaration of the request * @since 5.2 */ RequestHeadersSpec bodyValue(Object body); @@ -744,7 +745,7 @@ interface RequestBodySpec extends RequestHeadersSpec { * @param elementClass the class of elements contained in the publisher * @param the type of the elements contained in the publisher * @param the type of the {@code Publisher} - * @return spec for further declaration of the request + * @return this spec for further declaration of the request */ > RequestHeadersSpec body(S publisher, Class elementClass); @@ -755,7 +756,7 @@ interface RequestBodySpec extends RequestHeadersSpec { * @param elementTypeRef the type reference of elements contained in the publisher * @param the type of the elements contained in the publisher * @param the type of the {@code Publisher} - * @return spec for further declaration of the request + * @return this spec for further declaration of the request * @since 5.2 */ > RequestHeadersSpec body( @@ -769,7 +770,7 @@ > RequestHeadersSpec body( * {@link Publisher} or another producer adaptable to a * {@code Publisher} via {@link ReactiveAdapterRegistry} * @param elementClass the class of elements contained in the producer - * @return spec for further declaration of the request + * @return this spec for further declaration of the request * @since 5.2 */ RequestHeadersSpec body(Object producer, Class elementClass); @@ -782,18 +783,18 @@ > RequestHeadersSpec body( * {@link Publisher} or another producer adaptable to a * {@code Publisher} via {@link ReactiveAdapterRegistry} * @param elementTypeRef the type reference of elements contained in the producer - * @return spec for further declaration of the request + * @return this spec for further declaration of the request * @since 5.2 */ RequestHeadersSpec body(Object producer, ParameterizedTypeReference elementTypeRef); /** * Set the body of the request to the given {@code BodyInserter}. - * This method invokes the + *

    This method invokes the * {@link WebClient.RequestBodySpec#body(BodyInserter) * body(BodyInserter)} method on the underlying {@code WebClient}. * @param inserter the body inserter to use - * @return spec for further declaration of the request + * @return this spec for further declaration of the request * @see org.springframework.web.reactive.function.BodyInserters */ RequestHeadersSpec body(BodyInserter inserter); diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java index f7f7bd69c6c2..7b7385b4ae63 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java @@ -55,10 +55,11 @@ /** * Client for testing web servers that uses {@link RestClient} internally to * perform requests while also providing a fluent API to verify responses. - * This client can connect to any server over HTTP or to a {@link MockMvc} server - * with a mock request and response. * - *

    Use one of the bindToXxx methods to create an instance. For example: + *

    This client can connect to any server over HTTP or to a {@link MockMvc} + * server with a mock request and response. + * + *

    Use one of the {@code bindToXxx()} methods to create an instance. For example: *

      *
    • {@link #bindToController(Object...)} *
    • {@link #bindToRouterFunction(RouterFunction[])} @@ -74,10 +75,10 @@ public interface RestTestClient { /** - * The name of a request header used to assign a unique id to every request - * performed through the {@code RestTestClient}. This can be useful for - * storing contextual information at all phases of request processing (for example, - * from a server-side component) under that id and later to look up + * The name of a request header used to assign a unique ID to every request + * performed through the {@code RestTestClient}. This can be useful to + * store contextual information under that ID at all phases of request + * processing (for example, from a server-side component) and later look up * that information once an {@link ExchangeResult} is available. */ String RESTTESTCLIENT_REQUEST_ID = "RestTestClient-Request-Id"; @@ -139,7 +140,7 @@ public interface RestTestClient { /** - * Begin creating a {@link RestTestClient} with a {@link MockMvcBuilders#standaloneSetup + * Begin creating a {@link RestTestClient} with a {@linkplain MockMvcBuilders#standaloneSetup * Standalone MockMvc setup}. */ static StandaloneSetupBuilder bindToController(Object... controllers) { @@ -147,16 +148,16 @@ static StandaloneSetupBuilder bindToController(Object... controllers) { } /** - * Begin creating a {@link RestTestClient} with a {@link MockMvcBuilders#routerFunctions} - * RouterFunction's MockMvc setup}. + * Begin creating a {@link RestTestClient} with a {@linkplain MockMvcBuilders#routerFunctions + * RouterFunction MockMvc setup}. */ static RouterFunctionSetupBuilder bindToRouterFunction(RouterFunction... routerFunctions) { return new DefaultRestTestClientBuilder.DefaultRouterFunctionSetupBuilder(routerFunctions); } /** - * Begin creating a {@link RestTestClient} with a {@link MockMvcBuilders#webAppContextSetup} - * WebAppContext MockMvc setup}. + * Begin creating a {@link RestTestClient} with a {@linkplain MockMvcBuilders#webAppContextSetup + * WebApplicationContext MockMvc setup}. */ static WebAppContextSetupBuilder bindToApplicationContext(WebApplicationContext context) { return new DefaultRestTestClientBuilder.DefaultWebAppContextSetupBuilder(context); @@ -201,25 +202,28 @@ interface Builder> { /** * Configure a base URI as described in {@link RestClient#create(String)}. + * @return this builder */ T baseUrl(String baseUrl); /** * Provide a pre-configured {@link UriBuilderFactory} instance as an * alternative to and effectively overriding {@link #baseUrl(String)}. + * @return this builder */ T uriBuilderFactory(UriBuilderFactory uriBuilderFactory); /** - * Add the given header to all requests that haven't added it. + * Add the given header to all requests that have not added it. * @param headerName the header name * @param headerValues the header values + * @return this builder */ T defaultHeader(String headerName, String... headerValues); /** - * Manipulate the default headers with the given consumer. The - * headers provided to the consumer are "live", so that the consumer can be used to + * Manipulate the default headers with the given consumer. The headers + * provided to the consumer are "live", so that the consumer can be used to * {@linkplain HttpHeaders#set(String, String) overwrite} existing header values, * {@linkplain HttpHeaders#remove(String) remove} values, or use any of the other * {@link HttpHeaders} methods. @@ -229,15 +233,16 @@ interface Builder> { T defaultHeaders(Consumer headersConsumer); /** - * Add the given cookie to all requests that haven't already added it. + * Add the given cookie to all requests that have not already added it. * @param cookieName the cookie name * @param cookieValues the cookie values + * @return this builder */ T defaultCookie(String cookieName, String... cookieValues); /** - * Manipulate the default cookies with the given consumer. The - * map provided to the consumer is "live", so that the consumer can be used to + * Manipulate the default cookies with the given consumer. The map provided + * to the consumer is "live", so that the consumer can be used to * {@linkplain MultiValueMap#set(Object, Object) overwrite} existing header values, * {@linkplain MultiValueMap#remove(Object) remove} values, or use any of the other * {@link MultiValueMap} methods. @@ -251,7 +256,6 @@ interface Builder> { * if not already set. * @param version the version to use * @return this builder - * @since 7.0 */ T defaultApiVersion(Object version); @@ -260,7 +264,6 @@ interface Builder> { * specified via {@link RequestHeadersSpec#apiVersion(Object)} * is inserted into the request. * @param apiVersionInserter the inserter to use - * @since 7.0 */ T apiVersionInserter(ApiVersionInserter apiVersionInserter); @@ -287,10 +290,10 @@ interface Builder> { T configureMessageConverters(Consumer configurer); /** - * Configure an {@code EntityExchangeResult} callback that is invoked + * Configure an {@link EntityExchangeResult} callback that is invoked * every time after a response is fully decoded to a single entity, to a - * List of entities, or to a byte[]. In effect, equivalent to each and - * all of the below but registered once, globally: + * List of entities, or to a byte[]. In effect, this is equivalent to each + * of the below but registered only once, globally. *
       		 * client.get().uri("/accounts/1")
       		 *         .exchange()
      @@ -331,24 +334,24 @@ interface MockMvcSetupBuilder, M extends MockMvcBuilder> ex
       
       
       	/**
      -	 * Extension of {@link Builder} for tests витх а
      -	 * {@link MockMvcBuilders#standaloneSetup(Object...) standalone MockMvc setup}.
      +	 * Extension of {@link Builder} for tests against а
      +	 * {@linkplain MockMvcBuilders#standaloneSetup(Object...) standalone MockMvc setup}.
       	 */
       	interface StandaloneSetupBuilder extends MockMvcSetupBuilder {
       	}
       
       
       	/**
      -	 * Extension of {@link Builder} for tests витх а
      -	 * {@link MockMvcBuilders#routerFunctions(RouterFunction[]) RouterFunction MockMvc setup}.
      +	 * Extension of {@link Builder} for tests against а
      +	 * {@linkplain MockMvcBuilders#routerFunctions(RouterFunction[]) RouterFunction MockMvc setup}.
       	 */
       	interface RouterFunctionSetupBuilder extends MockMvcSetupBuilder {
       	}
       
       
       	/**
      -	 * Extension of {@link Builder} for tests витх а
      -	 * {@link MockMvcBuilders#webAppContextSetup(WebApplicationContext) WebAppContext MockMvc setup}.
      +	 * Extension of {@link Builder} for tests against а
      +	 * {@linkplain MockMvcBuilders#webAppContextSetup(WebApplicationContext) WebAppContext MockMvc setup}.
       	 */
       	interface WebAppContextSetupBuilder extends MockMvcSetupBuilder {
       	}
      @@ -408,7 +411,7 @@ interface RequestHeadersSpec> {
       		 * Set the list of acceptable {@linkplain MediaType media types}, as
       		 * specified by the {@code Accept} header.
       		 * @param acceptableMediaTypes the acceptable media types
      -		 * @return the same instance
      +		 * @return this spec for further declaration of the request
       		 */
       		S accept(MediaType... acceptableMediaTypes);
       
      @@ -416,7 +419,7 @@ interface RequestHeadersSpec> {
       		 * Set the list of acceptable {@linkplain Charset charsets}, as specified
       		 * by the {@code Accept-Charset} header.
       		 * @param acceptableCharsets the acceptable charsets
      -		 * @return the same instance
      +		 * @return this spec for further declaration of the request
       		 */
       		S acceptCharset(Charset... acceptableCharsets);
       
      @@ -424,7 +427,7 @@ interface RequestHeadersSpec> {
       		 * Add a cookie with the given name and value.
       		 * @param name the cookie name
       		 * @param value the cookie value
      -		 * @return the same instance
      +		 * @return this spec for further declaration of the request
       		 */
       		S cookie(String name, String value);
       
      @@ -435,7 +438,7 @@ interface RequestHeadersSpec> {
       		 * {@linkplain MultiValueMap#remove(Object) remove} values, or use any of the other
       		 * {@link MultiValueMap} methods.
       		 * @param cookiesConsumer a function that consumes the cookies map
      -		 * @return this builder
      +		 * @return this spec for further declaration of the request
       		 */
       		S cookies(Consumer> cookiesConsumer);
       
      @@ -444,14 +447,14 @@ interface RequestHeadersSpec> {
       		 * 

      The date should be specified as the number of milliseconds since * January 1, 1970 GMT. * @param ifModifiedSince the new value of the header - * @return the same instance + * @return this spec for further declaration of the request */ S ifModifiedSince(ZonedDateTime ifModifiedSince); /** * Set the values of the {@code If-None-Match} header. * @param ifNoneMatches the new value of the header - * @return the same instance + * @return this spec for further declaration of the request */ S ifNoneMatch(String... ifNoneMatches); @@ -459,7 +462,7 @@ interface RequestHeadersSpec> { * Add the given, single header value under the given name. * @param headerName the header name * @param headerValues the header value(s) - * @return the same instance + * @return this spec for further declaration of the request */ S header(String headerName, String... headerValues); @@ -470,7 +473,7 @@ interface RequestHeadersSpec> { * {@linkplain HttpHeaders#remove(String) remove} values, or use any of the other * {@link HttpHeaders} methods. * @param headersConsumer a function that consumes the {@code HttpHeaders} - * @return this builder + * @return this spec for further declaration of the request */ S headers(Consumer headersConsumer); @@ -481,7 +484,7 @@ interface RequestHeadersSpec> { * @param version the API version of the request; this can be a String or * some Object that can be formatted by the inserter — for example, * through an {@link ApiVersionFormatter} - * @since 7.0 + * @return this spec for further declaration of the request */ S apiVersion(Object version); @@ -489,7 +492,7 @@ interface RequestHeadersSpec> { * Set the attribute with the given name to the given value. * @param name the name of the attribute to add * @param value the value of the attribute to add - * @return this builder + * @return this spec for further declaration of the request */ S attribute(String name, Object value); @@ -498,20 +501,20 @@ interface RequestHeadersSpec> { * the consumer are "live", so that the consumer can be used to inspect attributes, * remove attributes, or use any of the other map-provided methods. * @param attributesConsumer a function that consumes the attributes - * @return this builder + * @return this spec for further declaration of the request */ S attributes(Consumer> attributesConsumer); /** * Perform the exchange without a request body. - * @return spec for decoding the response + * @return a spec for decoding the response */ ResponseSpec exchange(); } /** - * Specification for providing body of a request. + * Specification for providing the body of a request. */ interface RequestBodySpec extends RequestHeadersSpec { @@ -519,7 +522,7 @@ interface RequestBodySpec extends RequestHeadersSpec { * Set the length of the body in bytes, as specified by the * {@code Content-Length} header. * @param contentLength the content length - * @return the same instance + * @return this spec for further declaration of the request * @see HttpHeaders#setContentLength(long) */ RequestBodySpec contentLength(long contentLength); @@ -528,7 +531,7 @@ interface RequestBodySpec extends RequestHeadersSpec { * Set the {@linkplain MediaType media type} of the body, as specified * by the {@code Content-Type} header. * @param contentType the content type - * @return the same instance + * @return this spec for further declaration of the request * @see HttpHeaders#setContentType(MediaType) */ RequestBodySpec contentType(MediaType contentType); @@ -538,7 +541,7 @@ interface RequestBodySpec extends RequestHeadersSpec { * {@link RestClient.RequestBodySpec#body(Object)} (Object) * bodyValue} method on the underlying {@code RestClient}. * @param body the value to write to the request body - * @return spec for further declaration of the request + * @return a spec for further declaration of the request */ RequestHeadersSpec body(Object body); } @@ -662,7 +665,6 @@ interface BodySpec> { /** * Assert the extracted body with a {@link Matcher}. - * @since 5.1 */ T value(Matcher matcher); @@ -694,6 +696,7 @@ interface BodySpec> { * Spec for expectations on the response body content. */ interface BodyContentSpec { + /** * Assert the response body is empty and return the exchange result. */ From 6dc5bf76345c3e265add79b4f0a7b16d894137b9 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Tue, 2 Sep 2025 12:50:57 +0200 Subject: [PATCH 162/591] Clean up warnings related to deprecated HttpStatus values, etc. --- .../ResourceElementResolverMethodTests.java | 8 ++------ .../PackagePrivateMethodResourceSample.java | 2 +- .../annotation/PrivateMethodResourceSample.java | 2 +- .../PrivateMethodResourceWithCustomNameSample.java | 2 +- .../annotation/PublicMethodResourceSample.java | 2 +- .../web/servlet/client/samples/JsonContentTests.java | 2 +- .../web/servlet/client/samples/bind/FilterTests.java | 6 +++--- .../resultmatches/StatusAssertionTests.java | 8 ++++---- .../resultmatchers/StatusAssertionTests.java | 12 ++++++------ .../web/client/HttpClientErrorException.java | 1 + .../springframework/http/ResponseEntityTests.java | 2 +- .../DefaultResponseErrorHandlerHttpStatusTests.java | 1 + .../client/ExtractingResponseErrorHandlerTests.java | 8 ++++---- .../function/client/WebClientResponseException.java | 1 + .../server/DefaultServerResponseBuilderTests.java | 1 + .../WebFluxResponseStatusExceptionHandlerTests.java | 6 +++--- .../view/ViewResolutionResultHandlerTests.java | 1 + .../function/DefaultRenderingResponseTests.java | 2 +- .../function/DefaultServerResponseBuilderTests.java | 1 + ...ervletAnnotationControllerHandlerMethodTests.java | 4 ++-- 20 files changed, 37 insertions(+), 35 deletions(-) diff --git a/spring-context/src/test/java/org/springframework/context/annotation/ResourceElementResolverMethodTests.java b/spring-context/src/test/java/org/springframework/context/annotation/ResourceElementResolverMethodTests.java index 66622487a195..897525f99647 100644 --- a/spring-context/src/test/java/org/springframework/context/annotation/ResourceElementResolverMethodTests.java +++ b/spring-context/src/test/java/org/springframework/context/annotation/ResourceElementResolverMethodTests.java @@ -134,20 +134,16 @@ static class TestBean { private String one; - private String test; - - private Integer count; - public void setOne(String one) { this.one = one; } public void setTest(String test) { - this.test = test; + // no-op } public void setCount(Integer count) { - this.count = count; + // no-op } } diff --git a/spring-context/src/testFixtures/java/org/springframework/context/testfixture/context/annotation/PackagePrivateMethodResourceSample.java b/spring-context/src/testFixtures/java/org/springframework/context/testfixture/context/annotation/PackagePrivateMethodResourceSample.java index 0998df3bc50a..0a2d4633a12d 100644 --- a/spring-context/src/testFixtures/java/org/springframework/context/testfixture/context/annotation/PackagePrivateMethodResourceSample.java +++ b/spring-context/src/testFixtures/java/org/springframework/context/testfixture/context/annotation/PackagePrivateMethodResourceSample.java @@ -20,7 +20,7 @@ public class PackagePrivateMethodResourceSample { - private String one; + String one; @Resource void setOne(String one) { diff --git a/spring-context/src/testFixtures/java/org/springframework/context/testfixture/context/annotation/PrivateMethodResourceSample.java b/spring-context/src/testFixtures/java/org/springframework/context/testfixture/context/annotation/PrivateMethodResourceSample.java index be0cf4e73ca3..c57f9bb8ef84 100644 --- a/spring-context/src/testFixtures/java/org/springframework/context/testfixture/context/annotation/PrivateMethodResourceSample.java +++ b/spring-context/src/testFixtures/java/org/springframework/context/testfixture/context/annotation/PrivateMethodResourceSample.java @@ -20,7 +20,7 @@ public class PrivateMethodResourceSample { - private String one; + String one; @Resource private void setOne(String one) { diff --git a/spring-context/src/testFixtures/java/org/springframework/context/testfixture/context/annotation/PrivateMethodResourceWithCustomNameSample.java b/spring-context/src/testFixtures/java/org/springframework/context/testfixture/context/annotation/PrivateMethodResourceWithCustomNameSample.java index 9b10ba8f6e6f..57d4ff0318a8 100644 --- a/spring-context/src/testFixtures/java/org/springframework/context/testfixture/context/annotation/PrivateMethodResourceWithCustomNameSample.java +++ b/spring-context/src/testFixtures/java/org/springframework/context/testfixture/context/annotation/PrivateMethodResourceWithCustomNameSample.java @@ -20,7 +20,7 @@ public class PrivateMethodResourceWithCustomNameSample { - private String text; + String text; @Resource(name = "one") private void setText(String text) { diff --git a/spring-context/src/testFixtures/java/org/springframework/context/testfixture/context/annotation/PublicMethodResourceSample.java b/spring-context/src/testFixtures/java/org/springframework/context/testfixture/context/annotation/PublicMethodResourceSample.java index aaaa2d335da3..51bb08a48d52 100644 --- a/spring-context/src/testFixtures/java/org/springframework/context/testfixture/context/annotation/PublicMethodResourceSample.java +++ b/spring-context/src/testFixtures/java/org/springframework/context/testfixture/context/annotation/PublicMethodResourceSample.java @@ -20,7 +20,7 @@ public class PublicMethodResourceSample { - private String one; + String one; @Resource public void setOne(String one) { diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/JsonContentTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/JsonContentTests.java index 58d47dcebc6e..978b7a708bd1 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/JsonContentTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/JsonContentTests.java @@ -149,7 +149,7 @@ ResponseEntity savePerson(@RequestBody Person person) { } - private static class Person { + static class Person { private String firstName; private String lastName; diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/FilterTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/FilterTests.java index 5be11ab68b23..c59fdd5f8c13 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/FilterTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/FilterTests.java @@ -29,11 +29,11 @@ import org.springframework.test.web.servlet.client.RestTestClient; import org.springframework.web.servlet.function.ServerResponse; -import static org.springframework.http.HttpStatus.I_AM_A_TEAPOT; - +import static org.springframework.http.HttpStatus.EXPECTATION_FAILED; /** * Tests for a {@link Filter}. + * * @author Rob Worsnop */ class FilterTests { @@ -49,7 +49,7 @@ protected void doFilter(HttpServletRequest req, HttpServletResponse res, FilterC }; RestTestClient client = RestTestClient.bindToRouterFunction( - request -> Optional.of(req -> ServerResponse.status(I_AM_A_TEAPOT).build())) + request -> Optional.of(req -> ServerResponse.status(EXPECTATION_FAILED).build())) .configureServer(builder -> builder.addFilters(filter)) .build(); diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/StatusAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/StatusAssertionTests.java index 28e5f6cb1c71..70432654359b 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/StatusAssertionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/StatusAssertionTests.java @@ -35,8 +35,8 @@ import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.CREATED; +import static org.springframework.http.HttpStatus.EXPECTATION_FAILED; import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; -import static org.springframework.http.HttpStatus.I_AM_A_TEAPOT; import static org.springframework.http.HttpStatus.NOT_IMPLEMENTED; /** @@ -55,11 +55,11 @@ class StatusAssertionTests { @Test void statusInt() { - testClient.get().uri("/teaPot").exchange().expectStatus().isEqualTo(I_AM_A_TEAPOT.value()); + testClient.get().uri("/teaPot").exchange().expectStatus().isEqualTo(EXPECTATION_FAILED.value()); testClient.get().uri("/created").exchange().expectStatus().isEqualTo(CREATED.value()); testClient.get().uri("/createdWithComposedAnnotation").exchange().expectStatus().isEqualTo(CREATED.value()); testClient.get().uri("/badRequest").exchange().expectStatus().isEqualTo(BAD_REQUEST.value()); - testClient.get().uri("/throwsException").exchange().expectStatus().isEqualTo(I_AM_A_TEAPOT.value()); + testClient.get().uri("/throwsException").exchange().expectStatus().isEqualTo(EXPECTATION_FAILED.value()); } @Test @@ -88,7 +88,7 @@ void matcher() { } @RestController - @ResponseStatus(I_AM_A_TEAPOT) + @ResponseStatus(EXPECTATION_FAILED) private static class StatusController { @RequestMapping("/teaPot") diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/resultmatchers/StatusAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/resultmatchers/StatusAssertionTests.java index ea8cd9e122ae..3fa9a4516711 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/resultmatchers/StatusAssertionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/resultmatchers/StatusAssertionTests.java @@ -35,8 +35,8 @@ import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.CREATED; +import static org.springframework.http.HttpStatus.EXPECTATION_FAILED; import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; -import static org.springframework.http.HttpStatus.I_AM_A_TEAPOT; import static org.springframework.http.HttpStatus.NOT_IMPLEMENTED; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -62,11 +62,11 @@ void httpStatus() throws Exception { @Test void statusCode() throws Exception { - this.mockMvc.perform(get("/teaPot")).andExpect(status().is(I_AM_A_TEAPOT.value())); + this.mockMvc.perform(get("/expectationFailed")).andExpect(status().is(EXPECTATION_FAILED.value())); this.mockMvc.perform(get("/created")).andExpect(status().is(CREATED.value())); this.mockMvc.perform(get("/createdWithComposedAnnotation")).andExpect(status().is(CREATED.value())); this.mockMvc.perform(get("/badRequest")).andExpect(status().is(BAD_REQUEST.value())); - this.mockMvc.perform(get("/throwsException")).andExpect(status().is(I_AM_A_TEAPOT.value())); + this.mockMvc.perform(get("/throwsException")).andExpect(status().is(EXPECTATION_FAILED.value())); } @Test @@ -99,11 +99,11 @@ void reasonWithMatcher() throws Exception { } @RestController - @ResponseStatus(I_AM_A_TEAPOT) + @ResponseStatus(EXPECTATION_FAILED) private static class StatusController { - @RequestMapping("/teaPot") - void teaPot() { + @RequestMapping("/expectationFailed") + void expectationFailed() { } @RequestMapping("/created") diff --git a/spring-web/src/main/java/org/springframework/web/client/HttpClientErrorException.java b/spring-web/src/main/java/org/springframework/web/client/HttpClientErrorException.java index 56a794d8e371..d2add5870430 100644 --- a/spring-web/src/main/java/org/springframework/web/client/HttpClientErrorException.java +++ b/spring-web/src/main/java/org/springframework/web/client/HttpClientErrorException.java @@ -95,6 +95,7 @@ public static HttpClientErrorException create( * with an optional prepared message. * @since 5.2.2 */ + @SuppressWarnings("deprecation") public static HttpClientErrorException create(@Nullable String message, HttpStatusCode statusCode, String statusText, HttpHeaders headers, byte[] body, @Nullable Charset charset) { diff --git a/spring-web/src/test/java/org/springframework/http/ResponseEntityTests.java b/spring-web/src/test/java/org/springframework/http/ResponseEntityTests.java index e3f27b0b0a60..11c37744e243 100644 --- a/spring-web/src/test/java/org/springframework/http/ResponseEntityTests.java +++ b/spring-web/src/test/java/org/springframework/http/ResponseEntityTests.java @@ -175,7 +175,7 @@ void unprocessableContent() { } @Test - @SuppressWarnings("deprecate") + @SuppressWarnings("deprecation") void unprocessableEntity() { ResponseEntity responseEntity = ResponseEntity.unprocessableEntity().body("error"); diff --git a/spring-web/src/test/java/org/springframework/web/client/DefaultResponseErrorHandlerHttpStatusTests.java b/spring-web/src/test/java/org/springframework/web/client/DefaultResponseErrorHandlerHttpStatusTests.java index b0be9fc3a1e9..085158845e68 100644 --- a/spring-web/src/test/java/org/springframework/web/client/DefaultResponseErrorHandlerHttpStatusTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/DefaultResponseErrorHandlerHttpStatusTests.java @@ -86,6 +86,7 @@ void handleErrorException(HttpStatus httpStatus, Class expe .isThrownBy(() -> this.handler.handleError(URI.create("/"), HttpMethod.GET, this.response)); } + @SuppressWarnings("deprecation") static Stream errorCodes() { return Stream.of( // 4xx diff --git a/spring-web/src/test/java/org/springframework/web/client/ExtractingResponseErrorHandlerTests.java b/spring-web/src/test/java/org/springframework/web/client/ExtractingResponseErrorHandlerTests.java index d19f535c7f14..51f6b04a63ca 100644 --- a/spring-web/src/test/java/org/springframework/web/client/ExtractingResponseErrorHandlerTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/ExtractingResponseErrorHandlerTests.java @@ -57,14 +57,14 @@ void setup() { HttpMessageConverter converter = new JacksonJsonHttpMessageConverter(); this.errorHandler = new ExtractingResponseErrorHandler(List.of(converter)); - this.errorHandler.setStatusMapping(Map.of(HttpStatus.I_AM_A_TEAPOT, MyRestClientException.class)); + this.errorHandler.setStatusMapping(Map.of(HttpStatus.EXPECTATION_FAILED, MyRestClientException.class)); this.errorHandler.setSeriesMapping(Map.of(HttpStatus.Series.SERVER_ERROR, MyRestClientException.class)); } @Test void hasError() throws Exception { - given(this.response.getStatusCode()).willReturn(HttpStatus.I_AM_A_TEAPOT); + given(this.response.getStatusCode()).willReturn(HttpStatus.EXPECTATION_FAILED); assertThat(this.errorHandler.hasError(this.response)).isTrue(); given(this.response.getStatusCode()).willReturn(HttpStatus.INTERNAL_SERVER_ERROR); @@ -78,7 +78,7 @@ void hasError() throws Exception { void hasErrorOverride() throws Exception { this.errorHandler.setSeriesMapping(Collections.singletonMap(HttpStatus.Series.CLIENT_ERROR, null)); - given(this.response.getStatusCode()).willReturn(HttpStatus.I_AM_A_TEAPOT); + given(this.response.getStatusCode()).willReturn(HttpStatus.EXPECTATION_FAILED); assertThat(this.errorHandler.hasError(this.response)).isTrue(); given(this.response.getStatusCode()).willReturn(HttpStatus.NOT_FOUND); @@ -90,7 +90,7 @@ void hasErrorOverride() throws Exception { @Test void handleErrorStatusMatch() throws Exception { - given(this.response.getStatusCode()).willReturn(HttpStatus.I_AM_A_TEAPOT); + given(this.response.getStatusCode()).willReturn(HttpStatus.EXPECTATION_FAILED); HttpHeaders responseHeaders = new HttpHeaders(); responseHeaders.setContentType(MediaType.APPLICATION_JSON); given(this.response.getHeaders()).willReturn(responseHeaders); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java index c8948475231e..3babe8fe12fd 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/WebClientResponseException.java @@ -297,6 +297,7 @@ public static WebClientResponseException create( * Create {@code WebClientResponseException} or an HTTP status specific subclass. * @since 6.0 */ + @SuppressWarnings("deprecation") public static WebClientResponseException create( HttpStatusCode statusCode, String statusText, HttpHeaders headers, byte[] body, @Nullable Charset charset, @Nullable HttpRequest request) { diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/DefaultServerResponseBuilderTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/DefaultServerResponseBuilderTests.java index 34fd1c2bcbee..5ef4693e10e8 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/DefaultServerResponseBuilderTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/DefaultServerResponseBuilderTests.java @@ -178,6 +178,7 @@ void notFound() { } @Test + @SuppressWarnings("deprecation") void unprocessableEntity() { Mono result = ServerResponse.unprocessableEntity().build(); StepVerifier.create(result) diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/handler/WebFluxResponseStatusExceptionHandlerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/handler/WebFluxResponseStatusExceptionHandlerTests.java index 90b6ff59d97d..199b7dfebc17 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/handler/WebFluxResponseStatusExceptionHandlerTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/handler/WebFluxResponseStatusExceptionHandlerTests.java @@ -45,19 +45,19 @@ protected ResponseStatusExceptionHandler createResponseStatusExceptionHandler() void handleAnnotatedException() { Throwable ex = new CustomException(); this.handler.handle(this.exchange, ex).block(Duration.ofSeconds(5)); - assertThat(this.exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.I_AM_A_TEAPOT); + assertThat(this.exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.ALREADY_REPORTED); } @Test void handleNestedAnnotatedException() { Throwable ex = new Exception(new CustomException()); this.handler.handle(this.exchange, ex).block(Duration.ofSeconds(5)); - assertThat(this.exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.I_AM_A_TEAPOT); + assertThat(this.exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.ALREADY_REPORTED); } @SuppressWarnings("serial") - @ResponseStatus(HttpStatus.I_AM_A_TEAPOT) + @ResponseStatus(HttpStatus.ALREADY_REPORTED) private static class CustomException extends Exception { } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandlerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandlerTests.java index 6f2bd3b9e830..97199c88451c 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandlerTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandlerTests.java @@ -199,6 +199,7 @@ void handleReturnValueTypes() { testHandle("/account", returnType, 99L, "account: {id=123, myLong=99}", resolver); returnType = on(Handler.class).resolveReturnType(Rendering.class); + @SuppressWarnings("deprecation") HttpStatus status = HttpStatus.UNPROCESSABLE_ENTITY; returnValue = Rendering.view("account").modelAttribute("a", "a1").status(status).header("h", "h1").build(); String expected = "account: {a=a1, id=123}"; diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/DefaultRenderingResponseTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/DefaultRenderingResponseTests.java index 0bec82bd17bd..d823eefe15fe 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/DefaultRenderingResponseTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/DefaultRenderingResponseTests.java @@ -55,7 +55,7 @@ void create() throws Exception { @Test void status() throws Exception { - HttpStatus status = HttpStatus.I_AM_A_TEAPOT; + HttpStatus status = HttpStatus.ALREADY_REPORTED; RenderingResponse result = RenderingResponse.create("foo").status(status).build(); MockHttpServletRequest request = new MockHttpServletRequest(); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/DefaultServerResponseBuilderTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/DefaultServerResponseBuilderTests.java index 4d6f73b32e9e..d5d6f41bf1e2 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/DefaultServerResponseBuilderTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/DefaultServerResponseBuilderTests.java @@ -138,6 +138,7 @@ void notFound() { } @Test + @SuppressWarnings("deprecation") void unprocessableEntity() { ServerResponse response = ServerResponse.unprocessableEntity().build(); assertThat(response.statusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ServletAnnotationControllerHandlerMethodTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ServletAnnotationControllerHandlerMethodTests.java index c3985629e194..6b9e18651a74 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ServletAnnotationControllerHandlerMethodTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ServletAnnotationControllerHandlerMethodTests.java @@ -3889,7 +3889,7 @@ static class ModelAndViewController { @RequestMapping("/path") public ModelAndView methodWithHttpStatus(MyEntity object) { - return new ModelAndView("view", HttpStatus.UNPROCESSABLE_ENTITY); + return new ModelAndView("view", HttpStatus.UNPROCESSABLE_CONTENT); } @RequestMapping("/redirect") @@ -3904,7 +3904,7 @@ public void raiseException() throws Exception { @ExceptionHandler(TestException.class) public ModelAndView handleException() { - return new ModelAndView("view", HttpStatus.UNPROCESSABLE_ENTITY); + return new ModelAndView("view", HttpStatus.UNPROCESSABLE_CONTENT); } @SuppressWarnings("serial") From d32b7e9b4ae21f6d9d9f7f1af3f5583da5a4f692 Mon Sep 17 00:00:00 2001 From: Johnny Lim Date: Tue, 2 Sep 2025 23:13:27 +0900 Subject: [PATCH 163/591] Polish gh-35358 Signed-off-by: Johnny Lim --- .../modules/ROOT/pages/integration/observability.adoc | 2 +- .../OpenTelemetryServerRequestObservationConvention.java | 9 +-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/integration/observability.adoc b/framework-docs/modules/ROOT/pages/integration/observability.adoc index 8b3e163ef4d4..a32d4b5a2c04 100644 --- a/framework-docs/modules/ROOT/pages/integration/observability.adoc +++ b/framework-docs/modules/ROOT/pages/integration/observability.adoc @@ -190,7 +190,7 @@ This observation uses the `io.micrometer.jakarta9.instrument.jms.DefaultJmsProce == HTTP Server instrumentation HTTP server exchange observations are created with the name `"http.server.requests"` for Servlet and Reactive applications, -or "http.server.request.duration" if using the OpenTelemetry convention. +or `"http.server.request.duration"` if using the OpenTelemetry convention. [[observability.http-server.servlet]] === Servlet applications diff --git a/spring-web/src/main/java/org/springframework/http/server/observation/OpenTelemetryServerRequestObservationConvention.java b/spring-web/src/main/java/org/springframework/http/server/observation/OpenTelemetryServerRequestObservationConvention.java index 8a1dcfd95a72..24f2d7c6e5c5 100644 --- a/spring-web/src/main/java/org/springframework/http/server/observation/OpenTelemetryServerRequestObservationConvention.java +++ b/spring-web/src/main/java/org/springframework/http/server/observation/OpenTelemetryServerRequestObservationConvention.java @@ -69,13 +69,6 @@ public class OpenTelemetryServerRequestObservationConvention implements ServerRe private static final Set HTTP_METHODS = Stream.of(HttpMethod.values()).map(HttpMethod::name).collect(Collectors.toUnmodifiableSet()); - /** - * Create a convention. - */ - public OpenTelemetryServerRequestObservationConvention() { - } - - @Override public String getName() { return NAME; @@ -87,7 +80,7 @@ public String getName() { * SHOULD be {@code {method}}. *

      * The {@code {method}} MUST be {@code {http.request.method}} if the method represents the original - * method known to the instrumentation. In other cases (when Customize Toolbar… is + * method known to the instrumentation. In other cases (when {@code {http.request.method}} is * set to {@code _OTHER}), {@code {method}} MUST be HTTP. *

      * The {@code target} SHOULD be the {@code {http.route}}. From 10a288c9866c050fbbd3bc5fa0d3f4e5fc538359 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Tue, 2 Sep 2025 15:38:03 +0100 Subject: [PATCH 164/591] Polishing in StatusHandler See gh-35391 --- .../main/java/org/springframework/web/client/StatusHandler.java | 1 - 1 file changed, 1 deletion(-) diff --git a/spring-web/src/main/java/org/springframework/web/client/StatusHandler.java b/spring-web/src/main/java/org/springframework/web/client/StatusHandler.java index d943d9809379..7b822b86df2b 100644 --- a/spring-web/src/main/java/org/springframework/web/client/StatusHandler.java +++ b/spring-web/src/main/java/org/springframework/web/client/StatusHandler.java @@ -164,7 +164,6 @@ private static String getErrorMessage( private static Function initBodyConvertFunction( ClientHttpResponse response, byte[] body, List> messageConverters) { - Assert.state(!CollectionUtils.isEmpty(messageConverters), "Expected message converters"); return resolvableType -> { try { HttpMessageConverterExtractor extractor = From 79151a0bc23c9425a5e3078eccfbce454cbb73a9 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Tue, 2 Sep 2025 20:38:59 +0100 Subject: [PATCH 165/591] Spring MVC recognizes gRPC streams For this to work, a compatible message converter is necessary, but only available in spring-grpc. See gh-35401 --- .../method/annotation/ReactiveTypeHandler.java | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ReactiveTypeHandler.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ReactiveTypeHandler.java index 3cf979feb718..d6135ce98d0a 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ReactiveTypeHandler.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ReactiveTypeHandler.java @@ -81,6 +81,8 @@ class ReactiveTypeHandler { private static final MediaType WILDCARD_SUBTYPE_SUFFIXED_BY_NDJSON = MediaType.valueOf("application/*+x-ndjson"); + private static final MediaType APPLICATION_GRPC = MediaType.valueOf("application/grpc"); + private static final boolean isContextPropagationPresent = ClassUtils.isPresent( "io.micrometer.context.ContextSnapshot", ReactiveTypeHandler.class.getClassLoader()); @@ -165,9 +167,14 @@ public boolean isReactiveType(Class type) { new SseEmitterSubscriber(emitter, this.taskExecutor, taskDecorator).connect(adapter, returnValue); return emitter; } + if (mediaTypes.stream().anyMatch(APPLICATION_GRPC::includes)) { + ResponseBodyEmitter emitter = getEmitter(mediaType.orElse(APPLICATION_GRPC)); + new BasicEmitterSubscriber(emitter, APPLICATION_GRPC, this.taskExecutor).connect(adapter, returnValue); + return emitter; + } if (CharSequence.class.isAssignableFrom(elementClass)) { ResponseBodyEmitter emitter = getEmitter(mediaType.orElse(MediaType.TEXT_PLAIN)); - new TextEmitterSubscriber(emitter, this.taskExecutor).connect(adapter, returnValue); + new BasicEmitterSubscriber(emitter, MediaType.TEXT_PLAIN, this.taskExecutor).connect(adapter, returnValue); return emitter; } MediaType streamingResponseType = findConcreteJsonStreamMediaType(mediaTypes); @@ -475,15 +482,18 @@ protected void send(Object element) throws IOException { } - private static class TextEmitterSubscriber extends AbstractEmitterSubscriber { + private static class BasicEmitterSubscriber extends AbstractEmitterSubscriber { + + private final MediaType mediaType; - TextEmitterSubscriber(ResponseBodyEmitter emitter, TaskExecutor executor) { + BasicEmitterSubscriber(ResponseBodyEmitter emitter, MediaType mediaType, TaskExecutor executor) { super(emitter, executor, null); + this.mediaType = mediaType; } @Override protected void send(Object element) throws IOException { - getEmitter().send(element, MediaType.TEXT_PLAIN); + getEmitter().send(element, this.mediaType); } } From 33fe8d29c1e2312705422e923df712436c7d42df Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Wed, 3 Sep 2025 14:52:58 +0200 Subject: [PATCH 166/591] =?UTF-8?q?Document=20potential=20need=20to=20use?= =?UTF-8?q?=20Mockito.doXxx()=20to=20stub=20a=20@=E2=81=A0MockitoSpyBean?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes gh-35410 --- .../integration-spring/annotation-mockitobean.adoc | 11 +++++++++++ .../context/bean/override/mockito/MockitoSpyBean.java | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-mockitobean.adoc b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-mockitobean.adoc index 4cc25b1c1765..bb00980aa2d0 100644 --- a/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-mockitobean.adoc +++ b/framework-docs/modules/ROOT/pages/testing/annotations/integration-spring/annotation-mockitobean.adoc @@ -77,6 +77,17 @@ exactly one candidate bean exists. [TIP] ==== +As stated in the documentation for Mockito, there are times when using `Mockito.when()` is +inappropriate for stubbing a spy – for example, if calling a real method on a spy results +in undesired side effects. + +To avoid such undesired side effects, consider using +`Mockito.doReturn(...).when(spy)...`, `Mockito.doThrow(...).when(spy)...`, +`Mockito.doNothing().when(spy)...`, and similar methods. +==== + +[NOTE] +==== Only _singleton_ beans can be overridden. Any attempt to override a non-singleton bean will result in an exception. diff --git a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBean.java b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBean.java index c926e69595f4..6f4e3276afd1 100644 --- a/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBean.java +++ b/spring-test/src/main/java/org/springframework/test/context/bean/override/mockito/MockitoSpyBean.java @@ -67,6 +67,15 @@ * {@link org.springframework.beans.factory.config.ConfigurableListableBeanFactory#registerResolvableDependency(Class, Object) * registered directly} as resolvable dependencies. * + *

      NOTE: As stated in the documentation for Mockito, there are + * times when using {@code Mockito.when()} is inappropriate for stubbing a spy + * — for example, if calling a real method on a spy results in undesired + * side effects. To avoid such undesired side effects, consider using + * {@link org.mockito.Mockito#doReturn(Object) Mockito.doReturn(...).when(spy)...}, + * {@link org.mockito.Mockito#doThrow(Class) Mockito.doThrow(...).when(spy)...}, + * {@link org.mockito.Mockito#doNothing() Mockito.doNothing().when(spy)...}, and + * similar methods. + * *

      WARNING: Using {@code @MockitoSpyBean} in conjunction with * {@code @ContextHierarchy} can lead to undesirable results since each * {@code @MockitoSpyBean} will be applied to all context hierarchy levels by default. From 3a4315bf16c1f69bbe099f51c73f3d19facf0731 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Wed, 3 Sep 2025 15:45:12 +0200 Subject: [PATCH 167/591] Keep mainThreadPrefix exposed until background init threads finished Closes gh-35409 --- .../support/DefaultListableBeanFactory.java | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java index b8ee9acb4b1e..2c67f6655110 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java @@ -1113,11 +1113,10 @@ public void preInstantiateSingletons() throws BeansException { List beanNames = new ArrayList<>(this.beanDefinitionNames); // Trigger initialization of all non-lazy singleton beans... - List> futures = new ArrayList<>(); - this.preInstantiationThread.set(PreInstantiation.MAIN); this.mainThreadPrefix = getThreadNamePrefix(); try { + List> futures = new ArrayList<>(); for (String beanName : beanNames) { RootBeanDefinition mbd = getMergedLocalBeanDefinition(beanName); if (!mbd.isAbstract() && mbd.isSingleton()) { @@ -1127,21 +1126,20 @@ public void preInstantiateSingletons() throws BeansException { } } } + if (!futures.isEmpty()) { + try { + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + } + catch (CompletionException ex) { + ReflectionUtils.rethrowRuntimeException(ex.getCause()); + } + } } finally { this.mainThreadPrefix = null; this.preInstantiationThread.remove(); } - if (!futures.isEmpty()) { - try { - CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); - } - catch (CompletionException ex) { - ReflectionUtils.rethrowRuntimeException(ex.getCause()); - } - } - // Trigger post-initialization callback for all applicable beans... for (String beanName : beanNames) { Object singletonInstance = getSingleton(beanName, false); From db9e938ec452510e4c33680ce95f3de4228a4743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kv=C3=ADdera?= Date: Mon, 1 Sep 2025 13:05:31 +0200 Subject: [PATCH 168/591] Detect Informix error codes as DuplicateKeyException MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes gh-35400 Signed-off-by: Lukáš Kvídera --- .../SQLStateSQLExceptionTranslator.java | 4 +- .../SQLStateSQLExceptionTranslatorTests.java | 10 ++++ .../connection/ConnectionFactoryUtils.java | 4 +- .../ConnectionFactoryUtilsTests.java | 55 ++++++++++--------- 4 files changed, 45 insertions(+), 28 deletions(-) diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/SQLStateSQLExceptionTranslator.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/SQLStateSQLExceptionTranslator.java index 4b962d02ecb3..daecc4f2090d 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/support/SQLStateSQLExceptionTranslator.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/SQLStateSQLExceptionTranslator.java @@ -94,7 +94,9 @@ public class SQLStateSQLExceptionTranslator extends AbstractFallbackSQLException 301, // SAP HANA 1062, // MySQL/MariaDB 2601, // MS SQL Server - 2627 // MS SQL Server + 2627, // MS SQL Server + -239, // Informix + -268 // Informix ); diff --git a/spring-jdbc/src/test/java/org/springframework/jdbc/support/SQLStateSQLExceptionTranslatorTests.java b/spring-jdbc/src/test/java/org/springframework/jdbc/support/SQLStateSQLExceptionTranslatorTests.java index 94abeccca329..263eb8f829d2 100644 --- a/spring-jdbc/src/test/java/org/springframework/jdbc/support/SQLStateSQLExceptionTranslatorTests.java +++ b/spring-jdbc/src/test/java/org/springframework/jdbc/support/SQLStateSQLExceptionTranslatorTests.java @@ -90,6 +90,16 @@ void translateDuplicateKeySapHana() { assertTranslation("23000", 301, DuplicateKeyException.class); } + @Test + void translateDuplicateKeyInformix1() { + assertTranslation("23000", -239, DuplicateKeyException.class); + } + + @Test + void translateDuplicateKeyInformix2() { + assertTranslation("23000", -268, DuplicateKeyException.class); + } + @Test void translateDataAccessResourceFailure() { assertTranslation("53", DataAccessResourceFailureException.class); diff --git a/spring-r2dbc/src/main/java/org/springframework/r2dbc/connection/ConnectionFactoryUtils.java b/spring-r2dbc/src/main/java/org/springframework/r2dbc/connection/ConnectionFactoryUtils.java index 8741f89067fc..33d8a7619b88 100644 --- a/spring-r2dbc/src/main/java/org/springframework/r2dbc/connection/ConnectionFactoryUtils.java +++ b/spring-r2dbc/src/main/java/org/springframework/r2dbc/connection/ConnectionFactoryUtils.java @@ -76,7 +76,9 @@ public abstract class ConnectionFactoryUtils { 301, // SAP HANA 1062, // MySQL/MariaDB 2601, // MS SQL Server - 2627 // MS SQL Server + 2627, // MS SQL Server + -239, // Informix + -268 // Informix ); diff --git a/spring-r2dbc/src/test/java/org/springframework/r2dbc/connection/ConnectionFactoryUtilsTests.java b/spring-r2dbc/src/test/java/org/springframework/r2dbc/connection/ConnectionFactoryUtilsTests.java index 49861f0a4e81..902d4ce85147 100644 --- a/spring-r2dbc/src/test/java/org/springframework/r2dbc/connection/ConnectionFactoryUtilsTests.java +++ b/spring-r2dbc/src/test/java/org/springframework/r2dbc/connection/ConnectionFactoryUtilsTests.java @@ -25,7 +25,9 @@ import io.r2dbc.spi.R2dbcTimeoutException; import io.r2dbc.spi.R2dbcTransientResourceException; import org.junit.jupiter.api.Test; - +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.dao.CannotAcquireLockException; import org.springframework.dao.DataAccessResourceFailureException; import org.springframework.dao.DataIntegrityViolationException; @@ -37,8 +39,12 @@ import org.springframework.r2dbc.BadSqlGrammarException; import org.springframework.r2dbc.UncategorizedR2dbcException; +import java.util.stream.Stream; + import static org.assertj.core.api.Assertions.assertThat; + + /** * Tests for {@link ConnectionFactoryUtils}. * @@ -86,35 +92,32 @@ void shouldTranslateNonTransientResourceException() { assertThat(exception).isExactlyInstanceOf(DataAccessResourceFailureException.class); } + private static Stream duplicateKeyErrorCodes() { + return Stream.of( + Arguments.of("Oracle", "23505", 0), + Arguments.of("Oracle", "23000", 1), + Arguments.of("SAP HANA", "23000", 301), + Arguments.of("MySQL/MariaDB", "23000", 1062), + Arguments.of("MS SQL Server", "23000", 2601), + Arguments.of("MS SQL Server", "23000", 2627), + Arguments.of("Informix", "23000", -239), + Arguments.of("Informix", "23000", -268) + ); + } + + @ParameterizedTest + @MethodSource("duplicateKeyErrorCodes") + void shouldTranslateIntegrityViolationException(final String db, String sqlState, final int errorCode) { + Exception exception = ConnectionFactoryUtils.convertR2dbcException("", "", + new R2dbcDataIntegrityViolationException("reason", sqlState, errorCode)); + assertThat(exception).as(db).isExactlyInstanceOf(DuplicateKeyException.class); + } + @Test - void shouldTranslateIntegrityViolationException() { + void shouldTranslateGenericIntegrityViolationException() { Exception exception = ConnectionFactoryUtils.convertR2dbcException("", "", new R2dbcDataIntegrityViolationException()); assertThat(exception).isExactlyInstanceOf(DataIntegrityViolationException.class); - - exception = ConnectionFactoryUtils.convertR2dbcException("", "", - new R2dbcDataIntegrityViolationException("reason", "23505")); - assertThat(exception).isExactlyInstanceOf(DuplicateKeyException.class); - - exception = ConnectionFactoryUtils.convertR2dbcException("", "", - new R2dbcDataIntegrityViolationException("reason", "23000", 1)); - assertThat(exception).as("Oracle").isExactlyInstanceOf(DuplicateKeyException.class); - - exception = ConnectionFactoryUtils.convertR2dbcException("", "", - new R2dbcDataIntegrityViolationException("reason", "23000", 301)); - assertThat(exception).as("SAP HANA").isExactlyInstanceOf(DuplicateKeyException.class); - - exception = ConnectionFactoryUtils.convertR2dbcException("", "", - new R2dbcDataIntegrityViolationException("reason", "23000", 1062)); - assertThat(exception).as("MySQL/MariaDB").isExactlyInstanceOf(DuplicateKeyException.class); - - exception = ConnectionFactoryUtils.convertR2dbcException("", "", - new R2dbcDataIntegrityViolationException("reason", "23000", 2601)); - assertThat(exception).as("MS SQL Server").isExactlyInstanceOf(DuplicateKeyException.class); - - exception = ConnectionFactoryUtils.convertR2dbcException("", "", - new R2dbcDataIntegrityViolationException("reason", "23000", 2627)); - assertThat(exception).as("MS SQL Server").isExactlyInstanceOf(DuplicateKeyException.class); } @Test From 02f0f92a7296bbbbd8506fbdc07c076baaff78d2 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Wed, 3 Sep 2025 16:20:08 +0200 Subject: [PATCH 169/591] Polish contribution See gh-35400 --- .../ConnectionFactoryUtilsTests.java | 48 +++++++++---------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/spring-r2dbc/src/test/java/org/springframework/r2dbc/connection/ConnectionFactoryUtilsTests.java b/spring-r2dbc/src/test/java/org/springframework/r2dbc/connection/ConnectionFactoryUtilsTests.java index 902d4ce85147..c7a028032da5 100644 --- a/spring-r2dbc/src/test/java/org/springframework/r2dbc/connection/ConnectionFactoryUtilsTests.java +++ b/spring-r2dbc/src/test/java/org/springframework/r2dbc/connection/ConnectionFactoryUtilsTests.java @@ -16,6 +16,8 @@ package org.springframework.r2dbc.connection; +import java.util.List; + import io.r2dbc.spi.R2dbcBadGrammarException; import io.r2dbc.spi.R2dbcDataIntegrityViolationException; import io.r2dbc.spi.R2dbcException; @@ -27,7 +29,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.FieldSource; + import org.springframework.dao.CannotAcquireLockException; import org.springframework.dao.DataAccessResourceFailureException; import org.springframework.dao.DataIntegrityViolationException; @@ -39,11 +42,8 @@ import org.springframework.r2dbc.BadSqlGrammarException; import org.springframework.r2dbc.UncategorizedR2dbcException; -import java.util.stream.Stream; - import static org.assertj.core.api.Assertions.assertThat; - - +import static org.junit.jupiter.params.provider.Arguments.arguments; /** * Tests for {@link ConnectionFactoryUtils}. @@ -92,34 +92,32 @@ void shouldTranslateNonTransientResourceException() { assertThat(exception).isExactlyInstanceOf(DataAccessResourceFailureException.class); } - private static Stream duplicateKeyErrorCodes() { - return Stream.of( - Arguments.of("Oracle", "23505", 0), - Arguments.of("Oracle", "23000", 1), - Arguments.of("SAP HANA", "23000", 301), - Arguments.of("MySQL/MariaDB", "23000", 1062), - Arguments.of("MS SQL Server", "23000", 2601), - Arguments.of("MS SQL Server", "23000", 2627), - Arguments.of("Informix", "23000", -239), - Arguments.of("Informix", "23000", -268) - ); + @Test + void shouldTranslateIntegrityViolationException() { + Exception exception = ConnectionFactoryUtils.convertR2dbcException("", "", + new R2dbcDataIntegrityViolationException()); + assertThat(exception).isExactlyInstanceOf(DataIntegrityViolationException.class); } + static final List duplicateKeyErrorCodes = List.of( + arguments("Oracle", "23505", 0), + arguments("Oracle", "23000", 1), + arguments("SAP HANA", "23000", 301), + arguments("MySQL/MariaDB", "23000", 1062), + arguments("MS SQL Server", "23000", 2601), + arguments("MS SQL Server", "23000", 2627), + arguments("Informix", "23000", -239), + arguments("Informix", "23000", -268) + ); + @ParameterizedTest - @MethodSource("duplicateKeyErrorCodes") - void shouldTranslateIntegrityViolationException(final String db, String sqlState, final int errorCode) { + @FieldSource("duplicateKeyErrorCodes") + void shouldTranslateIntegrityViolationExceptionToDuplicateKeyException(String db, String sqlState, int errorCode) { Exception exception = ConnectionFactoryUtils.convertR2dbcException("", "", new R2dbcDataIntegrityViolationException("reason", sqlState, errorCode)); assertThat(exception).as(db).isExactlyInstanceOf(DuplicateKeyException.class); } - @Test - void shouldTranslateGenericIntegrityViolationException() { - Exception exception = ConnectionFactoryUtils.convertR2dbcException("", "", - new R2dbcDataIntegrityViolationException()); - assertThat(exception).isExactlyInstanceOf(DataIntegrityViolationException.class); - } - @Test void shouldTranslatePermissionDeniedException() { Exception exception = ConnectionFactoryUtils.convertR2dbcException("", "", From 64721b3bc02239c0d8f136a537991ce64610905a Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Wed, 3 Sep 2025 16:24:56 +0200 Subject: [PATCH 170/591] Polish formatting --- .../connection/ConnectionFactoryUtilsTests.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/spring-r2dbc/src/test/java/org/springframework/r2dbc/connection/ConnectionFactoryUtilsTests.java b/spring-r2dbc/src/test/java/org/springframework/r2dbc/connection/ConnectionFactoryUtilsTests.java index c7a028032da5..cd4fb8c91d86 100644 --- a/spring-r2dbc/src/test/java/org/springframework/r2dbc/connection/ConnectionFactoryUtilsTests.java +++ b/spring-r2dbc/src/test/java/org/springframework/r2dbc/connection/ConnectionFactoryUtilsTests.java @@ -136,24 +136,27 @@ void shouldTranslateBadSqlGrammarException() { void messageGeneration() { Exception exception = ConnectionFactoryUtils.convertR2dbcException("TASK", "SOME-SQL", new R2dbcTransientResourceException("MESSAGE")); - assertThat(exception).isExactlyInstanceOf( - TransientDataAccessResourceException.class).hasMessage("TASK; SQL [SOME-SQL]; MESSAGE"); + assertThat(exception) + .isExactlyInstanceOf(TransientDataAccessResourceException.class) + .hasMessage("TASK; SQL [SOME-SQL]; MESSAGE"); } @Test void messageGenerationNullSQL() { Exception exception = ConnectionFactoryUtils.convertR2dbcException("TASK", null, new R2dbcTransientResourceException("MESSAGE")); - assertThat(exception).isExactlyInstanceOf( - TransientDataAccessResourceException.class).hasMessage("TASK; MESSAGE"); + assertThat(exception) + .isExactlyInstanceOf(TransientDataAccessResourceException.class) + .hasMessage("TASK; MESSAGE"); } @Test void messageGenerationNullMessage() { Exception exception = ConnectionFactoryUtils.convertR2dbcException("TASK", "SOME-SQL", new R2dbcTransientResourceException()); - assertThat(exception).isExactlyInstanceOf( - TransientDataAccessResourceException.class).hasMessage("TASK; SQL [SOME-SQL]; null"); + assertThat(exception) + .isExactlyInstanceOf(TransientDataAccessResourceException.class) + .hasMessage("TASK; SQL [SOME-SQL]; null"); } From 8eca3a3eaf55deda88492e87c760249c03a540b0 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:10:17 +0200 Subject: [PATCH 171/591] Polishing --- .../test/context/cache/ContextCache.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/spring-test/src/main/java/org/springframework/test/context/cache/ContextCache.java b/spring-test/src/main/java/org/springframework/test/context/cache/ContextCache.java index 2a319d78b752..843936fcf40d 100644 --- a/spring-test/src/main/java/org/springframework/test/context/cache/ContextCache.java +++ b/spring-test/src/main/java/org/springframework/test/context/cache/ContextCache.java @@ -85,7 +85,7 @@ public interface ContextCache { /** * Determine whether there is a cached context for the given key. - * @param key the context key (never {@code null}) + * @param key the context key; never {@code null} * @return {@code true} if the cache contains a context with the given key */ boolean contains(MergedContextConfiguration key); @@ -99,7 +99,7 @@ public interface ContextCache { * restarted}. This applies to parent contexts as well. *

      In addition, the {@linkplain #getHitCount() hit} and * {@linkplain #getMissCount() miss} counts must be updated accordingly. - * @param key the context key (never {@code null}) + * @param key the context key; never {@code null} * @return the corresponding {@code ApplicationContext} instance, or {@code null} * if not found in the cache * @see #unregisterContextUsage(MergedContextConfiguration, Class) @@ -108,10 +108,11 @@ public interface ContextCache { @Nullable ApplicationContext get(MergedContextConfiguration key); /** - * Explicitly add an {@code ApplicationContext} instance to the cache - * under the given key, potentially honoring a custom eviction policy. - * @param key the context key (never {@code null}) - * @param context the {@code ApplicationContext} instance (never {@code null}) + * Explicitly add an {@link ApplicationContext} to the cache under the given + * key, potentially honoring a custom eviction policy. + * @param key the context key; never {@code null} + * @param context the {@code ApplicationContext}; never {@code null} + * @see #get(MergedContextConfiguration) */ void put(MergedContextConfiguration key, ApplicationContext context); From ca62119cb33e7805a080984b60902a4f0895d707 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Mon, 1 Sep 2025 16:23:49 +0200 Subject: [PATCH 172/591] Evict context from ContextCache before loading a new context Since Spring Framework 4.2, DefaultContextCache supported an LRU (least recently used) eviction policy via a custom LruCache which extended LinkedHashMap. The LruCache reacted to LinkedHashMap's removeEldestEntry() callback to remove the LRU context if the maxSize of the cache was exceeded. Due to the nature of the implementation in LinkedHashMap, the removeEldestEntry() callback is invoked after a new entry has been stored to the map. Consequently, a Spring ApplicationContext (C1) was evicted from the cache after a new context (C2) was loaded and added to the cache, leading to failure scenarios such as the following. - C1 and C2 share an external resource -- for example, a database. - C2 initializes the external resource with test data when C2 is loaded. - C1 cleans up the external resource when C1 is closed. - C1 is loaded and added to the cache. - C2 is loaded and added to the cache before C1 is evicted. - C1 is evicted and closed. - C2 tests fail, because C1 removed test data required for C2. To address such scenarios, this commit replaces the custom LruCache with custom LRU eviction logic in DefaultContextCache and revises the put(MergedContextConfiguration, ApplicationContext) method to delegate to a new evictLruContextIfNecessary() method. This commit also introduces a new put(MergedContextConfiguration, LoadFunction) method in the ContextCache API which is overridden by DefaultContextCache to ensure that an evicted context is removed and closed before a new context is loaded to take its place in the cache. In addition, DefaultCacheAwareContextLoaderDelegate has been revised to make use of the new put(MergedContextConfiguration, LoadFunction) API. Closes gh-21007 --- .../test/context/cache/ContextCache.java | 52 ++ ...efaultCacheAwareContextLoaderDelegate.java | 53 +- .../context/cache/DefaultContextCache.java | 70 +-- .../context/cache/LruContextCacheTests.java | 452 +++++++++++++++--- 4 files changed, 506 insertions(+), 121 deletions(-) diff --git a/spring-test/src/main/java/org/springframework/test/context/cache/ContextCache.java b/spring-test/src/main/java/org/springframework/test/context/cache/ContextCache.java index 843936fcf40d..66528de3ead6 100644 --- a/spring-test/src/main/java/org/springframework/test/context/cache/ContextCache.java +++ b/spring-test/src/main/java/org/springframework/test/context/cache/ContextCache.java @@ -21,6 +21,7 @@ import org.springframework.context.ApplicationContext; import org.springframework.test.annotation.DirtiesContext.HierarchyMode; import org.springframework.test.context.MergedContextConfiguration; +import org.springframework.util.Assert; /** * {@code ContextCache} defines the SPI for caching Spring @@ -102,6 +103,7 @@ public interface ContextCache { * @param key the context key; never {@code null} * @return the corresponding {@code ApplicationContext} instance, or {@code null} * if not found in the cache + * @see #put(MergedContextConfiguration, LoadFunction) * @see #unregisterContextUsage(MergedContextConfiguration, Class) * @see #remove(MergedContextConfiguration, HierarchyMode) */ @@ -113,9 +115,37 @@ public interface ContextCache { * @param key the context key; never {@code null} * @param context the {@code ApplicationContext}; never {@code null} * @see #get(MergedContextConfiguration) + * @see #put(MergedContextConfiguration, LoadFunction) */ void put(MergedContextConfiguration key, ApplicationContext context); + /** + * Explicitly add an {@link ApplicationContext} to the cache under the given + * key, potentially honoring a custom eviction policy. + *

      The supplied {@link LoadFunction} will be invoked to load the + * {@code ApplicationContext}. + *

      Concrete implementations which honor a custom eviction policy must + * override this method to ensure that an evicted context is removed from the + * cache and closed before a new context is loaded via the supplied + * {@code LoadFunction}. + * @param key the context key; never {@code null} + * @param loadFunction a function which loads the context for the supplied key; + * never {@code null} + * @return the {@code ApplicationContext}; never {@code null} + * @since 7.0 + * @see #get(MergedContextConfiguration) + * @see #put(MergedContextConfiguration, ApplicationContext) + */ + default ApplicationContext put(MergedContextConfiguration key, LoadFunction loadFunction) { + Assert.notNull(key, "Key must not be null"); + Assert.notNull(loadFunction, "LoadFunction must not be null"); + + ApplicationContext applicationContext = loadFunction.loadContext(key); + Assert.state(applicationContext != null, "LoadFunction must return a non-null ApplicationContext"); + put(key, applicationContext); + return applicationContext; + } + /** * Remove the context with the given key from the cache and explicitly * {@linkplain org.springframework.context.ConfigurableApplicationContext#close() close} @@ -281,4 +311,26 @@ default int getContextUsageCount() { */ void logStatistics(); + + /** + * Represents a function that loads an {@link ApplicationContext}. + * + * @since 7.0 + */ + @FunctionalInterface + interface LoadFunction { + + /** + * Load a new {@link ApplicationContext} based on the supplied + * {@link MergedContextConfiguration} and return the context in a fully + * refreshed state. + * @param mergedConfig the merged context configuration to use to load the + * application context + * @return a new application context; never {@code null} + * @see org.springframework.test.context.SmartContextLoader#loadContext(MergedContextConfiguration) + */ + ApplicationContext loadContext(MergedContextConfiguration mergedConfig); + + } + } diff --git a/spring-test/src/main/java/org/springframework/test/context/cache/DefaultCacheAwareContextLoaderDelegate.java b/spring-test/src/main/java/org/springframework/test/context/cache/DefaultCacheAwareContextLoaderDelegate.java index ac951d7b6777..a12ae6b93d8e 100644 --- a/spring-test/src/main/java/org/springframework/test/context/cache/DefaultCacheAwareContextLoaderDelegate.java +++ b/spring-test/src/main/java/org/springframework/test/context/cache/DefaultCacheAwareContextLoaderDelegate.java @@ -139,34 +139,44 @@ public boolean isContextLoaded(MergedContextConfiguration mergedConfig) { public ApplicationContext loadContext(MergedContextConfiguration mergedConfig) { mergedConfig = replaceIfNecessary(mergedConfig); synchronized (this.contextCache) { - ApplicationContext context = this.contextCache.get(mergedConfig); try { - if (context == null) { - int failureCount = this.contextCache.getFailureCount(mergedConfig); - if (failureCount >= this.failureThreshold) { - throw new IllegalStateException(""" - ApplicationContext failure threshold (%d) exceeded: \ - skipping repeated attempt to load context for %s""" - .formatted(this.failureThreshold, mergedConfig)); + ApplicationContext context = this.contextCache.get(mergedConfig); + if (context != null) { + if (logger.isTraceEnabled()) { + logger.trace("Retrieved ApplicationContext [%s] from cache with key %s".formatted( + System.identityHashCode(context), mergedConfig)); } + return context; + } + + int failureCount = this.contextCache.getFailureCount(mergedConfig); + if (failureCount >= this.failureThreshold) { + throw new IllegalStateException(""" + ApplicationContext failure threshold (%d) exceeded: \ + skipping repeated attempt to load context for %s""" + .formatted(this.failureThreshold, mergedConfig)); + } + + return this.contextCache.put(mergedConfig, key -> { try { - if (mergedConfig instanceof AotMergedContextConfiguration aotMergedConfig) { - context = loadContextInAotMode(aotMergedConfig); + ApplicationContext newContext; + if (key instanceof AotMergedContextConfiguration aotMergedConfig) { + newContext = loadContextInAotMode(aotMergedConfig); } else { - context = loadContextInternal(mergedConfig); + newContext = loadContextInternal(key); } if (logger.isTraceEnabled()) { logger.trace("Storing ApplicationContext [%s] in cache under key %s".formatted( - System.identityHashCode(context), mergedConfig)); + System.identityHashCode(newContext), key)); } - this.contextCache.put(mergedConfig, context); + return newContext; } catch (Exception ex) { if (logger.isTraceEnabled()) { - logger.trace("Incrementing ApplicationContext failure count for " + mergedConfig); + logger.trace("Incrementing ApplicationContext failure count for " + key); } - this.contextCache.incrementFailureCount(mergedConfig); + this.contextCache.incrementFailureCount(key); Throwable cause = ex; if (ex instanceof ContextLoadException cle) { cause = cle.getCause(); @@ -182,22 +192,13 @@ ApplicationContext failure threshold (%d) exceeded: \ } } } - throw new IllegalStateException( - "Failed to load ApplicationContext for " + mergedConfig, cause); + throw new IllegalStateException("Failed to load ApplicationContext for " + key, cause); } - } - else { - if (logger.isTraceEnabled()) { - logger.trace("Retrieved ApplicationContext [%s] from cache with key %s".formatted( - System.identityHashCode(context), mergedConfig)); - } - } + }); } finally { this.contextCache.logStatistics(); } - - return context; } } diff --git a/spring-test/src/main/java/org/springframework/test/context/cache/DefaultContextCache.java b/spring-test/src/main/java/org/springframework/test/context/cache/DefaultContextCache.java index 5451fb5854ab..b00c261393b0 100644 --- a/spring-test/src/main/java/org/springframework/test/context/cache/DefaultContextCache.java +++ b/spring-test/src/main/java/org/springframework/test/context/cache/DefaultContextCache.java @@ -19,6 +19,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -62,7 +63,7 @@ public class DefaultContextCache implements ContextCache { * Map of context keys to Spring {@code ApplicationContext} instances. */ private final Map contextMap = - Collections.synchronizedMap(new LruCache(32, 0.75f)); + Collections.synchronizedMap(new LinkedHashMap<>(32, 0.75f, true)); /** * Map of parent keys to sets of children keys, representing a top-down tree @@ -157,7 +158,41 @@ public void put(MergedContextConfiguration key, ApplicationContext context) { Assert.notNull(key, "Key must not be null"); Assert.notNull(context, "ApplicationContext must not be null"); + evictLruContextIfNecessary(); + putInternal(key, context); + } + + @Override + public ApplicationContext put(MergedContextConfiguration key, LoadFunction loadFunction) { + Assert.notNull(key, "Key must not be null"); + Assert.notNull(loadFunction, "LoadFunction must not be null"); + + evictLruContextIfNecessary(); + ApplicationContext context = loadFunction.loadContext(key); + Assert.state(context != null, "LoadFunction must return a non-null ApplicationContext"); + putInternal(key, context); + return context; + } + + /** + * Evict the least recently used (LRU) context if necessary. + * @since 7.0 + */ + private void evictLruContextIfNecessary() { + if (this.contextMap.size() >= this.maxSize) { + Iterator iterator = this.contextMap.keySet().iterator(); + Assert.state(iterator.hasNext(), "Failed to retrieve LRU context"); + // The least recently used (LRU) key is the first/head in a LinkedHashMap + // configured for access-order iteration order. + MergedContextConfiguration lruKey = iterator.next(); + remove(lruKey, HierarchyMode.CURRENT_LEVEL); + } + } + + private void putInternal(MergedContextConfiguration key, ApplicationContext context) { this.contextMap.put(key, context); + + // Update context hierarchy map. MergedContextConfiguration child = key; MergedContextConfiguration parent = child.getParent(); while (parent != null) { @@ -357,37 +392,4 @@ public String toString() { .toString(); } - - /** - * Simple cache implementation based on {@link LinkedHashMap} with a maximum - * size and a least recently used (LRU) eviction policy that - * properly closes application contexts. - * @since 4.3 - */ - @SuppressWarnings("serial") - private class LruCache extends LinkedHashMap { - - /** - * Create a new {@code LruCache} with the supplied initial capacity - * and load factor. - * @param initialCapacity the initial capacity - * @param loadFactor the load factor - */ - LruCache(int initialCapacity, float loadFactor) { - super(initialCapacity, loadFactor, true); - } - - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - if (this.size() > DefaultContextCache.this.getMaxSize()) { - // Do NOT delete "DefaultContextCache.this."; otherwise, we accidentally - // invoke java.util.Map.remove(Object, Object). - DefaultContextCache.this.remove(eldest.getKey(), HierarchyMode.CURRENT_LEVEL); - } - - // Return false since we invoke a custom eviction algorithm. - return false; - } - } - } diff --git a/spring-test/src/test/java/org/springframework/test/context/cache/LruContextCacheTests.java b/spring-test/src/test/java/org/springframework/test/context/cache/LruContextCacheTests.java index c9edd808d964..8067565fb25b 100644 --- a/spring-test/src/test/java/org/springframework/test/context/cache/LruContextCacheTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/cache/LruContextCacheTests.java @@ -16,14 +16,29 @@ package org.springframework.test.context.cache; +import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.InitializingBean; import org.springframework.context.ApplicationContext; import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.annotation.DirtiesContext.HierarchyMode; import org.springframework.test.context.MergedContextConfiguration; +import org.springframework.test.context.TestContext; +import org.springframework.test.context.TestContextTestUtils; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import static org.assertj.core.api.Assertions.as; import static org.assertj.core.api.Assertions.assertThat; @@ -33,6 +48,7 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.springframework.test.context.cache.ContextCacheTestUtils.assertContextCacheStatistics; /** * Tests for the LRU eviction policy in {@link DefaultContextCache}. @@ -65,89 +81,403 @@ void maxCacheSizeZero() { assertThatIllegalArgumentException().isThrownBy(() -> new DefaultContextCache(0)); } - @Test - void maxCacheSizeOne() { - DefaultContextCache cache = new DefaultContextCache(1); - assertThat(cache.size()).isEqualTo(0); - assertThat(cache.getMaxSize()).isEqualTo(1); - cache.put(fooConfig, fooContext); - assertCacheContents(cache, "Foo"); + @Nested + class PutUnitTests { - cache.put(fooConfig, fooContext); - assertCacheContents(cache, "Foo"); + @Test + void maxCacheSizeOne() { + DefaultContextCache cache = new DefaultContextCache(1); + assertThat(cache.size()).isEqualTo(0); + assertThat(cache.getMaxSize()).isEqualTo(1); - cache.put(barConfig, barContext); - assertCacheContents(cache, "Bar"); + cache.put(fooConfig, fooContext); + assertCacheContents(cache, "Foo"); - cache.put(fooConfig, fooContext); - assertCacheContents(cache, "Foo"); - } + cache.put(fooConfig, fooContext); + assertCacheContents(cache, "Foo"); - @Test - void maxCacheSizeThree() { - DefaultContextCache cache = new DefaultContextCache(3); - assertThat(cache.size()).isEqualTo(0); - assertThat(cache.getMaxSize()).isEqualTo(3); + cache.put(barConfig, barContext); + assertCacheContents(cache, "Bar"); - cache.put(fooConfig, fooContext); - assertCacheContents(cache, "Foo"); + cache.put(fooConfig, fooContext); + assertCacheContents(cache, "Foo"); + } - cache.put(fooConfig, fooContext); - assertCacheContents(cache, "Foo"); + @Test + void maxCacheSizeThree() { + DefaultContextCache cache = new DefaultContextCache(3); + assertThat(cache.size()).isEqualTo(0); + assertThat(cache.getMaxSize()).isEqualTo(3); - cache.put(barConfig, barContext); - assertCacheContents(cache, "Foo", "Bar"); + cache.put(fooConfig, fooContext); + assertCacheContents(cache, "Foo"); - cache.put(bazConfig, bazContext); - assertCacheContents(cache, "Foo", "Bar", "Baz"); + cache.put(fooConfig, fooContext); + assertCacheContents(cache, "Foo"); - cache.put(abcConfig, abcContext); - assertCacheContents(cache, "Bar", "Baz", "Abc"); - } + cache.put(barConfig, barContext); + assertCacheContents(cache, "Foo", "Bar"); - @Test - void ensureLruOrderingIsUpdated() { - DefaultContextCache cache = new DefaultContextCache(3); + cache.put(bazConfig, bazContext); + assertCacheContents(cache, "Foo", "Bar", "Baz"); + + cache.put(abcConfig, abcContext); + assertCacheContents(cache, "Bar", "Baz", "Abc"); + } + + @Test + void ensureLruOrderingIsUpdated() { + DefaultContextCache cache = new DefaultContextCache(3); + + // Note: when a new entry is added it is considered the MRU entry and inserted at the tail. + cache.put(fooConfig, fooContext); + cache.put(barConfig, barContext); + cache.put(bazConfig, bazContext); + assertCacheContents(cache, "Foo", "Bar", "Baz"); + + // Note: the MRU entry is moved to the tail when accessed. + cache.get(fooConfig); + assertCacheContents(cache, "Bar", "Baz", "Foo"); - // Note: when a new entry is added it is considered the MRU entry and inserted at the tail. - cache.put(fooConfig, fooContext); - cache.put(barConfig, barContext); - cache.put(bazConfig, bazContext); - assertCacheContents(cache, "Foo", "Bar", "Baz"); + cache.get(barConfig); + assertCacheContents(cache, "Baz", "Foo", "Bar"); - // Note: the MRU entry is moved to the tail when accessed. - cache.get(fooConfig); - assertCacheContents(cache, "Bar", "Baz", "Foo"); + cache.get(bazConfig); + assertCacheContents(cache, "Foo", "Bar", "Baz"); - cache.get(barConfig); - assertCacheContents(cache, "Baz", "Foo", "Bar"); + cache.get(barConfig); + assertCacheContents(cache, "Foo", "Baz", "Bar"); + } - cache.get(bazConfig); - assertCacheContents(cache, "Foo", "Bar", "Baz"); + @Test + void ensureEvictedContextsAreClosed() { + DefaultContextCache cache = new DefaultContextCache(2); - cache.get(barConfig); - assertCacheContents(cache, "Foo", "Baz", "Bar"); + cache.put(fooConfig, fooContext); + cache.put(barConfig, barContext); + assertCacheContents(cache, "Foo", "Bar"); + + cache.put(bazConfig, bazContext); + assertCacheContents(cache, "Bar", "Baz"); + verify(fooContext, times(1)).close(); + + cache.put(abcConfig, abcContext); + assertCacheContents(cache, "Baz", "Abc"); + verify(barContext, times(1)).close(); + + verify(abcContext, never()).close(); + verify(bazContext, never()).close(); + } } - @Test - void ensureEvictedContextsAreClosed() { - DefaultContextCache cache = new DefaultContextCache(2); + /** + * @since 7.0 + */ + @Nested + class PutWithLoadFunctionUnitTests { + + @Test + void maxCacheSizeOne() { + DefaultContextCache cache = new DefaultContextCache(1); + assertThat(cache.size()).isEqualTo(0); + assertThat(cache.getMaxSize()).isEqualTo(1); + + cache.put(fooConfig, key -> fooContext); + assertCacheContents(cache, "Foo"); + + cache.put(fooConfig, key -> fooContext); + assertCacheContents(cache, "Foo"); + + cache.put(barConfig, key -> barContext); + assertCacheContents(cache, "Bar"); - cache.put(fooConfig, fooContext); - cache.put(barConfig, barContext); - assertCacheContents(cache, "Foo", "Bar"); + cache.put(fooConfig, key -> fooContext); + assertCacheContents(cache, "Foo"); + } - cache.put(bazConfig, bazContext); - assertCacheContents(cache, "Bar", "Baz"); - verify(fooContext, times(1)).close(); + @Test + void maxCacheSizeThree() { + DefaultContextCache cache = new DefaultContextCache(3); + assertThat(cache.size()).isEqualTo(0); + assertThat(cache.getMaxSize()).isEqualTo(3); - cache.put(abcConfig, abcContext); - assertCacheContents(cache, "Baz", "Abc"); - verify(barContext, times(1)).close(); + cache.put(fooConfig, key -> fooContext); + assertCacheContents(cache, "Foo"); + + cache.put(fooConfig, key -> fooContext); + assertCacheContents(cache, "Foo"); + + cache.put(barConfig, key -> barContext); + assertCacheContents(cache, "Foo", "Bar"); + + cache.put(bazConfig, key -> bazContext); + assertCacheContents(cache, "Foo", "Bar", "Baz"); + + cache.put(abcConfig, key -> abcContext); + assertCacheContents(cache, "Bar", "Baz", "Abc"); + } + + @Test + void ensureLruOrderingIsUpdated() { + DefaultContextCache cache = new DefaultContextCache(3); + + // Note: when a new entry is added it is considered the MRU entry and inserted at the tail. + cache.put(fooConfig, key -> fooContext); + cache.put(barConfig, key -> barContext); + cache.put(bazConfig, key -> bazContext); + assertCacheContents(cache, "Foo", "Bar", "Baz"); + + // Note: the MRU entry is moved to the tail when accessed. + cache.get(fooConfig); + assertCacheContents(cache, "Bar", "Baz", "Foo"); + + cache.get(barConfig); + assertCacheContents(cache, "Baz", "Foo", "Bar"); + + cache.get(bazConfig); + assertCacheContents(cache, "Foo", "Bar", "Baz"); + + cache.get(barConfig); + assertCacheContents(cache, "Foo", "Baz", "Bar"); + } + + @Test + void ensureEvictedContextsAreClosed() { + DefaultContextCache cache = new DefaultContextCache(2); + + cache.put(fooConfig, key -> fooContext); + cache.put(barConfig, key -> barContext); + assertCacheContents(cache, "Foo", "Bar"); + + cache.put(bazConfig, key -> bazContext); + assertCacheContents(cache, "Bar", "Baz"); + verify(fooContext, times(1)).close(); + + cache.put(abcConfig, key -> abcContext); + assertCacheContents(cache, "Baz", "Abc"); + verify(barContext, times(1)).close(); + + verify(abcContext, never()).close(); + verify(bazContext, never()).close(); + } + } - verify(abcContext, never()).close(); - verify(bazContext, never()).close(); + /** + * @since 7.0 + */ + @Nested + class PutWithLoadFunctionIntegrationTests { + + /** + * Mimics a database shared across application contexts. + */ + private static final Set database = new HashSet<>(); + + private static final List events = new ArrayList<>(); + + + @BeforeEach + @AfterEach + void resetTracking() { + resetEvents(); + DatabaseInitializer.counter.set(0); + database.clear(); + } + + @Test + void maxCacheSizeOne() { + DefaultContextCache contextCache = new DefaultContextCache(1); + + // ----------------------------------------------------------------- + + // Get ApplicationContext for TestCase1. + Class testClass1 = TestCase1.class; + TestContext testContext1 = TestContextTestUtils.buildTestContext(testClass1, contextCache); + testContext1.getApplicationContext(); + assertContextCacheStatistics(contextCache, testClass1.getSimpleName(), 1, 1, 0, 1); + assertCacheContents(contextCache, "Config1"); + assertThat(database).containsExactly("enigma1"); + assertThat(events).containsExactly("START 1"); + resetEvents(); + + // ----------------------------------------------------------------- + + // Get ApplicationContext for TestCase2. + Class testClass2 = TestCase2.class; + TestContext testContext2 = TestContextTestUtils.buildTestContext(testClass2, contextCache); + testContext2.getApplicationContext(); + assertContextCacheStatistics(contextCache, testClass2.getSimpleName(), 1, 1, 0, 2); + assertCacheContents(contextCache, "Config2"); + assertThat(database).containsExactly("enigma2"); + assertThat(events).containsExactly("CLOSE 1", "START 2"); + resetEvents(); + + // ----------------------------------------------------------------- + + // Get ApplicationContext for TestCase3. + Class testClass3 = TestCase3.class; + TestContext testContext3 = TestContextTestUtils.buildTestContext(testClass3, contextCache); + testContext3.getApplicationContext(); + assertContextCacheStatistics(contextCache, testClass3.getSimpleName(), 1, 1, 0, 3); + assertCacheContents(contextCache, "Config3"); + assertThat(database).containsExactly("enigma3"); + assertThat(events).containsExactly("CLOSE 2", "START 3"); + resetEvents(); + + // ----------------------------------------------------------------- + + // Get ApplicationContext for TestCase1 again. + testContext1.getApplicationContext(); + assertContextCacheStatistics(contextCache, testClass1.getSimpleName(), 1, 1, 0, 4); + assertCacheContents(contextCache, "Config1"); + assertThat(database).containsExactly("enigma4"); + assertThat(events).containsExactly("CLOSE 3", "START 4"); + resetEvents(); + + // ----------------------------------------------------------------- + + testContext1.markApplicationContextDirty(HierarchyMode.EXHAUSTIVE); + assertThat(events).containsExactly("CLOSE 4"); + assertThat(database).isEmpty(); + assertThat(contextCache.size()).isZero(); + } + + @Test + void maxCacheSizeTwo() { + DefaultContextCache contextCache = new DefaultContextCache(2); + + // ----------------------------------------------------------------- + + // Get ApplicationContext for TestCase1. + Class testClass1 = TestCase1.class; + TestContext testContext1 = TestContextTestUtils.buildTestContext(testClass1, contextCache); + testContext1.getApplicationContext(); + assertContextCacheStatistics(contextCache, testClass1.getSimpleName(), 1, 1, 0, 1); + testContext1.markApplicationContextUnused(); + assertContextCacheStatistics(contextCache, testClass1.getSimpleName(), 1, 0, 0, 1); + assertCacheContents(contextCache, "Config1"); + assertThat(events).containsExactly("START 1"); + assertThat(database).containsExactly("enigma1"); + resetEvents(); + + // ----------------------------------------------------------------- + + // Get ApplicationContext for TestCase2. + Class testClass2 = TestCase2.class; + TestContext testContext2 = TestContextTestUtils.buildTestContext(testClass2, contextCache); + testContext2.getApplicationContext(); + assertContextCacheStatistics(contextCache, testClass2.getSimpleName(), 2, 1, 0, 2); + testContext2.markApplicationContextUnused(); + assertContextCacheStatistics(contextCache, testClass2.getSimpleName(), 2, 0, 0, 2); + assertCacheContents(contextCache, "Config1", "Config2"); + assertThat(events).containsExactly("START 2"); + assertThat(database).containsExactly("enigma1", "enigma2"); + resetEvents(); + + // ----------------------------------------------------------------- + + // Get ApplicationContext for TestCase3. + Class testClass3 = TestCase3.class; + TestContext testContext3 = TestContextTestUtils.buildTestContext(testClass3, contextCache); + testContext3.getApplicationContext(); + assertContextCacheStatistics(contextCache, testClass3.getSimpleName(), 2, 1, 0, 3); + testContext3.markApplicationContextUnused(); + assertContextCacheStatistics(contextCache, testClass3.getSimpleName(), 2, 0, 0, 3); + assertCacheContents(contextCache, "Config2", "Config3"); + assertThat(events).containsExactly("CLOSE 1", "START 3"); + // Closing App #1 removed "enigma1" and "enigma2" from the database. + assertThat(database).containsExactly("enigma3"); + resetEvents(); + + // ----------------------------------------------------------------- + + // Get ApplicationContext for TestCase1 again. + testContext1.getApplicationContext(); + assertContextCacheStatistics(contextCache, testClass1.getSimpleName(), 2, 1, 0, 4); + testContext1.markApplicationContextUnused(); + assertContextCacheStatistics(contextCache, testClass1.getSimpleName(), 2, 0, 0, 4); + assertCacheContents(contextCache, "Config3", "Config1"); + assertThat(events).containsExactly("CLOSE 2", "START 4"); + // Closing App #2 removed "enigma3" from the database. + assertThat(database).containsExactly("enigma4"); + resetEvents(); + + // ----------------------------------------------------------------- + + testContext3.markApplicationContextDirty(HierarchyMode.EXHAUSTIVE); + assertThat(events).containsExactly("CLOSE 3"); + resetEvents(); + + testContext1.markApplicationContextDirty(HierarchyMode.EXHAUSTIVE); + assertThat(events).containsExactly("CLOSE 4"); + assertThat(database).isEmpty(); + assertThat(contextCache.size()).isZero(); + } + + + private static void resetEvents() { + events.clear(); + } + + + /** + * Mimics a Spring component that inserts data into the database when the + * application context is started and drops data from a database when the + * application context is closed. + * + * @see org.springframework.jdbc.datasource.init.DataSourceInitializer + */ + static class DatabaseInitializer implements InitializingBean, DisposableBean { + + static final AtomicInteger counter = new AtomicInteger(); + + private final int count; + + + DatabaseInitializer() { + this.count = counter.incrementAndGet(); + } + + @Override + public void afterPropertiesSet() { + events.add("START " + this.count); + database.add("enigma" + this.count); + } + + @Override + public void destroy() { + events.add("CLOSE " + this.count); + database.clear(); + } + } + + @SpringJUnitConfig + static class TestCase1 { + + @Configuration + @Import(DatabaseInitializer.class) + static class Config1 { + } + } + + @SpringJUnitConfig + static class TestCase2 { + + @Configuration + @Import(DatabaseInitializer.class) + static class Config2 { + } + } + + @SpringJUnitConfig + static class TestCase3 { + + @Configuration + @Import(DatabaseInitializer.class) + static class Config3 { + } + } } From 02b3a2bf34a0781755c384fda7c8ed4568d9381c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Thu, 4 Sep 2025 14:37:44 +0200 Subject: [PATCH 173/591] Upgrade to Kotlin 2.2.10 Closes gh-35414 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 8d3de9c13196..12937b990162 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,7 +4,7 @@ org.gradle.caching=true org.gradle.jvmargs=-Xmx2048m org.gradle.parallel=true -kotlinVersion=2.2.0 +kotlinVersion=2.2.10 byteBuddyVersion=1.17.6 kotlin.jvm.target.validation.mode=ignore From e5b58effa3b74569ca6d3b091fa44f47bdc6ccea Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Thu, 4 Sep 2025 14:26:56 +0200 Subject: [PATCH 174/591] Deprecate put(MergedContextConfiguration, ApplicationContext) in ContextCache Closes gh-35415 --- .../org/springframework/test/context/cache/ContextCache.java | 3 +++ .../test/context/cache/DefaultContextCache.java | 1 + .../test/context/cache/LruContextCacheTests.java | 1 + 3 files changed, 5 insertions(+) diff --git a/spring-test/src/main/java/org/springframework/test/context/cache/ContextCache.java b/spring-test/src/main/java/org/springframework/test/context/cache/ContextCache.java index 66528de3ead6..260e8b60b17a 100644 --- a/spring-test/src/main/java/org/springframework/test/context/cache/ContextCache.java +++ b/spring-test/src/main/java/org/springframework/test/context/cache/ContextCache.java @@ -116,7 +116,10 @@ public interface ContextCache { * @param context the {@code ApplicationContext}; never {@code null} * @see #get(MergedContextConfiguration) * @see #put(MergedContextConfiguration, LoadFunction) + * @deprecated since Spring Framework 7.0 in favor of + * {@link #put(MergedContextConfiguration, LoadFunction)} */ + @Deprecated(since = "7.0") void put(MergedContextConfiguration key, ApplicationContext context); /** diff --git a/spring-test/src/main/java/org/springframework/test/context/cache/DefaultContextCache.java b/spring-test/src/main/java/org/springframework/test/context/cache/DefaultContextCache.java index b00c261393b0..3b102fa4e3d5 100644 --- a/spring-test/src/main/java/org/springframework/test/context/cache/DefaultContextCache.java +++ b/spring-test/src/main/java/org/springframework/test/context/cache/DefaultContextCache.java @@ -154,6 +154,7 @@ private void restartContextIfNecessary(ApplicationContext context) { } @Override + @Deprecated(since = "7.0") public void put(MergedContextConfiguration key, ApplicationContext context) { Assert.notNull(key, "Key must not be null"); Assert.notNull(context, "ApplicationContext must not be null"); diff --git a/spring-test/src/test/java/org/springframework/test/context/cache/LruContextCacheTests.java b/spring-test/src/test/java/org/springframework/test/context/cache/LruContextCacheTests.java index 8067565fb25b..186addab6b5f 100644 --- a/spring-test/src/test/java/org/springframework/test/context/cache/LruContextCacheTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/cache/LruContextCacheTests.java @@ -83,6 +83,7 @@ void maxCacheSizeZero() { @Nested + @SuppressWarnings("deprecation") class PutUnitTests { @Test From d2bdf11b3973cc7173a52c33ae7bbae2002442bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Fri, 5 Sep 2025 11:16:27 +0200 Subject: [PATCH 175/591] Refine Nullness for Kotlin functions returning Unit Should have unspecified nullness like for Java void/Void. Closes gh-35420 --- .../main/java/org/springframework/core/Nullness.java | 6 +++--- .../java/org/springframework/core/NullnessTests.java | 7 +++++++ .../org/springframework/core/NullnessKotlinTests.kt | 10 ++++++++++ .../core/testfixture/nullness/JSpecifyProcessor.java | 4 ++++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/core/Nullness.java b/spring-core/src/main/java/org/springframework/core/Nullness.java index 09542c908b76..e8d713fd7cbf 100644 --- a/spring-core/src/main/java/org/springframework/core/Nullness.java +++ b/spring-core/src/main/java/org/springframework/core/Nullness.java @@ -181,10 +181,10 @@ private static class KotlinDelegate { public static Nullness forMethodReturnType(Method method) { KFunction function = ReflectJvmMapping.getKotlinFunction(method); - if (function != null && function.getReturnType().isMarkedNullable()) { - return Nullness.NULLABLE; + if (function != null && ReflectJvmMapping.getJavaType(function.getReturnType()) != void.class) { + return (function.getReturnType().isMarkedNullable() ? Nullness.NULLABLE : Nullness.NON_NULL); } - return Nullness.NON_NULL; + return Nullness.UNSPECIFIED; } public static Nullness forParameter(Executable executable, int parameterIndex) { diff --git a/spring-core/src/test/java/org/springframework/core/NullnessTests.java b/spring-core/src/test/java/org/springframework/core/NullnessTests.java index 2921401dd806..1cd68ae90934 100644 --- a/spring-core/src/test/java/org/springframework/core/NullnessTests.java +++ b/spring-core/src/test/java/org/springframework/core/NullnessTests.java @@ -377,6 +377,13 @@ void customNullableField() throws NoSuchFieldException { Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE); } + @Test + void voidClassMethod() throws NoSuchMethodException { + var method = JSpecifyProcessor.class.getMethod("voidClassProcess"); + var nullness = Nullness.forMethodReturnType(method); + Assertions.assertThat(nullness).isEqualTo(Nullness.UNSPECIFIED); + } + // Primitive types @Test diff --git a/spring-core/src/test/kotlin/org/springframework/core/NullnessKotlinTests.kt b/spring-core/src/test/kotlin/org/springframework/core/NullnessKotlinTests.kt index 2c8d6b6e36be..87e9c4053925 100644 --- a/spring-core/src/test/kotlin/org/springframework/core/NullnessKotlinTests.kt +++ b/spring-core/src/test/kotlin/org/springframework/core/NullnessKotlinTests.kt @@ -37,6 +37,13 @@ class NullnessKotlinTests { Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE) } + @Test + fun unitReturnType() { + val method = ::unit.javaMethod!! + val nullness = Nullness.forMethodReturnType(method) + Assertions.assertThat(nullness).isEqualTo(Nullness.UNSPECIFIED) + } + @Test fun nullableParameter() { val method = ::nullable.javaMethod!! @@ -78,4 +85,7 @@ class NullnessKotlinTests { @Suppress("unused_parameter") fun nonNull(nonNull: String): String = "foo" + fun unit() { + } + } \ No newline at end of file diff --git a/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/JSpecifyProcessor.java b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/JSpecifyProcessor.java index 9eee0f44d52a..af0116d09aab 100644 --- a/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/JSpecifyProcessor.java +++ b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/JSpecifyProcessor.java @@ -37,5 +37,9 @@ public interface JSpecifyProcessor { @NullMarked @NonNull String nonNullMarkedProcess(); + @NullMarked void voidProcess(); + + @NullMarked + void voidClassProcess(); } From d218b0899a9312cd1f6a55ae917fddb04eedd246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Fri, 5 Sep 2025 17:37:44 +0200 Subject: [PATCH 176/591] Invalid Nullness information for Kotlin properties This commit adds support for Kotlin properties to Nullness forMethodReturnType and forParameter methods. Closes gh-35419 --- .../org/springframework/core/Nullness.java | 56 +++++++++++++++---- .../core/NullnessKotlinTests.kt | 32 +++++++++++ 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/core/Nullness.java b/spring-core/src/main/java/org/springframework/core/Nullness.java index e8d713fd7cbf..29382aac6654 100644 --- a/spring-core/src/main/java/org/springframework/core/Nullness.java +++ b/spring-core/src/main/java/org/springframework/core/Nullness.java @@ -27,9 +27,13 @@ import java.util.Objects; import java.util.function.Predicate; +import kotlin.jvm.JvmClassMappingKt; +import kotlin.reflect.KClass; import kotlin.reflect.KFunction; import kotlin.reflect.KParameter; import kotlin.reflect.KProperty; +import kotlin.reflect.KType; +import kotlin.reflect.full.KClasses; import kotlin.reflect.jvm.ReflectJvmMapping; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.NullMarked; @@ -181,8 +185,23 @@ private static class KotlinDelegate { public static Nullness forMethodReturnType(Method method) { KFunction function = ReflectJvmMapping.getKotlinFunction(method); - if (function != null && ReflectJvmMapping.getJavaType(function.getReturnType()) != void.class) { - return (function.getReturnType().isMarkedNullable() ? Nullness.NULLABLE : Nullness.NON_NULL); + if (function == null) { + String methodName = method.getName(); + if (methodName.startsWith("get")) { + String propertyName = accessorToPropertyName(methodName); + KClass kClass = JvmClassMappingKt.getKotlinClass(method.getDeclaringClass()); + for (KProperty property : KClasses.getMemberProperties(kClass)) { + if (property.getName().equals(propertyName)) { + return (property.getReturnType().isMarkedNullable() ? Nullness.NULLABLE : Nullness.NON_NULL); + } + } + } + } + else { + KType type = function.getReturnType(); + if (ReflectJvmMapping.getJavaType(type) != void.class) { + return (type.isMarkedNullable() ? Nullness.NULLABLE : Nullness.NON_NULL); + } } return Nullness.UNSPECIFIED; } @@ -200,12 +219,23 @@ public static Nullness forParameter(Executable executable, int parameterIndex) { KParameter.Kind.INSTANCE.equals(p.getKind())); } if (function == null) { - return Nullness.UNSPECIFIED; + String methodName = executable.getName(); + if (methodName.startsWith("set")) { + String propertyName = accessorToPropertyName(methodName); + KClass kClass = JvmClassMappingKt.getKotlinClass(executable.getDeclaringClass()); + for (KProperty property : KClasses.getMemberProperties(kClass)) { + if (property.getName().equals(propertyName)) { + return (property.getReturnType().isMarkedNullable() ? Nullness.NULLABLE : Nullness.NON_NULL); + } + } + } } - int i = 0; - for (KParameter kParameter : function.getParameters()) { - if (predicate.test(kParameter) && parameterIndex == i++) { - return (kParameter.getType().isMarkedNullable() ? Nullness.NULLABLE : Nullness.NON_NULL); + else { + int i = 0; + for (KParameter kParameter : function.getParameters()) { + if (predicate.test(kParameter) && parameterIndex == i++) { + return (kParameter.getType().isMarkedNullable() ? Nullness.NULLABLE : Nullness.NON_NULL); + } } } return Nullness.UNSPECIFIED; @@ -213,10 +243,16 @@ public static Nullness forParameter(Executable executable, int parameterIndex) { public static Nullness forField(Field field) { KProperty property = ReflectJvmMapping.getKotlinProperty(field); - if (property != null && property.getReturnType().isMarkedNullable()) { - return Nullness.NULLABLE; + if (property != null) { + return (property.getReturnType().isMarkedNullable() ? Nullness.NULLABLE : Nullness.NON_NULL); } - return Nullness.NON_NULL; + return Nullness.UNSPECIFIED; + } + + private static String accessorToPropertyName(String method) { + char[] methodNameChars = method.toCharArray(); + methodNameChars[3] = Character.toLowerCase(methodNameChars[3]); + return new String(methodNameChars, 3, methodNameChars.length - 3); } } diff --git a/spring-core/src/test/kotlin/org/springframework/core/NullnessKotlinTests.kt b/spring-core/src/test/kotlin/org/springframework/core/NullnessKotlinTests.kt index 87e9c4053925..11962aae5440 100644 --- a/spring-core/src/test/kotlin/org/springframework/core/NullnessKotlinTests.kt +++ b/spring-core/src/test/kotlin/org/springframework/core/NullnessKotlinTests.kt @@ -79,6 +79,34 @@ class NullnessKotlinTests { Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL) } + @Test + fun nullableDataClassGetter() { + val method = NullableName::class.java.getDeclaredMethod("getName") + val nullness = Nullness.forMethodReturnType(method) + Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE) + } + + @Test + fun nonNullableDataClassGetter() { + val method = NonNullableName::class.java.getDeclaredMethod("getName") + val nullness = Nullness.forMethodReturnType(method) + Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL) + } + + @Test + fun nullableDataClassSetter() { + val method = NullableName::class.java.getDeclaredMethod("setName", String::class.java) + val nullness = Nullness.forParameter(method.parameters[0]) + Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE) + } + + @Test + fun nonNullableDataClassSetter() { + val method = NonNullableName::class.java.getDeclaredMethod("setName", String::class.java) + val nullness = Nullness.forParameter(method.parameters[0]) + Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL) + } + @Suppress("unused_parameter") fun nullable(nullable: String?): String? = "foo" @@ -88,4 +116,8 @@ class NullnessKotlinTests { fun unit() { } + data class NullableName(var name: String?) + + data class NonNullableName(var name: String) + } \ No newline at end of file From 7b2730c271eaaddf3def5165653299d438b63b2b Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Sat, 6 Sep 2025 16:16:38 +0200 Subject: [PATCH 177/591] Include current exception in log message for failed retry attempt Prior to this commit, we included the initial exception in the log message for the initial invocation of a retryable operation; however, we did not include the current exception in the log message for subsequent attempts. For consistency, we now include the current exception in log messages for subsequent retry attempts as well. Closes gh-35433 --- .../main/java/org/springframework/core/retry/RetryTemplate.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java b/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java index faa6b242cd50..74be50418329 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java @@ -178,7 +178,7 @@ public RetryListener getRetryListener() { return result; } catch (Throwable currentException) { - logger.debug(() -> "Retry attempt for operation '%s' failed due to '%s'" + logger.debug(currentException, () -> "Retry attempt for operation '%s' failed due to '%s'" .formatted(retryableName, currentException)); this.retryListener.onRetryFailure(this.retryPolicy, retryable, currentException); exceptions.add(currentException); From 7484b9c49173a2da9d22ea00f2298ab4ec394e37 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Sat, 6 Sep 2025 16:39:22 +0200 Subject: [PATCH 178/591] Consistently include exceptions for previous attempts in RetryException In RetryTemplate, if we encounter an InterruptedException while sleeping for the configured back-off duration, we throw a RetryException with the InterruptedException as the cause. However, in contrast to the specification for RetryException, we do not currently include the exceptions for previous retry attempts as suppressed exceptions in the RetryException which is thrown in such scenarios. In order to comply with the documented contract for RetryException, this commit includes exceptions for previous attempts in the RetryException thrown for an InterruptedException as well. Closes gh-35434 --- .../core/retry/RetryException.java | 23 +++++++++++++++---- .../core/retry/RetryTemplate.java | 4 +++- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryException.java b/spring-core/src/main/java/org/springframework/core/retry/RetryException.java index eef16f68ef13..9a862d543d20 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryException.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryException.java @@ -22,13 +22,22 @@ /** * Exception thrown when a {@link RetryPolicy} has been exhausted. * - *

      A {@code RetryException} will contain the last exception thrown by the - * {@link Retryable} operation as the {@linkplain #getCause() cause} and any - * exceptions from previous attempts as {@linkplain #getSuppressed() suppressed + *

      A {@code RetryException} will typically contain the last exception thrown + * by the {@link Retryable} operation as the {@linkplain #getCause() cause} and + * any exceptions from previous attempts as {@linkplain #getSuppressed() suppressed * exceptions}. * + *

      However, if an {@link InterruptedException} is encountered while + * {@linkplain Thread#sleep(long) sleeping} for the current + * {@link org.springframework.util.backoff.BackOff BackOff} duration, a + * {@code RetryException} will contain the {@code InterruptedException} as the + * {@linkplain #getCause() cause} and any exceptions from previous attempts to + * invoke the {@code Retryable} operation as {@linkplain #getSuppressed() + * suppressed exceptions}. + * * @author Mahmoud Ben Hassine * @author Juergen Hoeller + * @author Sam Brannen * @since 7.0 * @see RetryOperations */ @@ -41,7 +50,9 @@ public class RetryException extends Exception { /** * Create a new {@code RetryException} for the supplied message and cause. * @param message the detail message - * @param cause the last exception thrown by the {@link Retryable} operation + * @param cause the last exception thrown by the {@link Retryable} operation, + * or an {@link InterruptedException} thrown while sleeping for the current + * {@code BackOff} duration */ public RetryException(String message, Throwable cause) { super(message, Objects.requireNonNull(cause, "cause must not be null")); @@ -49,7 +60,9 @@ public RetryException(String message, Throwable cause) { /** - * Get the last exception thrown by the {@link Retryable} operation. + * Get the last exception thrown by the {@link Retryable} operation, or an + * {@link InterruptedException} thrown while sleeping for the current + * {@code BackOff} duration. */ @Override public final Throwable getCause() { diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java b/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java index 74be50418329..bf55b724c670 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java @@ -164,9 +164,11 @@ public RetryListener getRetryListener() { } catch (InterruptedException interruptedException) { Thread.currentThread().interrupt(); - throw new RetryException( + RetryException retryException = new RetryException( "Unable to back off for retryable operation '%s'".formatted(retryableName), interruptedException); + exceptions.forEach(retryException::addSuppressed); + throw retryException; } logger.debug(() -> "Preparing to retry operation '%s'".formatted(retryableName)); try { From bce44b007dd3157cb90fbd3e079915271eaf742f Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Sun, 7 Sep 2025 12:20:27 +0200 Subject: [PATCH 179/591] Document programmatic retry support in the reference manual Closes gh-35436 --- .../modules/ROOT/pages/core/resilience.adoc | 181 ++++++++++++++---- 1 file changed, 148 insertions(+), 33 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/core/resilience.adoc b/framework-docs/modules/ROOT/pages/core/resilience.adoc index b2b4b3f817b5..e6d9fe743726 100644 --- a/framework-docs/modules/ROOT/pages/core/resilience.adoc +++ b/framework-docs/modules/ROOT/pages/core/resilience.adoc @@ -1,16 +1,19 @@ [[resilience]] = Resilience Features -As of 7.0, the core Spring Framework includes a couple of common resilience features, -in particular `@Retryable` and `@ConcurrencyLimit` annotations for method invocations. +As of 7.0, the core Spring Framework includes common resilience features, in particular +<> and <> +annotations for method invocations as well as <>. -[[resilience-retryable]] -== Using `@Retryable` +[[resilience-annotations-retryable]] +== `@Retryable` -`@Retryable` is a common annotation that specifies retry characteristics for an individual -method (with the annotation declared at the method level), or for all proxy-invoked -methods in a given class hierarchy (with the annotation declared at the type level). +{spring-framework-api}/resilience/annotation/Retryable.html[`@Retryable`] is an annotation +that specifies retry characteristics for an individual method (with the annotation +declared at the method level), or for all proxy-invoked methods in a given class hierarchy +(with the annotation declared at the type level). [source,java,indent=0,subs="verbatim,quotes"] ---- @@ -20,8 +23,8 @@ public void sendNotification() { } ---- -By default, the method invocation will be retried for any exception thrown: with at -most 3 retry attempts after an initial failure, and a delay of 1 second between attempts. +By default, the method invocation will be retried for any exception thrown: with at most 3 +retry attempts after an initial failure, and a delay of 1 second between attempts. This can be specifically adapted for every method if necessary – for example, by narrowing the exceptions to retry: @@ -38,38 +41,45 @@ Or for 5 retry attempts and an exponential back-off strategy with a bit of jitte [source,java,indent=0,subs="verbatim,quotes"] ---- -@Retryable(maxAttempts = 5, delay = 100, jitter = 10, multiplier = 2, maxDelay = 1000) +@Retryable( + includes = MessageDeliveryException.class, + maxAttempts = 5, + delay = 100, + jitter = 10, + multiplier = 2, + maxDelay = 1000) public void sendNotification() { this.jmsClient.destination("notifications").send(...); } ---- -Last but not least, `@Retryable` also works for reactive methods with a reactive -return type, decorating the pipeline with Reactor's retry capabilities: +Last but not least, `@Retryable` also works for reactive methods with a reactive return +type, decorating the pipeline with Reactor's retry capabilities: [source,java,indent=0,subs="verbatim,quotes"] ---- -@Retryable(maxAttempts = 5, delay = 100, jitter = 10, multiplier = 2, maxDelay = 1000) +@Retryable(maxAttempts = 5, delay = 100) public Mono sendNotification() { return Mono.from(...); // <1> } ---- <1> This raw `Mono` will get decorated with a retry spec. -For details on the various characteristics, see the available annotation attributes -in {spring-framework-api}/resilience/annotation/Retryable.html[`@Retryable`]. +For details on the various characteristics, see the available annotation attributes in +{spring-framework-api}/resilience/annotation/Retryable.html[`@Retryable`]. -NOTE: There a `String` variants with placeholder support available for several attributes -as well, as an alternative to the specifically typed annotation attributes used in the -above examples. +NOTE: There are `String` variants with placeholder support available for several +attributes as well, as an alternative to the specifically typed annotation attributes used +in the above examples. -[[resilience-concurrency]] -== Using `@ConcurrencyLimit` +[[resilience-annotations-concurrencylimit]] +== `@ConcurrencyLimit` -`@ConcurrencyLimit` is an annotation that specifies a concurrency limit for an individual -method (with the annotation declared at the method level), or for all proxy-invoked -methods in a given class hierarchy (with the annotation declared at the type level). +{spring-framework-api}/resilience/annotation/ConcurrencyLimit.html[`@ConcurrencyLimit`] is +an annotation that specifies a concurrency limit for an individual method (with the +annotation declared at the method level), or for all proxy-invoked methods in a given +class hierarchy (with the annotation declared at the type level). [source,java,indent=0,subs="verbatim,quotes"] ---- @@ -95,8 +105,8 @@ public void sendNotification() { ---- <1> 1 is the default, but specifying it makes the intent clearer. -Such limiting is particularly useful with Virtual Threads where there is generally -no thread pool limit in place. For asynchronous tasks, this can be constrained on +Such limiting is particularly useful with Virtual Threads where there is generally no +thread pool limit in place. For asynchronous tasks, this can be constrained on {spring-framework-api}/core/task/SimpleAsyncTaskExecutor.html[`SimpleAsyncTaskExecutor`]. For synchronous invocations, this annotation provides equivalent behavior through {spring-framework-api}/aop/interceptor/ConcurrencyThrottleInterceptor.html[`ConcurrencyThrottleInterceptor`] @@ -104,12 +114,117 @@ which has been available since Spring Framework 1.0 for programmatic use with th framework. -[[resilience-enable]] -== Configuring `@EnableResilientMethods` +[[resilience-annotations-configuration]] +== Enabling Resilient Methods -Note that like many of Spring's core annotation-based features, `@Retryable` and -`@ConcurrencyLimit` are designed as metadata that you can choose to honor or ignore. -The most convenient way to enable actual processing of the resilience annotations -through AOP interception is to declare `@EnableResilientMethods` on a corresponding -configuration class. Alternatively, you may declare `RetryAnnotationBeanPostProcessor` -and/or `ConcurrencyLimitBeanPostProcessor` individually. +Like many of Spring's core annotation-based features, `@Retryable` and `@ConcurrencyLimit` +are designed as metadata that you can choose to honor or ignore. The most convenient way +to enable processing of the resilience annotations is to declare +{spring-framework-api}/resilience/annotation/EnableResilientMethods.html[`@EnableResilientMethods`] +on a corresponding `@Configuration` class. + +Alternatively, these annotations can be individually enabled by defining a +`RetryAnnotationBeanPostProcessor` or a `ConcurrencyLimitBeanPostProcessor` bean in the +context. + + +[[resilience-programmatic-retry]] +== Programmatic Retry Support + +In contrast to <> which provides a declarative approach +for specifying retry semantics for methods within beans registered in the +`ApplicationContext`, +{spring-framework-api}/core/retry/RetryTemplate.html[`RetryTemplate`] provides a +programmatic API for retrying arbitrary blocks of code. + +Specifically, a `RetryTemplate` executes and potentially retries a +{spring-framework-api}/core/retry/Retryable.html[`Retryable`] operation based on a +configured {spring-framework-api}/core/retry/RetryPolicy.html[`RetryPolicy`]. + +[source,java,indent=0,subs="verbatim,quotes"] +---- + var retryTemplate = new RetryTemplate(); // <1> + + retryTemplate.execute( + () -> jmsClient.destination("notifications").send(...)); +---- +<1> Implicitly uses `RetryPolicy.withDefaults()`. + +By default, a retryable operation will be retried for any exception thrown: with at most 3 +retry attempts after an initial failure, and a delay of 1 second between attempts. + +If you only need to customize the number of retry attempts, you can use the +`RetryPolicy.withMaxAttempts()` factory method as demonstrated below. + +[source,java,indent=0,subs="verbatim,quotes"] +---- + var retryTemplate = new RetryTemplate(RetryPolicy.withMaxAttempts(5)); // <1> + + retryTemplate.execute( + () -> jmsClient.destination("notifications").send(...)); +---- +<1> Explicitly uses `RetryPolicy.withMaxAttempts(5)`. + +If you need to narrow the types of exceptions to retry, that can be achieved via the +`includes()` and `excludes()` builder methods. + +[source,java,indent=0,subs="verbatim,quotes"] +---- + var retryPolicy = RetryPolicy.builder() + .includes(MessageDeliveryException.class) // <1> + .excludes(...) // <2> + .build(); + + var retryTemplate = new RetryTemplate(retryPolicy); + + retryTemplate.execute( + () -> jmsClient.destination("notifications").send(...)); +---- +<1> Specify one or more exception types to include. +<2> Specify one or more exception types to exclude. + +[TIP] +==== +For advanced use cases, you can specify a custom `Predicate` via the +`predicate()` method in the `RetryPolicy.Builder`, and the predicate will be used to +determine whether to retry a failed operation based on a given `Throwable` – for example, +by checking the cause or the message of the `Throwable`. + +Custom predicates can be combined with `includes` and `excludes`; however, custom +predicates will always be applied after `includes` and `excludes` have been applied. +==== + +The following example demonstrates how to configure a `RetryPolicy` with 5 retry attempts +and an exponential back-off strategy with a bit of jitter. + +[source,java,indent=0,subs="verbatim,quotes"] +---- + var retryPolicy = RetryPolicy.builder() + .includes(MessageDeliveryException.class) + .maxAttempts(5) + .delay(Duration.ofMillis(100)) + .jitter(Duration.ofMillis(10)) + .multiplier(2) + .maxDelay(Duration.ofSeconds(1)) + .build(); + + var retryTemplate = new RetryTemplate(retryPolicy); + + retryTemplate.execute( + () -> jmsClient.destination("notifications").send(...)); +---- + +[TIP] +==== +A {spring-framework-api}/core/retry/RetryListener.html[`RetryListener`] can be registered +with a `RetryTemplate` to react to events published during key retry phases (before a +retry attempt, after a retry attempt, etc.), and you can compose multiple listeners via a +{spring-framework-api}/core/retry/support/CompositeRetryListener.html[`CompositeRetryListener`]. +==== + +Although the factory methods and builder API for `RetryPolicy` cover most common +configuration scenarios, you can implement a custom `RetryPolicy` for complete control +over the types of exceptions that should trigger a retry as well as the +{spring-framework-api}/util/backoff/BackOff.html[`BackOff`] strategy to use. Note that you +can also configure a customized `BackOff` strategy via the `backOff()` method in the +`RetryPolicy.Builder`. From 13d36a51ded530830595211eec7955f398b84938 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Mon, 8 Sep 2025 10:07:00 +0200 Subject: [PATCH 180/591] Upgrade to Jackson 3.0.0-rc9 and 2.20.0 Closes gh-35439 --- framework-platform/framework-platform.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index 7aadf832e7b7..76d9b6ad3892 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -7,7 +7,7 @@ javaPlatform { } dependencies { - api(platform("com.fasterxml.jackson:jackson-bom:2.20.0-rc1")) + api(platform("com.fasterxml.jackson:jackson-bom:2.20.0")) api(platform("io.micrometer:micrometer-bom:1.16.0-M2")) api(platform("io.netty:netty-bom:4.2.4.Final")) api(platform("io.projectreactor:reactor-bom:2025.0.0-M6")) @@ -21,7 +21,7 @@ dependencies { api(platform("org.jetbrains.kotlinx:kotlinx-serialization-bom:1.9.0")) api(platform("org.junit:junit-bom:5.13.4")) api(platform("org.mockito:mockito-bom:5.19.0")) - api(platform("tools.jackson:jackson-bom:3.0.0-rc8")) + api(platform("tools.jackson:jackson-bom:3.0.0-rc9")) constraints { api("com.fasterxml:aalto-xml:1.3.2") From 977582fcedd68486ee9ac541204db176808304c4 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Wed, 3 Sep 2025 10:10:20 +0100 Subject: [PATCH 181/591] Document data binding for functional endpoints Closes gh-35367 --- .../ROOT/pages/web/webflux-functional.adoc | 23 ++++++++++++++++++- .../ROOT/pages/web/webmvc-functional.adoc | 20 ++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/framework-docs/modules/ROOT/pages/web/webflux-functional.adoc b/framework-docs/modules/ROOT/pages/web/webflux-functional.adoc index 882cb422e39e..78f0fef09a2f 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux-functional.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux-functional.adoc @@ -294,7 +294,28 @@ allPartsEvents.windowUntil(PartEvent::isLast) ---- ====== -Note that the body contents of the `PartEvent` objects must be completely consumed, relayed, or released to avoid memory leaks. +NOTE: The body contents of the `PartEvent` objects must be completely consumed, relayed, or released to avoid memory leaks. + +The following shows how to bind request parameters, including an optional `DataBinder` customization: + +[tabs] +====== +Java:: ++ +[source,java] +---- +Pet pet = request.bind(Pet.class, dataBinder -> dataBinder.setAllowedFields("name")); +---- + +Kotlin:: ++ +[source,kotlin] +---- +val pet = request.bind(Pet::class.java, {dataBinder -> dataBinder.setAllowedFields("name")}) +---- +====== + + [[webflux-fn-response]] === ServerResponse diff --git a/framework-docs/modules/ROOT/pages/web/webmvc-functional.adoc b/framework-docs/modules/ROOT/pages/web/webmvc-functional.adoc index 82fb164a9498..60e48c69a5e6 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc-functional.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc-functional.adoc @@ -184,6 +184,26 @@ val map = request.params() ---- ====== +The following shows how to bind request parameters, including an optional `DataBinder` customization: + +[tabs] +====== +Java:: ++ +[source,java] +---- +Pet pet = request.bind(Pet.class, dataBinder -> dataBinder.setAllowedFields("name")); +---- + +Kotlin:: ++ +[source,kotlin] +---- +val pet = request.bind(Pet::class.java, {dataBinder -> dataBinder.setAllowedFields("name")}) +---- +====== + + [[webmvc-fn-response]] === ServerResponse From b2cdfbadf1698327cd8f5277290999b4f976d13d Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Mon, 8 Sep 2025 13:47:19 +0200 Subject: [PATCH 182/591] Introduce onRetryPolicyInterruption() callback in RetryListener In RetryTemplate, if we encounter an InterruptedException while sleeping for the configured back-off duration, we throw a RetryException with the InterruptedException as the cause. However, prior to this commit, that RetryException propagated to the caller without notifying the registered RetryListener. To address that, this commit introduces a new onRetryPolicyInterruption() callback in RetryListener as a companion to the existing onRetryPolicyExhaustion() callback. Closes gh-35442 --- .../core/retry/RetryListener.java | 22 +++++++++++-- .../core/retry/RetryTemplate.java | 1 + .../retry/support/CompositeRetryListener.java | 12 +++++-- .../core/retry/RetryTemplateTests.java | 33 +++++++++++++++++++ .../support/CompositeRetryListenerTests.java | 10 ++++++ 5 files changed, 72 insertions(+), 6 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryListener.java b/spring-core/src/main/java/org/springframework/core/retry/RetryListener.java index 2dd1c1b38a47..36aed9992019 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryListener.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryListener.java @@ -53,7 +53,7 @@ default void onRetrySuccess(RetryPolicy retryPolicy, Retryable retryable, @Nu } /** - * Called every time a retry attempt fails. + * Called after every failed retry attempt. * @param retryPolicy the {@link RetryPolicy} * @param retryable the {@link Retryable} operation * @param throwable the exception thrown by the {@code Retryable} operation @@ -65,8 +65,9 @@ default void onRetryFailure(RetryPolicy retryPolicy, Retryable retryable, Thr * Called if the {@link RetryPolicy} is exhausted. * @param retryPolicy the {@code RetryPolicy} * @param retryable the {@code Retryable} operation - * @param exception the resulting {@link RetryException}, including the last operation - * exception as a cause and all earlier operation exceptions as suppressed exceptions + * @param exception the resulting {@link RetryException}, with the last + * exception thrown by the {@link Retryable} operation as the cause and any + * exceptions from previous attempts as suppressed exceptions * @see RetryException#getCause() * @see RetryException#getSuppressed() * @see RetryException#getRetryCount() @@ -74,4 +75,19 @@ default void onRetryFailure(RetryPolicy retryPolicy, Retryable retryable, Thr default void onRetryPolicyExhaustion(RetryPolicy retryPolicy, Retryable retryable, RetryException exception) { } + /** + * Called if an {@link InterruptedException} is encountered while + * {@linkplain Thread#sleep(long) sleeping} between retry attempts. + * @param retryPolicy the {@code RetryPolicy} + * @param retryable the {@code Retryable} operation + * @param exception the resulting {@link RetryException}, with the + * {@code InterruptedException} as the cause and any exceptions from previous + * retry attempts as suppressed exceptions + * @see RetryException#getCause() + * @see RetryException#getSuppressed() + * @see RetryException#getRetryCount() + */ + default void onRetryPolicyInterruption(RetryPolicy retryPolicy, Retryable retryable, RetryException exception) { + } + } diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java b/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java index bf55b724c670..9d374d5e4675 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java @@ -168,6 +168,7 @@ public RetryListener getRetryListener() { "Unable to back off for retryable operation '%s'".formatted(retryableName), interruptedException); exceptions.forEach(retryException::addSuppressed); + this.retryListener.onRetryPolicyInterruption(this.retryPolicy, retryable, retryException); throw retryException; } logger.debug(() -> "Preparing to retry operation '%s'".formatted(retryableName)); diff --git a/spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java b/spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java index 219ab7b605ce..c9b16865a5ba 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java +++ b/spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java @@ -29,13 +29,14 @@ import org.springframework.util.Assert; /** - * A composite implementation of the {@link RetryListener} interface. - * Delegate listeners will be called in their registration order. + * A composite implementation of the {@link RetryListener} interface, which is + * used to compose multiple listeners within a {@link RetryTemplate}. * - *

      This class is used to compose multiple listeners within a {@link RetryTemplate}. + *

      Delegate listeners will be called in their registration order. * * @author Mahmoud Ben Hassine * @author Juergen Hoeller + * @author Sam Brannen * @since 7.0 */ public class CompositeRetryListener implements RetryListener { @@ -88,4 +89,9 @@ public void onRetryPolicyExhaustion(RetryPolicy retryPolicy, Retryable retrya this.listeners.forEach(listener -> listener.onRetryPolicyExhaustion(retryPolicy, retryable, exception)); } + @Override + public void onRetryPolicyInterruption(RetryPolicy retryPolicy, Retryable retryable, RetryException exception) { + this.listeners.forEach(listener -> listener.onRetryPolicyInterruption(retryPolicy, retryable, exception)); + } + } diff --git a/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java b/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java index d2e32f6c441a..8e8bbaa2fcf2 100644 --- a/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java +++ b/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java @@ -30,8 +30,11 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments.ArgumentSet; import org.junit.jupiter.params.provider.FieldSource; +import org.junit.platform.commons.util.ExceptionUtils; import org.mockito.InOrder; +import org.springframework.util.backoff.BackOff; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.junit.jupiter.params.provider.Arguments.argumentSet; @@ -213,6 +216,36 @@ public String getName() { verifyNoMoreInteractions(retryListener); } + @Test + void retryWithInterruptionDuringSleep() { + Exception exception = new RuntimeException("Boom!"); + InterruptedException interruptedException = new InterruptedException(); + + // Simulates interruption during sleep: + BackOff backOff = () -> () -> { + throw ExceptionUtils.throwAsUncheckedException(interruptedException); + }; + + RetryPolicy retryPolicy = RetryPolicy.builder().backOff(backOff).build(); + RetryTemplate retryTemplate = new RetryTemplate(retryPolicy); + retryTemplate.setRetryListener(retryListener); + Retryable retryable = () -> { + throw exception; + }; + + assertThatExceptionOfType(RetryException.class) + .isThrownBy(() -> retryTemplate.execute(retryable)) + .withMessageMatching("Unable to back off for retryable operation '.+?'") + .withCause(interruptedException) + .satisfies(throwable -> assertThat(throwable.getSuppressed()).containsExactly(exception)) + // TODO Fix retry count for InterruptedException scenario. + // Retry count should actually be 0. + .satisfies(throwable -> assertThat(throwable.getRetryCount()).isEqualTo(1)) + .satisfies(throwable -> inOrder.verify(retryListener).onRetryPolicyInterruption(retryPolicy, retryable, throwable)); + + verifyNoMoreInteractions(retryListener); + } + @Test void retryWithFailingRetryableAndMultiplePredicates() { var invocationCount = new AtomicInteger(); diff --git a/spring-core/src/test/java/org/springframework/core/retry/support/CompositeRetryListenerTests.java b/spring-core/src/test/java/org/springframework/core/retry/support/CompositeRetryListenerTests.java index 10bb628f2570..3ceb0e9bb609 100644 --- a/spring-core/src/test/java/org/springframework/core/retry/support/CompositeRetryListenerTests.java +++ b/spring-core/src/test/java/org/springframework/core/retry/support/CompositeRetryListenerTests.java @@ -92,4 +92,14 @@ void onRetryPolicyExhaustion() { verify(listener3).onRetryPolicyExhaustion(retryPolicy, retryable, exception); } + @Test + void onRetryPolicyInterruption() { + RetryException exception = new RetryException("", new Exception()); + compositeRetryListener.onRetryPolicyInterruption(retryPolicy, retryable, exception); + + verify(listener1).onRetryPolicyInterruption(retryPolicy, retryable, exception); + verify(listener2).onRetryPolicyInterruption(retryPolicy, retryable, exception); + verify(listener3).onRetryPolicyInterruption(retryPolicy, retryable, exception); + } + } From 1786eb2901d6ea8c9eec286f1aaab72020e0ab2d Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Mon, 8 Sep 2025 14:22:52 +0200 Subject: [PATCH 183/591] Introduce RetryInterruptedException to address off-by-one error Prior to this commit, a RetryException thrown for an InterruptedException returned the wrong value from getRetryCount(). Specifically, the count was one more than it should have been, since the suppressed exception list contains the initial exception as well as all retry attempt exceptions. To address that, this commit introduces an internal RetryInterruptedException which accounts for this off-by-one error. Closes gh-35434 --- .../core/retry/RetryTemplate.java | 18 +++++++++++++++++- .../core/retry/RetryTemplateTests.java | 4 +--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java b/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java index 9d374d5e4675..f7d9aad5cb53 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java @@ -164,7 +164,7 @@ public RetryListener getRetryListener() { } catch (InterruptedException interruptedException) { Thread.currentThread().interrupt(); - RetryException retryException = new RetryException( + RetryException retryException = new RetryInterruptedException( "Unable to back off for retryable operation '%s'".formatted(retryableName), interruptedException); exceptions.forEach(retryException::addSuppressed); @@ -200,4 +200,20 @@ public RetryListener getRetryListener() { } } + private static class RetryInterruptedException extends RetryException { + + private static final long serialVersionUID = 1L; + + + RetryInterruptedException(String message, InterruptedException cause) { + super(message, cause); + } + + @Override + public int getRetryCount() { + return (getSuppressed().length - 1); + } + + } + } diff --git a/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java b/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java index 8e8bbaa2fcf2..42a92dea3abe 100644 --- a/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java +++ b/spring-core/src/test/java/org/springframework/core/retry/RetryTemplateTests.java @@ -238,9 +238,7 @@ void retryWithInterruptionDuringSleep() { .withMessageMatching("Unable to back off for retryable operation '.+?'") .withCause(interruptedException) .satisfies(throwable -> assertThat(throwable.getSuppressed()).containsExactly(exception)) - // TODO Fix retry count for InterruptedException scenario. - // Retry count should actually be 0. - .satisfies(throwable -> assertThat(throwable.getRetryCount()).isEqualTo(1)) + .satisfies(throwable -> assertThat(throwable.getRetryCount()).isZero()) .satisfies(throwable -> inOrder.verify(retryListener).onRetryPolicyInterruption(retryPolicy, retryable, throwable)); verifyNoMoreInteractions(retryListener); From e93a6a723097e7e8e353c7ad79c5b3f78ddf3e0e Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Mon, 8 Sep 2025 14:39:46 +0200 Subject: [PATCH 184/591] Improve wording for retry exceptions --- .../core/retry/RetryException.java | 8 ++++---- .../springframework/core/retry/RetryListener.java | 15 +++++++-------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryException.java b/spring-core/src/main/java/org/springframework/core/retry/RetryException.java index 9a862d543d20..1ae45e977ecc 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryException.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryException.java @@ -20,7 +20,7 @@ import java.util.Objects; /** - * Exception thrown when a {@link RetryPolicy} has been exhausted. + * Exception thrown when a {@link RetryPolicy} has been exhausted or interrupted. * *

      A {@code RetryException} will typically contain the last exception thrown * by the {@link Retryable} operation as the {@linkplain #getCause() cause} and @@ -31,9 +31,9 @@ * {@linkplain Thread#sleep(long) sleeping} for the current * {@link org.springframework.util.backoff.BackOff BackOff} duration, a * {@code RetryException} will contain the {@code InterruptedException} as the - * {@linkplain #getCause() cause} and any exceptions from previous attempts to - * invoke the {@code Retryable} operation as {@linkplain #getSuppressed() - * suppressed exceptions}. + * {@linkplain #getCause() cause} and any exceptions from previous invocations + * of the {@code Retryable} operation as {@linkplain #getSuppressed() suppressed + * exceptions}. * * @author Mahmoud Ben Hassine * @author Juergen Hoeller diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryListener.java b/spring-core/src/main/java/org/springframework/core/retry/RetryListener.java index 36aed9992019..2e3241a3f65f 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryListener.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryListener.java @@ -64,9 +64,9 @@ default void onRetryFailure(RetryPolicy retryPolicy, Retryable retryable, Thr /** * Called if the {@link RetryPolicy} is exhausted. * @param retryPolicy the {@code RetryPolicy} - * @param retryable the {@code Retryable} operation + * @param retryable the {@link Retryable} operation * @param exception the resulting {@link RetryException}, with the last - * exception thrown by the {@link Retryable} operation as the cause and any + * exception thrown by the {@code Retryable} operation as the cause and any * exceptions from previous attempts as suppressed exceptions * @see RetryException#getCause() * @see RetryException#getSuppressed() @@ -76,13 +76,12 @@ default void onRetryPolicyExhaustion(RetryPolicy retryPolicy, Retryable retry } /** - * Called if an {@link InterruptedException} is encountered while - * {@linkplain Thread#sleep(long) sleeping} between retry attempts. + * Called if the {@link RetryPolicy} is interrupted between retry attempts. * @param retryPolicy the {@code RetryPolicy} - * @param retryable the {@code Retryable} operation - * @param exception the resulting {@link RetryException}, with the - * {@code InterruptedException} as the cause and any exceptions from previous - * retry attempts as suppressed exceptions + * @param retryable the {@link Retryable} operation + * @param exception the resulting {@link RetryException}, with an + * {@link InterruptedException} as the cause and any exceptions from previous + * invocations of the {@code Retryable} operation as suppressed exceptions * @see RetryException#getCause() * @see RetryException#getSuppressed() * @see RetryException#getRetryCount() From 736383e6cb4cc47485f872f8a2ba2e6a0f5b3660 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Mon, 8 Sep 2025 10:35:47 +0100 Subject: [PATCH 185/591] Remove HttpServiceClient annotation Closes gh-35431 --- .../ROOT/pages/integration/rest-clients.adoc | 40 ------- .../AbstractClientHttpServiceRegistrar.java | 62 ----------- .../AbstractHttpServiceRegistrar.java | 13 +-- .../service/registry/HttpServiceClient.java | 54 ---------- .../service/registry/ImportHttpServices.java | 3 - .../ClientHttpServiceRegistrarTests.java | 102 ------------------ .../service/registry/basic/BasicClient.java | 29 ----- .../service/registry/echo/EchoClientA.java | 29 ----- .../service/registry/echo/EchoClientB.java | 29 ----- 9 files changed, 1 insertion(+), 360 deletions(-) delete mode 100644 spring-web/src/main/java/org/springframework/web/service/registry/AbstractClientHttpServiceRegistrar.java delete mode 100644 spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceClient.java delete mode 100644 spring-web/src/test/java/org/springframework/web/service/registry/ClientHttpServiceRegistrarTests.java delete mode 100644 spring-web/src/test/java/org/springframework/web/service/registry/basic/BasicClient.java delete mode 100644 spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoClientA.java delete mode 100644 spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoClientB.java diff --git a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc index 21a9552395d7..0417613f496e 100644 --- a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc +++ b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc @@ -1195,46 +1195,6 @@ One way to declare HTTP Service groups is via `@ImportHttpServices` annotations <1> Manually list interfaces for group "echo" <2> Detect interfaces for group "greeting" under a base package -The above lets you declare HTTP Services and groups. As an alternative, you can also -annotate HTTP interfaces as follows: - -[source,java,indent=0,subs="verbatim,quotes"] ----- - @HttpServiceClient("echo") - public interface EchoServiceA { - // ... - } - - @HttpServiceClient("echo") - public interface EchoServiceB { - // ... - } ----- - -The above requires a dedicated import registrar as follows: - -[source,java,indent=0,subs="verbatim,quotes"] ----- - public class MyClientHttpServiceRegistrar implements AbstractClientHttpServiceRegistrar { // <1> - - @Override - protected void registerHttpServices(GroupRegistry registry, AnnotationMetadata metadata) { - findAndRegisterHttpServiceClients(groupRegistry, List.of("org.example.echo")); // <2> - } - } - - @Configuration - @Import(MyClientHttpServiceRegistrar.class) // <3> - public class ClientConfig { - } ----- -<1> Extend dedicated `AbstractClientHttpServiceRegistrar` -<2> Specify base packages where to find client interfaces -<3> Import the registrar - -TIP: `@HttpServiceClient` interfaces are excluded from `@ImportHttpServices` scans, so there -is no overlap with scans for client interfaces when pointed at the same package. - It is also possible to declare groups programmatically by creating an HTTP Service registrar and then importing it: diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/AbstractClientHttpServiceRegistrar.java b/spring-web/src/main/java/org/springframework/web/service/registry/AbstractClientHttpServiceRegistrar.java deleted file mode 100644 index e3fa444cc301..000000000000 --- a/spring-web/src/main/java/org/springframework/web/service/registry/AbstractClientHttpServiceRegistrar.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2002-present 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.service.registry; - - -import java.util.List; - -import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition; -import org.springframework.core.annotation.MergedAnnotations; -import org.springframework.core.type.AnnotationMetadata; - -/** - * Base class for an HTTP Service registrar that detects - * {@link HttpServiceClient @HttpServiceClient} annotated interfaces and - * registers them. - * - *

      Subclasses need to implement - * {@link #registerHttpServices(GroupRegistry, AnnotationMetadata)} and invoke - * {@link #findAndRegisterHttpServiceClients(GroupRegistry, List)} with the - * list of base packages to scan. - * - * @author Rossen Stoyanchev - * @since 7.0 - */ -public abstract class AbstractClientHttpServiceRegistrar extends AbstractHttpServiceRegistrar { - - /** - * Find all HTTP Services under the given base packages that also have an - * {@link HttpServiceClient @HttpServiceClient} annotation, and register them - * in the group specified on the annotation. - * @param registry the registry from {@link #registerHttpServices(GroupRegistry, AnnotationMetadata)} - * @param basePackages the base packages to scan - */ - protected void findAndRegisterHttpServiceClients(GroupRegistry registry, List basePackages) { - basePackages.stream() - .flatMap(this::findHttpServices) - .filter(definition -> definition instanceof AnnotatedBeanDefinition) - .map(definition -> (AnnotatedBeanDefinition) definition) - .filter(definition -> definition.getMetadata().hasAnnotation(HttpServiceClient.class.getName())) - .filter(definition -> definition.getBeanClassName() != null) - .forEach(definition -> { - MergedAnnotations annotations = definition.getMetadata().getAnnotations(); - String group = annotations.get(HttpServiceClient.class).getString("group"); - registry.forGroup(group).registerTypeNames(definition.getBeanClassName()); - }); - } - -} diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java b/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java index ea8af343994b..6eb81fe86d6d 100644 --- a/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java +++ b/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java @@ -211,7 +211,7 @@ private Object getProxyInstance(String groupName, String httpServiceType) { * @param basePackage the names of packages to look under * @return match bean definitions */ - protected Stream findHttpServices(String basePackage) { + private Stream findHttpServices(String basePackage) { if (this.scanner == null) { Assert.state(this.environment != null, "Environment has not been set"); Assert.state(this.resourceLoader != null, "ResourceLoader has not been set"); @@ -267,9 +267,6 @@ interface GroupSpec { /** * Detect HTTP Service types in the given packages, looking for * interfaces with type or method {@link HttpExchange} annotations. - *

      The performed scan, however, filters out any interfaces - * annotated with {@link HttpServiceClient} that are instead supported - * by {@link AbstractClientHttpServiceRegistrar}. */ GroupSpec detectInBasePackages(Class... packageClasses); @@ -326,19 +323,11 @@ public GroupRegistry.GroupSpec detectInBasePackages(String... packageNames) { private void detectInBasePackage(String packageName) { findHttpServices(packageName) - .filter(DefaultGroupSpec::isNotHttpServiceClientAnnotated) .map(BeanDefinition::getBeanClassName) .filter(Objects::nonNull) .forEach(this::registerServiceTypeName); } - private static boolean isNotHttpServiceClientAnnotated(BeanDefinition defintion) { - if (defintion instanceof AnnotatedBeanDefinition abd) { - return !abd.getMetadata().hasAnnotation(HttpServiceClient.class.getName()); - } - return true; - } - private void registerServiceTypeName(String httpServiceTypeName) { this.registration.httpServiceTypeNames().add(httpServiceTypeName); } diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceClient.java b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceClient.java deleted file mode 100644 index 03c285b76294..000000000000 --- a/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceClient.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2002-present 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.service.registry; - - -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -import org.springframework.core.annotation.AliasFor; - -/** - * Annotation to mark an HTTP Service interface as a candidate client proxy creation. - * Supported through the import of an {@link AbstractClientHttpServiceRegistrar}. - * - * @author Rossen Stoyanchev - * @since 7.0 - * @see AbstractClientHttpServiceRegistrar - */ -@Target(ElementType.TYPE) -@Retention(RetentionPolicy.RUNTIME) -@Documented -public @interface HttpServiceClient { - - /** - * An alias for {@link #group()}. - */ - @AliasFor("group") - String value() default HttpServiceGroup.DEFAULT_GROUP_NAME; - - /** - * The name of the HTTP Service group for this client. - *

      By default, this is {@link HttpServiceGroup#DEFAULT_GROUP_NAME}. - */ - @AliasFor("value") - String group() default HttpServiceGroup.DEFAULT_GROUP_NAME; - -} diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServices.java b/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServices.java index b1abea0323e9..03d47f36c02b 100644 --- a/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServices.java +++ b/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServices.java @@ -78,9 +78,6 @@ /** * Detect HTTP Services in the packages of the specified classes, looking * for interfaces with type or method {@link HttpExchange} annotations. - *

      The performed scan, however, filters out interfaces annotated with - * {@link HttpServiceClient} that are instead supported by - * {@link AbstractClientHttpServiceRegistrar}. */ Class[] basePackageClasses() default {}; diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/ClientHttpServiceRegistrarTests.java b/spring-web/src/test/java/org/springframework/web/service/registry/ClientHttpServiceRegistrarTests.java deleted file mode 100644 index 7f737c925013..000000000000 --- a/spring-web/src/test/java/org/springframework/web/service/registry/ClientHttpServiceRegistrarTests.java +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright 2002-present 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.service.registry; - -import java.util.List; -import java.util.Map; - -import org.junit.jupiter.api.Test; - -import org.springframework.context.annotation.AnnotationConfigApplicationContext; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Import; -import org.springframework.core.env.StandardEnvironment; -import org.springframework.core.io.support.PathMatchingResourcePatternResolver; -import org.springframework.core.type.AnnotationMetadata; -import org.springframework.web.service.registry.basic.BasicClient; -import org.springframework.web.service.registry.echo.EchoClientA; -import org.springframework.web.service.registry.echo.EchoClientB; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; - -/** - * Unit tests for {@link AbstractClientHttpServiceRegistrar}. - * - * @author Rossen Stoyanchev - */ -public class ClientHttpServiceRegistrarTests { - - private final TestGroupRegistry groupRegistry = new TestGroupRegistry(); - - - @Test - void register() { - - List basePackages = List.of( - BasicClient.class.getPackageName(), EchoClientA.class.getPackageName()); - - AbstractClientHttpServiceRegistrar registrar = new AbstractClientHttpServiceRegistrar() { - - @Override - protected void registerHttpServices(GroupRegistry registry, AnnotationMetadata importingClassMetadata) { - findAndRegisterHttpServiceClients(groupRegistry, basePackages); - } - }; - registrar.setEnvironment(new StandardEnvironment()); - registrar.setResourceLoader(new PathMatchingResourcePatternResolver()); - registrar.registerHttpServices(groupRegistry, mock(AnnotationMetadata.class)); - - assertGroups( - TestGroup.ofListing("default", BasicClient.class), - TestGroup.ofListing("echo", EchoClientA.class, EchoClientB.class)); - } - - @Test - void registerWhenNoClientsDoesNotCreateBeans() { - try (AnnotationConfigApplicationContext cxt = new AnnotationConfigApplicationContext(NoOpImportConfig.class)) { - assertThat(cxt.getBeanNamesForType(HttpServiceProxyRegistry.class)).isEmpty(); - } - } - - private void assertGroups(TestGroup... expectedGroups) { - Map groupMap = this.groupRegistry.groupMap(); - assertThat(groupMap.size()).isEqualTo(expectedGroups.length); - for (TestGroup expected : expectedGroups) { - TestGroup actual = groupMap.get(expected.name()); - assertThat(actual.httpServiceTypes()).isEqualTo(expected.httpServiceTypes()); - assertThat(actual.clientType()).isEqualTo(expected.clientType()); - assertThat(actual.packageNames()).isEqualTo(expected.packageNames()); - assertThat(actual.packageClasses()).isEqualTo(expected.packageClasses()); - } - } - - - @Configuration(proxyBeanMethods = false) - @Import(NoOpRegistrar.class) - static class NoOpImportConfig { - } - - - static class NoOpRegistrar extends AbstractClientHttpServiceRegistrar { - - @Override - protected void registerHttpServices(GroupRegistry registry, AnnotationMetadata metadata) { - } - } - -} diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/basic/BasicClient.java b/spring-web/src/test/java/org/springframework/web/service/registry/basic/BasicClient.java deleted file mode 100644 index 86f49a958f80..000000000000 --- a/spring-web/src/test/java/org/springframework/web/service/registry/basic/BasicClient.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2002-present 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.service.registry.basic; - -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.service.annotation.GetExchange; -import org.springframework.web.service.registry.HttpServiceClient; - -@HttpServiceClient -public interface BasicClient { - - @GetExchange - String handle(@RequestParam String input); - -} diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoClientA.java b/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoClientA.java deleted file mode 100644 index 9c25fc5e662b..000000000000 --- a/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoClientA.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2002-present 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.service.registry.echo; - -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.service.annotation.GetExchange; -import org.springframework.web.service.registry.HttpServiceClient; - -@HttpServiceClient("echo") -public interface EchoClientA { - - @GetExchange - String handle(@RequestParam String input); - -} diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoClientB.java b/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoClientB.java deleted file mode 100644 index e5b858409590..000000000000 --- a/spring-web/src/test/java/org/springframework/web/service/registry/echo/EchoClientB.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2002-present 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.service.registry.echo; - -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.service.annotation.GetExchange; -import org.springframework.web.service.registry.HttpServiceClient; - -@HttpServiceClient("echo") -public interface EchoClientB { - - @GetExchange - String handle(@RequestParam String input); - -} From 8f34a67024a37000e34e078efc3d3fcd34b07e07 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Mon, 8 Sep 2025 15:03:50 +0200 Subject: [PATCH 186/591] Polish core retry internals --- .../java/org/springframework/core/retry/RetryException.java | 2 +- .../java/org/springframework/core/retry/RetryTemplate.java | 6 ++++-- .../core/retry/support/CompositeRetryListener.java | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryException.java b/spring-core/src/main/java/org/springframework/core/retry/RetryException.java index 1ae45e977ecc..dc950b9bccd4 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryException.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryException.java @@ -44,7 +44,7 @@ public class RetryException extends Exception { @Serial - private static final long serialVersionUID = 5439915454935047936L; + private static final long serialVersionUID = 1L; /** diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java b/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java index f7d9aad5cb53..b04cb8da180e 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryTemplate.java @@ -16,6 +16,7 @@ package org.springframework.core.retry; +import java.io.Serial; import java.util.ArrayDeque; import java.util.Deque; @@ -148,7 +149,7 @@ public RetryListener getRetryListener() { .formatted(retryableName)); // Retry process starts here BackOffExecution backOffExecution = this.retryPolicy.getBackOff().start(); - Deque exceptions = new ArrayDeque<>(); + Deque exceptions = new ArrayDeque<>(4); exceptions.add(initialException); Throwable lastException = initialException; @@ -200,8 +201,10 @@ public RetryListener getRetryListener() { } } + private static class RetryInterruptedException extends RetryException { + @Serial private static final long serialVersionUID = 1L; @@ -213,7 +216,6 @@ private static class RetryInterruptedException extends RetryException { public int getRetryCount() { return (getSuppressed().length - 1); } - } } diff --git a/spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java b/spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java index c9b16865a5ba..c205b2141716 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java +++ b/spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java @@ -46,6 +46,7 @@ public class CompositeRetryListener implements RetryListener { /** * Create a new {@code CompositeRetryListener}. + * @see #addListener(RetryListener) */ public CompositeRetryListener() { } @@ -56,7 +57,7 @@ public CompositeRetryListener() { * @param listeners the list of delegate listeners to register; must not be empty */ public CompositeRetryListener(List listeners) { - Assert.notEmpty(listeners, "RetryListener List must not be empty"); + Assert.notEmpty(listeners, "RetryListener list must not be empty"); this.listeners.addAll(listeners); } From 114c3f7c9c39135168d6956903c510f6e32c3505 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Mon, 8 Sep 2025 15:04:33 +0200 Subject: [PATCH 187/591] Avoid unnecessary imports for Javadoc --- .../org/springframework/core/retry/RetryListener.java | 8 +++----- .../core/retry/support/CompositeRetryListener.java | 4 ++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/core/retry/RetryListener.java b/spring-core/src/main/java/org/springframework/core/retry/RetryListener.java index 2e3241a3f65f..7bfab43f9791 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/RetryListener.java +++ b/spring-core/src/main/java/org/springframework/core/retry/RetryListener.java @@ -18,20 +18,18 @@ import org.jspecify.annotations.Nullable; -import org.springframework.core.retry.support.CompositeRetryListener; - /** * {@code RetryListener} defines a listener API for reacting to events * published during the execution of a {@link Retryable} operation. * - *

      Typically registered in a {@link RetryTemplate}, and can be composed using - * a {@link CompositeRetryListener}. + *

      Typically registered in a {@link RetryTemplate}, and can be composed using a + * {@link org.springframework.core.retry.support.CompositeRetryListener CompositeRetryListener}. * * @author Mahmoud Ben Hassine * @author Sam Brannen * @author Juergen Hoeller * @since 7.0 - * @see CompositeRetryListener + * @see org.springframework.core.retry.support.CompositeRetryListener */ public interface RetryListener { diff --git a/spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java b/spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java index c205b2141716..cdf02bf33f33 100644 --- a/spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java +++ b/spring-core/src/main/java/org/springframework/core/retry/support/CompositeRetryListener.java @@ -24,13 +24,13 @@ import org.springframework.core.retry.RetryException; import org.springframework.core.retry.RetryListener; import org.springframework.core.retry.RetryPolicy; -import org.springframework.core.retry.RetryTemplate; import org.springframework.core.retry.Retryable; import org.springframework.util.Assert; /** * A composite implementation of the {@link RetryListener} interface, which is - * used to compose multiple listeners within a {@link RetryTemplate}. + * used to compose multiple listeners within a + * {@link org.springframework.core.retry.RetryTemplate RetryTemplate}. * *

      Delegate listeners will be called in their registration order. * From 27221581a125341fc3e77b460eacdd9cb54159b2 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Mon, 8 Sep 2025 17:03:07 +0200 Subject: [PATCH 188/591] Expose getFilePath() on Resource interface for consistent NIO support Closes gh-35435 --- .../beans/propertyeditors/PathEditor.java | 2 +- .../core/io/FileSystemResource.java | 8 +++++++ .../springframework/core/io/PathResource.java | 19 ++++++++------- .../org/springframework/core/io/Resource.java | 24 +++++++++++++++---- .../core/io/buffer/DataBufferUtils.java | 7 +++--- .../core/io/PathResourceTests.java | 15 ++++++------ .../core/io/ResourceTests.java | 12 ++++++++++ .../http/codec/ResourceHttpMessageWriter.java | 15 ++++++------ .../resource/EncodedResourceResolver.java | 6 +++++ .../resource/VersionResourceResolver.java | 6 +++++ .../resource/EncodedResourceResolver.java | 6 +++++ .../resource/VersionResourceResolver.java | 6 +++++ 12 files changed, 94 insertions(+), 32 deletions(-) diff --git a/spring-beans/src/main/java/org/springframework/beans/propertyeditors/PathEditor.java b/spring-beans/src/main/java/org/springframework/beans/propertyeditors/PathEditor.java index 7b53e70ff3a0..de26e7b70671 100644 --- a/spring-beans/src/main/java/org/springframework/beans/propertyeditors/PathEditor.java +++ b/spring-beans/src/main/java/org/springframework/beans/propertyeditors/PathEditor.java @@ -108,7 +108,7 @@ else if (nioPathCandidate && (!resource.isFile() || !resource.exists())) { } else { try { - setValue(resource.getFile().toPath()); + setValue(resource.getFilePath()); } catch (IOException ex) { String msg = "Could not resolve \"" + text + "\" to 'java.nio.file.Path' for " + resource + ": " + diff --git a/spring-core/src/main/java/org/springframework/core/io/FileSystemResource.java b/spring-core/src/main/java/org/springframework/core/io/FileSystemResource.java index cae57ac8ce43..ec51f4cefd35 100644 --- a/spring-core/src/main/java/org/springframework/core/io/FileSystemResource.java +++ b/spring-core/src/main/java/org/springframework/core/io/FileSystemResource.java @@ -292,6 +292,14 @@ public File getFile() { return (this.file != null ? this.file : this.filePath.toFile()); } + /** + * This implementation returns the underlying NIO Path reference. + */ + @Override + public Path getFilePath() { + return this.filePath; + } + /** * This implementation opens a FileChannel for the underlying file. * @see java.nio.channels.FileChannel diff --git a/spring-core/src/main/java/org/springframework/core/io/PathResource.java b/spring-core/src/main/java/org/springframework/core/io/PathResource.java index 7a6bd448823f..9b8eaaefe83e 100644 --- a/spring-core/src/main/java/org/springframework/core/io/PathResource.java +++ b/spring-core/src/main/java/org/springframework/core/io/PathResource.java @@ -219,15 +219,16 @@ public boolean isFile() { * This implementation returns the underlying {@link File} reference. */ @Override - public File getFile() throws IOException { - try { - return this.path.toFile(); - } - catch (UnsupportedOperationException ex) { - // Only paths on the default file system can be converted to a File: - // Do exception translation for cases where conversion is not possible. - throw new FileNotFoundException(this.path + " cannot be resolved to absolute file path"); - } + public File getFile() { + return this.path.toFile(); + } + + /** + * This implementation returns the underlying {@link Path} reference. + */ + @Override + public Path getFilePath() { + return this.path; } /** diff --git a/spring-core/src/main/java/org/springframework/core/io/Resource.java b/spring-core/src/main/java/org/springframework/core/io/Resource.java index 0ae3750f8d52..b7e394494071 100644 --- a/spring-core/src/main/java/org/springframework/core/io/Resource.java +++ b/spring-core/src/main/java/org/springframework/core/io/Resource.java @@ -25,6 +25,7 @@ import java.nio.channels.Channels; import java.nio.channels.ReadableByteChannel; import java.nio.charset.Charset; +import java.nio.file.Path; import org.jspecify.annotations.Nullable; @@ -91,11 +92,13 @@ default boolean isOpen() { /** * Determine whether this resource represents a file in a file system. - *

      A value of {@code true} strongly suggests (but does not guarantee) - * that a {@link #getFile()} call will succeed. + *

      A value of {@code true} suggests (but does not guarantee) that a + * {@link #getFile()} call will succeed. For non-default file systems, + * {@link #getFilePath()} is the more reliable follow-up call. *

      This is conservatively {@code false} by default. * @since 5.0 * @see #getFile() + * @see #getFilePath() */ default boolean isFile() { return false; @@ -118,13 +121,26 @@ default boolean isFile() { /** * Return a File handle for this resource. - * @throws java.io.FileNotFoundException if the resource cannot be resolved as - * absolute file path, i.e. if the resource is not available in a file system + *

      Note: This only works for files in the default file system. + * @throws UnsupportedOperationException if the resource is a file but cannot be + * exposed as a {@code java.io.File}; try {@link #getFilePath()} instead + * @throws java.io.FileNotFoundException if the resource cannot be resolved as a file * @throws IOException in case of general resolution/reading failures * @see #getInputStream() */ File getFile() throws IOException; + /** + * Return an NIO Path handle for this resource. + *

      Note: This works for files in non-default file systems as well. + * @throws java.io.FileNotFoundException if the resource cannot be resolved as a file + * @throws IOException in case of general resolution/reading failures + * @since 7.0 + */ + default Path getFilePath() throws IOException { + return getFile().toPath(); + } + /** * Return a {@link ReadableByteChannel}. *

      It is expected that each call creates a fresh channel. diff --git a/spring-core/src/main/java/org/springframework/core/io/buffer/DataBufferUtils.java b/spring-core/src/main/java/org/springframework/core/io/buffer/DataBufferUtils.java index 4c3c430e9545..5875b3c0d6a9 100644 --- a/spring-core/src/main/java/org/springframework/core/io/buffer/DataBufferUtils.java +++ b/spring-core/src/main/java/org/springframework/core/io/buffer/DataBufferUtils.java @@ -16,7 +16,6 @@ package org.springframework.core.io.buffer; -import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -223,9 +222,9 @@ public static Flux read( try { if (resource.isFile()) { - File file = resource.getFile(); + Path filePath = resource.getFilePath(); return readAsynchronousFileChannel( - () -> AsynchronousFileChannel.open(file.toPath(), StandardOpenOption.READ), + () -> AsynchronousFileChannel.open(filePath, StandardOpenOption.READ), position, bufferFactory, bufferSize); } } @@ -233,7 +232,7 @@ public static Flux read( // fallback to resource.readableChannel(), below } Flux result = readByteChannel(resource::readableChannel, bufferFactory, bufferSize); - return position == 0 ? result : skipUntilByteCount(result, position); + return (position == 0 ? result : skipUntilByteCount(result, position)); } diff --git a/spring-core/src/test/java/org/springframework/core/io/PathResourceTests.java b/spring-core/src/test/java/org/springframework/core/io/PathResourceTests.java index af9daeee0cb2..5823195032f5 100644 --- a/spring-core/src/test/java/org/springframework/core/io/PathResourceTests.java +++ b/spring-core/src/test/java/org/springframework/core/io/PathResourceTests.java @@ -186,10 +186,11 @@ void getUri() throws IOException { } @Test - void getFile() throws IOException { + void getFile() { PathResource resource = new PathResource(TEST_FILE); File file = new File(TEST_FILE); assertThat(resource.getFile().getAbsoluteFile()).isEqualTo(file.getAbsoluteFile()); + assertThat(resource.getFilePath()).isEqualTo(file.toPath()); } @Test @@ -198,7 +199,7 @@ void getFileUnsupported() { given(path.normalize()).willReturn(path); given(path.toFile()).willThrow(new UnsupportedOperationException()); PathResource resource = new PathResource(path); - assertThatExceptionOfType(FileNotFoundException.class).isThrownBy(resource::getFile); + assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(resource::getFile); } @Test @@ -236,13 +237,13 @@ void createRelativeFromFile() { @Test void filename() { - Resource resource = new PathResource(TEST_FILE); + PathResource resource = new PathResource(TEST_FILE); assertThat(resource.getFilename()).isEqualTo("example.properties"); } @Test void description() { - Resource resource = new PathResource(TEST_FILE); + PathResource resource = new PathResource(TEST_FILE); assertThat(resource.getDescription()).contains("path ["); assertThat(resource.getDescription()).contains(TEST_FILE); } @@ -261,9 +262,9 @@ void directoryIsNotWritable() { @Test void equalsAndHashCode() { - Resource resource1 = new PathResource(TEST_FILE); - Resource resource2 = new PathResource(TEST_FILE); - Resource resource3 = new PathResource(TEST_DIR); + PathResource resource1 = new PathResource(TEST_FILE); + PathResource resource2 = new PathResource(TEST_FILE); + PathResource resource3 = new PathResource(TEST_DIR); assertThat(resource1).isEqualTo(resource1); assertThat(resource1).isEqualTo(resource2); assertThat(resource2).isEqualTo(resource1); diff --git a/spring-core/src/test/java/org/springframework/core/io/ResourceTests.java b/spring-core/src/test/java/org/springframework/core/io/ResourceTests.java index ffb08bc50c3a..9385a210f99a 100644 --- a/spring-core/src/test/java/org/springframework/core/io/ResourceTests.java +++ b/spring-core/src/test/java/org/springframework/core/io/ResourceTests.java @@ -55,6 +55,8 @@ import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.junit.jupiter.params.provider.Arguments.argumentSet; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; /** * Tests for various {@link Resource} implementations. @@ -268,6 +270,16 @@ void relativeResourcesAreEqual() throws Exception { assertThat(relative).isEqualTo(new FileSystemResource("dir/subdir")); } + @Test + void getFilePath() throws Exception { + Path path = mock(); + given(path.normalize()).willReturn(path); + given(path.toFile()).willThrow(new UnsupportedOperationException()); + Resource resource = new FileSystemResource(path); + assertThat(resource.getFilePath()).isSameAs(path); + assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(resource::getFile); + } + @Test void readableChannelProvidesContent() throws Exception { Resource resource = new FileSystemResource(getClass().getResource("ResourceTests.class").getFile()); diff --git a/spring-web/src/main/java/org/springframework/http/codec/ResourceHttpMessageWriter.java b/spring-web/src/main/java/org/springframework/http/codec/ResourceHttpMessageWriter.java index a464a12108f7..5e56fe1e482a 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/ResourceHttpMessageWriter.java +++ b/spring-web/src/main/java/org/springframework/http/codec/ResourceHttpMessageWriter.java @@ -16,8 +16,9 @@ package org.springframework.http.codec; -import java.io.File; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import java.util.Map; @@ -193,17 +194,17 @@ private static Mono lengthOf(Resource resource) { if (message instanceof ZeroCopyHttpOutputMessage zeroCopyHttpOutputMessage && resource.isFile()) { try { - File file = resource.getFile(); - long pos = region != null ? region.getPosition() : 0; - long count = region != null ? region.getCount() : file.length(); + Path filePath = resource.getFilePath(); + long pos = (region != null ? region.getPosition() : 0); + long count = (region != null ? region.getCount() : Files.size(filePath)); if (logger.isDebugEnabled()) { String formatted = region != null ? "region " + pos + "-" + (count) + " of " : ""; logger.debug(Hints.getLogPrefix(hints) + "Zero-copy " + formatted + "[" + resource + "]"); } - return zeroCopyHttpOutputMessage.writeWith(file, pos, count); + return zeroCopyHttpOutputMessage.writeWith(filePath, pos, count); } - catch (IOException ex) { - // should not happen + catch (IOException ignore) { + // returning null below leads to fallback code path } } return null; diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/EncodedResourceResolver.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/EncodedResourceResolver.java index 2b12943e3a28..07c9345695b1 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/EncodedResourceResolver.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/EncodedResourceResolver.java @@ -23,6 +23,7 @@ import java.net.URL; import java.nio.channels.ReadableByteChannel; import java.nio.charset.Charset; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -240,6 +241,11 @@ public File getFile() throws IOException { return this.encoded.getFile(); } + @Override + public Path getFilePath() throws IOException { + return this.encoded.getFilePath(); + } + @Override public InputStream getInputStream() throws IOException { return this.encoded.getInputStream(); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/VersionResourceResolver.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/VersionResourceResolver.java index 24f2f34efb6a..860d055a0a02 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/VersionResourceResolver.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/VersionResourceResolver.java @@ -23,6 +23,7 @@ import java.net.URL; import java.nio.channels.ReadableByteChannel; import java.nio.charset.Charset; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; @@ -283,6 +284,11 @@ public File getFile() throws IOException { return this.original.getFile(); } + @Override + public Path getFilePath() throws IOException { + return this.original.getFilePath(); + } + @Override public InputStream getInputStream() throws IOException { return this.original.getInputStream(); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/EncodedResourceResolver.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/EncodedResourceResolver.java index 247791135046..0b80b3b40d85 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/EncodedResourceResolver.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/EncodedResourceResolver.java @@ -23,6 +23,7 @@ import java.net.URL; import java.nio.channels.ReadableByteChannel; import java.nio.charset.Charset; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -236,6 +237,11 @@ public File getFile() throws IOException { return this.encoded.getFile(); } + @Override + public Path getFilePath() throws IOException { + return this.encoded.getFilePath(); + } + @Override public InputStream getInputStream() throws IOException { return this.encoded.getInputStream(); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/VersionResourceResolver.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/VersionResourceResolver.java index d521889f2f0e..9786ffe99542 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/VersionResourceResolver.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/VersionResourceResolver.java @@ -23,6 +23,7 @@ import java.net.URL; import java.nio.channels.ReadableByteChannel; import java.nio.charset.Charset; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; @@ -279,6 +280,11 @@ public File getFile() throws IOException { return this.original.getFile(); } + @Override + public Path getFilePath() throws IOException { + return this.original.getFilePath(); + } + @Override public InputStream getInputStream() throws IOException { return this.original.getInputStream(); From b8f71b23214c60a62273eb5f69a4bf90d23a4b60 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Mon, 8 Sep 2025 17:03:18 +0200 Subject: [PATCH 189/591] Add DataFieldMaxValueIncrementer for SQLite (migrated from Spring Batch) Closes gh-35440 --- .../SqliteMaxValueIncrementer.java | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 spring-jdbc/src/main/java/org/springframework/jdbc/support/incrementer/SqliteMaxValueIncrementer.java diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/support/incrementer/SqliteMaxValueIncrementer.java b/spring-jdbc/src/main/java/org/springframework/jdbc/support/incrementer/SqliteMaxValueIncrementer.java new file mode 100644 index 000000000000..245f53f267d9 --- /dev/null +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/support/incrementer/SqliteMaxValueIncrementer.java @@ -0,0 +1,85 @@ +/* + * Copyright 2002-present 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.jdbc.support.incrementer; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import javax.sql.DataSource; + +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.jdbc.datasource.DataSourceUtils; +import org.springframework.jdbc.support.JdbcUtils; + +/** + * {@link DataFieldMaxValueIncrementer} that increments the maximum value of a given table with + * the equivalent of an auto-increment column, using a SQLite {@code select max(rowid)} query. + * + * @author Luke Taylor + * @author Juergen Hoeller + * @since 7.0 + */ +public class SqliteMaxValueIncrementer extends AbstractColumnMaxValueIncrementer { + + /** + * Default constructor for bean property style usage. + * @see #setDataSource + * @see #setIncrementerName + * @see #setColumnName + */ + public SqliteMaxValueIncrementer() { + } + + /** + * Convenience constructor. + * @param dataSource the DataSource to use + * @param incrementerName the name of the sequence/table to use + * @param columnName the name of the column in the sequence table to use + */ + public SqliteMaxValueIncrementer(DataSource dataSource, String incrementerName, String columnName) { + super(dataSource, incrementerName, columnName); + } + + + @Override + protected long getNextKey() { + Connection con = DataSourceUtils.getConnection(getDataSource()); + Statement stmt = null; + try { + stmt = con.createStatement(); + DataSourceUtils.applyTransactionTimeout(stmt, getDataSource()); + stmt.executeUpdate("insert into " + getIncrementerName() + " values(null)"); + ResultSet rs = stmt.executeQuery("select max(rowid) from " + getIncrementerName()); + if (!rs.next()) { + throw new DataAccessResourceFailureException("rowid query failed after executing an update"); + } + long nextKey = rs.getLong(1); + stmt.executeUpdate("delete from " + getIncrementerName() + " where " + getColumnName() + " < " + nextKey); + return nextKey; + } + catch (SQLException ex) { + throw new DataAccessResourceFailureException("Could not obtain rowid", ex); + } + finally { + JdbcUtils.closeStatement(stmt); + DataSourceUtils.releaseConnection(con, getDataSource()); + } + } + +} From ba521643736d6d4d03a005550f3192fc0bf881bf Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Mon, 8 Sep 2025 17:28:45 +0200 Subject: [PATCH 190/591] Provide graceful fallback for non-default NIO file systems Closes gh-35443 --- .../org/springframework/core/io/Resource.java | 6 ++- .../core/io/buffer/DataBufferUtils.java | 9 ++-- .../springframework/util/FileSystemUtils.java | 46 +++++++++++------- .../util/FileSystemUtilsTests.java | 47 +++++++++++-------- .../http/codec/ResourceHttpMessageWriter.java | 4 +- 5 files changed, 67 insertions(+), 45 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/core/io/Resource.java b/spring-core/src/main/java/org/springframework/core/io/Resource.java index a492ebc2ffcb..9e13d4961f68 100644 --- a/spring-core/src/main/java/org/springframework/core/io/Resource.java +++ b/spring-core/src/main/java/org/springframework/core/io/Resource.java @@ -117,8 +117,10 @@ default boolean isFile() { /** * Return a File handle for this resource. - * @throws java.io.FileNotFoundException if the resource cannot be resolved as - * absolute file path, i.e. if the resource is not available in a file system + *

      Note: This only works for files in the default file system. + * @throws UnsupportedOperationException if the resource is a file but cannot be + * exposed as a {@code java.io.File}; an alternative to {@code FileNotFoundException} + * @throws java.io.FileNotFoundException if the resource cannot be resolved as a file * @throws IOException in case of general resolution/reading failures * @see #getInputStream() */ diff --git a/spring-core/src/main/java/org/springframework/core/io/buffer/DataBufferUtils.java b/spring-core/src/main/java/org/springframework/core/io/buffer/DataBufferUtils.java index f705f16a9359..568c7be82c5f 100644 --- a/spring-core/src/main/java/org/springframework/core/io/buffer/DataBufferUtils.java +++ b/spring-core/src/main/java/org/springframework/core/io/buffer/DataBufferUtils.java @@ -16,7 +16,6 @@ package org.springframework.core.io.buffer; -import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -223,17 +222,17 @@ public static Flux read( try { if (resource.isFile()) { - File file = resource.getFile(); + Path filePath = resource.getFile().toPath(); return readAsynchronousFileChannel( - () -> AsynchronousFileChannel.open(file.toPath(), StandardOpenOption.READ), + () -> AsynchronousFileChannel.open(filePath, StandardOpenOption.READ), position, bufferFactory, bufferSize); } } - catch (IOException ignore) { + catch (IOException | UnsupportedOperationException ignore) { // fallback to resource.readableChannel(), below } Flux result = readByteChannel(resource::readableChannel, bufferFactory, bufferSize); - return position == 0 ? result : skipUntilByteCount(result, position); + return (position == 0 ? result : skipUntilByteCount(result, position)); } diff --git a/spring-core/src/main/java/org/springframework/util/FileSystemUtils.java b/spring-core/src/main/java/org/springframework/util/FileSystemUtils.java index be2ccc590b24..261c5574cc94 100644 --- a/spring-core/src/main/java/org/springframework/util/FileSystemUtils.java +++ b/spring-core/src/main/java/org/springframework/util/FileSystemUtils.java @@ -86,13 +86,12 @@ public static boolean deleteRecursively(@Nullable Path root) throws IOException Files.walkFileTree(root, new SimpleFileVisitor<>() { @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + public FileVisitResult visitFile(Path file, BasicFileAttributes attr) throws IOException { Files.delete(file); return FileVisitResult.CONTINUE; } - @Override - public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + public FileVisitResult postVisitDirectory(Path dir, IOException ex) throws IOException { Files.delete(dir); return FileVisitResult.CONTINUE; } @@ -127,19 +126,34 @@ public static void copyRecursively(Path src, Path dest) throws IOException { BasicFileAttributes srcAttr = Files.readAttributes(src, BasicFileAttributes.class); if (srcAttr.isDirectory()) { - Files.walkFileTree(src, EnumSet.of(FOLLOW_LINKS), Integer.MAX_VALUE, new SimpleFileVisitor<>() { - @Override - public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { - Files.createDirectories(dest.resolve(src.relativize(dir))); - return FileVisitResult.CONTINUE; - } - - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { - Files.copy(file, dest.resolve(src.relativize(file)), StandardCopyOption.REPLACE_EXISTING); - return FileVisitResult.CONTINUE; - } - }); + if (src.getClass() == dest.getClass()) { // dest.resolve(Path) only works for same Path type + Files.walkFileTree(src, EnumSet.of(FOLLOW_LINKS), Integer.MAX_VALUE, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attr) throws IOException { + Files.createDirectories(dest.resolve(src.relativize(dir))); + return FileVisitResult.CONTINUE; + } + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attr) throws IOException { + Files.copy(file, dest.resolve(src.relativize(file)), StandardCopyOption.REPLACE_EXISTING); + return FileVisitResult.CONTINUE; + } + }); + } + else { // use dest.resolve(String) for different Path types + Files.walkFileTree(src, EnumSet.of(FOLLOW_LINKS), Integer.MAX_VALUE, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attr) throws IOException { + Files.createDirectories(dest.resolve(src.relativize(dir).toString())); + return FileVisitResult.CONTINUE; + } + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attr) throws IOException { + Files.copy(file, dest.resolve(src.relativize(file).toString()), StandardCopyOption.REPLACE_EXISTING); + return FileVisitResult.CONTINUE; + } + }); + } } else if (srcAttr.isRegularFile()) { Files.copy(src, dest); diff --git a/spring-core/src/test/java/org/springframework/util/FileSystemUtilsTests.java b/spring-core/src/test/java/org/springframework/util/FileSystemUtilsTests.java index c91ccefc1640..8f8368bbf58e 100644 --- a/spring-core/src/test/java/org/springframework/util/FileSystemUtilsTests.java +++ b/spring-core/src/test/java/org/springframework/util/FileSystemUtilsTests.java @@ -17,20 +17,29 @@ package org.springframework.util; import java.io.File; +import java.net.URI; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.util.Map; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import static org.assertj.core.api.Assertions.assertThat; /** + * Tests for {@link FileSystemUtils}. + * * @author Rob Harrop + * @author Sam Brannen + * @author Juergen Hoeller */ class FileSystemUtilsTests { @Test - void deleteRecursively() throws Exception { - File root = new File("./tmp/root"); + void deleteRecursively(@TempDir File tempDir) throws Exception { + File root = new File(tempDir, "root"); File child = new File(root, "child"); File grandchild = new File(child, "grandchild"); @@ -53,8 +62,8 @@ void deleteRecursively() throws Exception { } @Test - void copyRecursively() throws Exception { - File src = new File("./tmp/src"); + void copyRecursively(@TempDir File tempDir) throws Exception { + File src = new File(tempDir, "src"); File child = new File(src, "child"); File grandchild = new File(child, "grandchild"); @@ -68,27 +77,25 @@ void copyRecursively() throws Exception { assertThat(grandchild).exists(); assertThat(bar).exists(); - File dest = new File("./dest"); + File dest = new File(tempDir, "/dest"); FileSystemUtils.copyRecursively(src, dest); assertThat(dest).exists(); - assertThat(new File(dest, child.getName())).exists(); + assertThat(new File(dest, "child")).exists(); + assertThat(new File(dest, "child/bar.txt")).exists(); - FileSystemUtils.deleteRecursively(src); - assertThat(src).doesNotExist(); - } + URI uri = URI.create("jar:file:/" + dest.toString().replace('\\', '/') + "/archive.zip"); + Map env = Map.of("create", "true"); + FileSystem zipfs = FileSystems.newFileSystem(uri, env); + Path ziproot = zipfs.getPath("/"); + FileSystemUtils.copyRecursively(src.toPath(), ziproot); + assertThat(zipfs.getPath("/child")).exists(); + assertThat(zipfs.getPath("/child/bar.txt")).exists(); - @AfterEach - void tearDown() { - File tmp = new File("./tmp"); - if (tmp.exists()) { - FileSystemUtils.deleteRecursively(tmp); - } - File dest = new File("./dest"); - if (dest.exists()) { - FileSystemUtils.deleteRecursively(dest); - } + zipfs.close(); + FileSystemUtils.deleteRecursively(src); + assertThat(src).doesNotExist(); } } diff --git a/spring-web/src/main/java/org/springframework/http/codec/ResourceHttpMessageWriter.java b/spring-web/src/main/java/org/springframework/http/codec/ResourceHttpMessageWriter.java index d5de3865b602..ddf10aa40f43 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/ResourceHttpMessageWriter.java +++ b/spring-web/src/main/java/org/springframework/http/codec/ResourceHttpMessageWriter.java @@ -203,8 +203,8 @@ private static Mono zeroCopy(Resource resource, @Nullable ResourceRegion r } return zeroCopyHttpOutputMessage.writeWith(file, pos, count); } - catch (IOException ex) { - // should not happen + catch (IOException | UnsupportedOperationException ignore) { + // returning null below leads to fallback code path } } return null; From 9ba954c3307e78a1fc5b61e1154036a5be531da8 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Mon, 8 Sep 2025 17:51:58 +0200 Subject: [PATCH 191/591] Fix FileSystemUtils for Windows/Linux path difference See gh-35443 --- .../java/org/springframework/util/FileSystemUtilsTests.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/spring-core/src/test/java/org/springframework/util/FileSystemUtilsTests.java b/spring-core/src/test/java/org/springframework/util/FileSystemUtilsTests.java index 8f8368bbf58e..a402c7016dd2 100644 --- a/spring-core/src/test/java/org/springframework/util/FileSystemUtilsTests.java +++ b/spring-core/src/test/java/org/springframework/util/FileSystemUtilsTests.java @@ -84,7 +84,11 @@ void copyRecursively(@TempDir File tempDir) throws Exception { assertThat(new File(dest, "child")).exists(); assertThat(new File(dest, "child/bar.txt")).exists(); - URI uri = URI.create("jar:file:/" + dest.toString().replace('\\', '/') + "/archive.zip"); + String destPath = dest.toString().replace('\\', '/'); + if (!destPath.startsWith("/")) { + destPath = "/" + destPath; + } + URI uri = URI.create("jar:file:" + destPath + "/archive.zip"); Map env = Map.of("create", "true"); FileSystem zipfs = FileSystems.newFileSystem(uri, env); Path ziproot = zipfs.getPath("/"); From ebb8e345706ae99289566dc4e82602f26e82604a Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Mon, 8 Sep 2025 18:11:27 +0200 Subject: [PATCH 192/591] Upgrade to Jetty 12.0.26, Jetty Reactive HttpClient 4.0.11, Netty 4.1.127, HtmlUnit 4.16 --- framework-platform/framework-platform.gradle | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index 47de7e2da7e8..a515e8ce933f 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -9,15 +9,15 @@ javaPlatform { dependencies { api(platform("com.fasterxml.jackson:jackson-bom:2.18.4.1")) api(platform("io.micrometer:micrometer-bom:1.14.10")) - api(platform("io.netty:netty-bom:4.1.124.Final")) + api(platform("io.netty:netty-bom:4.1.127.Final")) api(platform("io.netty:netty5-bom:5.0.0.Alpha5")) api(platform("io.projectreactor:reactor-bom:2024.0.9")) api(platform("io.rsocket:rsocket-bom:1.1.5")) api(platform("org.apache.groovy:groovy-bom:4.0.28")) api(platform("org.apache.logging.log4j:log4j-bom:2.21.1")) api(platform("org.assertj:assertj-bom:3.27.3")) - api(platform("org.eclipse.jetty:jetty-bom:12.0.25")) - api(platform("org.eclipse.jetty.ee10:jetty-ee10-bom:12.0.25")) + api(platform("org.eclipse.jetty:jetty-bom:12.0.26")) + api(platform("org.eclipse.jetty.ee10:jetty-ee10-bom:12.0.26")) api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.1")) api(platform("org.jetbrains.kotlinx:kotlinx-serialization-bom:1.6.3")) api(platform("org.junit:junit-bom:5.13.4")) @@ -115,7 +115,7 @@ dependencies { api("org.crac:crac:1.4.0") api("org.dom4j:dom4j:2.1.4") api("org.easymock:easymock:5.5.0") - api("org.eclipse.jetty:jetty-reactive-httpclient:4.0.9") + api("org.eclipse.jetty:jetty-reactive-httpclient:4.0.11") api("org.eclipse.persistence:org.eclipse.persistence.jpa:3.0.4") api("org.eclipse:yasson:2.0.4") api("org.ehcache:ehcache:3.10.8") @@ -129,7 +129,7 @@ dependencies { api("org.hibernate:hibernate-core-jakarta:5.6.15.Final") api("org.hibernate:hibernate-validator:7.0.5.Final") api("org.hsqldb:hsqldb:2.7.4") - api("org.htmlunit:htmlunit:4.15.0") + api("org.htmlunit:htmlunit:4.16.0") api("org.javamoney:moneta:1.4.4") api("org.jruby:jruby:9.4.13.0") api("org.junit.support:testng-engine:1.0.5") From ad796fb1a8564150c21f3f3f5eb4a5d64430f134 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Mon, 8 Sep 2025 18:28:16 +0200 Subject: [PATCH 193/591] Upgrade to Jetty 12.1.1, Netty 4.2.6, Checkstyle 11.0.1 --- .../org/springframework/build/CheckstyleConventions.java | 2 +- framework-platform/framework-platform.gradle | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/buildSrc/src/main/java/org/springframework/build/CheckstyleConventions.java b/buildSrc/src/main/java/org/springframework/build/CheckstyleConventions.java index c5294f452418..b38cba76cf8e 100644 --- a/buildSrc/src/main/java/org/springframework/build/CheckstyleConventions.java +++ b/buildSrc/src/main/java/org/springframework/build/CheckstyleConventions.java @@ -50,7 +50,7 @@ public void apply(Project project) { project.getPlugins().apply(CheckstylePlugin.class); project.getTasks().withType(Checkstyle.class).forEach(checkstyle -> checkstyle.getMaxHeapSize().set("1g")); CheckstyleExtension checkstyle = project.getExtensions().getByType(CheckstyleExtension.class); - checkstyle.setToolVersion("11.0.0"); + checkstyle.setToolVersion("11.0.1"); checkstyle.getConfigDirectory().set(project.getRootProject().file("src/checkstyle")); String version = SpringJavaFormatPlugin.class.getPackage().getImplementationVersion(); DependencySet checkstyleDependencies = project.getConfigurations().getByName("checkstyle").getDependencies(); diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index 7922134101ee..a2e43d0bb372 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -9,14 +9,14 @@ javaPlatform { dependencies { api(platform("com.fasterxml.jackson:jackson-bom:2.20.0")) api(platform("io.micrometer:micrometer-bom:1.16.0-M2")) - api(platform("io.netty:netty-bom:4.2.4.Final")) + api(platform("io.netty:netty-bom:4.2.6.Final")) api(platform("io.projectreactor:reactor-bom:2025.0.0-M6")) api(platform("io.rsocket:rsocket-bom:1.1.5")) api(platform("org.apache.groovy:groovy-bom:5.0.0-rc-1")) api(platform("org.apache.logging.log4j:log4j-bom:3.0.0-beta3")) api(platform("org.assertj:assertj-bom:3.27.3")) - api(platform("org.eclipse.jetty:jetty-bom:12.1.0")) - api(platform("org.eclipse.jetty.ee11:jetty-ee11-bom:12.1.0")) + api(platform("org.eclipse.jetty:jetty-bom:12.1.1")) + api(platform("org.eclipse.jetty.ee11:jetty-ee11-bom:12.1.1")) api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2")) api(platform("org.jetbrains.kotlinx:kotlinx-serialization-bom:1.9.0")) api(platform("org.junit:junit-bom:5.13.4")) From 3fb3b3d1b9fcd91d8073b63340a583706813b325 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Mon, 8 Sep 2025 18:55:52 +0200 Subject: [PATCH 194/591] Upgrade to Jetty 12.1 onWebSocketClose signature See gh-35345 --- .../reactive/socket/adapter/JettyWebSocketHandlerAdapter.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/socket/adapter/JettyWebSocketHandlerAdapter.java b/spring-webflux/src/main/java/org/springframework/web/reactive/socket/adapter/JettyWebSocketHandlerAdapter.java index a7aedbd16f0a..9fa9c6b70a2c 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/socket/adapter/JettyWebSocketHandlerAdapter.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/socket/adapter/JettyWebSocketHandlerAdapter.java @@ -98,9 +98,10 @@ public void onWebSocketPong(ByteBuffer payload) { } @Override - public void onWebSocketClose(int statusCode, String reason) { + public void onWebSocketClose(int statusCode, String reason, Callback callback) { Assert.state(this.delegateSession != null, "No delegate session available"); this.delegateSession.handleClose(CloseStatus.create(statusCode, reason)); + callback.succeed(); } @Override From 1107a43b658e709cc407632a165945867988f771 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Mon, 8 Sep 2025 19:09:56 +0200 Subject: [PATCH 195/591] Upgrade to Jetty 12.1 onWebSocketClose signature Includes switch to catching Throwable instead of Exception. See gh-35345 --- .../jetty/JettyWebSocketHandlerAdapter.java | 17 ++++++++++------- .../StandardWebSocketHandlerAdapter.java | 12 ++++++------ .../JettyWebSocketHandlerAdapterTests.java | 3 ++- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/adapter/jetty/JettyWebSocketHandlerAdapter.java b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/jetty/JettyWebSocketHandlerAdapter.java index bd950d0aec5c..893361aea01c 100644 --- a/spring-websocket/src/main/java/org/springframework/web/socket/adapter/jetty/JettyWebSocketHandlerAdapter.java +++ b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/jetty/JettyWebSocketHandlerAdapter.java @@ -57,6 +57,7 @@ public JettyWebSocketHandlerAdapter(WebSocketHandler webSocketHandler, JettyWebS this.wsSession = wsSession; } + @Override public void onWebSocketOpen(Session session) { try { @@ -65,7 +66,7 @@ public void onWebSocketOpen(Session session) { this.webSocketHandler.afterConnectionEstablished(this.wsSession); this.nativeSession.demand(); } - catch (Exception ex) { + catch (Throwable ex) { tryCloseWithError(ex); } } @@ -78,7 +79,7 @@ public void onWebSocketText(String payload) { this.webSocketHandler.handleMessage(this.wsSession, message); this.nativeSession.demand(); } - catch (Exception ex) { + catch (Throwable ex) { tryCloseWithError(ex); } } @@ -92,7 +93,7 @@ public void onWebSocketBinary(ByteBuffer payload, Callback callback) { this.webSocketHandler.handleMessage(this.wsSession, message); this.nativeSession.demand(); } - catch (Exception ex) { + catch (Throwable ex) { tryCloseWithError(ex); } } @@ -105,18 +106,19 @@ public void onWebSocketPong(ByteBuffer payload) { this.webSocketHandler.handleMessage(this.wsSession, message); this.nativeSession.demand(); } - catch (Exception ex) { + catch (Throwable ex) { tryCloseWithError(ex); } } @Override - public void onWebSocketClose(int statusCode, String reason) { + public void onWebSocketClose(int statusCode, String reason, Callback callback) { CloseStatus closeStatus = new CloseStatus(statusCode, reason); + callback.succeed(); try { this.webSocketHandler.afterConnectionClosed(this.wsSession, closeStatus); } - catch (Exception ex) { + catch (Throwable ex) { if (logger.isWarnEnabled()) { logger.warn("Unhandled exception from afterConnectionClosed for " + this, ex); } @@ -128,7 +130,7 @@ public void onWebSocketError(Throwable cause) { try { this.webSocketHandler.handleTransportError(this.wsSession, cause); } - catch (Exception ex) { + catch (Throwable ex) { if (logger.isWarnEnabled()) { logger.warn("Unhandled exception from handleTransportError for " + this, ex); } @@ -146,4 +148,5 @@ private void tryCloseWithError(Throwable t) { } } } + } diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/adapter/standard/StandardWebSocketHandlerAdapter.java b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/standard/StandardWebSocketHandlerAdapter.java index 44534f4ae819..106357428acc 100644 --- a/spring-websocket/src/main/java/org/springframework/web/socket/adapter/standard/StandardWebSocketHandlerAdapter.java +++ b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/standard/StandardWebSocketHandlerAdapter.java @@ -102,7 +102,7 @@ public void onMessage(jakarta.websocket.PongMessage message) { try { this.handler.afterConnectionEstablished(this.wsSession); } - catch (Exception ex) { + catch (Throwable ex) { ExceptionWebSocketHandlerDecorator.tryCloseWithError(this.wsSession, ex, logger); } } @@ -112,7 +112,7 @@ private void handleTextMessage(jakarta.websocket.Session session, String payload try { this.handler.handleMessage(this.wsSession, textMessage); } - catch (Exception ex) { + catch (Throwable ex) { ExceptionWebSocketHandlerDecorator.tryCloseWithError(this.wsSession, ex, logger); } } @@ -122,7 +122,7 @@ private void handleBinaryMessage(jakarta.websocket.Session session, ByteBuffer p try { this.handler.handleMessage(this.wsSession, binaryMessage); } - catch (Exception ex) { + catch (Throwable ex) { ExceptionWebSocketHandlerDecorator.tryCloseWithError(this.wsSession, ex, logger); } } @@ -132,7 +132,7 @@ private void handlePongMessage(jakarta.websocket.Session session, ByteBuffer pay try { this.handler.handleMessage(this.wsSession, pongMessage); } - catch (Exception ex) { + catch (Throwable ex) { ExceptionWebSocketHandlerDecorator.tryCloseWithError(this.wsSession, ex, logger); } } @@ -143,7 +143,7 @@ public void onClose(jakarta.websocket.Session session, CloseReason reason) { try { this.handler.afterConnectionClosed(this.wsSession, closeStatus); } - catch (Exception ex) { + catch (Throwable ex) { if (logger.isWarnEnabled()) { logger.warn("Unhandled on-close exception for " + this.wsSession, ex); } @@ -155,7 +155,7 @@ public void onError(jakarta.websocket.Session session, Throwable exception) { try { this.handler.handleTransportError(this.wsSession, exception); } - catch (Exception ex) { + catch (Throwable ex) { ExceptionWebSocketHandlerDecorator.tryCloseWithError(this.wsSession, ex, logger); } } diff --git a/spring-websocket/src/test/java/org/springframework/web/socket/adapter/jetty/JettyWebSocketHandlerAdapterTests.java b/spring-websocket/src/test/java/org/springframework/web/socket/adapter/jetty/JettyWebSocketHandlerAdapterTests.java index c2f946dbfbba..7a0c5c822034 100644 --- a/spring-websocket/src/test/java/org/springframework/web/socket/adapter/jetty/JettyWebSocketHandlerAdapterTests.java +++ b/spring-websocket/src/test/java/org/springframework/web/socket/adapter/jetty/JettyWebSocketHandlerAdapterTests.java @@ -16,6 +16,7 @@ package org.springframework.web.socket.adapter.jetty; +import org.eclipse.jetty.websocket.api.Callback; import org.eclipse.jetty.websocket.api.Session; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -57,7 +58,7 @@ void onOpen() throws Exception { @Test void onClose() throws Exception { - this.adapter.onWebSocketClose(1000, "reason"); + this.adapter.onWebSocketClose(1000, "reason", Callback.NOOP); verify(this.webSocketHandler).afterConnectionClosed(this.webSocketSession, CloseStatus.NORMAL.withReason("reason")); } From 9e8c64011d547dc167212c5552b6b2116532d707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Tue, 9 Sep 2025 11:14:21 +0200 Subject: [PATCH 196/591] Make JsonPathAssertions#isEqualTo parameter nullable Closes gh-35445 --- .../test/web/reactive/server/JsonPathAssertions.java | 2 +- .../test/util/JsonPathExpectationsHelperTests.java | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonPathAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonPathAssertions.java index acea1d3ae613..f758846b037a 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonPathAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonPathAssertions.java @@ -56,7 +56,7 @@ public class JsonPathAssertions { /** * Applies {@link JsonPathExpectationsHelper#assertValue(String, Object)}. */ - public WebTestClient.BodyContentSpec isEqualTo(Object expectedValue) { + public WebTestClient.BodyContentSpec isEqualTo(@Nullable Object expectedValue) { this.pathHelper.assertValue(this.content, expectedValue); return this.bodySpec; } diff --git a/spring-test/src/test/java/org/springframework/test/util/JsonPathExpectationsHelperTests.java b/spring-test/src/test/java/org/springframework/test/util/JsonPathExpectationsHelperTests.java index 21cdc4a1d4e6..964da8655cc2 100644 --- a/spring-test/src/test/java/org/springframework/test/util/JsonPathExpectationsHelperTests.java +++ b/spring-test/src/test/java/org/springframework/test/util/JsonPathExpectationsHelperTests.java @@ -57,7 +57,8 @@ class JsonPathExpectationsHelperTests { 'whitespace': ' ', 'emptyString': '', 'emptyArray': [], - 'emptyMap': {} + 'emptyMap': {}, + 'nullValue': null }"""; private static final String SIMPSONS = """ @@ -249,6 +250,11 @@ void assertValue() { new JsonPathExpectationsHelper("$.num").assertValue(CONTENT, 5); } + @Test + void assertNullValue() { + new JsonPathExpectationsHelper("$.nullValue").assertValue(CONTENT, (Object) null); + } + @Test // SPR-14498 void assertValueWithNumberConversion() { new JsonPathExpectationsHelper("$.num").assertValue(CONTENT, 5.0); From 1abd1d767d4119c42d9f2ba2fac3849f17ecb6e1 Mon Sep 17 00:00:00 2001 From: DongNyoung Lee <121621378+Dongnyoung@users.noreply.github.com> Date: Mon, 8 Sep 2025 22:11:15 +0900 Subject: [PATCH 197/591] Update mvc-jsp.adoc See gh-35444 Signed-off-by: DongNyoung Lee <121621378+Dongnyoung@users.noreply.github.com> --- .../ROOT/pages/web/webmvc-view/mvc-jsp.adoc | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-jsp.adoc b/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-jsp.adoc index 6ca828bef563..920081c0b781 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-jsp.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-jsp.adoc @@ -9,19 +9,41 @@ The Spring Framework has a built-in integration for using Spring MVC with JSP an When developing with JSPs, you typically declare an `InternalResourceViewResolver` bean. -`InternalResourceViewResolver` can be used for dispatching to any Servlet resource but in -particular for JSPs. As a best practice, we strongly encourage placing your JSP files in -a directory under the `'WEB-INF'` directory so there can be no direct access by clients. +`InternalResourceViewResolver` can be used for dispatching to any Servlet resource but in particular for JSPs. +As a best practice, we strongly encourage placing your JSP files in a directory under the `WEB-INF` directory so there can be no direct access by clients. -[source,xml,indent=0,subs="verbatim,quotes"] +[source,java] ---- - - - - - +@EnableWebMvc +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void configureViewResolvers(ViewResolverRegistry registry) { + // Use sensible defaults + registry.jsp(); + // Example of customizing: + // registry.jsp("/WEB-INF/views/", ".jsp"); + } +} ---- +[NOTE] +==== +For legacy XML configuration: + +[source,xml] +---- + + + +---- + +Prefer JavaConfig for new applications. +==== + +[.text-muted] +See the Javadoc of ViewResolverRegistry#jsp() for default prefix and suffix values. [[mvc-view-jsp-jstl]] == JSPs versus JSTL From dff489d0cf4f79d48eed9fc1b930030fd7fd94e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Tue, 9 Sep 2025 13:23:16 +0200 Subject: [PATCH 198/591] Refine JSP documentation contribution This commit refines the JSP view resolver documentation contribution by using tabs for Java and XML configuration, with Java displayed by default. Closes gh-35444 --- .../ROOT/pages/web/webmvc-view/mvc-jsp.adoc | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-jsp.adoc b/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-jsp.adoc index 920081c0b781..646684464e0b 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-jsp.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-jsp.adoc @@ -9,10 +9,18 @@ The Spring Framework has a built-in integration for using Spring MVC with JSP an When developing with JSPs, you typically declare an `InternalResourceViewResolver` bean. -`InternalResourceViewResolver` can be used for dispatching to any Servlet resource but in particular for JSPs. -As a best practice, we strongly encourage placing your JSP files in a directory under the `WEB-INF` directory so there can be no direct access by clients. +`InternalResourceViewResolver` can be used for dispatching to any Servlet resource but in +particular for JSPs. As a best practice, we strongly encourage placing your JSP files in +a directory under the `WEB-INF` directory so there can be no direct access by clients. -[source,java] +This is what is done by the configuration below which registers a JSP view resolver using +a default view name prefix of `"/WEB-INF/"` and a default suffix of `".jsp"`. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] ---- @EnableWebMvc @Configuration @@ -20,30 +28,24 @@ public class WebConfig implements WebMvcConfigurer { @Override public void configureViewResolvers(ViewResolverRegistry registry) { - // Use sensible defaults registry.jsp(); - // Example of customizing: - // registry.jsp("/WEB-INF/views/", ".jsp"); } } ---- -[NOTE] -==== -For legacy XML configuration: - -[source,xml] +XML:: ++ +[source,xml,indent=0,subs="verbatim,quotes"] ---- - + ---- +====== -Prefer JavaConfig for new applications. -==== +[NOTE] +You can specify custom prefix and suffix. -[.text-muted] -See the Javadoc of ViewResolverRegistry#jsp() for default prefix and suffix values. [[mvc-view-jsp-jstl]] == JSPs versus JSTL From f8823ddc11a75b5a64e17579eb3f2e6fd9312971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Tue, 9 Sep 2025 13:36:10 +0200 Subject: [PATCH 199/591] Polish JSP documentation This commit ensures consistency with the documentation of other view resolvers. See gh-35444 --- .../modules/ROOT/pages/web/webmvc-view/mvc-jsp.adoc | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-jsp.adoc b/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-jsp.adoc index 646684464e0b..7eb33c7180ac 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-jsp.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc-view/mvc-jsp.adoc @@ -37,9 +37,11 @@ XML:: + [source,xml,indent=0,subs="verbatim,quotes"] ---- - - - + + + + + ---- ====== From 563dccbbda793df58523c4435e168f4552400ad4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Tue, 9 Sep 2025 15:53:34 +0200 Subject: [PATCH 200/591] Add debug logging when no CORS configuration Closes gh-35314 --- .../org/springframework/web/cors/DefaultCorsProcessor.java | 3 +++ .../web/cors/reactive/DefaultCorsProcessor.java | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/spring-web/src/main/java/org/springframework/web/cors/DefaultCorsProcessor.java b/spring-web/src/main/java/org/springframework/web/cors/DefaultCorsProcessor.java index 1210c18aeccc..ab4bee1a5eea 100644 --- a/spring-web/src/main/java/org/springframework/web/cors/DefaultCorsProcessor.java +++ b/spring-web/src/main/java/org/springframework/web/cors/DefaultCorsProcessor.java @@ -73,6 +73,9 @@ public boolean processRequest(@Nullable CorsConfiguration config, HttpServletReq HttpServletResponse response) throws IOException { if (config == null) { + if (logger.isDebugEnabled() && CorsUtils.isCorsRequest(request)) { + logger.debug("Skip: no CORS configuration has been provided"); + } return true; } diff --git a/spring-web/src/main/java/org/springframework/web/cors/reactive/DefaultCorsProcessor.java b/spring-web/src/main/java/org/springframework/web/cors/reactive/DefaultCorsProcessor.java index 90c579bdafe5..20db603007c8 100644 --- a/spring-web/src/main/java/org/springframework/web/cors/reactive/DefaultCorsProcessor.java +++ b/spring-web/src/main/java/org/springframework/web/cors/reactive/DefaultCorsProcessor.java @@ -67,11 +67,15 @@ public class DefaultCorsProcessor implements CorsProcessor { @Override public boolean process(@Nullable CorsConfiguration config, ServerWebExchange exchange) { + ServerHttpRequest request = exchange.getRequest(); + if (config == null) { + if (logger.isDebugEnabled() && CorsUtils.isCorsRequest(request)) { + logger.debug("Skip: no CORS configuration has been provided"); + } return true; } - ServerHttpRequest request = exchange.getRequest(); ServerHttpResponse response = exchange.getResponse(); HttpHeaders responseHeaders = response.getHeaders(); From c788554b1d846eed7820188a31cfa0c0debd97ac Mon Sep 17 00:00:00 2001 From: Taeik Lim Date: Fri, 5 Sep 2025 00:27:00 +0900 Subject: [PATCH 201/591] Avoid thread pinning in SseEmitter, ResponseBodyEmitter Closes gh-35423 Signed-off-by: Taeik Lim --- .../annotation/ResponseBodyEmitter.java | 157 ++++++++++++------ .../mvc/method/annotation/SseEmitter.java | 6 - 2 files changed, 108 insertions(+), 55 deletions(-) diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitter.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitter.java index 3ba758a2d2e7..9867516277d6 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitter.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitter.java @@ -21,6 +21,8 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; import java.util.function.Consumer; import org.springframework.http.MediaType; @@ -62,6 +64,7 @@ * @author Rossen Stoyanchev * @author Juergen Hoeller * @author Brian Clozel + * @author Taeik Lim * @since 4.2 */ public class ResponseBodyEmitter { @@ -88,6 +91,8 @@ public class ResponseBodyEmitter { private final DefaultCallback completionCallback = new DefaultCallback(); + /** Guards access to write operations on the response. */ + protected final Lock writeLock = new ReentrantLock(); /** * Create a new ResponseBodyEmitter instance. @@ -117,36 +122,48 @@ public Long getTimeout() { } - synchronized void initialize(Handler handler) throws IOException { - this.handler = handler; - + void initialize(Handler handler) throws IOException { + this.writeLock.lock(); try { - sendInternal(this.earlySendAttempts); - } - finally { - this.earlySendAttempts.clear(); - } + this.handler = handler; + + try { + sendInternal(this.earlySendAttempts); + } + finally { + this.earlySendAttempts.clear(); + } - if (this.complete) { - if (this.failure != null) { - this.handler.completeWithError(this.failure); + if (this.complete) { + if (this.failure != null) { + this.handler.completeWithError(this.failure); + } + else { + this.handler.complete(); + } } else { - this.handler.complete(); + this.handler.onTimeout(this.timeoutCallback); + this.handler.onError(this.errorCallback); + this.handler.onCompletion(this.completionCallback); } } - else { - this.handler.onTimeout(this.timeoutCallback); - this.handler.onError(this.errorCallback); - this.handler.onCompletion(this.completionCallback); + finally { + this.writeLock.unlock(); } } - synchronized void initializeWithError(Throwable ex) { - this.complete = true; - this.failure = ex; - this.earlySendAttempts.clear(); - this.errorCallback.accept(ex); + void initializeWithError(Throwable ex) { + this.writeLock.lock(); + try { + this.complete = true; + this.failure = ex; + this.earlySendAttempts.clear(); + this.errorCallback.accept(ex); + } + finally { + this.writeLock.unlock(); + } } /** @@ -183,22 +200,28 @@ public void send(Object object) throws IOException { * @throws IOException raised when an I/O error occurs * @throws java.lang.IllegalStateException wraps any other errors */ - public synchronized void send(Object object, @Nullable MediaType mediaType) throws IOException { + public void send(Object object, @Nullable MediaType mediaType) throws IOException { Assert.state(!this.complete, () -> "ResponseBodyEmitter has already completed" + (this.failure != null ? " with error: " + this.failure : "")); - if (this.handler != null) { - try { - this.handler.send(object, mediaType); - } - catch (IOException ex) { - throw ex; + this.writeLock.lock(); + try { + if (this.handler != null) { + try { + this.handler.send(object, mediaType); + } + catch (IOException ex) { + throw ex; + } + catch (Throwable ex) { + throw new IllegalStateException("Failed to send " + object, ex); + } } - catch (Throwable ex) { - throw new IllegalStateException("Failed to send " + object, ex); + else { + this.earlySendAttempts.add(new DataWithMediaType(object, mediaType)); } } - else { - this.earlySendAttempts.add(new DataWithMediaType(object, mediaType)); + finally { + this.writeLock.unlock(); } } @@ -211,10 +234,16 @@ public synchronized void send(Object object, @Nullable MediaType mediaType) thro * @throws java.lang.IllegalStateException wraps any other errors * @since 6.0.12 */ - public synchronized void send(Set items) throws IOException { + public void send(Set items) throws IOException { Assert.state(!this.complete, () -> "ResponseBodyEmitter has already completed" + (this.failure != null ? " with error: " + this.failure : "")); - sendInternal(items); + this.writeLock.lock(); + try { + sendInternal(items); + } + finally { + this.writeLock.unlock(); + } } private void sendInternal(Set items) throws IOException { @@ -245,10 +274,16 @@ private void sendInternal(Set items) throws IOException { * to complete request processing. It should not be used after container * related events such as an error while {@link #send(Object) sending}. */ - public synchronized void complete() { - this.complete = true; - if (this.handler != null) { - this.handler.complete(); + public void complete() { + this.writeLock.lock(); + try { + this.complete = true; + if (this.handler != null) { + this.handler.complete(); + } + } + finally { + this.writeLock.unlock(); } } @@ -263,11 +298,17 @@ public synchronized void complete() { * container related events such as an error while * {@link #send(Object) sending}. */ - public synchronized void completeWithError(Throwable ex) { - this.complete = true; - this.failure = ex; - if (this.handler != null) { - this.handler.completeWithError(ex); + public void completeWithError(Throwable ex) { + this.writeLock.lock(); + try { + this.complete = true; + this.failure = ex; + if (this.handler != null) { + this.handler.completeWithError(ex); + } + } + finally { + this.writeLock.unlock(); } } @@ -276,8 +317,14 @@ public synchronized void completeWithError(Throwable ex) { * called from a container thread when an async request times out. *

      As of 6.2, one can register multiple callbacks for this event. */ - public synchronized void onTimeout(Runnable callback) { - this.timeoutCallback.addDelegate(callback); + public void onTimeout(Runnable callback) { + this.writeLock.lock(); + try { + this.timeoutCallback.addDelegate(callback); + } + finally { + this.writeLock.unlock(); + } } /** @@ -287,8 +334,14 @@ public synchronized void onTimeout(Runnable callback) { *

      As of 6.2, one can register multiple callbacks for this event. * @since 5.0 */ - public synchronized void onError(Consumer callback) { - this.errorCallback.addDelegate(callback); + public void onError(Consumer callback) { + this.writeLock.lock(); + try { + this.errorCallback.addDelegate(callback); + } + finally { + this.writeLock.unlock(); + } } /** @@ -298,8 +351,14 @@ public synchronized void onError(Consumer callback) { * detecting that a {@code ResponseBodyEmitter} instance is no longer usable. *

      As of 6.2, one can register multiple callbacks for this event. */ - public synchronized void onCompletion(Runnable callback) { - this.completionCallback.addDelegate(callback); + public void onCompletion(Runnable callback) { + this.writeLock.lock(); + try { + this.completionCallback.addDelegate(callback); + } + finally { + this.writeLock.unlock(); + } } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitter.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitter.java index 99f94668d60e..b9a9d04bf0d8 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitter.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitter.java @@ -21,8 +21,6 @@ import java.util.Collections; import java.util.LinkedHashSet; import java.util.Set; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -46,10 +44,6 @@ public class SseEmitter extends ResponseBodyEmitter { private static final MediaType TEXT_PLAIN = new MediaType("text", "plain", StandardCharsets.UTF_8); - /** Guards access to write operations on the response. */ - private final Lock writeLock = new ReentrantLock(); - - /** * Create a new SseEmitter instance. */ From 7f9aa39748f5a3dfea3fcf8d151f25e36a181945 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Tue, 9 Sep 2025 18:45:04 +0200 Subject: [PATCH 202/591] Polishing --- .../reactive/socket/adapter/JettyWebSocketHandlerAdapter.java | 1 - .../web/reactive/socket/adapter/JettyWebSocketSession.java | 4 +++- .../socket/adapter/jetty/JettyWebSocketHandlerAdapter.java | 2 ++ .../web/socket/adapter/jetty/JettyWebSocketSession.java | 4 +--- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/socket/adapter/JettyWebSocketHandlerAdapter.java b/spring-webflux/src/main/java/org/springframework/web/reactive/socket/adapter/JettyWebSocketHandlerAdapter.java index b481b0ddd4ef..3250f9e2b50d 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/socket/adapter/JettyWebSocketHandlerAdapter.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/socket/adapter/JettyWebSocketHandlerAdapter.java @@ -117,7 +117,6 @@ private static final class JettyCallbackDataBuffer implements CloseableDataBuffe private final Callback callback; - public JettyCallbackDataBuffer(DataBuffer delegate, Callback callback) { Assert.notNull(delegate, "'delegate` must not be null"); Assert.notNull(callback, "Callback must not be null"); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/socket/adapter/JettyWebSocketSession.java b/spring-webflux/src/main/java/org/springframework/web/reactive/socket/adapter/JettyWebSocketSession.java index 1cd01bd222dc..93591e20a35f 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/socket/adapter/JettyWebSocketSession.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/socket/adapter/JettyWebSocketSession.java @@ -68,6 +68,7 @@ public class JettyWebSocketSession extends AbstractWebSocketSession { @Nullable private final Sinks.Empty handlerCompletionSink; + public JettyWebSocketSession(Session session, HandshakeInfo info, DataBufferFactory factory) { this(session, info, factory, null); } @@ -107,6 +108,7 @@ public JettyWebSocketSession(Session session, HandshakeInfo info, DataBufferFact }); } + void handleMessage(WebSocketMessage message) { Assert.state(this.sink != null, "No sink available"); this.sink.next(message); @@ -189,7 +191,6 @@ public Mono send(Publisher messages) { } protected Mono sendMessage(WebSocketMessage message) { - Callback.Completable completable = new Callback.Completable(); DataBuffer dataBuffer = message.getPayload(); Session session = getDelegate(); @@ -245,4 +246,5 @@ protected void onCompleteFailure(Throwable cause) { } return Mono.fromFuture(completable); } + } diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/adapter/jetty/JettyWebSocketHandlerAdapter.java b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/jetty/JettyWebSocketHandlerAdapter.java index 1b93964d00a1..c6eecc030a36 100644 --- a/spring-websocket/src/main/java/org/springframework/web/socket/adapter/jetty/JettyWebSocketHandlerAdapter.java +++ b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/jetty/JettyWebSocketHandlerAdapter.java @@ -58,6 +58,7 @@ public JettyWebSocketHandlerAdapter(WebSocketHandler webSocketHandler, JettyWebS this.wsSession = wsSession; } + @Override public void onWebSocketOpen(Session session) { try { @@ -147,4 +148,5 @@ private void tryCloseWithError(Throwable t) { } } } + } diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/adapter/jetty/JettyWebSocketSession.java b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/jetty/JettyWebSocketSession.java index ad697d7c405c..aeeaa943ce77 100644 --- a/spring-websocket/src/main/java/org/springframework/web/socket/adapter/jetty/JettyWebSocketSession.java +++ b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/jetty/JettyWebSocketSession.java @@ -173,7 +173,6 @@ public boolean isOpen() { return getNativeSession().isOpen(); } - @Override public void initializeNativeSession(Session session) { super.initializeNativeSession(session); @@ -213,7 +212,6 @@ private List getExtensions(Session session) { return Collections.emptyList(); } - @Override protected void sendTextMessage(TextMessage message) throws IOException { useSession((session, callback) -> session.sendText(message.getPayload(), callback)); @@ -247,7 +245,6 @@ private void useSession(SessionConsumer sessionConsumer) throws IOException { } catch (ExecutionException ex) { Throwable cause = ex.getCause(); - if (cause instanceof IOException ioEx) { throw ioEx; } @@ -263,6 +260,7 @@ else if (cause instanceof UncheckedIOException uioEx) { } } + @FunctionalInterface private interface SessionConsumer { From 275fb52ad65463eaef4070bd9810720cad149e48 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Tue, 9 Sep 2025 18:45:38 +0200 Subject: [PATCH 203/591] Upgrade to Reactor 2024.0.10 and Micrometer 1.14.11 Closes gh-35454 Closes gh-35455 --- framework-platform/framework-platform.gradle | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index a515e8ce933f..809f83d72cfa 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -8,10 +8,10 @@ javaPlatform { dependencies { api(platform("com.fasterxml.jackson:jackson-bom:2.18.4.1")) - api(platform("io.micrometer:micrometer-bom:1.14.10")) + api(platform("io.micrometer:micrometer-bom:1.14.11")) api(platform("io.netty:netty-bom:4.1.127.Final")) api(platform("io.netty:netty5-bom:5.0.0.Alpha5")) - api(platform("io.projectreactor:reactor-bom:2024.0.9")) + api(platform("io.projectreactor:reactor-bom:2024.0.10")) api(platform("io.rsocket:rsocket-bom:1.1.5")) api(platform("org.apache.groovy:groovy-bom:4.0.28")) api(platform("org.apache.logging.log4j:log4j-bom:2.21.1")) @@ -100,7 +100,7 @@ dependencies { api("org.apache.derby:derbyclient:10.16.1.1") api("org.apache.derby:derbytools:10.16.1.1") api("org.apache.httpcomponents.client5:httpclient5:5.5") - api("org.apache.httpcomponents.core5:httpcore5-reactive:5.3.4") + api("org.apache.httpcomponents.core5:httpcore5-reactive:5.3.5") api("org.apache.poi:poi-ooxml:5.2.5") api("org.apache.tomcat.embed:tomcat-embed-core:10.1.28") api("org.apache.tomcat.embed:tomcat-embed-websocket:10.1.28") From f7b0d44bfd970ed038af6ad47b91f4f58f760f41 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Tue, 9 Sep 2025 18:50:10 +0200 Subject: [PATCH 204/591] Upgrade to Reactor 2025.0.0-M7 and Micrometer 1.16.0-M3 Closes gh-35452 Closes gh-35453 --- framework-platform/framework-platform.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index 67b2f167f5c8..b395849417a5 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -8,9 +8,9 @@ javaPlatform { dependencies { api(platform("com.fasterxml.jackson:jackson-bom:2.20.0")) - api(platform("io.micrometer:micrometer-bom:1.16.0-M2")) + api(platform("io.micrometer:micrometer-bom:1.16.0-M3")) api(platform("io.netty:netty-bom:4.2.6.Final")) - api(platform("io.projectreactor:reactor-bom:2025.0.0-M6")) + api(platform("io.projectreactor:reactor-bom:2025.0.0-M7")) api(platform("io.rsocket:rsocket-bom:1.1.5")) api(platform("org.apache.groovy:groovy-bom:5.0.0-rc-1")) api(platform("org.apache.logging.log4j:log4j-bom:3.0.0-beta3")) From 21e52a4283c037d445299e304c398c1ffed8fd77 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Tue, 9 Sep 2025 18:54:52 +0200 Subject: [PATCH 205/591] Upgrade to Tomcat 11.0.11 and EclipseLink 5.0.0-B10 --- framework-platform/framework-platform.gradle | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index b395849417a5..97441892d50b 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -97,10 +97,10 @@ dependencies { api("org.apache.httpcomponents.client5:httpclient5:5.5") api("org.apache.httpcomponents.core5:httpcore5-reactive:5.3.5") api("org.apache.poi:poi-ooxml:5.2.5") - api("org.apache.tomcat.embed:tomcat-embed-core:11.0.7") - api("org.apache.tomcat.embed:tomcat-embed-websocket:11.0.7") - api("org.apache.tomcat:tomcat-util:11.0.7") - api("org.apache.tomcat:tomcat-websocket:11.0.7") + api("org.apache.tomcat.embed:tomcat-embed-core:11.0.11") + api("org.apache.tomcat.embed:tomcat-embed-websocket:11.0.11") + api("org.apache.tomcat:tomcat-util:11.0.11") + api("org.apache.tomcat:tomcat-websocket:11.0.11") api("org.aspectj:aspectjrt:1.9.24") api("org.aspectj:aspectjtools:1.9.24") api("org.aspectj:aspectjweaver:1.9.24") @@ -112,7 +112,7 @@ dependencies { api("org.easymock:easymock:5.5.0") api("org.eclipse.angus:angus-mail:2.0.3") api("org.eclipse.jetty:jetty-reactive-httpclient:4.0.11") - api("org.eclipse.persistence:org.eclipse.persistence.jpa:5.0.0-B08") + api("org.eclipse.persistence:org.eclipse.persistence.jpa:5.0.0-B10") api("org.eclipse:yasson:3.0.4") api("org.ehcache:ehcache:3.10.8") api("org.ehcache:jcache:1.0.1") From ef2a403df673b64b5e3f6a8f4039681d3aed9429 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 9 Sep 2025 21:37:08 +0200 Subject: [PATCH 206/591] Add PropagationContextElement Kotlin operator Prior to this commit, the Micrometer context-propagation project would help propagating information from `ThreadLocal`, Reactor `Context` and other context objects. This is already well supported for Micrometer Observations. In the case of Kotlin suspending functions, the processing of tasks would not necessarily update the `ThreadLocal` when the function is scheduled on a different thread. This commit introduces the `PropagationContextElement` operator that connects the `ThreadLocal`, Reactor `Context` and Coroutine `Context` for all libraries using the "context-propagation" project. Applications must manually use this operator in suspending functions like so: ``` suspend fun suspendingFunction() { return withContext(PropagationContextElement(currentCoroutineContext())) { logger.info("Suspending function with traceId") } } ``` Closes gh-35185 --- framework-docs/framework-docs.gradle | 3 + .../pages/languages/kotlin/coroutines.adoc | 33 +++++++ .../propagation/ContextPropagationSample.kt | 40 ++++++++ spring-core/spring-core.gradle | 2 + .../core/PropagationContextElement.kt | 75 +++++++++++++++ .../core/PropagationContextElementTests.kt | 93 +++++++++++++++++++ 6 files changed, 246 insertions(+) create mode 100644 framework-docs/src/main/kotlin/org/springframework/docs/languages/kotlin/coroutines/propagation/ContextPropagationSample.kt create mode 100644 spring-core/src/main/kotlin/org/springframework/core/PropagationContextElement.kt create mode 100644 spring-core/src/test/kotlin/org/springframework/core/PropagationContextElementTests.kt diff --git a/framework-docs/framework-docs.gradle b/framework-docs/framework-docs.gradle index 89250dcf590b..0f2700358290 100644 --- a/framework-docs/framework-docs.gradle +++ b/framework-docs/framework-docs.gradle @@ -65,6 +65,7 @@ dependencies { implementation("com.github.ben-manes.caffeine:caffeine") implementation("com.mchange:c3p0:0.9.5.5") implementation("com.oracle.database.jdbc:ojdbc11") + implementation("io.micrometer:context-propagation") implementation("io.projectreactor.netty:reactor-netty-http") implementation("jakarta.jms:jakarta.jms-api") implementation("jakarta.servlet:jakarta.servlet-api") @@ -78,6 +79,8 @@ dependencies { implementation("org.assertj:assertj-core") implementation("org.eclipse.jetty.websocket:jetty-websocket-jetty-api") implementation("org.jetbrains.kotlin:kotlin-stdlib") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") implementation("org.junit.jupiter:junit-jupiter-api") implementation("tools.jackson.core:jackson-databind") implementation("tools.jackson.dataformat:jackson-dataformat-xml") diff --git a/framework-docs/modules/ROOT/pages/languages/kotlin/coroutines.adoc b/framework-docs/modules/ROOT/pages/languages/kotlin/coroutines.adoc index 7d8dd95ae73f..5b595197308c 100644 --- a/framework-docs/modules/ROOT/pages/languages/kotlin/coroutines.adoc +++ b/framework-docs/modules/ROOT/pages/languages/kotlin/coroutines.adoc @@ -250,3 +250,36 @@ For Kotlin `Flow`, a `Flow.transactional` extension is provided. } } ---- + +[[coroutines.propagation]] +== Context Propagation + +Spring applications are xref:integration/observability.adoc[instrumented with Micrometer for Observability support]. +For tracing support, the current observation is propagated through a `ThreadLocal` for blocking code, +or the Reactor `Context` for reactive pipelines. But the current observation also needs to be made available +in the execution context of a suspended function. Without that, the current "traceId" will not be automatically prepended +to logged statements from coroutines. + +The `org.springframework.core.PropagationContextElement` operator generally ensures that the +{micrometer-context-propagation-docs}/[Micrometer Context Propagation library] works with Kotlin Coroutines. + +The `PropagationContextElement` requires the following dependencies: + +`build.gradle.kts` +[source,kotlin,indent=0] +---- +dependencies { + implementation("io.micrometer:context-propagation:${contextPropagationVersion}") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:${coroutinesVersion}") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:${coroutinesVersion}") +} +---- + +Applications can then use the `PropagationContextElement` operator to connect the `currentCoroutineContext()` +with the context propagation mechanism: + +include-code::./ContextPropagationSample[tag=context,indent=0] + +Here, assuming that Micrometer Tracing is configured, the resulting logging statement +will show the current "traceId" and unlock better observability for your application. + diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/languages/kotlin/coroutines/propagation/ContextPropagationSample.kt b/framework-docs/src/main/kotlin/org/springframework/docs/languages/kotlin/coroutines/propagation/ContextPropagationSample.kt new file mode 100644 index 000000000000..e8f0721511a2 --- /dev/null +++ b/framework-docs/src/main/kotlin/org/springframework/docs/languages/kotlin/coroutines/propagation/ContextPropagationSample.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2002-present 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.languages.kotlin.coroutines.propagation + +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.withContext +import org.apache.commons.logging.Log +import org.apache.commons.logging.LogFactory +import org.springframework.core.PropagationContextElement + +class ContextPropagationSample { + + companion object { + private val logger: Log = LogFactory.getLog( + ContextPropagationSample::class.java + ) + } + + // tag::context[] + suspend fun suspendingFunction() { + return withContext(PropagationContextElement(currentCoroutineContext())) { + logger.info("Suspending function with traceId") + } + } + // end::context[] +} \ No newline at end of file diff --git a/spring-core/spring-core.gradle b/spring-core/spring-core.gradle index a6445f5438cf..cd88a0ee6d14 100644 --- a/spring-core/spring-core.gradle +++ b/spring-core/spring-core.gradle @@ -104,6 +104,8 @@ dependencies { testImplementation("jakarta.xml.bind:jakarta.xml.bind-api") testImplementation("org.jetbrains.kotlinx:kotlinx-serialization-json") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") + testImplementation("io.micrometer:context-propagation") + testImplementation("io.micrometer:micrometer-observation-test") testImplementation("org.mockito:mockito-core") testImplementation("com.networknt:json-schema-validator"); testImplementation("org.skyscreamer:jsonassert") diff --git a/spring-core/src/main/kotlin/org/springframework/core/PropagationContextElement.kt b/spring-core/src/main/kotlin/org/springframework/core/PropagationContextElement.kt new file mode 100644 index 000000000000..c55d66fd4162 --- /dev/null +++ b/spring-core/src/main/kotlin/org/springframework/core/PropagationContextElement.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2002-present 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.core + +import io.micrometer.context.ContextRegistry +import io.micrometer.context.ContextSnapshot +import io.micrometer.context.ContextSnapshotFactory +import kotlinx.coroutines.ThreadContextElement +import kotlinx.coroutines.reactor.ReactorContext +import reactor.util.context.ContextView +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext + + +/** + * [ThreadContextElement] that restores `ThreadLocals` from the Reactor [ContextSnapshot] + * every time the coroutine with this element in the context is resumed on a thread. + * + * This effectively ensures that Kotlin Coroutines, Reactor and Micrometer Context Propagation + * work together in an application, typically for observability purposes. + * + * Applications need to have both `"io.micrometer:context-propagation"` and + * `"org.jetbrains.kotlinx:kotlinx-coroutines-reactor"` on the classpath to use this context element. + * + * The `PropagationContextElement` can be used like this: + * + * ```kotlin + * suspend fun suspendable() { + * withContext(PropagationContextElement(coroutineContext)) { + * logger.info("Log statement with traceId") + * } + * } + * ``` + * + * @author Brian Clozel + * @since 7.0 + */ +class PropagationContextElement(private val context: CoroutineContext) : ThreadContextElement, + AbstractCoroutineContextElement(Key) { + + companion object Key : CoroutineContext.Key + + val contextSnapshot: ContextSnapshot + get() { + val contextView: ContextView? = context[ReactorContext]?.context + val contextSnapshotFactory = + ContextSnapshotFactory.builder().contextRegistry(ContextRegistry.getInstance()).build() + if (contextView != null) { + return contextSnapshotFactory.captureFrom(contextView) + } + return contextSnapshotFactory.captureAll() + } + + override fun restoreThreadContext(context: CoroutineContext, oldState: ContextSnapshot.Scope) { + oldState.close() + } + + override fun updateThreadContext(context: CoroutineContext): ContextSnapshot.Scope { + return contextSnapshot.setThreadLocals() + } +} \ No newline at end of file diff --git a/spring-core/src/test/kotlin/org/springframework/core/PropagationContextElementTests.kt b/spring-core/src/test/kotlin/org/springframework/core/PropagationContextElementTests.kt new file mode 100644 index 000000000000..d7a7bdf5711b --- /dev/null +++ b/spring-core/src/test/kotlin/org/springframework/core/PropagationContextElementTests.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2002-present 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.core + +import io.micrometer.observation.Observation +import io.micrometer.observation.tck.TestObservationRegistry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.assertj.core.api.Assertions +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.reactivestreams.Publisher +import reactor.core.publisher.Hooks +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers +import kotlin.coroutines.Continuation + + +/** + * Kotlin tests for [PropagationContextElement]. + * + * @author Brian Clozel + */ +class PropagationContextElementTests { + + private val observationRegistry = TestObservationRegistry.create() + + companion object { + + @BeforeAll + @JvmStatic + fun init() { + Hooks.enableAutomaticContextPropagation() + } + + @AfterAll + @JvmStatic + fun cleanup() { + Hooks.disableAutomaticContextPropagation() + } + + } + + @Test + fun restoresFromThreadLocal() { + val observation = Observation.createNotStarted("coroutine", observationRegistry) + observation.observe { + val result = runBlocking(Dispatchers.Unconfined) { + suspendingFunction("test") + } + Assertions.assertThat(result).isEqualTo("coroutine") + } + } + + @Test + @Suppress("UNCHECKED_CAST") + fun restoresFromReactorContext() { + val method = PropagationContextElementTests::class.java.getDeclaredMethod("suspendingFunction", String::class.java, Continuation::class.java) + val publisher = CoroutinesUtils.invokeSuspendingFunction(method, this, "test", null) as Publisher + val observation = Observation.createNotStarted("coroutine", observationRegistry) + observation.observe { + val result = Mono.from(publisher).publishOn(Schedulers.boundedElastic()).block() + assertThat(result).isEqualTo("coroutine") + } + } + + suspend fun suspendingFunction(value: String): String? { + return withContext(PropagationContextElement(currentCoroutineContext())) { + val currentObservation = observationRegistry.currentObservation + assertThat(currentObservation).isNotNull + currentObservation?.context?.name + } + } + +} From 86fb62c05998f776e42b7960d9c039f00645ebb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Wed, 10 Sep 2025 14:03:30 +0200 Subject: [PATCH 207/591] Upgrade to Kotlin 2.2.20 Closes gh-35414 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 12937b990162..514cca9f3dcd 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,7 +4,7 @@ org.gradle.caching=true org.gradle.jvmargs=-Xmx2048m org.gradle.parallel=true -kotlinVersion=2.2.10 +kotlinVersion=2.2.20 byteBuddyVersion=1.17.6 kotlin.jvm.target.validation.mode=ignore From 4745c7cf3c3f1623d5590873b384b59b21fb0652 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Wed, 10 Sep 2025 14:08:20 +0200 Subject: [PATCH 208/591] Name local variables consistently --- .../springframework/core/annotation/AnnotationsScanner.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/core/annotation/AnnotationsScanner.java b/spring-core/src/main/java/org/springframework/core/annotation/AnnotationsScanner.java index e81a08d18b55..1d57bd30075b 100644 --- a/spring-core/src/main/java/org/springframework/core/annotation/AnnotationsScanner.java +++ b/spring-core/src/main/java/org/springframework/core/annotation/AnnotationsScanner.java @@ -372,14 +372,14 @@ private static boolean hasSameParameterTypes(Method rootMethod, Method candidate private static boolean hasSameGenericTypeParameters( Method rootMethod, Method candidateMethod, Class[] rootParameterTypes) { - Class sourceDeclaringClass = rootMethod.getDeclaringClass(); + Class rootDeclaringClass = rootMethod.getDeclaringClass(); Class candidateDeclaringClass = candidateMethod.getDeclaringClass(); - if (!candidateDeclaringClass.isAssignableFrom(sourceDeclaringClass)) { + if (!candidateDeclaringClass.isAssignableFrom(rootDeclaringClass)) { return false; } for (int i = 0; i < rootParameterTypes.length; i++) { Class resolvedParameterType = ResolvableType.forMethodParameter( - candidateMethod, i, sourceDeclaringClass).toClass(); + candidateMethod, i, rootDeclaringClass).toClass(); if (rootParameterTypes[i] != resolvedParameterType) { return false; } From 0e3e34bee0c5b452b51c6ff6184e071a73ee2d40 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Wed, 10 Sep 2025 14:23:59 +0200 Subject: [PATCH 209/591] Find annotations on parameters in overridden non-public methods Prior to this commit, annotations were not found on parameters in an overridden method unless the method was public. Specifically, the search algorithm in AnnotatedMethod did not consider a protected or package-private method in a superclass to be a potential override candidate. This affects parameter annotation searches in spring-messaging, spring-webmvc, spring-webflux, and any other components that use or extend AnnotatedMethod. To address that, this commit revises the search algorithm in AnnotatedMethod to consider all non-final declared methods as potential override candidates, thereby aligning with the search logic in AnnotationsScanner for the MergedAnnotations API. Closes gh-35349 --- .../core/annotation/AnnotatedMethod.java | 9 ++++--- .../core/annotation/AnnotatedMethodTests.java | 25 ++++++++++++++++--- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/core/annotation/AnnotatedMethod.java b/spring-core/src/main/java/org/springframework/core/annotation/AnnotatedMethod.java index ed9ed5253002..519133a62398 100644 --- a/spring-core/src/main/java/org/springframework/core/annotation/AnnotatedMethod.java +++ b/spring-core/src/main/java/org/springframework/core/annotation/AnnotatedMethod.java @@ -18,6 +18,7 @@ import java.lang.annotation.Annotation; import java.lang.reflect.Method; +import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -38,6 +39,7 @@ * interface-declared parameter annotations from the concrete target method. * * @author Juergen Hoeller + * @author Sam Brannen * @since 6.1 * @see #getMethodAnnotation(Class) * @see #getMethodParameters() @@ -181,7 +183,7 @@ private List getInheritedParameterAnnotations() { clazz = null; } if (clazz != null) { - for (Method candidate : clazz.getMethods()) { + for (Method candidate : clazz.getDeclaredMethods()) { if (isOverrideFor(candidate)) { parameterAnnotations.add(candidate.getParameterAnnotations()); } @@ -194,8 +196,9 @@ private List getInheritedParameterAnnotations() { } private boolean isOverrideFor(Method candidate) { - if (!candidate.getName().equals(this.method.getName()) || - candidate.getParameterCount() != this.method.getParameterCount()) { + if (Modifier.isPrivate(candidate.getModifiers()) || + !candidate.getName().equals(this.method.getName()) || + (candidate.getParameterCount() != this.method.getParameterCount())) { return false; } Class[] paramTypes = this.method.getParameterTypes(); diff --git a/spring-core/src/test/java/org/springframework/core/annotation/AnnotatedMethodTests.java b/spring-core/src/test/java/org/springframework/core/annotation/AnnotatedMethodTests.java index 1118239d614b..c2c073e62449 100644 --- a/spring-core/src/test/java/org/springframework/core/annotation/AnnotatedMethodTests.java +++ b/spring-core/src/test/java/org/springframework/core/annotation/AnnotatedMethodTests.java @@ -19,12 +19,15 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.reflect.Method; +import java.lang.reflect.Modifier; import org.junit.jupiter.api.Test; import org.springframework.core.MethodParameter; -import org.springframework.util.ClassUtils; +import org.springframework.util.ReflectionUtils; +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.joining; import static org.assertj.core.api.Assertions.assertThat; /** @@ -55,6 +58,12 @@ void shouldFindAnnotationOnMethodInGenericInterface() { @Test void shouldFindAnnotationOnMethodParameterInGenericAbstractSuperclass() { + // Prerequisites for gh-35349 + Method abstractMethod = ReflectionUtils.findMethod(GenericAbstractSuperclass.class, "processTwo", Object.class); + assertThat(abstractMethod).isNotNull(); + assertThat(Modifier.isAbstract(abstractMethod.getModifiers())).as("abstract").isTrue(); + assertThat(Modifier.isPublic(abstractMethod.getModifiers())).as("public").isFalse(); + Method processTwo = getMethod("processTwo", String.class); AnnotatedMethod annotatedMethod = new AnnotatedMethod(processTwo); @@ -78,7 +87,14 @@ void shouldFindAnnotationOnMethodParameterInGenericInterface() { private static Method getMethod(String name, Class...parameterTypes) { - return ClassUtils.getMethod(GenericInterfaceImpl.class, name, parameterTypes); + Class clazz = GenericInterfaceImpl.class; + Method method = ReflectionUtils.findMethod(clazz, name, parameterTypes); + if (method == null) { + String parameterNames = stream(parameterTypes).map(Class::getName).collect(joining(", ")); + throw new IllegalStateException("Expected method not found: %s#%s(%s)" + .formatted(clazz.getSimpleName(), name, parameterNames)); + } + return method; } @@ -103,13 +119,14 @@ public void processOneAndTwo(Long value1, C value2) { } @Handler - public abstract void processTwo(@Param C value); + // Intentionally NOT public + abstract void processTwo(@Param C value); } static class GenericInterfaceImpl extends GenericAbstractSuperclass { @Override - public void processTwo(String value) { + void processTwo(String value) { } } From 3702031f825362221faa46a3c51591cd0de5628b Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Tue, 9 Sep 2025 16:18:33 +0100 Subject: [PATCH 210/591] Improve docs on versioning by path segment Closes gh-35421 --- framework-docs/modules/ROOT/pages/web/webflux/config.adoc | 8 ++++++-- .../ROOT/pages/web/webmvc/mvc-config/api-version.adoc | 8 ++++++-- .../web/reactive/config/ApiVersionConfigurer.java | 2 +- .../servlet/config/annotation/ApiVersionConfigurer.java | 2 +- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/web/webflux/config.adoc b/framework-docs/modules/ROOT/pages/web/webflux/config.adoc index 2fdd068f99e1..65259c6c18fa 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/config.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/config.adoc @@ -718,8 +718,12 @@ alternatively use a custom `ApiVersionResolver`: - Path segment - Media type parameter -TIP: When using a path segment, consider configuring a shared path prefix externally -in xref:web/webmvc/mvc-config/path-matching.adoc[Path Matching] options. +To resolve from a path segment, you need to specify the index of the path segment expected +to contain the version. The path segment must be declared as a URI variable, e.g. +"/\{version}", "/api/\{version}", etc. where the actual name is not important. +As the version is typically at the start of the path, consider configuring it externally +as a common path prefix for all handlers through the +xref:web/webflux/config.adoc#webflux-config-path-matching[Path Matching] options. By default, the version is parsed with `SemanticVersionParser`, but you can also configure a custom xref:web/webflux-versioning.adoc#webflux-versioning-parser[ApiVersionParser]. diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/api-version.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/api-version.adoc index c989de0dcdd5..0f221f33a8ee 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/api-version.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-config/api-version.adoc @@ -15,8 +15,12 @@ alternatively use a custom `ApiVersionResolver`: - Path segment - Media type parameter -TIP: When using a path segment, consider configuring a shared path prefix externally -in xref:web/webmvc/mvc-config/path-matching.adoc[Path Matching] options. +To resolve from a path segment, you need to specify the index of the path segment expected +to contain the version. The path segment must be declared as a URI variable, e.g. +"/\{version}", "/api/\{version}", etc. where the actual name is not important. +As the version is typically at the start of the path, consider configuring it externally +as a common path prefix for all handlers through the +xref:web/webmvc/mvc-config/path-matching.adoc[Path Matching] options. By default, the version is parsed with `SemanticVersionParser`, but you can also configure a custom xref:web/webmvc-versioning.adoc#mvc-versioning-parser[ApiVersionParser]. diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java b/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java index 549120c5857e..183d563169e9 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/config/ApiVersionConfigurer.java @@ -99,7 +99,7 @@ public ApiVersionConfigurer useMediaTypeParameter(MediaType compatibleMediaType, *

      Note that this resolver never returns {@code null}, and therefore * cannot yield to other resolvers, see {@link org.springframework.web.accept.PathApiVersionResolver}. * @param index the index of the path segment to check; e.g. for URL's like - * "/{version}/..." use index 0, for "/api/{version}/..." index 1. + * {@code "/{version}/..."} use index 0, for {@code "/api/{version}/..."} index 1. */ public ApiVersionConfigurer usePathSegment(int index) { this.versionResolvers.add(new PathApiVersionResolver(index)); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java index f3db4095d4ad..cbf9d9a4b63c 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java @@ -100,7 +100,7 @@ public ApiVersionConfigurer useMediaTypeParameter(MediaType compatibleMediaType, *

      Note that this resolver never returns {@code null}, and therefore * cannot yield to other resolvers, see {@link PathApiVersionResolver}. * @param index the index of the path segment to check; e.g. for URL's like - * "/{version}/..." use index 0, for "/api/{version}/..." index 1. + * {@code "/{version}/..."} use index 0, for {@code "/api/{version}/..."} index 1. */ public ApiVersionConfigurer usePathSegment(int index) { this.versionResolvers.add(new PathApiVersionResolver(index)); From dd60fddaf0ccb06831a99072aedba57ed24f3ab7 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Wed, 10 Sep 2025 15:15:01 +0100 Subject: [PATCH 211/591] Always process SSE "data:" line ServerSentEventHttpMessageReader now always processes "data:" lines, including empty lines, as per SSE spec. Closes gh-35412 --- .../ServerSentEventHttpMessageReader.java | 4 ++-- ...ServerSentEventHttpMessageReaderTests.java | 20 ++++++++++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/http/codec/ServerSentEventHttpMessageReader.java b/spring-web/src/main/java/org/springframework/http/codec/ServerSentEventHttpMessageReader.java index b6752741b74f..3966bcba7d70 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/ServerSentEventHttpMessageReader.java +++ b/spring-web/src/main/java/org/springframework/http/codec/ServerSentEventHttpMessageReader.java @@ -145,15 +145,15 @@ public Flux read( for (String line : lines) { if (line.startsWith("data:")) { + data = (data != null ? data : new StringBuilder()); int length = line.length(); if (length > 5) { int index = (line.charAt(5) != ' ' ? 5 : 6); if (length > index) { - data = (data != null ? data : new StringBuilder()); data.append(line, index, line.length()); - data.append('\n'); } } + data.append('\n'); } else if (shouldWrap) { if (line.startsWith("id:")) { diff --git a/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventHttpMessageReaderTests.java b/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventHttpMessageReaderTests.java index 4bcf7c8c11d6..3693bed3755d 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventHttpMessageReaderTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventHttpMessageReaderTests.java @@ -67,7 +67,9 @@ void readServerSentEvents() { MockServerHttpRequest request = MockServerHttpRequest.post("/") .body(Mono.just(stringBuffer( "id:c42\nevent:foo\nretry:123\n:bla\n:bla bla\n:bla bla bla\ndata:bar\n\n" + - "id:c43\nevent:bar\nretry:456\ndata:baz\n\ndata:\n\ndata: \n\n"))); + "id:c43\nevent:bar\nretry:456\ndata:baz\n\n" + + "data:\n\n" + + "data: \n\n"))); Flux events = this.reader .read(ResolvableType.forClassWithGenerics(ServerSentEvent.class, String.class), @@ -78,8 +80,8 @@ void readServerSentEvents() { .retry(Duration.ofMillis(123)).comment("bla\nbla bla\nbla bla bla").data("bar").build()) .expectNext(ServerSentEvent.builder().id("c43").event("bar") .retry(Duration.ofMillis(456)).data("baz").build()) - .consumeNextWith(event -> assertThat(event.data()).isNull()) - .consumeNextWith(event -> assertThat(event.data()).isNull()) + .consumeNextWith(event -> assertThat(event.data()).isEqualTo("")) + .consumeNextWith(event -> assertThat(event.data()).isEqualTo("")) .expectComplete() .verify(); } @@ -135,6 +137,18 @@ void trimWhitespace() { .verify(); } + @Test // gh-35412 + void emptyLines() { + MockServerHttpRequest request = MockServerHttpRequest.post("/") + .body(Mono.just(stringBuffer("id:1\nevent:message\ndata:\ndata:\ndata:\n\n"))); + + Flux data = new ServerSentEventHttpMessageReader() + .read(ResolvableType.forClass(String.class), request, Collections.emptyMap()) + .cast(String.class); + + StepVerifier.create(data).expectNext("\n\n").verifyComplete(); + } + @Test void readPojo() { MockServerHttpRequest request = MockServerHttpRequest.post("/") From 59804ab39664054c72132ddb9afce854f3acac29 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Wed, 10 Sep 2025 17:41:25 +0200 Subject: [PATCH 212/591] Align JpaTransactionManager default for nestedTransactionAllowed flag Closes gh-35457 --- .../orm/jpa/JpaTransactionManager.java | 12 +++++------- .../jpa/hibernate/HibernateTransactionManager.java | 4 ++-- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/JpaTransactionManager.java b/spring-orm/src/main/java/org/springframework/orm/jpa/JpaTransactionManager.java index d02950e98bd0..676a66929ed8 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/JpaTransactionManager.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/JpaTransactionManager.java @@ -93,11 +93,11 @@ * *

      This transaction manager supports nested transactions via JDBC Savepoints. * The {@link #setNestedTransactionAllowed "nestedTransactionAllowed"} flag defaults - * to {@code false} though, since nested transactions will just apply to the JDBC - * Connection, not to the JPA EntityManager and its cached entity objects and related - * context. You can manually set the flag to {@code true} if you want to use nested - * transactions for JDBC access code which participates in JPA transactions (provided - * that your JDBC driver supports Savepoints). Note that JPA itself does not support + * to "false", though, as nested transactions will just apply to the JDBC Connection, + * not to the JPA EntityManager and its cached entity objects and related context. + * You can manually set the flag to "true" if you want to use nested transactions + * for JDBC access code which participates in JPA transactions (provided that your + * JDBC driver supports savepoints). Note that JPA itself does not support * nested transactions! Hence, do not expect JPA access code to semantically * participate in a nested transaction. * @@ -136,7 +136,6 @@ public class JpaTransactionManager extends AbstractPlatformTransactionManager * @see #setEntityManagerFactory */ public JpaTransactionManager() { - setNestedTransactionAllowed(true); } /** @@ -144,7 +143,6 @@ public JpaTransactionManager() { * @param emf the EntityManagerFactory to manage transactions for */ public JpaTransactionManager(EntityManagerFactory emf) { - this(); this.entityManagerFactory = emf; afterPropertiesSet(); } diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/HibernateTransactionManager.java b/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/HibernateTransactionManager.java index aa1568ab410b..55a488eb2d81 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/HibernateTransactionManager.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/HibernateTransactionManager.java @@ -88,12 +88,12 @@ * such a scenario (see container setup). * *

      This transaction manager supports nested transactions via JDBC Savepoints. - * The {@link #setNestedTransactionAllowed} "nestedTransactionAllowed"} flag defaults + * The {@link #setNestedTransactionAllowed "nestedTransactionAllowed"} flag defaults * to "false", though, as nested transactions will just apply to the JDBC Connection, * not to the Hibernate Session and its cached entity objects and related context. * You can manually set the flag to "true" if you want to use nested transactions * for JDBC access code which participates in Hibernate transactions (provided that - * your JDBC driver supports Savepoints). Note that Hibernate itself does not + * your JDBC driver supports savepoints). Note that Hibernate itself does not * support nested transactions! Hence, do not expect Hibernate access code to * semantically participate in a nested transaction. * From 5b387615c67fb147c2cbd87881cf4b58b042e994 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Wed, 10 Sep 2025 18:06:44 +0200 Subject: [PATCH 213/591] Clarify intended nestedTransactionAllowed default in JpaTransactionManager Closes gh-35212 --- .../hibernate5/HibernateTransactionManager.java | 4 ++-- .../orm/jpa/JpaTransactionManager.java | 15 ++++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/spring-orm/src/main/java/org/springframework/orm/hibernate5/HibernateTransactionManager.java b/spring-orm/src/main/java/org/springframework/orm/hibernate5/HibernateTransactionManager.java index f44a5c249c1f..01d6518c4a5f 100644 --- a/spring-orm/src/main/java/org/springframework/orm/hibernate5/HibernateTransactionManager.java +++ b/spring-orm/src/main/java/org/springframework/orm/hibernate5/HibernateTransactionManager.java @@ -88,12 +88,12 @@ * such a scenario (see container setup). * *

      This transaction manager supports nested transactions via JDBC Savepoints. - * The {@link #setNestedTransactionAllowed} "nestedTransactionAllowed"} flag defaults + * The {@link #setNestedTransactionAllowed "nestedTransactionAllowed"} flag defaults * to "false", though, as nested transactions will just apply to the JDBC Connection, * not to the Hibernate Session and its cached entity objects and related context. * You can manually set the flag to "true" if you want to use nested transactions * for JDBC access code which participates in Hibernate transactions (provided that - * your JDBC driver supports Savepoints). Note that Hibernate itself does not + * your JDBC driver supports savepoints). Note that Hibernate itself does not * support nested transactions! Hence, do not expect Hibernate access code to * semantically participate in a nested transaction. * diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/JpaTransactionManager.java b/spring-orm/src/main/java/org/springframework/orm/jpa/JpaTransactionManager.java index bec3bda232f7..e77520e416c7 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/JpaTransactionManager.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/JpaTransactionManager.java @@ -93,13 +93,14 @@ * *

      This transaction manager supports nested transactions via JDBC Savepoints. * The {@link #setNestedTransactionAllowed "nestedTransactionAllowed"} flag defaults - * to {@code false} though, since nested transactions will just apply to the JDBC - * Connection, not to the JPA EntityManager and its cached entity objects and related - * context. You can manually set the flag to {@code true} if you want to use nested - * transactions for JDBC access code which participates in JPA transactions (provided - * that your JDBC driver supports Savepoints). Note that JPA itself does not support - * nested transactions! Hence, do not expect JPA access code to semantically - * participate in a nested transaction. + * to "true" but should rather be "false", as nested transactions will just apply to + * the JDBC Connection, not to the JPA EntityManager and its cached entity objects + * and related context. As of Spring Framework 7.0, the default will be "false" in + * alignment with other transaction managers, requiring an explicit switch to "true" + * if you want to use nested transactions for JDBC access code which participates + * in JPA transactions (provided that your JDBC driver supports savepoints). + * Note that JPA itself does not support nested transactions! Hence, do not + * expect JPA access code to semantically participate in a nested transaction. * * @author Juergen Hoeller * @since 2.0 From d17601e01c2aa9d95739699e67f0acc374456948 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Wed, 10 Sep 2025 18:38:20 +0200 Subject: [PATCH 214/591] Upgrade to Undertow 2.3.19, RxJava 3.1.11, Aalto 1.3.3 --- framework-platform/framework-platform.gradle | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index 809f83d72cfa..5b42b34336e1 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -24,7 +24,7 @@ dependencies { api(platform("org.mockito:mockito-bom:5.19.0")) constraints { - api("com.fasterxml:aalto-xml:1.3.2") + api("com.fasterxml:aalto-xml:1.3.3") api("com.fasterxml.woodstox:woodstox-core:6.7.0") api("com.github.ben-manes.caffeine:caffeine:3.2.2") api("com.github.librepdf:openpdf:1.3.43") @@ -53,11 +53,11 @@ dependencies { api("io.r2dbc:r2dbc-h2:1.0.0.RELEASE") api("io.r2dbc:r2dbc-spi-test:1.0.0.RELEASE") api("io.r2dbc:r2dbc-spi:1.0.0.RELEASE") - api("io.reactivex.rxjava3:rxjava:3.1.10") + api("io.reactivex.rxjava3:rxjava:3.1.11") api("io.smallrye.reactive:mutiny:1.10.0") - api("io.undertow:undertow-core:2.3.18.Final") - api("io.undertow:undertow-servlet:2.3.18.Final") - api("io.undertow:undertow-websockets-jsr:2.3.18.Final") + api("io.undertow:undertow-core:2.3.19.Final") + api("io.undertow:undertow-servlet:2.3.19.Final") + api("io.undertow:undertow-websockets-jsr:2.3.19.Final") api("io.vavr:vavr:0.10.4") api("jakarta.activation:jakarta.activation-api:2.0.1") api("jakarta.annotation:jakarta.annotation-api:2.0.0") From cbdd1077998a27ded20c59148d5ab0313503981f Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Thu, 11 Sep 2025 09:40:23 +0200 Subject: [PATCH 215/591] Next development version (v6.2.12-SNAPSHOT) --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 25e5cb4cd721..b8ba27838bc3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=6.2.11-SNAPSHOT +version=6.2.12-SNAPSHOT org.gradle.caching=true org.gradle.jvmargs=-Xmx2048m From 7a5d3a55feb0ee3c8efc6fbd6009725616b03ce6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Thu, 11 Sep 2025 15:24:51 +0200 Subject: [PATCH 216/591] Refine EntityManagerRuntimeHints for Hibernate 7.1+ This commit adds support for Hibernate 7.1+ SqmQueryImpl class in EntityManagerRuntimeHints, keeps the support for the former QuerySqmImpl class for Hibernate 7.0 compatibility and adds a related test. Closes gh-35462 --- .../orm/jpa/EntityManagerRuntimeHints.java | 10 ++++++++++ .../orm/jpa/EntityManagerRuntimeHintsTests.java | 15 +++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/EntityManagerRuntimeHints.java b/spring-orm/src/main/java/org/springframework/orm/jpa/EntityManagerRuntimeHints.java index 9f11670367fb..b7517b38ad05 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/EntityManagerRuntimeHints.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/EntityManagerRuntimeHints.java @@ -39,8 +39,12 @@ class EntityManagerRuntimeHints implements RuntimeHintsRegistrar { private static final String ENTITY_MANAGER_FACTORY_CLASS_NAME = "jakarta.persistence.EntityManagerFactory"; + // Up to Hibernate 7.0 private static final String QUERY_SQM_IMPL_CLASS_NAME = "org.hibernate.query.sqm.internal.QuerySqmImpl"; + // As of Hibernate 7.1 + private static final String SQM_QUERY_IMPL_CLASS_NAME = "org.hibernate.query.sqm.internal.SqmQueryImpl"; + private static final String NATIVE_QUERY_IMPL_CLASS_NAME = "org.hibernate.query.sql.internal.NativeQueryImpl"; @@ -66,6 +70,12 @@ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) } catch (ClassNotFoundException ignored) { } + try { + Class clazz = ClassUtils.forName(SQM_QUERY_IMPL_CLASS_NAME, classLoader); + hints.proxies().registerJdkProxy(ClassUtils.getAllInterfacesForClass(clazz, classLoader)); + } + catch (ClassNotFoundException ignored) { + } try { Class clazz = ClassUtils.forName(NATIVE_QUERY_IMPL_CLASS_NAME, classLoader); hints.proxies().registerJdkProxy(ClassUtils.getAllInterfacesForClass(clazz, classLoader)); diff --git a/spring-orm/src/test/java/org/springframework/orm/jpa/EntityManagerRuntimeHintsTests.java b/spring-orm/src/test/java/org/springframework/orm/jpa/EntityManagerRuntimeHintsTests.java index cca7f0472682..1ecb134a82c7 100644 --- a/spring-orm/src/test/java/org/springframework/orm/jpa/EntityManagerRuntimeHintsTests.java +++ b/spring-orm/src/test/java/org/springframework/orm/jpa/EntityManagerRuntimeHintsTests.java @@ -19,6 +19,11 @@ import jakarta.persistence.EntityManagerFactory; import org.hibernate.Session; import org.hibernate.SessionFactory; +import org.hibernate.query.CommonQueryContract; +import org.hibernate.query.SelectionQuery; +import org.hibernate.query.hql.spi.SqmQueryImplementor; +import org.hibernate.query.spi.DomainQueryExecutionContext; +import org.hibernate.query.sqm.spi.InterpretationsKeySource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -63,4 +68,14 @@ void entityManagerFactoryHasReflectionHints() { assertThat(RuntimeHintsPredicates.reflection().onMethodInvocation(EntityManagerFactory.class, "getCriteriaBuilder")).accepts(this.hints); assertThat(RuntimeHintsPredicates.reflection().onMethodInvocation(EntityManagerFactory.class, "getMetamodel")).accepts(this.hints); } + + @Test + void sqmQueryHints() { + assertThat(RuntimeHintsPredicates.proxies().forInterfaces( + SqmQueryImplementor.class, + InterpretationsKeySource.class, + DomainQueryExecutionContext.class, + SelectionQuery.class, + CommonQueryContract.class)).accepts(this.hints); + } } From 20e1149dde7ff042154e4098d49939a886661c3e Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Fri, 12 Sep 2025 09:12:33 +0200 Subject: [PATCH 217/591] Fix synchronization in ResponseBodyEmitter See gh-35423 Fixes gh-35466 --- .../mvc/method/annotation/ResponseBodyEmitter.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitter.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitter.java index f7a3aa218d33..5a368b5d994e 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitter.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitter.java @@ -198,10 +198,10 @@ public void send(Object object) throws IOException { * @throws java.lang.IllegalStateException wraps any other errors */ public void send(Object object, @Nullable MediaType mediaType) throws IOException { - Assert.state(!this.complete, () -> "ResponseBodyEmitter has already completed" + - (this.failure != null ? " with error: " + this.failure : "")); this.writeLock.lock(); try { + Assert.state(!this.complete, () -> "ResponseBodyEmitter has already completed" + + (this.failure != null ? " with error: " + this.failure : "")); if (this.handler != null) { try { this.handler.send(object, mediaType); @@ -232,10 +232,10 @@ public void send(Object object, @Nullable MediaType mediaType) throws IOExceptio * @since 6.0.12 */ public void send(Set items) throws IOException { - Assert.state(!this.complete, () -> "ResponseBodyEmitter has already completed" + - (this.failure != null ? " with error: " + this.failure : "")); this.writeLock.lock(); try { + Assert.state(!this.complete, () -> "ResponseBodyEmitter has already completed" + + (this.failure != null ? " with error: " + this.failure : "")); sendInternal(items); } finally { From 2faed3cdbb206bb5a92b3bd64bb18b2ad3501900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Fri, 12 Sep 2025 10:43:46 +0200 Subject: [PATCH 218/591] Refine PropagationContextElement This commit apply several refinements to PropagationContextElement: - Capture the ThreadLocal when instantiating the PropagationContextElement in order to support dispatchers switching threads - Remove the constructor parameter which is not idiomatic and breaks the support when switching threads, and use instead the updateThreadContext(context: CoroutineContext) parameter - Make the kotlinx-coroutines-reactor dependency optional - Make the properties private The Javadoc and tests are also updated to use the `Dispatchers.IO + PropagationContextElement()` pattern performed outside of the suspending lambda, which is the typical use case. Closes gh-35469 --- .../pages/languages/kotlin/coroutines.adoc | 25 ++----- .../propagation/ContextPropagationSample.kt | 16 ++-- .../core/PropagationContextElement.kt | 73 ++++++++++++------- .../core/PropagationContextElementTests.kt | 43 ++++------- 4 files changed, 81 insertions(+), 76 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/languages/kotlin/coroutines.adoc b/framework-docs/modules/ROOT/pages/languages/kotlin/coroutines.adoc index 5b595197308c..57d2342c4476 100644 --- a/framework-docs/modules/ROOT/pages/languages/kotlin/coroutines.adoc +++ b/framework-docs/modules/ROOT/pages/languages/kotlin/coroutines.adoc @@ -257,29 +257,20 @@ For Kotlin `Flow`, a `Flow.transactional` extension is provided. Spring applications are xref:integration/observability.adoc[instrumented with Micrometer for Observability support]. For tracing support, the current observation is propagated through a `ThreadLocal` for blocking code, or the Reactor `Context` for reactive pipelines. But the current observation also needs to be made available -in the execution context of a suspended function. Without that, the current "traceId" will not be automatically prepended -to logged statements from coroutines. +in the execution context of a suspended function. Without that, the current "traceId" will not be automatically +prepended to logged statements from coroutines. -The `org.springframework.core.PropagationContextElement` operator generally ensures that the +The {spring-framework-api-kdoc}/spring-core/org.springframework.core/-propagation-context-element/index.html[`PropagationContextElement`] operator generally ensures that the {micrometer-context-propagation-docs}/[Micrometer Context Propagation library] works with Kotlin Coroutines. -The `PropagationContextElement` requires the following dependencies: +It requires the `io.micrometer:context-propagation` dependency and optionally the +`org.jetbrains.kotlinx:kotlinx-coroutines-reactor` one. -`build.gradle.kts` -[source,kotlin,indent=0] ----- -dependencies { - implementation("io.micrometer:context-propagation:${contextPropagationVersion}") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:${coroutinesVersion}") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:${coroutinesVersion}") -} ----- - -Applications can then use the `PropagationContextElement` operator to connect the `currentCoroutineContext()` +Applications can then use the `PropagationContextElement` to augment the `CoroutineContext` with the context propagation mechanism: include-code::./ContextPropagationSample[tag=context,indent=0] -Here, assuming that Micrometer Tracing is configured, the resulting logging statement -will show the current "traceId" and unlock better observability for your application. +Here, assuming that Micrometer Tracing is configured, the resulting logging statement will show the current "traceId" +and unlock better observability for your application. diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/languages/kotlin/coroutines/propagation/ContextPropagationSample.kt b/framework-docs/src/main/kotlin/org/springframework/docs/languages/kotlin/coroutines/propagation/ContextPropagationSample.kt index e8f0721511a2..06e7926de27e 100644 --- a/framework-docs/src/main/kotlin/org/springframework/docs/languages/kotlin/coroutines/propagation/ContextPropagationSample.kt +++ b/framework-docs/src/main/kotlin/org/springframework/docs/languages/kotlin/coroutines/propagation/ContextPropagationSample.kt @@ -16,8 +16,9 @@ package org.springframework.docs.languages.kotlin.coroutines.propagation -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.withContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking import org.apache.commons.logging.Log import org.apache.commons.logging.LogFactory import org.springframework.core.PropagationContextElement @@ -31,10 +32,15 @@ class ContextPropagationSample { } // tag::context[] + fun main() { + runBlocking(Dispatchers.IO + PropagationContextElement()) { + suspendingFunction() + } + } + suspend fun suspendingFunction() { - return withContext(PropagationContextElement(currentCoroutineContext())) { - logger.info("Suspending function with traceId") - } + delay(1) + logger.info("Suspending function with traceId") } // end::context[] } \ No newline at end of file diff --git a/spring-core/src/main/kotlin/org/springframework/core/PropagationContextElement.kt b/spring-core/src/main/kotlin/org/springframework/core/PropagationContextElement.kt index c55d66fd4162..ad7ae35948a7 100644 --- a/spring-core/src/main/kotlin/org/springframework/core/PropagationContextElement.kt +++ b/spring-core/src/main/kotlin/org/springframework/core/PropagationContextElement.kt @@ -21,55 +21,78 @@ import io.micrometer.context.ContextSnapshot import io.micrometer.context.ContextSnapshotFactory import kotlinx.coroutines.ThreadContextElement import kotlinx.coroutines.reactor.ReactorContext +import org.springframework.util.ClassUtils import reactor.util.context.ContextView import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext /** - * [ThreadContextElement] that restores `ThreadLocals` from the Reactor [ContextSnapshot] - * every time the coroutine with this element in the context is resumed on a thread. + * [ThreadContextElement] that ensures that contexts registered with the + * Micrometer Context Propagation library are captured and restored when + * a coroutine is resumed on a thread. This is typically being used for + * Micrometer Tracing support in Kotlin suspended functions. * - * This effectively ensures that Kotlin Coroutines, Reactor and Micrometer Context Propagation - * work together in an application, typically for observability purposes. + * It requires the `io.micrometer:context-propagation` library. If the + * `org.jetbrains.kotlinx:kotlinx-coroutines-reactor` dependency is also + * on the classpath, this element also supports Reactor `Context`. * - * Applications need to have both `"io.micrometer:context-propagation"` and - * `"org.jetbrains.kotlinx:kotlinx-coroutines-reactor"` on the classpath to use this context element. + * `PropagationContextElement` can be used like this: * - * The `PropagationContextElement` can be used like this: - * * ```kotlin - * suspend fun suspendable() { - * withContext(PropagationContextElement(coroutineContext)) { - * logger.info("Log statement with traceId") - * } + * fun main() { + * runBlocking(Dispatchers.IO + PropagationContextElement()) { + * suspendingFunction() + * } * } + * + * suspend fun suspendingFunction() { + * delay(1) + * logger.info("Log statement with traceId") + * } * ``` * * @author Brian Clozel + * @author Sebastien Deleuze * @since 7.0 */ -class PropagationContextElement(private val context: CoroutineContext) : ThreadContextElement, +class PropagationContextElement : ThreadContextElement, AbstractCoroutineContextElement(Key) { - companion object Key : CoroutineContext.Key + companion object Key : CoroutineContext.Key { - val contextSnapshot: ContextSnapshot - get() { - val contextView: ContextView? = context[ReactorContext]?.context - val contextSnapshotFactory = - ContextSnapshotFactory.builder().contextRegistry(ContextRegistry.getInstance()).build() - if (contextView != null) { - return contextSnapshotFactory.captureFrom(contextView) - } - return contextSnapshotFactory.captureAll() - } + private val contextSnapshotFactory = + ContextSnapshotFactory.builder().contextRegistry(ContextRegistry.getInstance()).build() + + private val coroutinesReactorPresent = + ClassUtils.isPresent("kotlinx.coroutines.reactor.ReactorContext", + PropagationContextElement::class.java.classLoader); + } + + // Context captured from the the ThreadLocal where the PropagationContextElement is instantiated + private val threadLocalContextSnapshot: ContextSnapshot = contextSnapshotFactory.captureAll() override fun restoreThreadContext(context: CoroutineContext, oldState: ContextSnapshot.Scope) { oldState.close() } override fun updateThreadContext(context: CoroutineContext): ContextSnapshot.Scope { + val contextSnapshot = if (coroutinesReactorPresent) { + ReactorDelegate().captureFrom(context) ?: threadLocalContextSnapshot + } else { + threadLocalContextSnapshot + } return contextSnapshot.setThreadLocals() } -} \ No newline at end of file + + private class ReactorDelegate { + + fun captureFrom(context: CoroutineContext): ContextSnapshot? { + val contextView: ContextView? = context[ReactorContext]?.context + if (contextView != null) { + return contextSnapshotFactory.captureFrom(contextView) + } + return null; + } + } +} diff --git a/spring-core/src/test/kotlin/org/springframework/core/PropagationContextElementTests.kt b/spring-core/src/test/kotlin/org/springframework/core/PropagationContextElementTests.kt index d7a7bdf5711b..544379966681 100644 --- a/spring-core/src/test/kotlin/org/springframework/core/PropagationContextElementTests.kt +++ b/spring-core/src/test/kotlin/org/springframework/core/PropagationContextElementTests.kt @@ -19,18 +19,14 @@ package org.springframework.core import io.micrometer.observation.Observation import io.micrometer.observation.tck.TestObservationRegistry import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext import org.assertj.core.api.Assertions import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.AfterAll -import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test import org.reactivestreams.Publisher import reactor.core.publisher.Hooks import reactor.core.publisher.Mono -import reactor.core.scheduler.Schedulers import kotlin.coroutines.Continuation @@ -38,32 +34,18 @@ import kotlin.coroutines.Continuation * Kotlin tests for [PropagationContextElement]. * * @author Brian Clozel + * @author Sebastien Deleuze */ class PropagationContextElementTests { private val observationRegistry = TestObservationRegistry.create() - companion object { - - @BeforeAll - @JvmStatic - fun init() { - Hooks.enableAutomaticContextPropagation() - } - - @AfterAll - @JvmStatic - fun cleanup() { - Hooks.disableAutomaticContextPropagation() - } - - } - @Test fun restoresFromThreadLocal() { val observation = Observation.createNotStarted("coroutine", observationRegistry) observation.observe { - val result = runBlocking(Dispatchers.Unconfined) { + val coroutineContext = Dispatchers.IO + PropagationContextElement() + val result = runBlocking(coroutineContext) { suspendingFunction("test") } Assertions.assertThat(result).isEqualTo("coroutine") @@ -74,20 +56,23 @@ class PropagationContextElementTests { @Suppress("UNCHECKED_CAST") fun restoresFromReactorContext() { val method = PropagationContextElementTests::class.java.getDeclaredMethod("suspendingFunction", String::class.java, Continuation::class.java) - val publisher = CoroutinesUtils.invokeSuspendingFunction(method, this, "test", null) as Publisher + val coroutineContext = Dispatchers.IO + PropagationContextElement() + val publisher = CoroutinesUtils.invokeSuspendingFunction(coroutineContext, method, this, "test", null) as Publisher val observation = Observation.createNotStarted("coroutine", observationRegistry) + Hooks.enableAutomaticContextPropagation() observation.observe { - val result = Mono.from(publisher).publishOn(Schedulers.boundedElastic()).block() + val mono = Mono.from(publisher) + val result = mono.block() assertThat(result).isEqualTo("coroutine") } + Hooks.disableAutomaticContextPropagation() } suspend fun suspendingFunction(value: String): String? { - return withContext(PropagationContextElement(currentCoroutineContext())) { - val currentObservation = observationRegistry.currentObservation - assertThat(currentObservation).isNotNull - currentObservation?.context?.name - } + delay(1) + val currentObservation = observationRegistry.currentObservation + assertThat(currentObservation).isNotNull + return currentObservation?.context?.name } } From 84bd44e5de11702d9f9e97b497ce40c8eb26bbf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Mon, 15 Sep 2025 10:18:04 +0200 Subject: [PATCH 219/591] Use nullable body consistently in HttpClientErrorException Closes gh-35482 --- .../web/client/HttpClientErrorException.java | 53 ++++++++++--------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/web/client/HttpClientErrorException.java b/spring-web/src/main/java/org/springframework/web/client/HttpClientErrorException.java index d2add5870430..ce6bf490db63 100644 --- a/spring-web/src/main/java/org/springframework/web/client/HttpClientErrorException.java +++ b/spring-web/src/main/java/org/springframework/web/client/HttpClientErrorException.java @@ -28,6 +28,7 @@ * Exception thrown when an HTTP 4xx is received. * * @author Arjen Poutsma + * @author Sebastien Deleuze * @since 3.0 * @see DefaultResponseErrorHandler */ @@ -85,7 +86,7 @@ public HttpClientErrorException(String message, HttpStatusCode statusCode, Strin * @since 5.1 */ public static HttpClientErrorException create( - HttpStatusCode statusCode, String statusText, HttpHeaders headers, byte[] body, @Nullable Charset charset) { + HttpStatusCode statusCode, String statusText, HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { return create(null, statusCode, statusText, headers, body, charset); } @@ -97,7 +98,7 @@ public static HttpClientErrorException create( */ @SuppressWarnings("deprecation") public static HttpClientErrorException create(@Nullable String message, HttpStatusCode statusCode, - String statusText, HttpHeaders headers, byte[] body, @Nullable Charset charset) { + String statusText, HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { if (statusCode instanceof HttpStatus status) { return switch (status) { @@ -160,12 +161,12 @@ public static HttpClientErrorException create(@Nullable String message, HttpStat @SuppressWarnings("serial") public static final class BadRequest extends HttpClientErrorException { - private BadRequest(String statusText, HttpHeaders headers, byte[] body, @Nullable Charset charset) { + private BadRequest(String statusText, HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { super(HttpStatus.BAD_REQUEST, statusText, headers, body, charset); } private BadRequest(String message, String statusText, - HttpHeaders headers, byte[] body, @Nullable Charset charset) { + HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { super(message, HttpStatus.BAD_REQUEST, statusText, headers, body, charset); } @@ -178,12 +179,12 @@ private BadRequest(String message, String statusText, @SuppressWarnings("serial") public static final class Unauthorized extends HttpClientErrorException { - private Unauthorized(String statusText, HttpHeaders headers, byte[] body, @Nullable Charset charset) { + private Unauthorized(String statusText, HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { super(HttpStatus.UNAUTHORIZED, statusText, headers, body, charset); } private Unauthorized(String message, String statusText, - HttpHeaders headers, byte[] body, @Nullable Charset charset) { + HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { super(message, HttpStatus.UNAUTHORIZED, statusText, headers, body, charset); } @@ -196,12 +197,12 @@ private Unauthorized(String message, String statusText, @SuppressWarnings("serial") public static final class Forbidden extends HttpClientErrorException { - private Forbidden(String statusText, HttpHeaders headers, byte[] body, @Nullable Charset charset) { + private Forbidden(String statusText, HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { super(HttpStatus.FORBIDDEN, statusText, headers, body, charset); } private Forbidden(String message, String statusText, - HttpHeaders headers, byte[] body, @Nullable Charset charset) { + HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { super(message, HttpStatus.FORBIDDEN, statusText, headers, body, charset); } @@ -214,12 +215,12 @@ private Forbidden(String message, String statusText, @SuppressWarnings("serial") public static final class NotFound extends HttpClientErrorException { - private NotFound(String statusText, HttpHeaders headers, byte[] body, @Nullable Charset charset) { + private NotFound(String statusText, HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { super(HttpStatus.NOT_FOUND, statusText, headers, body, charset); } private NotFound(String message, String statusText, - HttpHeaders headers, byte[] body, @Nullable Charset charset) { + HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { super(message, HttpStatus.NOT_FOUND, statusText, headers, body, charset); } @@ -232,12 +233,12 @@ private NotFound(String message, String statusText, @SuppressWarnings("serial") public static final class MethodNotAllowed extends HttpClientErrorException { - private MethodNotAllowed(String statusText, HttpHeaders headers, byte[] body, @Nullable Charset charset) { + private MethodNotAllowed(String statusText, HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { super(HttpStatus.METHOD_NOT_ALLOWED, statusText, headers, body, charset); } private MethodNotAllowed(String message, String statusText, - HttpHeaders headers, byte[] body, @Nullable Charset charset) { + HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { super(message, HttpStatus.METHOD_NOT_ALLOWED, statusText, headers, body, charset); } @@ -250,12 +251,12 @@ private MethodNotAllowed(String message, String statusText, @SuppressWarnings("serial") public static final class NotAcceptable extends HttpClientErrorException { - private NotAcceptable(String statusText, HttpHeaders headers, byte[] body, @Nullable Charset charset) { + private NotAcceptable(String statusText, HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { super(HttpStatus.NOT_ACCEPTABLE, statusText, headers, body, charset); } private NotAcceptable(String message, String statusText, - HttpHeaders headers, byte[] body, @Nullable Charset charset) { + HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { super(message, HttpStatus.NOT_ACCEPTABLE, statusText, headers, body, charset); } @@ -268,11 +269,11 @@ private NotAcceptable(String message, String statusText, @SuppressWarnings("serial") public static final class Conflict extends HttpClientErrorException { - private Conflict(String statusText, HttpHeaders headers, byte[] body, @Nullable Charset charset) { + private Conflict(String statusText, HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { super(HttpStatus.CONFLICT, statusText, headers, body, charset); } - private Conflict(String message, String statusText, HttpHeaders headers, byte[] body, @Nullable Charset charset) { + private Conflict(String message, String statusText, HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { super(message, HttpStatus.CONFLICT, statusText, headers, body, charset); } } @@ -284,11 +285,11 @@ private Conflict(String message, String statusText, HttpHeaders headers, byte[] @SuppressWarnings("serial") public static final class Gone extends HttpClientErrorException { - private Gone(String statusText, HttpHeaders headers, byte[] body, @Nullable Charset charset) { + private Gone(String statusText, HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { super(HttpStatus.GONE, statusText, headers, body, charset); } - private Gone(String message, String statusText, HttpHeaders headers, byte[] body, @Nullable Charset charset) { + private Gone(String message, String statusText, HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { super(message, HttpStatus.GONE, statusText, headers, body, charset); } } @@ -300,12 +301,12 @@ private Gone(String message, String statusText, HttpHeaders headers, byte[] body @SuppressWarnings("serial") public static final class UnsupportedMediaType extends HttpClientErrorException { - private UnsupportedMediaType(String statusText, HttpHeaders headers, byte[] body, @Nullable Charset charset) { + private UnsupportedMediaType(String statusText, HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { super(HttpStatus.UNSUPPORTED_MEDIA_TYPE, statusText, headers, body, charset); } private UnsupportedMediaType(String message, String statusText, - HttpHeaders headers, byte[] body, @Nullable Charset charset) { + HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { super(message, HttpStatus.UNSUPPORTED_MEDIA_TYPE, statusText, headers, body, charset); } @@ -318,12 +319,12 @@ private UnsupportedMediaType(String message, String statusText, @SuppressWarnings("serial") public static final class UnprocessableContent extends HttpClientErrorException { - private UnprocessableContent(String statusText, HttpHeaders headers, byte[] body, @Nullable Charset charset) { + private UnprocessableContent(String statusText, HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { super(HttpStatus.UNPROCESSABLE_CONTENT, statusText, headers, body, charset); } private UnprocessableContent(String message, String statusText, - HttpHeaders headers, byte[] body, @Nullable Charset charset) { + HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { super(message, HttpStatus.UNPROCESSABLE_CONTENT, statusText, headers, body, charset); } @@ -338,12 +339,12 @@ private UnprocessableContent(String message, String statusText, @SuppressWarnings("serial") public static final class UnprocessableEntity extends HttpClientErrorException { - private UnprocessableEntity(String statusText, HttpHeaders headers, byte[] body, @Nullable Charset charset) { + private UnprocessableEntity(String statusText, HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { super(HttpStatus.UNPROCESSABLE_ENTITY, statusText, headers, body, charset); } private UnprocessableEntity(String message, String statusText, - HttpHeaders headers, byte[] body, @Nullable Charset charset) { + HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { super(message, HttpStatus.UNPROCESSABLE_ENTITY, statusText, headers, body, charset); } @@ -356,12 +357,12 @@ private UnprocessableEntity(String message, String statusText, @SuppressWarnings("serial") public static final class TooManyRequests extends HttpClientErrorException { - private TooManyRequests(String statusText, HttpHeaders headers, byte[] body, @Nullable Charset charset) { + private TooManyRequests(String statusText, HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { super(HttpStatus.TOO_MANY_REQUESTS, statusText, headers, body, charset); } private TooManyRequests(String message, String statusText, - HttpHeaders headers, byte[] body, @Nullable Charset charset) { + HttpHeaders headers, byte @Nullable [] body, @Nullable Charset charset) { super(message, HttpStatus.TOO_MANY_REQUESTS, statusText, headers, body, charset); } From 0a48984fab3657727ab5ed2766186f7486fe4a4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Nicoll?= Date: Tue, 16 Sep 2025 16:08:15 +0200 Subject: [PATCH 220/591] Suppress deprecating warnings for getInstanceSupplier() method as well This commit fixes code generation for a bean produced by a protected factory method. Previously only the code generated for public methods, i.e. without a dedicated instance supplier method, was handled. Closes gh-35486 --- .../aot/InstanceSupplierCodeGenerator.java | 3 ++ .../InstanceSupplierCodeGeneratorTests.java | 30 +++++++++++++++++++ ...precatedForRemovalMemberConfiguration.java | 10 +++++++ .../DeprecatedMemberConfiguration.java | 5 ++++ 4 files changed, 48 insertions(+) diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/aot/InstanceSupplierCodeGenerator.java b/spring-beans/src/main/java/org/springframework/beans/factory/aot/InstanceSupplierCodeGenerator.java index 91a84acf0c2e..b19b34218e26 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/aot/InstanceSupplierCodeGenerator.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/aot/InstanceSupplierCodeGenerator.java @@ -294,9 +294,12 @@ private CodeBlock generateCodeForInaccessibleFactoryMethod( this.generationContext.getRuntimeHints().reflection().registerMethod(factoryMethod, ExecutableMode.INVOKE); GeneratedMethod getInstanceMethod = generateGetInstanceSupplierMethod(method -> { + CodeWarnings codeWarnings = new CodeWarnings(); Class suppliedType = ClassUtils.resolvePrimitiveIfNecessary(factoryMethod.getReturnType()); + codeWarnings.detectDeprecation(suppliedType, factoryMethod); method.addJavadoc("Get the bean instance supplier for '$L'.", beanName); method.addModifiers(PRIVATE_STATIC); + codeWarnings.suppress(method); method.returns(ParameterizedTypeName.get(BeanInstanceSupplier.class, suppliedType)); method.addStatement(generateInstanceSupplierForFactoryMethod( factoryMethod, suppliedType, targetClass, factoryMethod.getName())); diff --git a/spring-beans/src/test/java/org/springframework/beans/factory/aot/InstanceSupplierCodeGeneratorTests.java b/spring-beans/src/test/java/org/springframework/beans/factory/aot/InstanceSupplierCodeGeneratorTests.java index 8ab92b8f97ec..57e72aa3ed0d 100644 --- a/spring-beans/src/test/java/org/springframework/beans/factory/aot/InstanceSupplierCodeGeneratorTests.java +++ b/spring-beans/src/test/java/org/springframework/beans/factory/aot/InstanceSupplierCodeGeneratorTests.java @@ -418,6 +418,16 @@ void generateWhenTargetFactoryMethodReturnTypeIsDeprecated() { compileAndCheckWarnings(beanDefinition); } + @Test + void generateWhenTargetFactoryMethodIsProtectedAndReturnTypeIsDeprecated() { + BeanDefinition beanDefinition = BeanDefinitionBuilder + .rootBeanDefinition(DeprecatedBean.class) + .setFactoryMethodOnBean("deprecatedReturnTypeProtected", "config").getBeanDefinition(); + beanFactory.registerBeanDefinition("config", BeanDefinitionBuilder + .genericBeanDefinition(DeprecatedMemberConfiguration.class).getBeanDefinition()); + compileAndCheckWarnings(beanDefinition); + } + private void compileAndCheckWarnings(BeanDefinition beanDefinition) { assertThatNoException().isThrownBy(() -> compile(TEST_COMPILER, beanDefinition, ((instanceSupplier, compiled) -> {}))); @@ -464,6 +474,26 @@ void generateWhenTargetFactoryMethodParameterIsDeprecatedForRemoval() { compileAndCheckWarnings(beanDefinition); } + @Test + void generateWhenTargetFactoryMethodReturnTypeIsDeprecatedForRemoval() { + BeanDefinition beanDefinition = BeanDefinitionBuilder + .rootBeanDefinition(DeprecatedForRemovalBean.class) + .setFactoryMethodOnBean("deprecatedReturnType", "config").getBeanDefinition(); + beanFactory.registerBeanDefinition("config", BeanDefinitionBuilder + .genericBeanDefinition(DeprecatedForRemovalMemberConfiguration.class).getBeanDefinition()); + compileAndCheckWarnings(beanDefinition); + } + + @Test + void generateWhenTargetFactoryMethodIsProtectedAndReturnTypeIsDeprecatedForRemoval() { + BeanDefinition beanDefinition = BeanDefinitionBuilder + .rootBeanDefinition(DeprecatedForRemovalBean.class) + .setFactoryMethodOnBean("deprecatedReturnTypeProtected", "config").getBeanDefinition(); + beanFactory.registerBeanDefinition("config", BeanDefinitionBuilder + .genericBeanDefinition(DeprecatedForRemovalMemberConfiguration.class).getBeanDefinition()); + compileAndCheckWarnings(beanDefinition); + } + private void compileAndCheckWarnings(BeanDefinition beanDefinition) { assertThatNoException().isThrownBy(() -> compile(TEST_COMPILER, beanDefinition, ((instanceSupplier, compiled) -> {}))); diff --git a/spring-beans/src/testFixtures/java/org/springframework/beans/testfixture/beans/factory/generator/deprecation/DeprecatedForRemovalMemberConfiguration.java b/spring-beans/src/testFixtures/java/org/springframework/beans/testfixture/beans/factory/generator/deprecation/DeprecatedForRemovalMemberConfiguration.java index eabb36092149..68c077a8e816 100644 --- a/spring-beans/src/testFixtures/java/org/springframework/beans/testfixture/beans/factory/generator/deprecation/DeprecatedForRemovalMemberConfiguration.java +++ b/spring-beans/src/testFixtures/java/org/springframework/beans/testfixture/beans/factory/generator/deprecation/DeprecatedForRemovalMemberConfiguration.java @@ -33,4 +33,14 @@ public String deprecatedParameter(DeprecatedForRemovalBean bean) { return bean.toString(); } + @SuppressWarnings("removal") + public DeprecatedForRemovalBean deprecatedReturnType() { + return new DeprecatedForRemovalBean(); + } + + @SuppressWarnings("removal") + DeprecatedForRemovalBean deprecatedReturnTypeProtected() { + return new DeprecatedForRemovalBean(); + } + } diff --git a/spring-beans/src/testFixtures/java/org/springframework/beans/testfixture/beans/factory/generator/deprecation/DeprecatedMemberConfiguration.java b/spring-beans/src/testFixtures/java/org/springframework/beans/testfixture/beans/factory/generator/deprecation/DeprecatedMemberConfiguration.java index 96c3a3453958..3508df0a88de 100644 --- a/spring-beans/src/testFixtures/java/org/springframework/beans/testfixture/beans/factory/generator/deprecation/DeprecatedMemberConfiguration.java +++ b/spring-beans/src/testFixtures/java/org/springframework/beans/testfixture/beans/factory/generator/deprecation/DeprecatedMemberConfiguration.java @@ -38,4 +38,9 @@ public DeprecatedBean deprecatedReturnType() { return new DeprecatedBean(); } + @SuppressWarnings("deprecation") + DeprecatedBean deprecatedReturnTypeProtected() { + return new DeprecatedBean(); + } + } From c7121d048cfa6691dc3b0a675ddc790d5f7dc713 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Wed, 17 Sep 2025 11:48:49 +0100 Subject: [PATCH 221/591] Replace X-API-Version with API-Version Closes gh-35494 --- framework-docs/modules/ROOT/pages/web/webflux/config.adoc | 4 ++-- .../mvcconfig/mvcconfigapiversion/WebConfiguration.java | 2 +- .../mvcconfig/mvcconfigapiversion/WebConfiguration.kt | 2 +- .../test/web/reactive/server/samples/ApiVersionTests.java | 4 ++-- .../test/web/servlet/client/samples/ApiVersionTests.java | 4 ++-- .../web/client/RestClientVersionTests.java | 8 ++++---- .../web/client/support/RestClientAdapterTests.java | 4 ++-- .../reactive/function/client/WebClientVersionTests.java | 8 ++++---- .../server/support/RouterFunctionMappingVersionTests.java | 6 +++--- .../annotation/RequestMappingVersionIntegrationTests.java | 4 ++-- .../WebMvcConfigurationSupportExtensionTests.java | 2 +- .../support/RouterFunctionMappingVersionTests.java | 6 +++--- .../RequestMappingVersionHandlerMethodTests.java | 4 ++-- 13 files changed, 29 insertions(+), 29 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/web/webflux/config.adoc b/framework-docs/modules/ROOT/pages/web/webflux/config.adoc index 65259c6c18fa..9f3e371686f7 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/config.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/config.adoc @@ -691,7 +691,7 @@ Java:: @Override public void configureApiVersioning(ApiVersionConfigurer configurer) { - configurer.useRequestHeader("X-API-Version"); + configurer.useRequestHeader("API-Version"); } } ---- @@ -704,7 +704,7 @@ Kotlin:: class WebConfiguration : WebMvcConfigurer { override fun configureApiVersioning(configurer: ApiVersionConfigurer) { - configurer.useRequestHeader("X-API-Version") + configurer.useRequestHeader("API-Version") } } ---- diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigapiversion/WebConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigapiversion/WebConfiguration.java index a58293146da2..e99f555c14e1 100644 --- a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigapiversion/WebConfiguration.java +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigapiversion/WebConfiguration.java @@ -26,7 +26,7 @@ public class WebConfiguration implements WebMvcConfigurer { @Override public void configureApiVersioning(ApiVersionConfigurer configurer) { - configurer.useRequestHeader("X-API-Version"); } + configurer.useRequestHeader("API-Version"); } // end::snippet[] diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigapiversion/WebConfiguration.kt b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigapiversion/WebConfiguration.kt index dec34ad91964..4a315aef00be 100644 --- a/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigapiversion/WebConfiguration.kt +++ b/framework-docs/src/main/kotlin/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigapiversion/WebConfiguration.kt @@ -25,7 +25,7 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer class WebConfiguration : WebMvcConfigurer { override fun configureApiVersioning(configurer: ApiVersionConfigurer) { - configurer.useRequestHeader("X-API-Version") + configurer.useRequestHeader("API-Version") } } // end::snippet[] \ No newline at end of file diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/ApiVersionTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/ApiVersionTests.java index 6efcc987caff..457db241c656 100644 --- a/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/ApiVersionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/ApiVersionTests.java @@ -40,7 +40,7 @@ public class ApiVersionTests { @Test void header() { - String header = "X-API-Version"; + String header = "API-Version"; Map result = performRequest( configurer -> configurer.useRequestHeader(header), @@ -92,7 +92,7 @@ private Map performRequest( @RestController static class TestController { - private static final String HEADER = "X-API-Version"; + private static final String HEADER = "API-Version"; @GetMapping(path = "/**", version = "1.2") Map handle(ServerHttpRequest request) { diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ApiVersionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ApiVersionTests.java index 70af17d22e09..a9b8d0a4ff58 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ApiVersionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/ApiVersionTests.java @@ -44,7 +44,7 @@ public class ApiVersionTests { @Test void header() { - String header = "X-API-Version"; + String header = "API-Version"; Map result = performRequest( request -> request.getHeader(header), ApiVersionInserter.useHeader(header)); @@ -96,7 +96,7 @@ private Map performRequest( @RestController private static class TestController { - private static final String HEADER = "X-API-Version"; + private static final String HEADER = "API-Version"; @GetMapping(path = "/**", version = "1.2") Map handle(HttpServletRequest request) { diff --git a/spring-web/src/test/java/org/springframework/web/client/RestClientVersionTests.java b/spring-web/src/test/java/org/springframework/web/client/RestClientVersionTests.java index 7ea43c0ab950..c570c1834076 100644 --- a/spring-web/src/test/java/org/springframework/web/client/RestClientVersionTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/RestClientVersionTests.java @@ -64,8 +64,8 @@ void shutdown() { @Test void header() { - performRequest(ApiVersionInserter.useHeader("X-API-Version")); - expectRequest(request -> assertThat(request.getHeaders().get("X-API-Version")).isEqualTo("1.2")); + performRequest(ApiVersionInserter.useHeader("API-Version")); + expectRequest(request -> assertThat(request.getHeaders().get("API-Version")).isEqualTo("1.2")); } @Test @@ -101,11 +101,11 @@ void pathSegmentIndexGreaterThanSize() { @Test void defaultVersion() { - ApiVersionInserter inserter = ApiVersionInserter.useHeader("X-API-Version"); + ApiVersionInserter inserter = ApiVersionInserter.useHeader("API-Version"); RestClient restClient = restClientBuilder.defaultApiVersion(1.2).apiVersionInserter(inserter).build(); restClient.get().uri("/path").retrieve().body(String.class); - expectRequest(request -> assertThat(request.getHeaders().get("X-API-Version")).isEqualTo("1.2")); + expectRequest(request -> assertThat(request.getHeaders().get("API-Version")).isEqualTo("1.2")); } private void performRequest(ApiVersionInserter versionInserter) { diff --git a/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java b/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java index 648accfd3f9a..2a7ec5921454 100644 --- a/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/support/RestClientAdapterTests.java @@ -187,7 +187,7 @@ void greetingWithApiVersion() throws Exception { RestClient restClient = RestClient.builder() .baseUrl(anotherServer.url("/").toString()) - .apiVersionInserter(ApiVersionInserter.useHeader("X-API-Version")) + .apiVersionInserter(ApiVersionInserter.useHeader("API-Version")) .build(); RestClientAdapter adapter = RestClientAdapter.create(restClient); @@ -196,7 +196,7 @@ void greetingWithApiVersion() throws Exception { String actualResponse = service.getGreetingWithVersion(); RecordedRequest request = anotherServer.takeRequest(); - assertThat(request.getHeaders().get("X-API-Version")).isEqualTo("1.2"); + assertThat(request.getHeaders().get("API-Version")).isEqualTo("1.2"); assertThat(actualResponse).isEqualTo("Hello Spring 2!"); } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientVersionTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientVersionTests.java index 0daabd73cbf5..23d552599072 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientVersionTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientVersionTests.java @@ -61,8 +61,8 @@ void shutdown() { @Test void header() { - performRequest(ApiVersionInserter.useHeader("X-API-Version")); - expectRequest(request -> assertThat(request.getHeaders().get("X-API-Version")).isEqualTo("1.2")); + performRequest(ApiVersionInserter.useHeader("API-Version")); + expectRequest(request -> assertThat(request.getHeaders().get("API-Version")).isEqualTo("1.2")); } @Test @@ -92,11 +92,11 @@ void pathSegmentIndexGreaterThanSize() { @Test void defaultVersion() { - ApiVersionInserter inserter = ApiVersionInserter.useHeader("X-API-Version"); + ApiVersionInserter inserter = ApiVersionInserter.useHeader("API-Version"); WebClient webClient = webClientBuilder.defaultApiVersion(1.2).apiVersionInserter(inserter).build(); webClient.get().uri("/path").retrieve().bodyToMono(String.class).block(); - expectRequest(request -> assertThat(request.getHeaders().get("X-API-Version")).isEqualTo("1.2")); + expectRequest(request -> assertThat(request.getHeaders().get("API-Version")).isEqualTo("1.2")); } private void performRequest(ApiVersionInserter versionInserter) { diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/support/RouterFunctionMappingVersionTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/support/RouterFunctionMappingVersionTests.java index 65bd85ad1dd3..1a07389da412 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/support/RouterFunctionMappingVersionTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/server/support/RouterFunctionMappingVersionTests.java @@ -70,7 +70,7 @@ void mapVersion() { private void testGetHandler(String version, String expectedBody) { MockServerWebExchange exchange = MockServerWebExchange.from( - MockServerHttpRequest.get("/").header("X-API-Version", version)); + MockServerHttpRequest.get("/").header("API-Version", version)); Mono result = this.mapping.getHandler(exchange); @@ -82,7 +82,7 @@ private void testGetHandler(String version, String expectedBody) { @Test void deprecation() { MockServerWebExchange exchange = MockServerWebExchange.from( - MockServerHttpRequest.get("/").header("X-API-Version", "1")); + MockServerHttpRequest.get("/").header("API-Version", "1")); Mono result = this.mapping.getHandler(exchange); @@ -105,7 +105,7 @@ public void configureApiVersioning(ApiVersionConfigurer configurer) { StandardApiVersionDeprecationHandler handler = new StandardApiVersionDeprecationHandler(); handler.configureVersion("1").setDeprecationLink(URI.create("https://example.org/deprecation")); - configurer.useRequestHeader("X-API-Version") + configurer.useRequestHeader("API-Version") .addSupportedVersions("1", "1.1", "1.3") .setDeprecationHandler(handler); } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingVersionIntegrationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingVersionIntegrationTests.java index c80065d55c1f..e4be854d953a 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingVersionIntegrationTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingVersionIntegrationTests.java @@ -74,7 +74,7 @@ void deprecation(HttpServer httpServer) throws Exception { private ResponseEntity exchangeWithVersion(String version) { String url = "http://localhost:" + this.port; - RequestEntity requestEntity = RequestEntity.get(url).header("X-API-Version", version).build(); + RequestEntity requestEntity = RequestEntity.get(url).header("API-Version", version).build(); return getRestTemplate().exchange(requestEntity, String.class); } @@ -88,7 +88,7 @@ public void configureApiVersioning(ApiVersionConfigurer configurer) { StandardApiVersionDeprecationHandler handler = new StandardApiVersionDeprecationHandler(); handler.configureVersion("1").setDeprecationLink(URI.create("https://example.org/deprecation")); - configurer.useRequestHeader("X-API-Version") + configurer.useRequestHeader("API-Version") .addSupportedVersions("1", "1.1", "1.3", "1.6") .setDeprecationHandler(handler); } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportExtensionTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportExtensionTests.java index 18db77722bdf..8ab56bdafde9 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportExtensionTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportExtensionTests.java @@ -394,7 +394,7 @@ public void configureContentNegotiation(ContentNegotiationConfigurer configurer) @Override public void configureApiVersioning(ApiVersionConfigurer configurer) { - configurer.useRequestHeader("X-API-Version").setVersionRequired(false); + configurer.useRequestHeader("API-Version").setVersionRequired(false); } @Override diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/support/RouterFunctionMappingVersionTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/support/RouterFunctionMappingVersionTests.java index ebf4388550a6..d6afa45c483f 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/function/support/RouterFunctionMappingVersionTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/function/support/RouterFunctionMappingVersionTests.java @@ -74,7 +74,7 @@ void mapVersion() throws Exception { private void testGetHandler(String version, String expectedBody) throws Exception { MockHttpServletRequest request = new MockHttpServletRequest("GET", "/"); - request.addHeader("X-API-Version", version); + request.addHeader("API-Version", version); HandlerFunction handler = (HandlerFunction) this.mapping.getHandler(request).getHandler(); assertThat(((TestHandler) handler).body()).isEqualTo(expectedBody); } @@ -82,7 +82,7 @@ private void testGetHandler(String version, String expectedBody) throws Exceptio @Test void deprecation() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest("GET", "/"); - request.addHeader("X-API-Version", "1"); + request.addHeader("API-Version", "1"); HandlerExecutionChain chain = this.mapping.getHandler(request); assertThat(chain).isNotNull(); @@ -107,7 +107,7 @@ public void configureApiVersioning(ApiVersionConfigurer configurer) { StandardApiVersionDeprecationHandler handler = new StandardApiVersionDeprecationHandler(); handler.configureVersion("1").setDeprecationLink(URI.create("https://example.org/deprecation")); - configurer.useRequestHeader("X-API-Version") + configurer.useRequestHeader("API-Version") .addSupportedVersions("1", "1.1", "1.3") .setDeprecationHandler(handler); } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingVersionHandlerMethodTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingVersionHandlerMethodTests.java index 72cfa67e944b..018c76df79ed 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingVersionHandlerMethodTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingVersionHandlerMethodTests.java @@ -80,7 +80,7 @@ void deprecation() throws Exception { private MockHttpServletResponse requestWithVersion(String version) throws ServletException, IOException { MockHttpServletRequest request = new MockHttpServletRequest("GET", "/"); - request.addHeader("X-API-VERSION", version); + request.addHeader("API-Version", version); MockHttpServletResponse response = new MockHttpServletResponse(); this.dispatcherServlet.service(request, response); return response; @@ -96,7 +96,7 @@ public void configureApiVersioning(ApiVersionConfigurer configurer) { StandardApiVersionDeprecationHandler handler = new StandardApiVersionDeprecationHandler(); handler.configureVersion("1").setDeprecationLink(URI.create("https://example.org/deprecation")); - configurer.useRequestHeader("X-API-Version") + configurer.useRequestHeader("API-Version") .addSupportedVersions("1", "1.1", "1.3", "1.6") .setDeprecationHandler(handler); } From 8ac5cdb47e4aa43a20d243f3d61cc5169841413e Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Wed, 17 Sep 2025 11:55:37 +0100 Subject: [PATCH 222/591] Fix typo See gh-35494 --- .../webmvc/mvcconfig/mvcconfigapiversion/WebConfiguration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigapiversion/WebConfiguration.java b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigapiversion/WebConfiguration.java index e99f555c14e1..d1baeae21328 100644 --- a/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigapiversion/WebConfiguration.java +++ b/framework-docs/src/main/java/org/springframework/docs/web/webmvc/mvcconfig/mvcconfigapiversion/WebConfiguration.java @@ -26,7 +26,7 @@ public class WebConfiguration implements WebMvcConfigurer { @Override public void configureApiVersioning(ApiVersionConfigurer configurer) { - } configurer.useRequestHeader("API-Version"); + } } // end::snippet[] From da0a36bfd6fe7f9635404d989cb7f908deb9c2ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Wed, 17 Sep 2025 14:48:07 +0200 Subject: [PATCH 223/591] Upgrade to NullAway 0.12.10 and refine nullability Closes gh-35492 --- gradle/spring-module.gradle | 3 +++ .../util/function/SingletonSupplier.java | 2 +- .../springframework/jdbc/core/JdbcTemplate.java | 17 +++-------------- .../jdbc/core/RowMapperResultSetExtractor.java | 4 +--- .../namedparam/NamedParameterJdbcTemplate.java | 2 -- .../jdbc/core/simple/AbstractJdbcInsert.java | 1 - .../jdbc/core/simple/DefaultJdbcClient.java | 5 +---- .../jdbc/core/simple/JdbcClient.java | 2 -- .../reactive/server/DefaultWebTestClient.java | 15 +++++++-------- .../test/web/reactive/server/WebTestClient.java | 6 +++--- .../servlet/client/DefaultRestTestClient.java | 2 -- .../dao/support/DataAccessUtils.java | 2 +- .../web/client/DefaultRestClient.java | 4 ++-- .../RequestMappingHandlerMapping.java | 7 ++++--- .../RequestMappingHandlerMapping.java | 7 ++++--- 15 files changed, 30 insertions(+), 49 deletions(-) diff --git a/gradle/spring-module.gradle b/gradle/spring-module.gradle index a899900abf56..fbece5148fc7 100644 --- a/gradle/spring-module.gradle +++ b/gradle/spring-module.gradle @@ -119,3 +119,6 @@ publishing { components.java.withVariantsFromConfiguration(configurations.testFixturesApiElements) { skip() } components.java.withVariantsFromConfiguration(configurations.testFixturesRuntimeElements) { skip() } +nullability { + nullAwayVersion = "0.12.10" +} diff --git a/spring-core/src/main/java/org/springframework/util/function/SingletonSupplier.java b/spring-core/src/main/java/org/springframework/util/function/SingletonSupplier.java index 5b53f023b6a7..865cc6888a5d 100644 --- a/spring-core/src/main/java/org/springframework/util/function/SingletonSupplier.java +++ b/spring-core/src/main/java/org/springframework/util/function/SingletonSupplier.java @@ -39,7 +39,7 @@ * @since 5.1 * @param the type of results supplied by this supplier */ -public class SingletonSupplier implements Supplier<@Nullable T> { +public class SingletonSupplier implements Supplier { private final @Nullable Supplier instanceSupplier; diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/JdbcTemplate.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/JdbcTemplate.java index 096d397cd37d..da05923cae2e 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/JdbcTemplate.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/JdbcTemplate.java @@ -441,7 +441,6 @@ protected Connection createConnectionProxy(Connection con) { } @Override - @SuppressWarnings("NullAway") // See https://github.com/uber/NullAway/issues/1075 public void execute(String sql) throws DataAccessException { if (logger.isDebugEnabled()) { logger.debug("Executing SQL statement [" + sql + "]"); @@ -464,7 +463,6 @@ public String getSql() { } @Override - @SuppressWarnings("NullAway") // See https://github.com/uber/NullAway/issues/1075 public T query(String sql, ResultSetExtractor rse) throws DataAccessException { Assert.notNull(sql, "SQL must not be null"); Assert.notNull(rse, "ResultSetExtractor must not be null"); @@ -475,7 +473,7 @@ public String getSql() { // Callback to execute the query. class QueryStatementCallback implements StatementCallback, SqlProvider { @Override - public @Nullable T doInStatement(Statement stmt) throws SQLException { + public T doInStatement(Statement stmt) throws SQLException { ResultSet rs = null; try { rs = stmt.executeQuery(sql); @@ -495,7 +493,6 @@ public String getSql() { } @Override - @SuppressWarnings("NullAway") // See https://github.com/uber/NullAway/issues/1075 public void query(String sql, RowCallbackHandler rch) throws DataAccessException { query(sql, new RowCallbackHandlerResultSetExtractor(rch, this.maxRows)); } @@ -544,7 +541,6 @@ public String getSql() { } @Override - @SuppressWarnings("NullAway") // See https://github.com/uber/NullAway/issues/1075 public List<@Nullable T> queryForList(String sql, Class elementType) throws DataAccessException { return query(sql, getSingleColumnRowMapper(elementType)); } @@ -725,7 +721,7 @@ private String appendSql(@Nullable String sql, String statement) { * @return an arbitrary result object, as returned by the ResultSetExtractor * @throws DataAccessException if there is any problem */ - public @Nullable T query( + public T query( PreparedStatementCreator psc, @Nullable PreparedStatementSetter pss, ResultSetExtractor rse) throws DataAccessException { @@ -751,13 +747,11 @@ private String appendSql(@Nullable String sql, String statement) { } @Override - @SuppressWarnings("NullAway") // See https://github.com/uber/NullAway/issues/1075 public T query(PreparedStatementCreator psc, ResultSetExtractor rse) throws DataAccessException { return query(psc, null, rse); } @Override - @SuppressWarnings("NullAway") // See https://github.com/uber/NullAway/issues/1075 public T query(String sql, @Nullable PreparedStatementSetter pss, ResultSetExtractor rse) throws DataAccessException { return query(new SimplePreparedStatementCreator(sql), pss, rse); } @@ -779,13 +773,11 @@ private String appendSql(@Nullable String sql, String statement) { } @Override - @SuppressWarnings("NullAway") // See https://github.com/uber/NullAway/issues/1075 public void query(PreparedStatementCreator psc, RowCallbackHandler rch) throws DataAccessException { query(psc, new RowCallbackHandlerResultSetExtractor(rch, this.maxRows)); } @Override - @SuppressWarnings("NullAway") // See https://github.com/uber/NullAway/issues/1075 public void query(String sql, @Nullable PreparedStatementSetter pss, RowCallbackHandler rch) throws DataAccessException { query(sql, pss, new RowCallbackHandlerResultSetExtractor(rch, this.maxRows)); } @@ -930,20 +922,17 @@ public void query(String sql, RowCallbackHandler rch, @Nullable Object @Nullable } @Override - @SuppressWarnings("NullAway") // See https://github.com/uber/NullAway/issues/1075 public List<@Nullable T> queryForList(String sql, @Nullable Object @Nullable [] args, int[] argTypes, Class elementType) throws DataAccessException { return query(sql, args, argTypes, getSingleColumnRowMapper(elementType)); } @Deprecated(since = "5.3") @Override - @SuppressWarnings("NullAway") // See https://github.com/uber/NullAway/issues/1075 public List<@Nullable T> queryForList(String sql, @Nullable Object @Nullable [] args, Class elementType) throws DataAccessException { return query(sql, newArgPreparedStatementSetter(args), getSingleColumnRowMapper(elementType)); } @Override - @SuppressWarnings("NullAway") // See https://github.com/uber/NullAway/issues/1075 public List<@Nullable T> queryForList(String sql, Class elementType, @Nullable Object @Nullable ... args) throws DataAccessException { return query(sql, newArgPreparedStatementSetter(args), getSingleColumnRowMapper(elementType)); } @@ -1413,7 +1402,7 @@ else if (param.getResultSetExtractor() != null) { * @return the RowMapper to use * @see SingleColumnRowMapper */ - protected RowMapper getSingleColumnRowMapper(Class requiredType) { + protected RowMapper<@Nullable T> getSingleColumnRowMapper(Class requiredType) { return new SingleColumnRowMapper<>(requiredType); } diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/RowMapperResultSetExtractor.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/RowMapperResultSetExtractor.java index 837139ae2743..fbeac97dcb9b 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/RowMapperResultSetExtractor.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/RowMapperResultSetExtractor.java @@ -21,8 +21,6 @@ import java.util.ArrayList; import java.util.List; -import org.jspecify.annotations.Nullable; - import org.springframework.util.Assert; /** @@ -61,7 +59,7 @@ * @see JdbcTemplate * @see org.springframework.jdbc.object.MappingSqlQuery */ -public class RowMapperResultSetExtractor implements ResultSetExtractor> { +public class RowMapperResultSetExtractor implements ResultSetExtractor> { private final RowMapper rowMapper; diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/namedparam/NamedParameterJdbcTemplate.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/namedparam/NamedParameterJdbcTemplate.java index fd7ae1996c62..db76fa820dea 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/namedparam/NamedParameterJdbcTemplate.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/namedparam/NamedParameterJdbcTemplate.java @@ -294,7 +294,6 @@ public List query(String sql, RowMapper rowMapper) throws DataAccessEx } @Override - @SuppressWarnings("NullAway") // See https://github.com/uber/NullAway/issues/1075 public List<@Nullable T> queryForList(String sql, SqlParameterSource paramSource, Class elementType) throws DataAccessException { @@ -302,7 +301,6 @@ public List query(String sql, RowMapper rowMapper) throws DataAccessEx } @Override - @SuppressWarnings("NullAway") // See https://github.com/uber/NullAway/issues/1075 public List<@Nullable T> queryForList(String sql, Map paramMap, Class elementType) throws DataAccessException { diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/AbstractJdbcInsert.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/AbstractJdbcInsert.java index 485f2959a7db..56775367231f 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/AbstractJdbcInsert.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/AbstractJdbcInsert.java @@ -448,7 +448,6 @@ private Number executeInsertAndReturnKeyInternal(List values) { /** * Delegate method to execute the insert, generating any number of keys. */ - @SuppressWarnings("NullAway") // See https://github.com/uber/NullAway/issues/1075 private KeyHolder executeInsertAndReturnKeyHolderInternal(List values) { if (logger.isDebugEnabled()) { logger.debug("The following parameters are used for call " + getInsertString() + " with: " + values); diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/DefaultJdbcClient.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/DefaultJdbcClient.java index c378c4e606f7..6f8d5b8f86a9 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/DefaultJdbcClient.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/DefaultJdbcClient.java @@ -240,7 +240,7 @@ public ResultQuerySpec query() { } @Override - @SuppressWarnings({"unchecked", "NullAway"}) // See https://github.com/uber/NullAway/issues/1075 + @SuppressWarnings("unchecked") public MappedQuerySpec<@Nullable T> query(Class mappedClass) { RowMapper rowMapper = rowMapperCache.computeIfAbsent(mappedClass, key -> BeanUtils.isSimpleProperty(mappedClass) ? @@ -342,7 +342,6 @@ public SqlRowSet rowSet() { } @Override - @SuppressWarnings("NullAway") // See https://github.com/uber/NullAway/issues/1075 public List<@Nullable Object> singleColumn() { return classicOps.queryForList(sql, Object.class, indexedParams.toArray()); } @@ -362,13 +361,11 @@ public SqlRowSet rowSet() { } @Override - @SuppressWarnings("NullAway") // See https://github.com/uber/NullAway/issues/1075 public Map singleRow() { return namedParamOps.queryForMap(sql, namedParamSource); } @Override - @SuppressWarnings("NullAway") // See https://github.com/uber/NullAway/issues/1075 public List<@Nullable Object> singleColumn() { return namedParamOps.queryForList(sql, namedParamSource, Object.class); } diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/JdbcClient.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/JdbcClient.java index e98b421bf2be..e67dfada1132 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/JdbcClient.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/JdbcClient.java @@ -391,7 +391,6 @@ interface ResultQuerySpec { * @see #optionalValue() * @see DataAccessUtils#requiredSingleResult(Collection) */ - @SuppressWarnings("NullAway") // See https://github.com/uber/NullAway/issues/1075 default Object singleValue() { return DataAccessUtils.requiredSingleResult(singleColumn()); } @@ -403,7 +402,6 @@ default Object singleValue() { * @see #singleValue() * @see DataAccessUtils#optionalResult(Collection) */ - @SuppressWarnings("NullAway") // See https://github.com/uber/NullAway/issues/1075 default Optional optionalValue() { return DataAccessUtils.optionalResult(singleColumn()); } diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java index 78e287b4248f..e9d4a5fa3239 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java @@ -596,7 +596,6 @@ public T value(Function<@Nullable B, @Nullable R> bodyMapper, M } @Override - @SuppressWarnings("NullAway") // https://github.com/uber/NullAway/issues/1129 public T value(Consumer<@Nullable B> consumer) { this.result.assertWithDiagnostics(() -> consumer.accept(this.result.getResponseBody())); return self(); @@ -620,7 +619,7 @@ public EntityExchangeResult returnResult() { } - private static class DefaultListBodySpec extends DefaultBodySpec, ListBodySpec> + private static class DefaultListBodySpec extends DefaultBodySpec, ListBodySpec> implements ListBodySpec { DefaultListBodySpec(EntityExchangeResult> result) { @@ -629,7 +628,7 @@ private static class DefaultListBodySpec extends DefaultBodySpec hasSize(int size) { - List<@Nullable E> actual = getResult().getResponseBody(); + List actual = getResult().getResponseBody(); String message = "Response body does not contain " + size + " elements"; getResult().assertWithDiagnostics(() -> AssertionErrors.assertEquals(message, size, (actual != null ? actual.size() : 0))); @@ -638,9 +637,9 @@ public ListBodySpec hasSize(int size) { @Override @SuppressWarnings("unchecked") - public ListBodySpec contains(@Nullable E... elements) { + public ListBodySpec contains(E... elements) { List expected = Arrays.asList(elements); - List<@Nullable E> actual = getResult().getResponseBody(); + List actual = getResult().getResponseBody(); String message = "Response body does not contain " + expected; getResult().assertWithDiagnostics(() -> AssertionErrors.assertTrue(message, (actual != null && actual.containsAll(expected)))); @@ -649,9 +648,9 @@ public ListBodySpec contains(@Nullable E... elements) { @Override @SuppressWarnings("unchecked") - public ListBodySpec doesNotContain(@Nullable E... elements) { + public ListBodySpec doesNotContain(E... elements) { List expected = Arrays.asList(elements); - List<@Nullable E> actual = getResult().getResponseBody(); + List actual = getResult().getResponseBody(); String message = "Response body should not have contained " + expected; getResult().assertWithDiagnostics(() -> AssertionErrors.assertTrue(message, (actual == null || !actual.containsAll(expected)))); @@ -659,7 +658,7 @@ public ListBodySpec doesNotContain(@Nullable E... elements) { } @Override - public EntityExchangeResult> returnResult() { + public EntityExchangeResult> returnResult() { return getResult(); } } diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java index 17a98ebed231..f8c896493a0f 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java @@ -975,7 +975,7 @@ interface BodySpec> { * * @param the body list element type */ - interface ListBodySpec extends BodySpec, ListBodySpec> { + interface ListBodySpec extends BodySpec, ListBodySpec> { /** * Assert the extracted list of values is of the given size. @@ -988,14 +988,14 @@ interface ListBodySpec extends BodySpec, ListBodySpec> { * @param elements the elements to check */ @SuppressWarnings("unchecked") - ListBodySpec contains(@Nullable E... elements); + ListBodySpec contains(E... elements); /** * Assert the extracted list of values doesn't contain the given elements. * @param elements the elements to check */ @SuppressWarnings("unchecked") - ListBodySpec doesNotContain(@Nullable E... elements); + ListBodySpec doesNotContain(E... elements); } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java index c6ee841720ea..900bd402f0be 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java @@ -367,7 +367,6 @@ public T value(Matcher matcher) { } @Override - @SuppressWarnings("NullAway") // https://github.com/uber/NullAway/issues/1129 public T value(Function<@Nullable B, @Nullable R> bodyMapper, Matcher matcher) { this.result.assertWithDiagnostics(() -> { B body = this.result.getResponseBody(); @@ -377,7 +376,6 @@ public T value(Function<@Nullable B, @Nullable R> bodyMapper, M } @Override - @SuppressWarnings("NullAway") // https://github.com/uber/NullAway/issues/1129 public T value(Consumer<@Nullable B> consumer) { this.result.assertWithDiagnostics(() -> consumer.accept(this.result.getResponseBody())); return self(); diff --git a/spring-tx/src/main/java/org/springframework/dao/support/DataAccessUtils.java b/spring-tx/src/main/java/org/springframework/dao/support/DataAccessUtils.java index 58309754b55b..a6b1b56e5251 100644 --- a/spring-tx/src/main/java/org/springframework/dao/support/DataAccessUtils.java +++ b/spring-tx/src/main/java/org/springframework/dao/support/DataAccessUtils.java @@ -116,7 +116,7 @@ public abstract class DataAccessUtils { * element has been found in the given Collection * @since 6.1 */ - public static Optional<@NonNull T> optionalResult(@Nullable Collection results) + public static Optional optionalResult(@Nullable Collection results) throws IncorrectResultSizeDataAccessException { return Optional.ofNullable(singleResult(results)); diff --git a/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java b/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java index 600bafe9aa09..bdc292be0abe 100644 --- a/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java +++ b/spring-web/src/main/java/org/springframework/web/client/DefaultRestClient.java @@ -803,13 +803,13 @@ private ResponseSpec onStatusInternal(StatusHandler statusHandler) { } @Override - @SuppressWarnings("NullAway") // See https://github.com/uber/NullAway/issues/1075 + @SuppressWarnings("NullAway") // See https://github.com/uber/NullAway/issues/1290 public @Nullable T body(Class bodyType) { return executeAndExtract((request, response) -> readBody(request, response, bodyType, bodyType, this.hints)); } @Override - @SuppressWarnings("NullAway") // See https://github.com/uber/NullAway/issues/1075 + @SuppressWarnings("NullAway") // See https://github.com/uber/NullAway/issues/1290 public @Nullable T body(ParameterizedTypeReference bodyType) { Type type = bodyType.getType(); Class bodyClass = bodyClass(type); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java index fa910868172a..503d473f94eb 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java @@ -23,6 +23,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.function.Predicate; import java.util.stream.Stream; @@ -340,14 +341,14 @@ protected RequestMappingInfo createRequestMappingInfo( * Resolve placeholder values in the given array of patterns. * @return a new array with updated patterns */ - protected @Nullable String[] resolveEmbeddedValuesInPatterns(String[] patterns) { + protected String[] resolveEmbeddedValuesInPatterns(String[] patterns) { if (this.embeddedValueResolver == null) { return patterns; } else { - @Nullable String[] resolvedPatterns = new String[patterns.length]; + String[] resolvedPatterns = new String[patterns.length]; for (int i = 0; i < patterns.length; i++) { - resolvedPatterns[i] = this.embeddedValueResolver.resolveStringValue(patterns[i]); + resolvedPatterns[i] = Objects.requireNonNull(this.embeddedValueResolver.resolveStringValue(patterns[i])); } return resolvedPatterns; } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java index 2cc4f4676e37..1ef162b67993 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java @@ -23,6 +23,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.function.Predicate; import java.util.stream.Stream; @@ -367,14 +368,14 @@ protected RequestMappingInfo createRequestMappingInfo( * Resolve placeholder values in the given array of patterns. * @return a new array with updated patterns */ - protected @Nullable String[] resolveEmbeddedValuesInPatterns(String[] patterns) { + protected String[] resolveEmbeddedValuesInPatterns(String[] patterns) { if (this.embeddedValueResolver == null) { return patterns; } else { - @Nullable String[] resolvedPatterns = new String[patterns.length]; + String[] resolvedPatterns = new String[patterns.length]; for (int i = 0; i < patterns.length; i++) { - resolvedPatterns[i] = this.embeddedValueResolver.resolveStringValue(patterns[i]); + resolvedPatterns[i] = Objects.requireNonNull(this.embeddedValueResolver.resolveStringValue(patterns[i])); } return resolvedPatterns; } From 23d1b0e881e0874bfb42fd196eece975e2ee8f29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Wed, 17 Sep 2025 15:15:42 +0200 Subject: [PATCH 224/591] Polishing --- .../web/reactive/result/method/InvocableHandlerMethod.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/InvocableHandlerMethod.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/InvocableHandlerMethod.java index 7675eddacfd9..ce7e2c088229 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/InvocableHandlerMethod.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/InvocableHandlerMethod.java @@ -37,6 +37,7 @@ import kotlin.reflect.jvm.KCallablesJvm; import kotlin.reflect.jvm.ReflectJvmMapping; import org.jspecify.annotations.Nullable; +import org.reactivestreams.Publisher; import reactor.core.publisher.Mono; import reactor.core.publisher.SynchronousSink; import reactor.core.scheduler.Scheduler; @@ -332,7 +333,7 @@ private static class KotlinDelegate { if (isSuspendingFunction) { Object coroutineContext = exchange.getAttribute(COROUTINE_CONTEXT_ATTRIBUTE); - Object result = (coroutineContext == null ? CoroutinesUtils.invokeSuspendingFunction(method, target, args) : + Publisher result = (coroutineContext == null ? CoroutinesUtils.invokeSuspendingFunction(method, target, args) : CoroutinesUtils.invokeSuspendingFunction((CoroutineContext) coroutineContext, method, target, args)); return (result instanceof Mono mono ? mono.handle(KotlinDelegate::handleResult) : result); } From 11dd0d61182021e208cbc80f4cce87f52252d06b Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Wed, 17 Sep 2025 15:50:30 +0100 Subject: [PATCH 225/591] Provide access to raw content in RestTestClient Closes gh-35399 --- .../servlet/client/DefaultRestTestClient.java | 47 ++++++++++++++-- .../client/DefaultRestTestClientBuilder.java | 2 +- .../web/servlet/client/ExchangeResult.java | 55 ++++++++++++++++++- .../servlet/client/RestTestClientTests.java | 25 ++++++++- 4 files changed, 119 insertions(+), 10 deletions(-) diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java index 900bd402f0be..fe01246f775e 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java @@ -16,12 +16,14 @@ package org.springframework.test.web.servlet.client; +import java.io.IOException; import java.net.URI; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.time.ZonedDateTime; import java.util.Map; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; import java.util.function.Function; @@ -33,13 +35,18 @@ import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; +import org.springframework.http.HttpRequest; import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; import org.springframework.test.json.JsonAssert; import org.springframework.test.json.JsonComparator; import org.springframework.test.json.JsonCompareMode; import org.springframework.test.util.AssertionErrors; import org.springframework.test.util.ExceptionCollector; import org.springframework.test.util.XmlExpectationsHelper; +import org.springframework.util.Assert; import org.springframework.util.MimeType; import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestClient; @@ -56,6 +63,8 @@ class DefaultRestTestClient implements RestTestClient { private final RestClient restClient; + private final WiretapInterceptor wiretapInterceptor = new WiretapInterceptor(); + private final Consumer> entityResultConsumer; private final DefaultRestTestClientBuilder restTestClientBuilder; @@ -67,7 +76,7 @@ class DefaultRestTestClient implements RestTestClient { RestClient.Builder builder, Consumer> entityResultConsumer, DefaultRestTestClientBuilder restTestClientBuilder) { - this.restClient = builder.build(); + this.restClient = builder.requestInterceptor(this.wiretapInterceptor).build(); this.entityResultConsumer = entityResultConsumer; this.restTestClientBuilder = restTestClientBuilder; } @@ -128,12 +137,14 @@ private class DefaultRequestBodyUriSpec implements RequestBodyUriSpec { private final RestClient.RequestBodyUriSpec requestHeadersUriSpec; + private final String requestId; + private @Nullable String uriTemplate; DefaultRequestBodyUriSpec(RestClient.RequestBodyUriSpec spec) { this.requestHeadersUriSpec = spec; - String requestId = String.valueOf(requestIndex.incrementAndGet()); - this.requestHeadersUriSpec.header(RESTTESTCLIENT_REQUEST_ID, requestId); + this.requestId = String.valueOf(requestIndex.incrementAndGet()); + this.requestHeadersUriSpec.header(RESTTESTCLIENT_REQUEST_ID, this.requestId); } @Override @@ -252,7 +263,10 @@ public RequestHeadersSpec body(Object body) { public ResponseSpec exchange() { return new DefaultResponseSpec( this.requestHeadersUriSpec.exchangeForRequiredValue( - (request, response) -> new ExchangeResult(request, response, this.uriTemplate), false), + (request, response) -> { + byte[] requestBody = wiretapInterceptor.getRequestContent(this.requestId); + return new ExchangeResult(request, response, this.uriTemplate, requestBody); + }, false), DefaultRestTestClient.this.entityResultConsumer); } } @@ -476,4 +490,29 @@ public EntityExchangeResult returnResult() { return this.result; } } + + + private static class WiretapInterceptor implements ClientHttpRequestInterceptor { + + private final Map requestContentMap = new ConcurrentHashMap<>(); + + @Override + public ClientHttpResponse intercept( + HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { + + String header = RestTestClient.RESTTESTCLIENT_REQUEST_ID; + String requestId = request.getHeaders().getFirst(header); + Assert.state(requestId != null, () -> "No \"" + header + "\" header"); + this.requestContentMap.put(requestId, body); + return execution.execute(request, body); + } + + public byte[] getRequestContent(String requestId) { + byte[] bytes = this.requestContentMap.remove(requestId); + Assert.state(bytes != null, () -> + "No match for %s=%s".formatted(RestTestClient.RESTTESTCLIENT_REQUEST_ID, requestId)); + return bytes; + } + } + } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java index 53168bf60d34..484ebbf698a2 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java @@ -61,7 +61,7 @@ class DefaultRestTestClientBuilder> implemen } DefaultRestTestClientBuilder(RestClient.Builder restClientBuilder) { - this.restClientBuilder = restClientBuilder; + this.restClientBuilder = restClientBuilder.bufferContent((uri, httpMethod) -> true); } DefaultRestTestClientBuilder(DefaultRestTestClientBuilder other) { diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/ExchangeResult.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/ExchangeResult.java index 771a9eb1e0a4..99cf979c0fa2 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/ExchangeResult.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/ExchangeResult.java @@ -19,6 +19,8 @@ import java.io.IOException; import java.net.HttpCookie; import java.net.URI; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Optional; import java.util.regex.Matcher; @@ -34,10 +36,12 @@ import org.springframework.http.HttpRequest; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; import org.springframework.http.ResponseCookie; import org.springframework.util.Assert; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.util.StreamUtils; import org.springframework.web.client.RestClient.RequestHeadersSpec.ConvertibleClientHttpResponse; /** @@ -54,6 +58,10 @@ public class ExchangeResult { private static final Pattern PARTITIONED_PATTERN = Pattern.compile("(?i).*;\\s*Partitioned(\\s*;.*|\\s*)$"); + private static final List PRINTABLE_MEDIA_TYPES = List.of( + MediaType.parseMediaType("application/*+json"), MediaType.APPLICATION_XML, + MediaType.parseMediaType("text/*"), MediaType.APPLICATION_FORM_URLENCODED); + private static final Log logger = LogFactory.getLog(ExchangeResult.class); @@ -64,22 +72,26 @@ public class ExchangeResult { private final @Nullable String uriTemplate; + private final byte[] requestBody; + /** Ensure single logging; for example, for expectAll. */ private boolean diagnosticsLogged; ExchangeResult( - HttpRequest request, ConvertibleClientHttpResponse response, @Nullable String uriTemplate) { + HttpRequest request, ConvertibleClientHttpResponse response, @Nullable String uriTemplate, + byte[] requestBody) { Assert.notNull(request, "HttpRequest must not be null"); Assert.notNull(response, "ClientHttpResponse must not be null"); this.request = request; this.clientResponse = response; this.uriTemplate = uriTemplate; + this.requestBody = requestBody; } ExchangeResult(ExchangeResult result) { - this(result.request, result.clientResponse, result.uriTemplate); + this(result.request, result.clientResponse, result.uriTemplate, result.requestBody); this.diagnosticsLogged = result.diagnosticsLogged; } @@ -159,6 +171,13 @@ private static ResponseCookie toResponseCookie(HttpCookie cookie, @Nullable Stri .build(); } + /** + * Return the raw request body content written through the request. + */ + public byte[] getRequestBodyContent() { + return this.requestBody; + } + /** * Provide access to the response. For internal use to decode the body. */ @@ -166,6 +185,18 @@ ConvertibleClientHttpResponse getClientResponse() { return this.clientResponse; } + /** + * Return the raw response body read through the response. + */ + public byte[] getResponseBodyContent() { + try { + return StreamUtils.copyToByteArray(this.clientResponse.getBody()); + } + catch (IOException ex) { + throw new IllegalStateException("Failed to get response content: " + ex); + } + } + /** * Execute the given Runnable, catch any {@link AssertionError}, log details * about the request and response at ERROR level under the class log @@ -190,8 +221,12 @@ public String toString() { "> " + getMethod() + " " + getUrl() + "\n" + "> " + formatHeaders(getRequestHeaders(), "\n> ") + "\n" + "\n" + + formatBody(getRequestHeaders().getContentType(), this.requestBody) + "\n" + + "\n" + "< " + formatStatus(getStatus()) + "\n" + - "< " + formatHeaders(getResponseHeaders(), "\n< ") + "\n"; + "< " + formatHeaders(getResponseHeaders(), "\n< ") + "\n" + + "\n" + + formatBody(getResponseHeaders().getContentType(), getResponseBodyContent()) +"\n"; } private String formatStatus(HttpStatusCode statusCode) { @@ -208,4 +243,18 @@ private String formatHeaders(HttpHeaders headers, String delimiter) { .collect(Collectors.joining(delimiter)); } + private String formatBody(@Nullable MediaType contentType, byte[] bytes) { + if (contentType == null) { + return bytes.length + " bytes of content (unknown content-type)."; + } + Charset charset = contentType.getCharset(); + if (charset != null) { + return new String(bytes, charset); + } + if (PRINTABLE_MEDIA_TYPES.stream().anyMatch(contentType::isCompatibleWith)) { + return new String(bytes, StandardCharsets.UTF_8); + } + return bytes.length + " bytes of content."; + } + } diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/RestTestClientTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/RestTestClientTests.java index 6c72769348f4..ee1393c16b15 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/RestTestClientTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/RestTestClientTests.java @@ -36,6 +36,8 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -317,6 +319,19 @@ void testReturnResultParameterizedTypeReference() { }); assertThat(result.getResponseBody().get("uri")).isEqualTo("/test"); } + + @Test + void testResultContent() { + String body = "body-in"; + EntityExchangeResult result = RestTestClientTests.this.client.post().uri("/body") + .body(body) + .exchange() + .expectStatus().isOk() + .expectBody(String.class) + .returnResult(); + assertThat(result.getRequestBodyContent()).isEqualTo(body.getBytes(StandardCharsets.UTF_8)); + assertThat(result.getResponseBodyContent()).isEqualTo((body + "-out").getBytes(StandardCharsets.UTF_8)); + } } @@ -325,14 +340,20 @@ static class TestController { @RequestMapping(path = {"/test", "/test/*"}, produces = "application/json") public Map handle( - @RequestHeader HttpHeaders headers, - HttpServletRequest request, HttpServletResponse response) { + @RequestHeader HttpHeaders headers, HttpServletRequest request, HttpServletResponse response) { + response.addCookie(new Cookie("session", "abc")); + return Map.of( "method", request.getMethod(), "uri", request.getRequestURI(), "headers", headers.toSingleValueMap() ); } + + @PostMapping("/body") + public String echoBody(@RequestBody String body) { + return body + "-out"; + } } } From fa5ebbc1a85c6216aaea9033d40cdf51c32cb599 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Wed, 17 Sep 2025 17:37:36 +0200 Subject: [PATCH 226/591] Upgrade to Hibernate 7.1.1, Groovy 5.0.1, Commons Pool 2.12.1, SnakeYAML 2.5, Protobuf 4.32.1, ActiveMQ 5.17.7 and Artemis 2.42, EasyMock 5.6, AssertJ 3.27.4, XMLUnit 2.10.4, Dom4J 2.2 Includes downgrade to Log4J 2.25.1 from 3.0.0 beta (for Spring Framework 7.0 RC1) --- framework-platform/framework-platform.gradle | 32 +++++++++---------- .../aop/target/CommonsPool2TargetSource.java | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index 56061d0e40fc..4c2c444a0b52 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -12,9 +12,9 @@ dependencies { api(platform("io.netty:netty-bom:4.2.6.Final")) api(platform("io.projectreactor:reactor-bom:2025.0.0-M7")) api(platform("io.rsocket:rsocket-bom:1.1.5")) - api(platform("org.apache.groovy:groovy-bom:5.0.0-rc-1")) - api(platform("org.apache.logging.log4j:log4j-bom:3.0.0-beta3")) - api(platform("org.assertj:assertj-bom:3.27.3")) + api(platform("org.apache.groovy:groovy-bom:5.0.1")) + api(platform("org.apache.logging.log4j:log4j-bom:2.25.1")) + api(platform("org.assertj:assertj-bom:3.27.4")) api(platform("org.eclipse.jetty:jetty-bom:12.1.1")) api(platform("org.eclipse.jetty.ee11:jetty-ee11-bom:12.1.1")) api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2")) @@ -31,7 +31,7 @@ dependencies { api("com.google.code.findbugs:findbugs:3.0.1") api("com.google.code.findbugs:jsr305:3.0.2") api("com.google.code.gson:gson:2.13.1") - api("com.google.protobuf:protobuf-java-util:4.32.0") + api("com.google.protobuf:protobuf-java-util:4.32.1") api("com.h2database:h2:2.3.232") api("com.jayway.jsonpath:json-path:2.9.0") api("com.networknt:json-schema-validator:1.5.3") @@ -85,12 +85,12 @@ dependencies { api("junit:junit:4.13.2") api("net.sf.jopt-simple:jopt-simple:5.0.4") api("org.apache-extras.beanshell:bsh:2.0b6") - api("org.apache.activemq:activemq-broker:5.17.6") - api("org.apache.activemq:activemq-kahadb-store:5.17.6") - api("org.apache.activemq:activemq-stomp:5.17.6") - api("org.apache.activemq:artemis-jakarta-client:2.31.2") - api("org.apache.activemq:artemis-junit-5:2.31.2") - api("org.apache.commons:commons-pool2:2.9.0") + api("org.apache.activemq:activemq-broker:5.17.7") + api("org.apache.activemq:activemq-kahadb-store:5.17.7") + api("org.apache.activemq:activemq-stomp:5.17.7") + api("org.apache.activemq:artemis-jakarta-client:2.42.0") + api("org.apache.activemq:artemis-junit-5:2.42.0") + api("org.apache.commons:commons-pool2:2.12.1") api("org.apache.derby:derby:10.16.1.1") api("org.apache.derby:derbyclient:10.16.1.1") api("org.apache.derby:derbytools:10.16.1.1") @@ -108,8 +108,8 @@ dependencies { api("org.bouncycastle:bcpkix-jdk18on:1.72") api("org.codehaus.jettison:jettison:1.5.4") api("org.crac:crac:1.4.0") - api("org.dom4j:dom4j:2.1.4") - api("org.easymock:easymock:5.5.0") + api("org.dom4j:dom4j:2.2.0") + api("org.easymock:easymock:5.6.0") api("org.eclipse.angus:angus-mail:2.0.3") api("org.eclipse.jetty:jetty-reactive-httpclient:4.0.11") api("org.eclipse.persistence:org.eclipse.persistence.jpa:5.0.0-B10") @@ -121,7 +121,7 @@ dependencies { api("org.glassfish:jakarta.el:4.0.2") api("org.graalvm.sdk:graal-sdk:22.3.1") api("org.hamcrest:hamcrest:3.0") - api("org.hibernate.orm:hibernate-core:7.1.0.Final") + api("org.hibernate.orm:hibernate-core:7.1.1.Final") api("org.hibernate.validator:hibernate-validator:9.0.1.Final") api("org.hsqldb:hsqldb:2.7.4") api("org.htmlunit:htmlunit:4.16.0") @@ -141,8 +141,8 @@ dependencies { api("org.testng:testng:7.11.0") api("org.webjars:underscorejs:1.8.3") api("org.webjars:webjars-locator-lite:1.1.0") - api("org.xmlunit:xmlunit-assertj:2.10.3") - api("org.xmlunit:xmlunit-matchers:2.10.3") - api("org.yaml:snakeyaml:2.4") + api("org.xmlunit:xmlunit-assertj:2.10.4") + api("org.xmlunit:xmlunit-matchers:2.10.4") + api("org.yaml:snakeyaml:2.5") } } diff --git a/spring-aop/src/main/java/org/springframework/aop/target/CommonsPool2TargetSource.java b/spring-aop/src/main/java/org/springframework/aop/target/CommonsPool2TargetSource.java index 9db40a729542..6d4b8b0d93e1 100644 --- a/spring-aop/src/main/java/org/springframework/aop/target/CommonsPool2TargetSource.java +++ b/spring-aop/src/main/java/org/springframework/aop/target/CommonsPool2TargetSource.java @@ -63,7 +63,7 @@ * @see #setTimeBetweenEvictionRunsMillis * @see #setMinEvictableIdleTimeMillis */ -@SuppressWarnings({"rawtypes", "unchecked", "serial"}) +@SuppressWarnings({"rawtypes", "unchecked", "serial", "deprecation"}) public class CommonsPool2TargetSource extends AbstractPoolingTargetSource implements PooledObjectFactory { private int maxIdle = GenericObjectPoolConfig.DEFAULT_MAX_IDLE; From b213344d259aabb8a509140f52b156159f5a9c81 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Fri, 12 Sep 2025 09:12:33 +0200 Subject: [PATCH 227/591] Fix synchronization in ResponseBodyEmitter See gh-35423 Fixes gh-35466 (cherry picked from commit 20e1149dde7ff042154e4098d49939a886661c3e) --- .../mvc/method/annotation/ResponseBodyEmitter.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitter.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitter.java index 9867516277d6..e0704a2d7ae5 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitter.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitter.java @@ -201,10 +201,10 @@ public void send(Object object) throws IOException { * @throws java.lang.IllegalStateException wraps any other errors */ public void send(Object object, @Nullable MediaType mediaType) throws IOException { - Assert.state(!this.complete, () -> "ResponseBodyEmitter has already completed" + - (this.failure != null ? " with error: " + this.failure : "")); this.writeLock.lock(); try { + Assert.state(!this.complete, () -> "ResponseBodyEmitter has already completed" + + (this.failure != null ? " with error: " + this.failure : "")); if (this.handler != null) { try { this.handler.send(object, mediaType); @@ -235,10 +235,10 @@ public void send(Object object, @Nullable MediaType mediaType) throws IOExceptio * @since 6.0.12 */ public void send(Set items) throws IOException { - Assert.state(!this.complete, () -> "ResponseBodyEmitter has already completed" + - (this.failure != null ? " with error: " + this.failure : "")); this.writeLock.lock(); try { + Assert.state(!this.complete, () -> "ResponseBodyEmitter has already completed" + + (this.failure != null ? " with error: " + this.failure : "")); sendInternal(items); } finally { From bf715ac23e9900a8a99ce59a2fb1defaa40b2255 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Wed, 17 Sep 2025 18:10:19 +0200 Subject: [PATCH 228/591] Polishing --- .../web/servlet/mvc/method/annotation/ResponseBodyEmitter.java | 1 + .../web/servlet/mvc/method/annotation/SseEmitter.java | 1 + 2 files changed, 2 insertions(+) diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitter.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitter.java index e0704a2d7ae5..fc7fa782ad1c 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitter.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitter.java @@ -94,6 +94,7 @@ public class ResponseBodyEmitter { /** Guards access to write operations on the response. */ protected final Lock writeLock = new ReentrantLock(); + /** * Create a new ResponseBodyEmitter instance. */ diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitter.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitter.java index b9a9d04bf0d8..85752eee9e8c 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitter.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/SseEmitter.java @@ -44,6 +44,7 @@ public class SseEmitter extends ResponseBodyEmitter { private static final MediaType TEXT_PLAIN = new MediaType("text", "plain", StandardCharsets.UTF_8); + /** * Create a new SseEmitter instance. */ From 931686a5ee1b4aa6e102f2527d8a880610c10a8d Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Wed, 17 Sep 2025 18:18:35 +0200 Subject: [PATCH 229/591] Upgrade to SnakeYAML 2.5, Protobuf 4.32.1, ActiveMQ 5.17.7 and Artemis 2.42, EasyMock 5.6, AssertJ 3.27.4, XMLUnit 2.10.4, Dom4J 2.2 --- framework-platform/framework-platform.gradle | 24 ++++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index 5b42b34336e1..d588007f9960 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -15,7 +15,7 @@ dependencies { api(platform("io.rsocket:rsocket-bom:1.1.5")) api(platform("org.apache.groovy:groovy-bom:4.0.28")) api(platform("org.apache.logging.log4j:log4j-bom:2.21.1")) - api(platform("org.assertj:assertj-bom:3.27.3")) + api(platform("org.assertj:assertj-bom:3.27.4")) api(platform("org.eclipse.jetty:jetty-bom:12.0.26")) api(platform("org.eclipse.jetty.ee10:jetty-ee10-bom:12.0.26")) api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.1")) @@ -31,7 +31,7 @@ dependencies { api("com.google.code.findbugs:findbugs:3.0.1") api("com.google.code.findbugs:jsr305:3.0.2") api("com.google.code.gson:gson:2.13.1") - api("com.google.protobuf:protobuf-java-util:4.32.0") + api("com.google.protobuf:protobuf-java-util:4.32.1") api("com.h2database:h2:2.3.232") api("com.jayway.jsonpath:json-path:2.9.0") api("com.oracle.database.jdbc:ojdbc11:21.9.0.0") @@ -90,11 +90,11 @@ dependencies { api("junit:junit:4.13.2") api("net.sf.jopt-simple:jopt-simple:5.0.4") api("org.apache-extras.beanshell:bsh:2.0b6") - api("org.apache.activemq:activemq-broker:5.17.6") - api("org.apache.activemq:activemq-kahadb-store:5.17.6") - api("org.apache.activemq:activemq-stomp:5.17.6") - api("org.apache.activemq:artemis-jakarta-client:2.31.2") - api("org.apache.activemq:artemis-junit-5:2.31.2") + api("org.apache.activemq:activemq-broker:5.17.7") + api("org.apache.activemq:activemq-kahadb-store:5.17.7") + api("org.apache.activemq:activemq-stomp:5.17.7") + api("org.apache.activemq:artemis-jakarta-client:2.42.0") + api("org.apache.activemq:artemis-junit-5:2.42.0") api("org.apache.commons:commons-pool2:2.9.0") api("org.apache.derby:derby:10.16.1.1") api("org.apache.derby:derbyclient:10.16.1.1") @@ -113,8 +113,8 @@ dependencies { api("org.bouncycastle:bcpkix-jdk18on:1.72") api("org.codehaus.jettison:jettison:1.5.4") api("org.crac:crac:1.4.0") - api("org.dom4j:dom4j:2.1.4") - api("org.easymock:easymock:5.5.0") + api("org.dom4j:dom4j:2.2.0") + api("org.easymock:easymock:5.6.0") api("org.eclipse.jetty:jetty-reactive-httpclient:4.0.11") api("org.eclipse.persistence:org.eclipse.persistence.jpa:3.0.4") api("org.eclipse:yasson:2.0.4") @@ -145,8 +145,8 @@ dependencies { api("org.webjars:underscorejs:1.8.3") api("org.webjars:webjars-locator-core:0.59") api("org.webjars:webjars-locator-lite:1.1.0") - api("org.xmlunit:xmlunit-assertj:2.10.3") - api("org.xmlunit:xmlunit-matchers:2.10.3") - api("org.yaml:snakeyaml:2.4") + api("org.xmlunit:xmlunit-assertj:2.10.4") + api("org.xmlunit:xmlunit-matchers:2.10.4") + api("org.yaml:snakeyaml:2.5") } } From fe04bfcadb5d68a66c74f7975a7ee01aeccd0fc9 Mon Sep 17 00:00:00 2001 From: Byeong-Uk Park <114344042+Rockernun@users.noreply.github.com> Date: Wed, 17 Sep 2025 16:19:22 +0900 Subject: [PATCH 230/591] =?UTF-8?q?Document=20placeholder=20and=20pattern?= =?UTF-8?q?=20support=20for=20@=E2=81=A0ComponentScan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JavaDoc: clarify that basePackages/value resolve ${…} via Environment and accept Ant-style package patterns (e.g., com.example.**); note patterns don’t apply to basePackageClasses. - Reference: add “Property placeholders and Ant-style patterns” subsection in classpath-scanning.adoc with Java/Kotlin + properties examples. See gh-35288 Closes gh-35491 Signed-off-by: Byeong-Uk Park <114344042+Rockernun@users.noreply.github.com> --- .../pages/core/beans/classpath-scanning.adoc | 39 +++++++++++++++++++ .../context/annotation/ComponentScan.java | 13 +++++++ 2 files changed, 52 insertions(+) diff --git a/framework-docs/modules/ROOT/pages/core/beans/classpath-scanning.adoc b/framework-docs/modules/ROOT/pages/core/beans/classpath-scanning.adoc index b3d7e1869177..9f6cc02797f6 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/classpath-scanning.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/classpath-scanning.adoc @@ -323,6 +323,45 @@ sure that they are 'opened' (that is, that they use an `opens` declaration inste `exports` declaration in your `module-info` descriptor). ==== +==== Property placeholders and Ant-style patterns + +`@ComponentScan(basePackages)` supports `${…}` property placeholders resolved +against the `Environment` and Ant-style package patterns such as `com.example.**`. +Multiple packages and/or patterns may be specified. + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- +@Configuration +@ComponentScan(basePackages = "${app.scan.packages}") +public class AppConfig { + // ... +} +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- +@Configuration +@ComponentScan(basePackages = ["\${app.scan.packages}"]) +class AppConfig { + // ... +} +---- +====== + +[source,properties,indent=0,subs="verbatim,quotes"] +---- +app.scan.packages=com.example.**,org.acme.* +---- + +NOTE: Ant-style patterns do not apply to `basePackageClasses`, which accepts concrete +classes and derives packages from those classes. + Furthermore, the `AutowiredAnnotationBeanPostProcessor` and `CommonAnnotationBeanPostProcessor` are both implicitly included when you use the component-scan element. That means that the two components are autodetected and diff --git a/spring-context/src/main/java/org/springframework/context/annotation/ComponentScan.java b/spring-context/src/main/java/org/springframework/context/annotation/ComponentScan.java index a6e4f7f873c0..34ecb8519872 100644 --- a/spring-context/src/main/java/org/springframework/context/annotation/ComponentScan.java +++ b/spring-context/src/main/java/org/springframework/context/annotation/ComponentScan.java @@ -76,6 +76,10 @@ *

      Allows for more concise annotation declarations if no other attributes * are needed — for example, {@code @ComponentScan("org.my.pkg")} * instead of {@code @ComponentScan(basePackages = "org.my.pkg")}. + *

      This attribute has the same semantics as {@link #basePackages}, including + * support for {@code ${...}} placeholders (resolved against the + * {@link org.springframework.core.env.Environment Environment}) and + * Ant-style package patterns (for example, {@code com.example.**}). */ @AliasFor("basePackages") String[] value() default {}; @@ -86,6 +90,13 @@ * attribute. *

      Use {@link #basePackageClasses} for a type-safe alternative to * String-based package names. + *

      Supports {@code ${...}} placeholders resolved against the + * {@link org.springframework.core.env.Environment Environment} as well as + * Ant-style package patterns (for example, {@code com.example.**}). + * Multiple packages and/or patterns may be specified. + *

      Note: Ant-style patterns are not applicable to + * {@link #basePackageClasses()}, which accepts concrete classes for type-safe + * package selection. */ @AliasFor("value") String[] basePackages() default {}; @@ -95,6 +106,8 @@ * to scan for annotated components. The package of each class specified will be scanned. *

      Consider creating a special no-op marker class or interface in each package * that serves no purpose other than being referenced by this attribute. + *

      Note: Ant-style package patterns do not apply here; this + * attribute accepts concrete classes only and derives packages from those classes. */ Class[] basePackageClasses() default {}; From dbb9bf939c47405e5cd7089a23a8181ba7438d62 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Thu, 18 Sep 2025 17:40:16 +0200 Subject: [PATCH 231/591] Revise contribution See gh-35491 --- .../pages/core/beans/classpath-scanning.adoc | 51 ++++++++++++------- .../context/annotation/ComponentScan.java | 25 ++++----- 2 files changed, 42 insertions(+), 34 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/core/beans/classpath-scanning.adoc b/framework-docs/modules/ROOT/pages/core/beans/classpath-scanning.adoc index 9f6cc02797f6..6c0ef21f9136 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/classpath-scanning.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/classpath-scanning.adoc @@ -323,11 +323,29 @@ sure that they are 'opened' (that is, that they use an `opens` declaration inste `exports` declaration in your `module-info` descriptor). ==== -==== Property placeholders and Ant-style patterns +Furthermore, the `AutowiredAnnotationBeanPostProcessor` and +`CommonAnnotationBeanPostProcessor` are both implicitly included when you use the +`` element. That means that the two components are autodetected +and wired together -- all without any bean configuration metadata provided in XML. + +NOTE: You can disable the registration of `AutowiredAnnotationBeanPostProcessor` and +`CommonAnnotationBeanPostProcessor` by including the `annotation-config` attribute +with a value of `false`. + + +[[beans-scanning-placeholders-and-patterns]] +=== Property Placeholders and Ant-style Patterns + +The `basePackages` and `value` attributes in `@ComponentScan` support `${...}` property +placeholders which are resolved against the `Environment` as well as Ant-style package +patterns such as `"org.example.+++**+++"`. + +In addition, multiple packages or patterns may be specified, either separately or within +a single String — for example, `{"org.example.config", "org.example.service.+++**+++"}` +or `"org.example.config, org.example.service.+++**+++"`. -`@ComponentScan(basePackages)` supports `${…}` property placeholders resolved -against the `Environment` and Ant-style package patterns such as `com.example.**`. -Multiple packages and/or patterns may be specified. +The following example specifies the `app.scan.packages` property placeholder for the +implicit `value` attribute in `@ComponentScan`. [tabs] ====== @@ -336,41 +354,36 @@ Java:: [source,java,indent=0,subs="verbatim,quotes"] ---- @Configuration -@ComponentScan(basePackages = "${app.scan.packages}") +@ComponentScan("${app.scan.packages}") // <1> public class AppConfig { // ... } ---- +<1> `app.scan.packages` property placeholder to be resolved against the `Environment` Kotlin:: + [source,kotlin,indent=0,subs="verbatim,quotes"] ---- @Configuration -@ComponentScan(basePackages = ["\${app.scan.packages}"]) +@ComponentScan(["\${app.scan.packages}"]) // <1> class AppConfig { // ... } ---- +<1> `app.scan.packages` property placeholder to be resolved against the `Environment` ====== +The following listing represents a properties file which defines the `app.scan.packages` +property. In the preceding example, it is assumed that this properties file has been +registered with the `Environment` – for example, via `@PropertySource` or a similar +mechanism. + [source,properties,indent=0,subs="verbatim,quotes"] ---- -app.scan.packages=com.example.**,org.acme.* +app.scan.packages=org.example.config, org.example.service.** ---- -NOTE: Ant-style patterns do not apply to `basePackageClasses`, which accepts concrete -classes and derives packages from those classes. - -Furthermore, the `AutowiredAnnotationBeanPostProcessor` and -`CommonAnnotationBeanPostProcessor` are both implicitly included when you use the -component-scan element. That means that the two components are autodetected and -wired together -- all without any bean configuration metadata provided in XML. - -NOTE: You can disable the registration of `AutowiredAnnotationBeanPostProcessor` and -`CommonAnnotationBeanPostProcessor` by including the `annotation-config` attribute -with a value of `false`. - [[beans-scanning-filters]] == Using Filters to Customize Scanning diff --git a/spring-context/src/main/java/org/springframework/context/annotation/ComponentScan.java b/spring-context/src/main/java/org/springframework/context/annotation/ComponentScan.java index 34ecb8519872..621fd708e8b0 100644 --- a/spring-context/src/main/java/org/springframework/context/annotation/ComponentScan.java +++ b/spring-context/src/main/java/org/springframework/context/annotation/ComponentScan.java @@ -74,12 +74,8 @@ /** * Alias for {@link #basePackages}. *

      Allows for more concise annotation declarations if no other attributes - * are needed — for example, {@code @ComponentScan("org.my.pkg")} - * instead of {@code @ComponentScan(basePackages = "org.my.pkg")}. - *

      This attribute has the same semantics as {@link #basePackages}, including - * support for {@code ${...}} placeholders (resolved against the - * {@link org.springframework.core.env.Environment Environment}) and - * Ant-style package patterns (for example, {@code com.example.**}). + * are needed — for example, {@code @ComponentScan("org.example")} + * instead of {@code @ComponentScan(basePackages = "org.example")}. */ @AliasFor("basePackages") String[] value() default {}; @@ -88,15 +84,16 @@ * Base packages to scan for annotated components. *

      {@link #value} is an alias for (and mutually exclusive with) this * attribute. + *

      Supports {@code ${...}} placeholders which are resolved against the + * {@link org.springframework.core.env.Environment Environment} as well as + * Ant-style package patterns — for example, {@code "org.example.**"}. + *

      Multiple packages or patterns may be specified, either separately or + * within a single {@code String} — for example, + * {@code {"org.example.config", "org.example.service.**"}} or + * {@code "org.example.config, org.example.service.**"}. *

      Use {@link #basePackageClasses} for a type-safe alternative to * String-based package names. - *

      Supports {@code ${...}} placeholders resolved against the - * {@link org.springframework.core.env.Environment Environment} as well as - * Ant-style package patterns (for example, {@code com.example.**}). - * Multiple packages and/or patterns may be specified. - *

      Note: Ant-style patterns are not applicable to - * {@link #basePackageClasses()}, which accepts concrete classes for type-safe - * package selection. + * @see org.springframework.context.ConfigurableApplicationContext#CONFIG_LOCATION_DELIMITERS */ @AliasFor("value") String[] basePackages() default {}; @@ -106,8 +103,6 @@ * to scan for annotated components. The package of each class specified will be scanned. *

      Consider creating a special no-op marker class or interface in each package * that serves no purpose other than being referenced by this attribute. - *

      Note: Ant-style package patterns do not apply here; this - * attribute accepts concrete classes only and derives packages from those classes. */ Class[] basePackageClasses() default {}; From abdc3200b244aabb50fcfa4b83a9c0bb5071710b Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Thu, 18 Sep 2025 17:42:29 +0200 Subject: [PATCH 232/591] Restructure and polish the classpath scanning chapter --- .../pages/core/beans/classpath-scanning.adoc | 508 +++++++++--------- 1 file changed, 254 insertions(+), 254 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/core/beans/classpath-scanning.adoc b/framework-docs/modules/ROOT/pages/core/beans/classpath-scanning.adoc index 6c0ef21f9136..612a3c23229c 100644 --- a/framework-docs/modules/ROOT/pages/core/beans/classpath-scanning.adoc +++ b/framework-docs/modules/ROOT/pages/core/beans/classpath-scanning.adoc @@ -9,7 +9,7 @@ annotations. Even in those examples, however, the "base" bean definitions are ex defined in the XML file, while the annotations drive only the dependency injection. This section describes an option for implicitly detecting the candidate components by -scanning the classpath. Candidate components are classes that match against a filter +scanning the classpath. Candidate components are classes that match against filter criteria and have a corresponding bean definition registered with the container. This removes the need to use XML to perform bean registration. Instead, you can use annotations (for example, `@Component`), AspectJ type expressions, or your own @@ -70,7 +70,7 @@ Java:: // ... } ---- -<1> The `@Component` causes `@Service` to be treated in the same way as `@Component`. +<1> The `@Component` meta-annotation causes `@Service` to be treated in the same way as `@Component`. Kotlin:: + @@ -85,7 +85,7 @@ Kotlin:: // ... } ---- -<1> The `@Component` causes `@Service` to be treated in the same way as `@Component`. +<1> The `@Component` meta-annotation causes `@Service` to be treated in the same way as `@Component`. ====== You can also combine meta-annotations to create "`composed annotations`". For example, @@ -97,7 +97,7 @@ meta-annotations to allow customization. This can be particularly useful when yo want to only expose a subset of the meta-annotation's attributes. For example, Spring's `@SessionScope` annotation hard codes the scope name to `session` but still allows customization of the `proxyMode`. The following listing shows the definition of the -`SessionScope` annotation: +`@SessionScope` annotation: [tabs] ====== @@ -211,7 +211,7 @@ Java:: @Service public class SimpleMovieLister { - private MovieFinder movieFinder; + private final MovieFinder movieFinder; public SimpleMovieLister(MovieFinder movieFinder) { this.movieFinder = movieFinder; @@ -251,11 +251,11 @@ Kotlin:: ---- ====== - To autodetect these classes and register the corresponding beans, you need to add -`@ComponentScan` to your `@Configuration` class, where the `basePackages` attribute -is a common parent package for the two classes. (Alternatively, you can specify a -comma- or semicolon- or space-separated list that includes the parent package of each class.) +`@ComponentScan` to your `@Configuration` class, where the `basePackages` attribute is +configured with a common parent package for the two classes. Alternatively, you can +specify a comma-, semicolon-, or space-separated list that includes the parent package +of each class. [tabs] ====== @@ -282,10 +282,10 @@ Kotlin:: ---- ====== -NOTE: For brevity, the preceding example could have used the `value` attribute of the -annotation (that is, `@ComponentScan("org.example")`). +TIP: For brevity, the preceding example could have used the implicit `value` attribute of +the annotation instead: `@ComponentScan("org.example")` -The following alternative uses XML: +The following example uses XML configuration: [source,xml,indent=0,subs="verbatim,quotes"] ---- @@ -386,7 +386,7 @@ app.scan.packages=org.example.config, org.example.service.** [[beans-scanning-filters]] -== Using Filters to Customize Scanning +=== Using Filters to Customize Scanning By default, classes annotated with `@Component`, `@Repository`, `@Service`, `@Controller`, `@Configuration`, or a custom annotation that itself is annotated with `@Component` are @@ -423,8 +423,8 @@ The following table describes the filtering options: | A custom implementation of the `org.springframework.core.type.TypeFilter` interface. |=== -The following example shows the configuration ignoring all `@Repository` annotations -and using "`stub`" repositories instead: +The following example shows `@ComponentScan` configuration that excludes all +`@Repository` annotations and includes "`Stub`" repositories instead: [tabs] ====== @@ -476,244 +476,8 @@ annotated or meta-annotated with `@Component`, `@Repository`, `@Service`, `@Cont `@RestController`, or `@Configuration`. -[[beans-factorybeans-annotations]] -== Defining Bean Metadata within Components - -Spring components can also contribute bean definition metadata to the container. You can do -this with the same `@Bean` annotation used to define bean metadata within `@Configuration` -annotated classes. The following example shows how to do so: - -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes"] ----- - @Component - public class FactoryMethodComponent { - - @Bean - @Qualifier("public") - public TestBean publicInstance() { - return new TestBean("publicInstance"); - } - - public void doWork() { - // Component method implementation omitted - } - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes"] ----- - @Component - class FactoryMethodComponent { - - @Bean - @Qualifier("public") - fun publicInstance() = TestBean("publicInstance") - - fun doWork() { - // Component method implementation omitted - } - } ----- -====== - -The preceding class is a Spring component that has application-specific code in its -`doWork()` method. However, it also contributes a bean definition that has a factory -method referring to the method `publicInstance()`. The `@Bean` annotation identifies the -factory method and other bean definition properties, such as a qualifier value through -the `@Qualifier` annotation. Other method-level annotations that can be specified are -`@Scope`, `@Lazy`, and custom qualifier annotations. - -[[beans-factorybeans-annotations-lazy-injection-points]] -[TIP] -==== -In addition to its role for component initialization, you can also place the `@Lazy` -annotation on injection points marked with `@Autowired` or `@Inject`. In this context, -it leads to the injection of a lazy-resolution proxy. However, such a proxy approach -is rather limited. For sophisticated lazy interactions, in particular in combination -with optional dependencies, we recommend `ObjectProvider` instead. -==== - -Autowired fields and methods are supported, as previously discussed, with additional -support for autowiring of `@Bean` methods. The following example shows how to do so: - -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes"] ----- - @Component - public class FactoryMethodComponent { - - private static int i; - - @Bean - @Qualifier("public") - public TestBean publicInstance() { - return new TestBean("publicInstance"); - } - - // use of a custom qualifier and autowiring of method parameters - @Bean - protected TestBean protectedInstance( - @Qualifier("public") TestBean spouse, - @Value("#{privateInstance.age}") String country) { - TestBean tb = new TestBean("protectedInstance", 1); - tb.setSpouse(spouse); - tb.setCountry(country); - return tb; - } - - @Bean - private TestBean privateInstance() { - return new TestBean("privateInstance", i++); - } - - @Bean - @RequestScope - public TestBean requestScopedInstance() { - return new TestBean("requestScopedInstance", 3); - } - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes"] ----- - @Component - class FactoryMethodComponent { - - companion object { - private var i: Int = 0 - } - - @Bean - @Qualifier("public") - fun publicInstance() = TestBean("publicInstance") - - // use of a custom qualifier and autowiring of method parameters - @Bean - protected fun protectedInstance( - @Qualifier("public") spouse: TestBean, - @Value("#{privateInstance.age}") country: String) = TestBean("protectedInstance", 1).apply { - this.spouse = spouse - this.country = country - } - - @Bean - private fun privateInstance() = TestBean("privateInstance", i++) - - @Bean - @RequestScope - fun requestScopedInstance() = TestBean("requestScopedInstance", 3) - } ----- -====== - -The example autowires the `String` method parameter `country` to the value of the `age` -property on another bean named `privateInstance`. A Spring Expression Language element -defines the value of the property through the notation `#{ }`. For `@Value` -annotations, an expression resolver is preconfigured to look for bean names when -resolving expression text. - -As of Spring Framework 4.3, you may also declare a factory method parameter of type -`InjectionPoint` (or its more specific subclass: `DependencyDescriptor`) to -access the requesting injection point that triggers the creation of the current bean. -Note that this applies only to the actual creation of bean instances, not to the -injection of existing instances. As a consequence, this feature makes most sense for -beans of prototype scope. For other scopes, the factory method only ever sees the -injection point that triggered the creation of a new bean instance in the given scope -(for example, the dependency that triggered the creation of a lazy singleton bean). -You can use the provided injection point metadata with semantic care in such scenarios. -The following example shows how to use `InjectionPoint`: - -[tabs] -====== -Java:: -+ -[source,java,indent=0,subs="verbatim,quotes"] ----- - @Component - public class FactoryMethodComponent { - - @Bean @Scope("prototype") - public TestBean prototypeInstance(InjectionPoint injectionPoint) { - return new TestBean("prototypeInstance for " + injectionPoint.getMember()); - } - } ----- - -Kotlin:: -+ -[source,kotlin,indent=0,subs="verbatim,quotes"] ----- - @Component - class FactoryMethodComponent { - - @Bean - @Scope("prototype") - fun prototypeInstance(injectionPoint: InjectionPoint) = - TestBean("prototypeInstance for ${injectionPoint.member}") - } ----- -====== - -The `@Bean` methods in a regular Spring component are processed differently than their -counterparts inside a Spring `@Configuration` class. The difference is that `@Component` -classes are not enhanced with CGLIB to intercept the invocation of methods and fields. -CGLIB proxying is the means by which invoking methods or fields within `@Bean` methods -in `@Configuration` classes creates bean metadata references to collaborating objects. -Such methods are not invoked with normal Java semantics but rather go through the -container in order to provide the usual lifecycle management and proxying of Spring -beans, even when referring to other beans through programmatic calls to `@Bean` methods. -In contrast, invoking a method or field in a `@Bean` method within a plain `@Component` -class has standard Java semantics, with no special CGLIB processing or other -constraints applying. - -[NOTE] -==== -You may declare `@Bean` methods as `static`, allowing for them to be called without -creating their containing configuration class as an instance. This makes particular -sense when defining post-processor beans (for example, of type `BeanFactoryPostProcessor` -or `BeanPostProcessor`), since such beans get initialized early in the container -lifecycle and should avoid triggering other parts of the configuration at that point. - -Calls to static `@Bean` methods never get intercepted by the container, not even within -`@Configuration` classes (as described earlier in this section), due to technical -limitations: CGLIB subclassing can override only non-static methods. As a consequence, -a direct call to another `@Bean` method has standard Java semantics, resulting -in an independent instance being returned straight from the factory method itself. - -The Java language visibility of `@Bean` methods does not have an immediate impact on -the resulting bean definition in Spring's container. You can freely declare your -factory methods as you see fit in non-`@Configuration` classes and also for static -methods anywhere. However, regular `@Bean` methods in `@Configuration` classes need -to be overridable -- that is, they must not be declared as `private` or `final`. - -`@Bean` methods are also discovered on base classes of a given component or -configuration class, as well as on Java 8 default methods declared in interfaces -implemented by the component or configuration class. This allows for a lot of -flexibility in composing complex configuration arrangements, with even multiple -inheritance being possible through Java 8 default methods as of Spring 4.2. - -Finally, a single class may hold multiple `@Bean` methods for the same -bean, as an arrangement of multiple factory methods to use depending on available -dependencies at runtime. This is the same algorithm as for choosing the "`greediest`" -constructor or factory method in other configuration scenarios: The variant with -the largest number of satisfiable dependencies is picked at construction time, -analogous to how the container selects between multiple `@Autowired` constructors. -==== - - [[beans-scanning-name-generator]] -== Naming Autodetected Components +=== Naming Autodetected Components When a component is autodetected as part of the scanning process, its bean name is generated by the `BeanNameGenerator` strategy known to that scanner. @@ -844,7 +608,7 @@ auto-generated names are adequate whenever the container is responsible for wiri [[beans-scanning-scope-resolver]] -== Providing a Scope for Autodetected Components +=== Providing a Scope for Autodetected Components As with Spring-managed components in general, the default and most common scope for autodetected components is `singleton`. However, sometimes you need a different scope @@ -967,7 +731,7 @@ Kotlin:: [[beans-scanning-qualifiers]] -== Providing Qualifier Metadata with Annotations +=== Providing Qualifier Metadata with Annotations The `@Qualifier` annotation is discussed in xref:core/beans/annotation-config/autowired-qualifiers.adoc[Fine-tuning Annotation-based Autowiring with Qualifiers]. @@ -1057,3 +821,239 @@ NOTE: As with most annotation-based alternatives, keep in mind that the annotati bound to the class definition itself, while the use of XML allows for multiple beans of the same type to provide variations in their qualifier metadata, because that metadata is provided per-instance rather than per-class. + + +[[beans-factorybeans-annotations]] +== Defining Bean Metadata within Components + +Spring components can also contribute bean definition metadata to the container. You can do +this with the same `@Bean` annotation used to define bean metadata within `@Configuration` +annotated classes. The following example shows how to do so: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @Component + public class FactoryMethodComponent { + + @Bean + @Qualifier("public") + public TestBean publicInstance() { + return new TestBean("publicInstance"); + } + + public void doWork() { + // Component method implementation omitted + } + } +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + @Component + class FactoryMethodComponent { + + @Bean + @Qualifier("public") + fun publicInstance() = TestBean("publicInstance") + + fun doWork() { + // Component method implementation omitted + } + } +---- +====== + +The preceding class is a Spring component that has application-specific code in its +`doWork()` method. However, it also contributes a bean definition that has a factory +method referring to the method `publicInstance()`. The `@Bean` annotation identifies the +factory method and other bean definition properties, such as a qualifier value through +the `@Qualifier` annotation. Other method-level annotations that can be specified are +`@Scope`, `@Lazy`, and custom qualifier annotations. + +[[beans-factorybeans-annotations-lazy-injection-points]] +[TIP] +==== +In addition to its role for component initialization, you can also place the `@Lazy` +annotation on injection points marked with `@Autowired` or `@Inject`. In this context, +it leads to the injection of a lazy-resolution proxy. However, such a proxy approach +is rather limited. For sophisticated lazy interactions, in particular in combination +with optional dependencies, we recommend `ObjectProvider` instead. +==== + +Autowired fields and methods are supported, as previously discussed, with additional +support for autowiring of `@Bean` methods. The following example shows how to do so: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @Component + public class FactoryMethodComponent { + + private static int i; + + @Bean + @Qualifier("public") + public TestBean publicInstance() { + return new TestBean("publicInstance"); + } + + // use of a custom qualifier and autowiring of method parameters + @Bean + protected TestBean protectedInstance( + @Qualifier("public") TestBean spouse, + @Value("#{privateInstance.age}") String country) { + TestBean tb = new TestBean("protectedInstance", 1); + tb.setSpouse(spouse); + tb.setCountry(country); + return tb; + } + + @Bean + private TestBean privateInstance() { + return new TestBean("privateInstance", i++); + } + + @Bean + @RequestScope + public TestBean requestScopedInstance() { + return new TestBean("requestScopedInstance", 3); + } + } +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + @Component + class FactoryMethodComponent { + + companion object { + private var i: Int = 0 + } + + @Bean + @Qualifier("public") + fun publicInstance() = TestBean("publicInstance") + + // use of a custom qualifier and autowiring of method parameters + @Bean + protected fun protectedInstance( + @Qualifier("public") spouse: TestBean, + @Value("#{privateInstance.age}") country: String) = TestBean("protectedInstance", 1).apply { + this.spouse = spouse + this.country = country + } + + @Bean + private fun privateInstance() = TestBean("privateInstance", i++) + + @Bean + @RequestScope + fun requestScopedInstance() = TestBean("requestScopedInstance", 3) + } +---- +====== + +The example autowires the `String` method parameter `country` to the value of the `age` +property on another bean named `privateInstance`. A Spring Expression Language element +defines the value of the property through the notation `#{ }`. For `@Value` +annotations, an expression resolver is preconfigured to look for bean names when +resolving expression text. + +As of Spring Framework 4.3, you may also declare a factory method parameter of type +`InjectionPoint` (or its more specific subclass: `DependencyDescriptor`) to +access the requesting injection point that triggers the creation of the current bean. +Note that this applies only to the actual creation of bean instances, not to the +injection of existing instances. As a consequence, this feature makes most sense for +beans of prototype scope. For other scopes, the factory method only ever sees the +injection point that triggered the creation of a new bean instance in the given scope +(for example, the dependency that triggered the creation of a lazy singleton bean). +You can use the provided injection point metadata with semantic care in such scenarios. +The following example shows how to use `InjectionPoint`: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes"] +---- + @Component + public class FactoryMethodComponent { + + @Bean @Scope("prototype") + public TestBean prototypeInstance(InjectionPoint injectionPoint) { + return new TestBean("prototypeInstance for " + injectionPoint.getMember()); + } + } +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes"] +---- + @Component + class FactoryMethodComponent { + + @Bean + @Scope("prototype") + fun prototypeInstance(injectionPoint: InjectionPoint) = + TestBean("prototypeInstance for ${injectionPoint.member}") + } +---- +====== + +The `@Bean` methods in a regular Spring component are processed differently than their +counterparts inside a Spring `@Configuration` class. The difference is that `@Component` +classes are not enhanced with CGLIB to intercept the invocation of methods and fields. +CGLIB proxying is the means by which invoking methods or fields within `@Bean` methods +in `@Configuration` classes creates bean metadata references to collaborating objects. +Such methods are not invoked with normal Java semantics but rather go through the +container in order to provide the usual lifecycle management and proxying of Spring +beans, even when referring to other beans through programmatic calls to `@Bean` methods. +In contrast, invoking a method or field in a `@Bean` method within a plain `@Component` +class has standard Java semantics, with no special CGLIB processing or other +constraints applying. + +[NOTE] +==== +You may declare `@Bean` methods as `static`, allowing for them to be called without +creating their containing configuration class as an instance. This makes particular +sense when defining post-processor beans (for example, of type `BeanFactoryPostProcessor` +or `BeanPostProcessor`), since such beans get initialized early in the container +lifecycle and should avoid triggering other parts of the configuration at that point. + +Calls to static `@Bean` methods never get intercepted by the container, not even within +`@Configuration` classes (as described earlier in this section), due to technical +limitations: CGLIB subclassing can override only non-static methods. As a consequence, +a direct call to another `@Bean` method has standard Java semantics, resulting +in an independent instance being returned straight from the factory method itself. + +The Java language visibility of `@Bean` methods does not have an immediate impact on +the resulting bean definition in Spring's container. You can freely declare your +factory methods as you see fit in non-`@Configuration` classes and also for static +methods anywhere. However, regular `@Bean` methods in `@Configuration` classes need +to be overridable -- that is, they must not be declared as `private` or `final`. + +`@Bean` methods are also discovered on base classes of a given component or +configuration class, as well as on Java 8 default methods declared in interfaces +implemented by the component or configuration class. This allows for a lot of +flexibility in composing complex configuration arrangements, with even multiple +inheritance being possible through Java 8 default methods as of Spring 4.2. + +Finally, a single class may hold multiple `@Bean` methods for the same +bean, as an arrangement of multiple factory methods to use depending on available +dependencies at runtime. This is the same algorithm as for choosing the "`greediest`" +constructor or factory method in other configuration scenarios: The variant with +the largest number of satisfiable dependencies is picked at construction time, +analogous to how the container selects between multiple `@Autowired` constructors. +==== From cb485b666f14a7f02a678e7c944c5625e4359923 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Thu, 18 Sep 2025 18:02:18 +0200 Subject: [PATCH 233/591] Polishing --- .../ClassPathScanningCandidateComponentProvider.java | 4 ++-- .../java/org/springframework/core/type/ClassMetadata.java | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/spring-context/src/main/java/org/springframework/context/annotation/ClassPathScanningCandidateComponentProvider.java b/spring-context/src/main/java/org/springframework/context/annotation/ClassPathScanningCandidateComponentProvider.java index ab4ebefaaea8..6d6b21b6ba82 100644 --- a/spring-context/src/main/java/org/springframework/context/annotation/ClassPathScanningCandidateComponentProvider.java +++ b/spring-context/src/main/java/org/springframework/context/annotation/ClassPathScanningCandidateComponentProvider.java @@ -418,9 +418,9 @@ private Set addCandidateComponentsFromIndex(CandidateComponentsI private Set scanCandidateComponents(String basePackage) { Set candidates = new LinkedHashSet<>(); try { - String packageSearchPath = ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + + String packageSearchPattern = ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX + resolveBasePackage(basePackage) + '/' + this.resourcePattern; - Resource[] resources = getResourcePatternResolver().getResources(packageSearchPath); + Resource[] resources = getResourcePatternResolver().getResources(packageSearchPattern); boolean traceEnabled = logger.isTraceEnabled(); boolean debugEnabled = logger.isDebugEnabled(); for (Resource resource : resources) { diff --git a/spring-core/src/main/java/org/springframework/core/type/ClassMetadata.java b/spring-core/src/main/java/org/springframework/core/type/ClassMetadata.java index b575be3cd173..4897abbf7731 100644 --- a/spring-core/src/main/java/org/springframework/core/type/ClassMetadata.java +++ b/spring-core/src/main/java/org/springframework/core/type/ClassMetadata.java @@ -65,9 +65,9 @@ default boolean isConcrete() { boolean isFinal(); /** - * Determine whether the underlying class is independent, i.e. whether - * it is a top-level class or a nested class (static inner class) that - * can be constructed independently of an enclosing class. + * Determine whether the underlying class is independent, i.e. whether it is + * a top-level class or a static nested class that can be constructed + * independently of an enclosing class. */ boolean isIndependent(); From 01c8f04d98da697795e67994cbe3b006710cdff3 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Thu, 18 Sep 2025 18:02:59 +0200 Subject: [PATCH 234/591] Improve Javadoc for ConfigurableApplicationContext --- .../ConfigurableApplicationContext.java | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/spring-context/src/main/java/org/springframework/context/ConfigurableApplicationContext.java b/spring-context/src/main/java/org/springframework/context/ConfigurableApplicationContext.java index fe471a2d252c..6bef23396300 100644 --- a/spring-context/src/main/java/org/springframework/context/ConfigurableApplicationContext.java +++ b/spring-context/src/main/java/org/springframework/context/ConfigurableApplicationContext.java @@ -17,7 +17,6 @@ package org.springframework.context; import java.io.Closeable; -import java.util.concurrent.Executor; import org.jspecify.annotations.Nullable; @@ -25,7 +24,6 @@ import org.springframework.beans.factory.config.BeanFactoryPostProcessor; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.core.env.ConfigurableEnvironment; -import org.springframework.core.env.Environment; import org.springframework.core.io.ProtocolResolver; import org.springframework.core.metrics.ApplicationStartup; @@ -47,8 +45,8 @@ public interface ConfigurableApplicationContext extends ApplicationContext, Lifecycle, Closeable { /** - * Any number of these characters are considered delimiters between - * multiple context config paths in a single String value. + * Any number of these characters are considered delimiters between multiple + * context config paths in a single {@code String} value: {@value}. * @see org.springframework.context.support.AbstractXmlApplicationContext#setConfigLocation * @see org.springframework.web.context.ContextLoader#CONFIG_LOCATION_PARAM * @see org.springframework.web.servlet.FrameworkServlet#setContextConfigLocation @@ -56,8 +54,9 @@ public interface ConfigurableApplicationContext extends ApplicationContext, Life String CONFIG_LOCATION_DELIMITERS = ",; \t\n"; /** - * The name of the {@link Executor bootstrap executor} bean in the context. - * If none is supplied, no background bootstrapping will be active. + * The name of the {@linkplain java.util.concurrent.Executor bootstrap executor} + * bean in the context: {@value}. + *

      If none is supplied, no background bootstrapping will be active. * @since 6.2 * @see java.util.concurrent.Executor * @see org.springframework.core.task.TaskExecutor @@ -66,48 +65,50 @@ public interface ConfigurableApplicationContext extends ApplicationContext, Life String BOOTSTRAP_EXECUTOR_BEAN_NAME = "bootstrapExecutor"; /** - * Name of the ConversionService bean in the factory. - * If none is supplied, default conversion rules apply. + * Name of the {@code ConversionService} bean in the factory: {@value}. + *

      If none is supplied, default conversion rules apply. * @since 3.0 * @see org.springframework.core.convert.ConversionService */ String CONVERSION_SERVICE_BEAN_NAME = "conversionService"; /** - * Name of the LoadTimeWeaver bean in the factory. If such a bean is supplied, - * the context will use a temporary ClassLoader for type matching, in order - * to allow the LoadTimeWeaver to process all actual bean classes. + * Name of the {@code LoadTimeWeaver} bean in the factory: {@value}. + *

      If such a bean is supplied, the context will use a temporary {@link ClassLoader} + * for type matching, in order to allow the {@code LoadTimeWeaver} to process + * all actual bean classes. * @since 2.5 * @see org.springframework.instrument.classloading.LoadTimeWeaver */ String LOAD_TIME_WEAVER_BEAN_NAME = "loadTimeWeaver"; /** - * Name of the {@link Environment} bean in the factory. + * Name of the {@link org.springframework.core.env.Environment Environment} + * bean in the factory: {@value}. * @since 3.1 */ String ENVIRONMENT_BEAN_NAME = "environment"; /** - * Name of the System properties bean in the factory. + * Name of the JVM System properties bean in the factory: {@value}. * @see java.lang.System#getProperties() */ String SYSTEM_PROPERTIES_BEAN_NAME = "systemProperties"; /** - * Name of the System environment bean in the factory. + * Name of the Operating System environment bean in the factory: {@value}. * @see java.lang.System#getenv() */ String SYSTEM_ENVIRONMENT_BEAN_NAME = "systemEnvironment"; /** - * Name of the {@link ApplicationStartup} bean in the factory. + * Name of the {@link ApplicationStartup} bean in the factory: {@value}. * @since 5.3 */ String APPLICATION_STARTUP_BEAN_NAME = "applicationStartup"; /** - * {@link Thread#getName() Name} of the {@linkplain #registerShutdownHook() + * {@linkplain Thread#getName() Name} of the {@linkplain #registerShutdownHook() * shutdown hook} thread: {@value}. * @since 5.2 * @see #registerShutdownHook() @@ -116,7 +117,7 @@ public interface ConfigurableApplicationContext extends ApplicationContext, Life /** - * Set the unique id of this application context. + * Set the unique ID of this application context. * @since 3.0 */ void setId(String id); From 1e2991129265aae015473588b727d2380327db6f Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Fri, 19 Sep 2025 11:23:08 +0200 Subject: [PATCH 235/591] Upgrade to AssertJ 3.27.5 --- framework-platform/framework-platform.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index d588007f9960..df7ff97e007b 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -15,7 +15,7 @@ dependencies { api(platform("io.rsocket:rsocket-bom:1.1.5")) api(platform("org.apache.groovy:groovy-bom:4.0.28")) api(platform("org.apache.logging.log4j:log4j-bom:2.21.1")) - api(platform("org.assertj:assertj-bom:3.27.4")) + api(platform("org.assertj:assertj-bom:3.27.5")) api(platform("org.eclipse.jetty:jetty-bom:12.0.26")) api(platform("org.eclipse.jetty.ee10:jetty-ee10-bom:12.0.26")) api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.1")) From d038269ec3a4d96286927b21fed3504445869892 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Fri, 19 Sep 2025 17:40:32 +0200 Subject: [PATCH 236/591] Upgrade to Gradle 9.1 This commit upgrades the build to use Gradle 9.1. To achieve that, the following changes were necessary. - Stop using Groovy safe-navigation operator (?.) in framework-api.gradle due to a NullPointerException. - Switch from the io.github.goooler.shadow plugin to the com.gradleup.shadow plugin, since the former is no longer maintained and the latter is a fork that replaces it. Closes gh-35508 --- build.gradle | 2 +- framework-api/framework-api.gradle | 7 +++++-- gradle/wrapper/gradle-wrapper.jar | Bin 43764 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 5 +---- gradlew.bat | 3 +-- 6 files changed, 9 insertions(+), 10 deletions(-) diff --git a/build.gradle b/build.gradle index d0dce05f0fae..8f6ca47040eb 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ plugins { id 'org.jetbrains.kotlin.plugin.serialization' version "${kotlinVersion}" apply false id 'org.jetbrains.dokka' id 'com.github.bjornvester.xjc' version '1.8.2' apply false - id 'io.github.goooler.shadow' version '8.1.8' apply false + id 'com.gradleup.shadow' version "9.1.0" apply false id 'me.champeau.jmh' version '0.7.2' apply false id 'io.spring.nullability' version '0.0.4' apply false } diff --git a/framework-api/framework-api.gradle b/framework-api/framework-api.gradle index fc3ecb8637f1..b1941e086f04 100644 --- a/framework-api/framework-api.gradle +++ b/framework-api/framework-api.gradle @@ -98,9 +98,12 @@ tasks.register('schemaZip', Zip) { moduleProjects.each { module -> def Properties schemas = new Properties(); - module.sourceSets.main.resources.find { + def schemaFile = module.sourceSets.main.resources.find { (it.path.endsWith("META-INF/spring.schemas") || it.path.endsWith("META-INF\\spring.schemas")) - }?.withInputStream { schemas.load(it) } + } + if (schemaFile != null) { + schemaFile.withInputStream { schemas.load(it) } + } for (def key : schemas.keySet()) { def shortName = key.replaceAll(/http.*schema.(.*).spring-.*/, '$1') diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 1b33c55baabb587c669f562ae36f953de2481846..8bdaf60c75ab801e22807dde59e12a8735a34077 100644 GIT binary patch delta 37256 zcmXVXV`E)y({>tT2aRppNn_h+Y}>|ev}4@T^BTF zt*UbFk22?fVj8UBV<>NN?oj)e%q3;ANZn%w$&6vqe{^I;QY|jWDMG5ZEZRBH(B?s8 z#P8OsAZjB^hSJcmj0htMiurSj*&pTVc4Q?J8pM$O*6ZGZT*uaKX|LW}Zf>VRnC5;1 zSCWN+wVs*KP6h)5YXeKX;l)oxK^6fH2%+TI+348tQ+wXDQZ>noe$eDa5Q{7FH|_d$ zq!-(Ga2avI1+K!}Fz~?<`hpS3Wc|u#W4`{F+&Nx(g8|DLU<^u~GRNe<35m05WFc~C zJM?2zO{8IPPG0XVWI?@BD!7)~mw6VdR;u4HGN~g^lH|h}=DgO$ec8G3#Dt?Lfc6k3v*{%viJm3wtS3c`aA;J< z(RqusS%t%}c#2l@(X#MCoIQR?Y3d#=zx#Htg_B4Z`ziM-Yui|#6&+YD^=T?@ZJ=Q! z7X;7vYNp%yy01j=nt5jfk%Ab9gFk=quaas)6_6)er_Ks2Qh&>!>f&1U`fyq-TmJot z_`m-)A=X+#_6-coG4Yz0AhDL2FcBpe18AnYp@620t{2)2unUz%5Wf!O*0+?E{bOwx z&NPT1{oMo(@?he0(ujvS+seFH%;Zq;9>!Ol43(Wl;Emujm}x&JU>#L|x_ffl=Az*- z-2mA00ap9V4D*kZ+!4FEEERo9KUG6hZNzZpu`xR zCT(HG$m%9BO;66C-({?7Y(ECD43@i3C=ZbhpaT+{3$R>6ZHlQ&i3pzF>(4O}8@gYB&wID6mkHHFf2O_edpaHIMV3E)&;(0bLUyGf(6&=B*)37Tubx zHB;CkwoF#&_%LCS1Z*Zb3L|n5dIIY!N;GMpEC7OFUVdYiJc=!tt2vh+nB)X?L(Oa@nCM zl-Bb`R~({aYF$Ra(UKd97mfin1l~*Gb=WWk^92POcsy+`D=Z~3OIqqKV5^))b_q;? zWBLW8oTQ)h>o_oRyIm3jvoS(7PH0%~HTbc)qm&v@^@;bii|1$&9ivbs@f*{wQd-OVj> zEX>{AAD?oGdcgR^a`qPH<|g)G3i_)cNbF38YRiWMjiCIe9y|}B=kFnO;`HDYua)9l zVnd68O;nXZwU?p8GRZ!9n#|TQr*|2roF-~1si~E3v9J{pCGXZ-ccUnmPA=iiB0SaT zB5m^|Hln3*&hcHX&xUoD>-k2$_~0h9EkW(|gP=1wXf`E4^2MK3TArmO)3vjy^OzgoV}n6JNYQbgAZF~MYA}XYKgLN~(fx3`trMC7 z+h#$&mI0I*fticKJhCd$0Y_X>DN2^G?;zz|qMwk-1^JIZuqo?{{I++YVr5He2{?S3 zGd9eykq!l0w+LGaCofT%nhOc8bxls9V&CfZCm?V-6R}2dDY3$wk@te znGy2pS$=3|wz!fmujPu+FRUD+c7r}#duG$YH>n$rKZ|}O1#y=(+3kdF`bP3J{+iAM zmK@PKt=WU}a%@pgV3y3-#+%I@(1sQDOqF5K#L+mDe_JDc*p<%i$FU_c#BG;9B9v-8 zhtRMK^5##f*yb&Vr6Lon$;53^+*QMDjeeQZ8pLE1vwa~J7|gv7pY$w#Gn3*JhNzn% z*x_dM@O4QdmT*3#qMUd!iJI=2%H92&`g0n;3NE4S=ci5UHpw4eEw&d{mKZ0CPu`>L zEGO4nq=X#uG3`AVlsAO`HQvhWL9gz=#%qTB?{&c=p-5E3qynmL{6yi$(uItGt%;M& zq?CXHG>1Tt$Mjj@64xL>@;LQJoyxJT+z$Pm9UvQu_ zOgARy33XHSDAhd8-{CQHxxFO#)$ND8OWSSc`FXxJ&_81xa)#GmUEWaMU2U$uRfh{2 z^Bbt+m?(qq*8>{CU&3iux+pH3iR@fwq?AloyDXq-H7PI9Z_h^cN>b$JE|ye(Utu_3 zui=tU1gn{DlJ-V-pQ;UUMC_0_DR$&vkG$?5ycZL$h>(9sRbYm0J7m|>+vJezi}Tpj zu0Fagr*Uq#I>f}E*mrje=kpuUQ*0f$Gv0Cvzwq`i(*jym$x1Qn#y06$L3$rIw{D2Y z2t0)ZBY}{5>^%oGuosKCxx|fkm~97o#vC2!bNu7J_b>5x?mw3YD!97su~EaDW+jm9 zv5U5ts0LRP4NcW@Hs2>X+-8kkXjdP?lra!W44a5rQy42ENhP|AR9IrceE`Z5hZ=A# zdB{w_f`EXrRy*=6lM|=@uFjWSQYrvM{6VopTHD)Zh2U;L8Jq!Y z<4W)hb34~;^0;c=TT-!TT;PP%cx!N;$wAaD@g7}7L}qcr!|HZzHUn=zKXh}kA!LED zDGexnb?~xbXC?grP;wvpPPTsM$VD?sydh3d2xJK>phZ6;=?-{oR#4l?ief)`Hx;ns zJzma8sr}#;{F|TLPXpQxGK+IeHY!a{G?nc#PY5zy#28x)OU*bD^UuApH^4mcoDZwz zUh+GFec2(}foDhw)Iv9#+=U+4{jN_s$7LpWkeL{jGo*;_8M7z;4p{TJkD*f>e9M*T z1QMGNw&0*5uwPs8%w=>7!(4o?fo$lYV%E3U#@GYFzFOu;-{Ts0`Sp1g0PPI_ec$xF zd1BpP!DZUBUJ$p^&pEyINuKZXQmexrV0hww?-0%NVpB80R5sMiec)m>^oV{S4E%us zn(z>anDpcWVNO~3& zrdL}9J$`}x4{=FZ?eJ<4U|@+b{~>MyM-FJCgKvS;ZJ>#*Su9OLHJZ0(t5AC`;$kWD z%_N}MZXBG2xYf#*_Z(>=crE*4l0JBua>;s8J9dfo#&%&)w8|=EC`0ywO7L0l>zDo~ zSk1&)d1%BFZwCV2s?_zwB=5`{-;9solZ)pu^4H6Q!#8|Mh26hJvKG8K$T2oIH2lD9 zSa;|Hv_3~>`yy6QSsN%hrm!+tp{**j{pe&fYcWg8S0z^Q$66BFdDg6)Br*)!n3T+f z7~s_8eK4HtrT|%K<&t_`(NsPW+(IQ1f3GA*0oO{eCE7J%-fGL;6Y~#&-N-r*DV!hA zvj}4FFW~Cd9z#EaR@nx`bW z48Tg|k5nzV-I*vIoC0a)@?_;DtZk(JY;n_LrA^uee{j#$h3}fNY*15` zl2wj>M{PmUHB3KRXBP2GWW|B7RZW({nuZJGN2O-u=#BA(@vG^ow3n$e7u=+dSJo%+ zF)UA%K8xA+r94&p-?FYx+LqfW)RrjSnFBj{B;6(5co4rV6V#XI75BFVh*?at%%o6j$5)u2|TE&BCB`euH0!jNz z5(Lf$;>D3VQP||uintqX8WPrn*?+)6mD`K=Txz+5gD>2GE zk!IdlA{A#%`Ll-BJj08U>fA!r6S02S^dX(izeGM4LcY>~g^U$)vw% zdV@b2g#?}*)+*iDWmOHR`-VCd(rD_1PSCs(b~8Qr69bhp8>?*1qdrRZCA|m@3{+tW zQyre2^zuuMI6PZ0R9!Ql_Aws+fjw68TGiR%jK(IzwVTEvUZ`9~SQ_RVJiVHHcO_mgr5 z9H|@8GY4tUvG3DNTjSb~kv-P$F03=Cz+u6nW_AlsxpZ4xg~w3!#g}`r_j0 z13GpvKRIs?B&h=op~7Uj?qKy19pd+{>E+8^0+v2g1$NZ-xTn zJ4$dp9pdQ7%qaPC?N<1@tQC+7uL#of)%e3l>Yx4D5#Cl6XQNp9h0XZDULW-sj`9-D z3CtoYO*jY0X-GVdAz1}9N%DcyYnA(fSSQO zK{a}k4~XXsiA^I#~52amxe4@gMu*wKLS>TvYXUagd*_35z z>6%E?8_dAs2hN;s-nHDRO?Cgg5)aebjwl7r`)r{!~?JECl!xiYr+P}B4Zwr zdOmbCd<-2k`nIs9F#}u;+-FE0a&2T;YbUu)1S^!r3)DNr(+8fvzuzy2oJlVtLnEdF zE8NQJ0W#O+F<$|RG3pNI1V1a*r_M&b`pi2HLJ)v|s;GTci%_ItdssFmUAmPi<9zLCJR60QB!W zv+(O(NpSnRy_Uh2#;ko|eWNWMk1Dhm7xV7q!=uPIT+hO2+2KU*-#)1itWE(L6tH&A zGhHP!cUcQA(;qKqZ^&S>%-90>_??#B3+tPkX!G+a94?X-R>fCt_^FaHOo%frkS`E> z@PzQMtrMaHn;1v>s}CYTJFn1=yizNIjcd;lN8@Psf;vOSZ3^4j^E;3BYS|daR6GP% z^m+F}lmIfj+sjDeLd`>m>78^3+?3Uo?btw;L#_{d!w9MvI&55j!1ZJGwz+UsAo^BQo?GdP^G*6=p&BL-`U1i#!DO>F=UztubL7A~l6wQKufoz!z|qq>)y!yvC?!cww9 zsN?(kvGVUGnGzaPX0c`^uk05P+fog+pTv9A0&jevIjlNrP}1MQHo{^-N^cJB22-tk z`5~#kg~Buvol0Nfve2_7ZDcNiqKt+#S);@IaC1w69Z4GR0lxxV6?~3BgH2>aAxTI|0-FcbzV01b9Ppiur#_!#Y zjY<41$oTWx?dbfsvix`{xE$*OVqrf=%ay$&4J}yK2<{S|6|=SC6bhJk)j_eLZgIEi zEH1*&%$`YPSzHsJoq@YFLK#k{s`2@fVD^0%vz1duXAirWESQ}jXjYU&FGAeY+S8Z2 z=+9u@YuUFbl143hX}wNPhCXJ!B#HSrK8x@|`}DD*d^;Da78#i{-F6YAN`mJfC4!D# z;kMqJXz_P<{=fWLnk0$BMypYBtXR*ZyGH|R5=mbzCY+&I@jo67#GS_jm?fkPa)JpGZ5&uc^>dPC^oW@oY zaxVTa-6P{GoTQU{yamt!qNk953k|$?n6XRjQ6J&~NxR62I1#X^`ouJ1I{CTcZLs2} z?+0J0*2mIcjoF!5`WU{kg?Z|={u^D|O4Rnl^q;H@6oUF3dJc>LjF~{sh;N`rA6WPt zHb_rKj|w)MHU2!G#dPNUu#jtTQ4h8b)$l;b5G|b@ZLNuO^Ld9#*1 zv{4vY`NUnYD>ZP)h&*VP*}32*8Gs(e!j9dqQ{O79-YjXdQcoX5&Kxj?GR!jcTiwo` zM^Tv$=7?5`1+bky_D01RwT5CYM5WdtrjeaD#APPq{&SQerwMYaizh?qH}rQPY`}7u zU`a4!?`Ti>a%$t5CQ2}!kkk?-}8_CjS|b3n7IoVIft*o$!U~yM&_@FToop( zr8!`nZ>CgUP{J8yVGll;5+l_$*8dv5a3(%}`Cr4!K>asPsi-7@@``vYC3 zS*?}cQYaIc>-n%KsKg|+;=iPZ0y0;4*RVUclP{uaNuEhQu(D_$dXZ0JMWRG$y+t4T zX708p?)DY%(m?5y?7zo;uYWGL zS&B^c=(JH19VlFfZg9~ADPAaCEpdKY8HSpVawMnVSdZ-f-tsvuzIq3D|JjG#RrNdhlof{loQVHL~Nt5_OJhCO6z)h z%}+h1yoKLmTolWBVht(^hv^z?fj|NiHL z`z6MU5+ow>A^*=^Ody9&G@-!;I-m-p^FzR*W6{h;G+VprFeqWF2;$D;64~ynHc7}K zcBdKPq}V;tH6Snzehvmlssi z8y{UmbEFNwe-Qg4C3P-ITAE>sRRpVrlLcJbJA83gcg020 zEylMTgg5^SQl#5eZsc$;s3=9ob<{>x$?FDG4P2FUi@L}k+=1)5MVe3Tb-CBoOax?` z+xlo{I%+m}4sRR$Mbz=`tvwPXe>JVe=-lMi1lE(hmAmWO>(;Ny&V9Jhda;wVi!GoC zr9%LJhlho2y$YF8WT0UvrCVb%#9jyNBHaHhHL~UyeILeAWAw^}i8$ltMr2Yp6{lvV zK9^=_@Plr%z5x2-QX1Anic_;-*AT8u%f@;5Q|x_-kS9$kbl9T;Fw3Wq_32zfcdGQ5 zsqsFFE{(;u!m_6vYVP3QUCZ>KRV8wyg@_%Ds`oA$S%wPo65gLLYhLnyP zhK{0!Ha52RV4CQ^+&a3%%Ob};CA+=XzwNEcPnc3ZouzDBxHb#WSWog z6vF+G-6b?>jfUO8f%*V2oSPN_!R6?kzr8|c+Fo*tt-C&MyzV zT>M65Pa)4#)7ao^6Jj_{`^jb;T@hb{neRGTuMwj~SD9U}q;=niF!g78n!Y0jEXRlT zrSw;qZiU2rtnnEMvN);}=q2Ww&2bA5PV9^W|0f30Zk7Ust-%Q#F!V~jy33y^($hsQ zh@n}s$T7sZUzn69tccDf-a;lg4UWYYI|2?*Lms2$ZW)GI-yaymOBZq!&aOm4 zg4iuvQM|}-y=U>fOaLFvu(`K}T5BANqjBpqrY+RxviWLz<wNld3Q zOBi{x%;Dka>Yc!KK(3mP@37jmo@Mz0cH(Rqg|+z2!Th&@QRP$Zlhz@#qUVwNe+&<| z*r@@F%Q4dEBnm;=G#@xvANE`CUE53}ZBNBrRuqYi#x%afta6su7&}a?a=G)rKmkK) zfjZ$n!{l&|aa2~)$69+Gbq!LA1^Pti_X2wMfoZ6VO{Rm1AT#$uuVZ(BazVh&l@OW- zT&hmX+Zb!T-c3!_KhLAl`Sd4aJnvwWL)ATcbxTo)LJ8GZ-c{m0EPu+zW~Ir!S2p^R z)7utF6qj3+BpAq8RU~RXZ#vwr6fQzM@c$4CPixQ3Z%q~(Alx$As{Y5{Cbp0;11^${C_}W!KX=~W!zReTO z?aa+Pn73jCR%p?&9s643`gJ$-OuXOBFgbk78U`PTq*5GyBOEGeW2FOdY!hji?{7H` zRjP4h^JZ8T0%?nBNA2PC9Cc=m(>G{}=##WMe%2j)u<5pldvt2csC#l0wc#&V%;cyk zWRp}bwR8iEi_c7JC-~eFiuoiUu+mE;l12%pk|UO09_2 z>eE1B&MK95QzvySEAf?itp=4n5RZtQ$!2{B1<9x*@cLWsfmJqMk*oh}fD%5O4^GCN z37Y83rWzv~4>w0jdKxzV49lPdpX1creItd8F$w=Lfu!az*ai2r-M*`MZH*OY?sCX@ z?U*kR}2ccC4KCV_h!awS%0cY($fD>sPlU`(3S4OKo!ffovsG`JkUc7-2 z+}NOCASI}n03S7Dz*1Nh^82}i7z7eqFyri!Um!##*VNy`%3$mPBlXn`ip9zHJE%}z zjt$;Rdq|?+3{hmT35bHJV`Xj#uR;re^f zVF>~hbu#vv>)49SP@HCVD>4wm#-7fGzH~Z-9-*WcYooVzz{or zHO^zLrYU#h5{)1kv@V6piPMn0s+=lG*1O{VbBXjx5ulO4{>LN16ph1ywnupD^sa3h z{9pWV8PrlGDV-}pwGz5rxpW)Z(q30FkGDvx1W6VP!)@%IFF_mSnV1O`ZQ$AS zV)FekW4=%FoffthfbITk2Cog9DeIOG7_#t?iBD)|IpeTaI7hjKs;ifz&LZkngi5Wr zq)SCWvFU4}GhS1suQ|iWl!Y^~AE{Q=B1LN-Yso3?Mq1awyiJKEQNP)DY_us6|1NE7 z@F1QJFadv}7N2~GY3Sm`2%flyD#nF-`4clNI)PeTwqS{Fc$tuL_Pdys03a zLfHbhkh#b2K=}JRhlBUBrTb(i5Ms{M31^PWk_L(CKf4i|xOFA=L1 z2SGxSA@2%mUXb(@mx-R_4nKMaa&=-!aEDk2@CjeWjUNVuFxPho4@zMH-fnRE*kiq| z7W?IE;$LX@ZJBKX5xaxurB-HUadHl%5+u|?J5D^3F-7gEyPIBZuNqHJhp&W_b9eBC zJ#)RQwBB6^@slM1%ggGG#<9WBa0k7#8Q-rdGsMQE@7z%_x3TZ;k?!c2MQ7u^jDu4ZI;T9Fnv^rB~;`xB+I-fZa&&=T>N@GuNZd-jiU%R`> zdg41iOzr9Z`rfOKj-A8r=gst5Bv@tY-j?$)^TPH6IGW1>FRrd?y9AsafFhfac5sfS z!z_v2h`^Y(y_>97r`7yy%gWc{J7hW2&B`p#p}HXCVi*^HJvp2-WzYKK^I4;72ymXKPRH?=UE&U!VZMv+EHmXG9J91O ztTxu>>##+KkI0EuT}Sq zm1AnDS6&3GWLaQSXKe1bcPXaJ;Cpn1(2ZpSgh-+t8pu7ACtHW-w z<%tjAl1TPw3()A?%a1aRDEusI&LO}cTlZJv#_Wah0tMU9+=ab6I>onMsi!pR?C8Qi5hBK zz~WZrR}JHGK$y_~ryEaJGbP-M9fs{8KKm|Oo5bMEcgeL%l-iZiSFYCuq@`3!w!#Yr zyuV`jA#slqYf5hz*}vq-Jjk;>@MVJEG$gD>268u)mQ?UX5_cq>+I9Gg=_XKP8SSI# zm9^(40#wZfS(o{m6fCDHa@iWB9K#B^&xd3Yd%)Z;i8n9=i54mA7VAyT<~E*Q{aT*% z>qGD?#Y6ot;FivJ6HSn$Px^aWo!iJ*j@fA8l#tVL{}|ZWe)`UXEmhPU<5(Wmr}hqO z5x8Si8g(bqEp+Rc$fq(aPVy$*?HhLEd5uAd1MD6Ghg$&DI5kDBsqMpF5gO+JmIpY3 z#vKA2w~URZy?*7nOwW>Fa^-6H1BJ1%*}Y?Wm4yL%!Ls>9fr5L9%(BKIDLKy%@Q+J- zK+!+kCvuSEn$lGSdns&>@c#nqJf7k*gglAyXSUIASL-C4oMoCYoJ4-@)SNK9mW)SsFda!>q`@Vq;j9o6kQcuH( z41;6DW{~4lbk1Ug=5gfQLld^uo+$*@YA}!bN}ekTEtA3B=6-ztZ9^KDzT#S7BUr#& zYXGhILp+T`lKFHBX7me|SCAm+5~iY87Hb=_z8oEE5o+W=4-*xQBPrada%)U72lD)Fm8Xpm0}{*^f>JwiSpjvoLD#q#n@nTuW!I4?JUPJ1AjXgc!au&1fu zo+XX`WjA*dTfSjj)_M5wrVFz?6r2)$`Hr){4FK{m7Eh1Mm<=PBV3=*yl_^UNfO z6)R`HRf7)be9|yAPbcC5(Q*gZm#o zt7hlICpCLq(o&n`0gy2Qnt->2DdUH$g*Zcp^05HspJd7idiX14g>j&@ROzf%K=6EGx<> z%L$cau&Jb&x^VE1z}9jo{_lJ$L1I59^a$x#uI>l4``?WWR>Z$t(*p+*j0#c^W}pw`7oI1R9MI?&A37S03`}wlOp_CBmD~javahP%)DcMTJMSDph`RPAvUaWgQo-L;&Ag)hZsl zl;s>Lq?@9lJI=cSo(K)Y^Z7{cQAo0GXA+zc0iwhzC07UV^X_0(CRx|h96VB!R3e+B z0g(jHwBdryOVB5jtt>yrYsRdLU-%G_vUv1JU>Z)CKUNy&7lyb#bDn&t{_KJx+H*i)ia<4j*Tru1+K zHg8V11BJ*|KFH>(B&-T&fc>~VYEE#1>W<%1amEqb;Cx7lTKzpD1Ltn_;l1=%z>2OyrQ=%ByoQnP`;Y zP?U`ye<0gnxlJ~8ulNd&7IC%B6y_+)3TZi+BD2+0PjA0V7J<>wYjxO#bM8kp!qfOy zZ|e$u8^hUt8J6Z7f`)!#Ad7Cn6ZiPSNC`GYMq>`S-JwwZ4Yn1-9@020LZ#Ya>i-!O zG4rl1X#e(NTK_Ll@f1`9D$6UP3#0f=U9z6nlhIReA4B4S;HWbZvC%~D$yp-$TofHH zY#aEAPIK0T!roE7epx6;AmQ^r7c6GL4F~y^UV2|GRmeQd{M!r#%Q-0PP0h?iJ~$&z zu~t|k=Z0ToUqw{Q!CW6zIo3)$LNne>AUO>iOLxu7h|lPtb?ci0s^Lm@2*(GP(TnK$ z3>M6F^KhG15qwqU{v2lBHD}#CPO2BP5c_EXSAb9-s^2dhkwi&j!H)bBF#=VWwXksQH>v4%Bsp=NgY>HV9E&8kcoFGVNHb7LbeNdKxm7L zkFWH_GKiz)r$?X%_ROX;8o)O;drZG+3b()@^9Kmi))@1!v=uxh7tia$+1mBk$+;48 z1V`@<9-9K>&np9#xsaOg` z>wl~mcXr=877@BzV*93nP^h^U0@UwC@K8%jIAe_IctQCA3zYNWWSLTET@9=gqXH{! z4ek8YxI1;`Wb)i>s(eY1M;?EaBqS)E?#sJmf#Y6jsG2G!^E73>AAgVPgi4f^yXsza zwq3<{qW`cY#YMU|8*oCt3z{IC1(Z?o%w3iV6}=*V=nx5*Po(u_^{%DqCLXU_6htol z={XfRa_S~F;4Zsw;6RSl-A(OGkDu48`uD*3(noV(L0!J@%sPptPL%FO^cKplLC;iq zTaTB<+O+D&*~2DrK6^u%XT})Jrc7>+Hj@xOlJlVxz4fy*1?b@Oi^8FG!bqlBH8o!n z>~F#%7}Poj%beNU1S&5x!B+k`Ca=z5lnsMj@seyz#H( zBmYWn0(6TaaS}moWyC)pJxlfy`-$oV7Oskdn!-)Yc;V#3KYe*_ZGMhVdQ0L9fyF4c z-wSiCOl=1PDWzMyw4}bo!6xYM|Aw?nLrCr0-s!v16Bb%Hvl_Espc#9hP&tv$`U6UJ zy^vaxzV#q$tN}oEh{kW^cVrO~8#|ojb2+G<0z_A%FyCY0<2yecnF&67?RhxR%0bwr zO1dvJ%fy*DkD7waZn&$Lz4m{SZpn@EBm`Cp(=5XLnY8jZbN*?W$|%bwS@18_msB5O z^ixjhgR#<2tP2uito2!ptSztQDEd+KV~yUAEvp{s`!dF3N-51kNJ)|L9zzB!N5})3 z2~gg%x^~{W$L4p;hMSn>=&!~jT53Mq?9VDefsY0g6wH<%_B|S_J#guV>7?S+x6XC>d?#MLnx+j~p-a?O2PWCkw%M$X&jl*xmluhFy(z79P;5Y|x!^O`&yOpw?&mCBxakmlR07DAM zRKSK)gruDZtjP-;Vx;=Gn^iT?OiB&G4uqX;G{a(>XF9;n%3+=X3NV{`kG@klzsL`M zWx^4-d7^~n9gOVl;0ud;e}}M95=h0L2^TQr*7uYZ8A1f9<+bLS;AnnuDu$&T@j{>!r3Ytg>hxTM*Uy13Vi)!1oH?iC1C2m=wdh8b%2p`n&3zYo) z4OH-=jYTC1udKOaeuVSp#60OwD!vyCRY{Fk?2`xa9NN<_w%%DGfe5?g#KahJyn6?%AwY{L&=pPJZj?FaEXqYa29=8TUx^^gTZ_L0x2tI&!QN-Jy^qVvtg z98&rSm50IM)&OVeW7$c1)yh7`RPp(`f~=Z@M9T;!`J~BnlcYPzzXHC$1~A>FOYZD0 z%s+A8EeGmXA&j-+NVD;*hLrAb&m><5a1r^wEEPV~O{9&oT&XQFn* zSI0G0vXOaD`|zKYld3NhDff?|p#EP1E+#Ds)cN0A_iy7vCxro14W*N*bVEc(xzAa- zk5s=`2rN1p*?bl0V%)uD+Ftm7=NY>NGnS2F@==Nz|2Rs6uAGisqqK*`^vm>*oga5o zpU*F+2*2pk%siXg+T#54m|R@cxqtYnacSIt+j5Phm^kYG!xNsLiDsJGkGY9Ql)DSIe$RC;4mV*-foNZg$JC$AX`+)tBlw zp|Eva!~!~Uny7m}0}x1LGd;$Um<|$JE9I3bq0FI3$RcDohUM`xy?b4HomEe&Cl_<# zct@|E6X^qCl>bnhX`;-G_mlO@;!$M$QYO$`P%=PtmK!j_hvOzNJ9*26h0+58UYc zChyB)J`r^Y>V3XqNQ?_W?_oRBY+@RYXAOZCAa-&H9>VfzCc%Ls&)0{~dXtWEQFS;qps^H_eaWb63T%Jmdq=132qfOJj; z^o!D$8dRA3XPaeB3}}qvc%-aXuob>UCE)F6P5ro3cb!#ay8C7=2MI0M<@Spslua!Y zfH*S;lhxG@Wof;QAa_?t7?03?HrKqeQ}NtxoW(0tgJ!6g%uz&UZQvZiZ*_<&^~U)- z!V4a&9U%vfoGl5RFBq{M(&r|a^e5(;xiFM2v(CV25AGXix*J<43);ewr!ap|`~|Q+ zS`#Wf2A!X__5S-QwC|AR<0n_t;F<7&+wb%%%ga`QI~+7ES{4qW)(xE-yUne2BLUGF zLiYE5v|w~x`RfrTF`QoXzl=h`?yvA4(EnqD8EIz(F#ixD{C@~ZmSX~H!g=bdV|+TW zB|h;G$gmZKoUwdtC5;IqG(~hz_Q#1&Af@26lr)YiCcPcwmxS+8ZxE$V%bPuiBw zA~$U}Fp1)kwt;jZ{+_Zrt|`kt6?#^q+=mSgS7BK4EI~GblcEW9r_8B)a7`JJwB^q| zcK7Y#Fg9o4uj(DCHB1$#9BF7z4>w?~jV#fHY63KA(IxJ2j(Mmn&r(orNO3#p;AHYD zr0%tDqJtl6piy77+VT@EB51Y9Jx!xv(Pp!}PR{}0+MzwL70welF?GrCu9oi_ExX6I zzE5m#Ssb>iJJJAY2>?_j^ogDOl;$*+)|Io4uK9LeP(BTp0I%^ga~6!?QHo=n;ywLd zrG-{s8x$%dWiW)gw7o*>c8sk4-_8q7BdA$`N}I~fC`~)ztO$y4!A`gXa0|ugSqk-_ z3A?SP(W1zbG54hBLZN|)<2|!d3)ra~joK(-lEa5y+08P57Aaw*;FsN-whG_mRCX_AxC%{gOp!hzWL&%q_W2e#Y<$R!6rv^!siuqhAa@0It`#*?lO zbBF~rIau~T>n$sgYaKlMkd8b@bvT6s>v*YIq!F@9D|}ZuJFIfX37Sb#-wB-92wI zp6&n&FXp-hxYAVVf@P!=P**GZyQ#!Mg3g+ z^51krxe`VAv-L}OC9J&}ndx%_-ek%vwpfAk&fgfw-Ao%jMm104avlW`Z}&9^IqCI{7K>-}u>Hat;!vgwmJ9T3l$o@^nn>Ua`9s;MQ`(w-+g10mim*e5 zxlQXo{h%Vfx^0A{E!?>xTlB>8Z04xGDa?68hp-sQOkWQA-p(Wt#tUIN5Q<&B(d-VC zRg|2etlG(wZ<_M+>&m!qCmX-I?*cH?hiINamr#w|+kms1= zgoZbkmpe<=OGI%2@TC1rTW9{Rdh;E04XjLu7mz3|*)|&vr>%cIXr=qr^(;p5Tr4cq zx0NKfuash^OEFWpuX;##)kymY2e|{J$a=>aPb$c4w17i_zbv{ZpOGz(M54{ezi!;9 zHIB&tIp_%n<7jaD7#Xe>KBw>dK#TFTAY2Yl`;4z{z9%(iYWd7mnlNG60du1ShP-Pe z!(8til%B7jxcdQBGwtER!)bJ%PrKecGyk(}=O{?a*>H0~2#-Hda;S~agxd^w)RrP| z_eSB2nJQ*b=B9MRJ&<*AhVI)$t|i|SSfeTia9LfKm%q%QJ=yZl62HQGHV0GO)k(to z@WU%$pv}3hE_O4iJ|V!;xI1&VhUgBuidgh)-y|J_!Z7=K17xIOM@Jvk*L@q18(BW9 zzKr?f)v;0v5A*&@dw`F|jeiDM$tJf&sCq+IE~56;tmN-J!qAj#0GupAa%ucNK)@p*ffr-`???~*)~kK<6qjrpyNjhUvc+9h;xo!t{&Y<( zKwnT7J*x=^wfL26KtPUTCO_!2eo=c+1{n*ZhtW*YmfIugMdvRDJ(W4|?~m&JCrB02 zV#==*`M>VgQbW1o8YGHr`TI5ZklZ>$J151Kj{Ar)%d5MMV?BQ`a%n$>OK}>{vo5EF zO=nnE~;1JIL)smt2q ztjvq09vBFtO5B2}3sjcZ+Hyg$!A24`+wyS|X($ZaA_(Wia@uR|N{khIjMoOGo^V0$ zkc*@h80LxC3EJT+qiD=>N;g0AF)H7~;8S8gJhhgZ{yzYFK!m^G*<`RVa9MvOxnsvT z);1kLd-DNon82oFXVW+?jvPSO(gWxz;?n&P|K?%~5+&)Ii4tzPa02~Fp`nP&I$2i{ z+q;X{c|j2at-d07tG|e$*4ju@^U|;{><`zDWB0z!30TR{m636{4@o8S=zWnRFV@L1 zghg^(Om8ePF2U(?)NqCz8?b*uj-CsGV3S0WM-<}KiRQUvVuB*TXl#nyiw&XSgLw5E z@@t)>_DJe6)J@>pq~MI>_4na=an3nXZ7t@Uc7(z^N#6nDEhAND(O8GK;H};U>}gt6 zOXGa0@@-P(!)QzPNctURy4Cj>8p8CWP2k34bmutURm3d|T8p?XOg?|QrHI>m_Cjqc z;{83*L-6gVuggLo*jdDfZ%2@HwTC`h#3w_a?iBJ}q5b3dY>51NFqv%ig(iyleCUfc z58yx%hg$uiFAMrBKBAK~p|2%~8TK=pR*HC%xJoiwv)Ui}b`jrOt z-if>AxS#wY#z(1s&!O=ts=8u)2G7dzIXo{%FBW}JU%-YJ1)$pq?~4R%72G3HJ&DUv zBO!hxu>=SR`!(=SvE;`CV&a)2h)>Fl6@-lJVoGlDUqijLlTCkOhv8!+Oi}&?R+V6M zD*_UvHwcuA!2YTn*iJ$Hrc8AS>UU+TTTp)}Q$2$E(@{VO@-I`Qe}O8zOzL;E*4Bic zPxwNAPxzyW+ORL7g#8IMl2}mNlvtoNCqjqAwfEu0eKH@ZWs-QU`8QBY2MFdV&OX@* z008C^002-+0|b-zI~J2vdKZ(=rv{U7Rw92<5IvUy-F~20QBYKLRVWGD4StXYi3v)9 zhZ;<4O?+x@cc`<1)9HN?md@n0AdG@AGW{87f)qA`jOzT7)=X3or+x%b=m&tCyN zz_P%*ikOEuZ)UCe0rdy#Oxt>hiFfjbkCdL(cBxB;>K*okOAZr+>eyo3Q z_N5oonjSfZFC)XvYVJ6)}Y z>+B`rX{x|n^`Fg`a5H1xDnmn|fGOM-n0(5Q&AXpMoKq$e8j2|KeV4rzOt1wk ze!OhyP@r)+S3lBd^ zM5~n>nC`mirk!hFQ_*2We~y@m&Wd0~q^qL3B4WjRqcI~LwGx52)oEfqX~s+=Wn#0( zNChH2X5>gJ6HiqHyNp=Mtgh(o4#bV#KvdA^sHuo9nU zqC1)}&15vujn$)OGKI6SzP9GdnzeyW^JvBEG-4*b-O3~*=B8-Oe`H#0CA(|8lSXIE ztUZ=AdV9@e?PmG8*ZyiXq6w9pOw(^LjvBQwBhg*Ez2gQml2*yhsz@8brWilV#JWs9a{#NSTpLGMetI9S^hKLmrx< zQz=blT5xe#m8LUIf5AbGP?jw*)BFiXjP8QCm&$aSK{J`=Oa`UWET&SB4OtOsOeiK# zG-0M|ckc{=&>ZsVG@Ir!dB*OjG@r?pws!AqnSj;;v<0+Kr_0D+h}NP~1yc#mY=@7; zA;!!+>R4@iXfZ9(X%Srkt8~G*8dVlp&4yEHIg{JGF#{iCe=4sGjW_H1W&1o-O#z*% zs0OyOIf+`ef@bXwBi#cdu3&P2A^1;ap%8hQ#=?WORdl6JD`_>8cjCTEbzmuN*&aEf z7l4QrV6UZhrL=~E;HHS1sdRPT8{~4EB|WXl?Al~y5}nP-q?J@@V_vB_vMOE6qzXp_ z2Oes$b=L?+f3A)uqUnv}bTi`89%`mdI@Qx=+a^1Vq?t&2s6`N{r>!>8HY09&C}gj- zg6M&o8;s;)jkd#kYI>6vA}bv=QyRSrd?n4^m?0uEnSx5!7CE;FC&fIVopuSc?Pgkf zX+)$rdj*r%+0kN)BNXJJeY8&O>}T?i$r6!R6!8#`e;bL;5b_NWQYQ3!5FSx!(>tWo z^>i4YbOE;E~MM*G! zqed{8f9u9f)J$u16e~>{9fyfieW|n=4+ukR^lGN5l1wHYjn#&tDWuNVLa25#?Y9B_ zIgjY`TV4KikLlmKr`2C+)^ykS15NQhvAZGOchrbw%w;ti-Gmc5%~T{A&FRNm%o%Q` zTLhoC=97Rty*`;V`Vhcxgm#UT;Du>Pfp+s*e;`!IG6=qj-mKFJx^1E^r4w|H(Wpvq zh4MxzY%x+j5LczQp(NN=O*Qn{tin-3g^;aAFOGXVy+b(3J0}prwo3m60i;6UQgbTD za@%OdVs<3}kvr+#I-R8VF!?Hr!`MFiKArBMQ=*WCCUBhtdB0A#)7?yUuM`Z68_X^% ze`$wvd!{3|uhIvZHdkK6X>IKF;~^#}H^yT?f?9IxP|wHd6Q%Sq>SwBcMXBsZd)i2Y{-^Ti7En~_)5w45X4=f-X_*iZ?4P0g zOX)s(0A(p5mkY~R&fh%rIeJjQeIEWAe>eI%Oq`TVZ_jyn(PRwbXDF-Fy)?k21Ogg8 z#1wc%LF&7}ZZ03GG$aDxQg!}_PG6u$A!8u0|N0FFt2BBHA8{j%%AE4hmjpLe^ktNW zRHh@9bMNxXmZI7Et8`94KaR|6B?_e7cZnt76-BiPjR(`ZiP=O>~;ax1%yRp}ZCk zeV4u`boG7V%Po_s^M?ZDN9b^^M13xeGc^?Rod1;DAJemf+y6m++gr{_g$;ug(&0tGfuRQyTEK+-?ap9P7( zAb+GSd(%TNibm#n`WuXe9sy}FuU-%RgYFla`KQ!6)Yuy{)94*uvd#N4e>jO@FiH2w zYyd+J1CXj1b4aO`XtQ#CfrlMJ!}qcnG$ft8Ihqrl9(IeK;$Bt@`&n5!RW8YOE+b9V z_<}IHv);p{?9o~0DMF!8^wpQ*9TT#_XnVoaQ5ARw(-oJ7qjDJ%LTFq;&K1}@xx9pD z@~nKSO4$ykjeLd3xxyi(+cRCByH-RI#e;eYI7Ocu^m^wp+^F-wSre>D^G?nt3o#p?tF z#)*YvN+%kEZX+fGzWI2>%vlSg#XOr;Kgyavo{6QSaB;ugdemsVQRfXJ;1=efIxREh zPgrSyA2t0(qR$2eWIej_NvG}I$OBu@_l7L%NTye13?g%ynm5(&4(&R$d1rl7sQJ+D z_U4_3wrp>0_HZ*=e>-mCO(TtSjcA-}WaG?R>;X0B8GUfgOG*Jy`c~d1Vj~2y=^P(OPz7>}GN5xN9VS3%^yE<#rgUR^vO6e-1FYrd#Ze%ERxlivZ>-MpnWc zrKXH7b9XYzv|y6koDtG@^1FqCF-}cMTlMXYEiJhgf!`-DP#7bWqqXTOjo%LsEWAW( zHB%|0+iZ$nw{r3{Rh$O+`4E3t=MOTbAlL3)n*wV!7K0DSHuR;1 z_suFse{+9>hd<7r5K2HXb!U1zk@G>Ja({!URiEN}1nytap4x_JcS|B|$^`Kl zAazO(M5d7B9^lUkoX=sWvPF`Cy*{t={d`(bkHj*m=uvs& zTOWx)g{?*cT0~fH80&jc2$)P5G5cmNW<`!bUA4`VqC@|W^Aja-%C9lapFH3euT&Y+ zM)IP;ROo5NLLx`4=w8umXj|bMI-ln!ZLg45IH(^518DAEhrh|+(n;l~Vbq#f;Xad-!{H-pBk=8bz0%L?>Y-(SH2UUdPZeca-AJOd^duIi`*HF=nJjD--LK ztwAJd!sGnC@~+L_nWyIOvXXwGcE2!yUt^3L)4+9oN6Lz2(xz?MpUO)`{+Z6tioQcj z7zs;cW!YeF_3$tGSE4rm+C}2uw1#UPf5hK;EI)NX-8)f9t+;JTc@xSQEG`?lmW}in ziG&$TNwYNCA1ePoFW>}_5ExeZ4;a9c$29(<&d-U0t_yA3U`&@+j=2^tMjzV$3;$K1 zz6d8yC;J3Zk&Y(A6Z=5=JO4xH=NZGt`u~R?tNaog8F}Z>7_(C5tHgC)tZy`Xf8cbv zAx1md&R*bQonKa{U>@1k1G9Fjih@*u&gw)h0!a1v616Brr4FL z;?UA`;j$}ISsGCMzf=6=hNQ4>P>g8mer zxF`1Ke%lCnl=qr+jW=Gu9O$bhV3%p#eROpIdS>&M>`)!Gk zWq;w%FOy))Y@jUFmAOhK$`=ZXh(6nB&Nm8*mv>NE^= z^7n{VGu>lBplgc|*gt{5SdvMzOWcXp+7v*0of6ckR9RneV^IjDDjSd_qlu%|5hS2> zMFz>qua*mjGUXcOT3y+we_%**MMSK5lt%bHjMc={JeoRV;%7Hg-jUnd^XIkc-&()Z zA5G+!$Cgh2(j}>-HJXBX$&DO~fDlnFMi)RlB#k+gemG-1yfXY zuI&0pr$4)N34M=F!g6-PK^UwyHX?~*sS|@_G9FEs{)q6yUQ{+Ie=eE%w;D-*SJI06 zBUY!`0ip9IJe+SUe{-EedtV}L93LZZhq(Q@2=ASOclfGP{HBXMfJ_-Vf&pTefI+<# zS2b;!c!!ykD@gG!Qe`Pce36F#Sm`F3au{!=L|VDmm8EG}D$mlqEL|QBWofB*S(a)~ zsn1jm(p3);;wRKk-n~OqA8xJ6Qqur!sSYi#%71Uee{J3!f8L#0+A~1mEFG}_LPKSWr%JM2c1K7M>uer-j${I4$xf#^noGzP&nuc_?!cD&qMS{rl8yBeuzHHbc)aU zT;lyS(_k&J#ZMP?pYT z>FJ=WfA~J^e@E`ui2dmsvh;&G0ay;uXKc`Nm-DcEdm>9e5lF{?^fQU%7f8-gP@n1^ z1>5l;{qioF1K?jvV0S;24$*JJ1N6UV13&|0P=nMye=SSTouZk7mUz$eHa(D|9V`)0 zB@*flKGzUEANG|T^1d)Yf6UTfv-EedcOF7#>0hU)EH9|d#)Yr>@NpsNa@A?&norHL za?gb`K3BQsJS-$F*QBUHO_J3L$lAitsI{r3z}98FAj_AB>$JORhM-r*i?Y0Q zZ~ySqJ}HV%b(CvD8r69?XKK0qd7m>J5Jy&dyM>_NeC=8LwL!c-$eZ_;amygL z;;eI2EOTe`Y~d*iSpnLm&jz$~>U^T)~olxCvGs5i81_ zRl$;gPxF-sN&!LWG(R>%3(hHtL8pRR$!Y#_IH>2TmH1pCA*G%tc15+Xq-qSIbA^O* zukI0=r}^tcd_ElVK~kTy8Y+D%%ioq+INU1Y+Oev&pIqEpeU93Pl)2#pAwbN_DhpbjkI-ddM|Jz4vN)?; zF`z6PR0248WtnniR#}7H(s0P(-Oyg9ti|%xSWvOByq)pYus5qTe@>`Pe=cuxQ~_-B z@bclf=lcOJrbnou!#*7^Z5aN`&UoVydKToDVq9 zs81@_IR~BR=_91tAM)>dm2Ow*UX|`6dWq^(s#>`Eied7Ke+Fq7jgnRr7GMH= zF`mP;sR+=Md7xpmRV9BE_lA& zI4Q}#Oe+L~f2Re*v_~jIA10k#@tDJ)NC8QAYpQOJ;Gg;`O zIE>`-WlCty7o|$4e~gGb0ZxKQLv9oY7XVRSXZ4z^Nz(kM;QKam2t7%p`8H)fFTcgV z+(x-=Cb^;Vb1FaYRQZMcZUZ`H0n5*e|2+r4Qc8x&U4Zj~jq_X{M4D-NjNTa+D=M-cednUESgQS3}zW!9}%Ytwo*z)e>a5nN@?WZh}Y;7mq<{) z?gDuvF>$hBVv)^++>9tuJZos1oFdj?e+NX{M@}*!a};{%1IFvY@w;I1dvFLESNaqv z-Urh@fOve0rqRuu+!to+4ayn?SQ>7)&X>^6tOG}-VROzgyWzN;K z+_{FTob^=gyp96SgH+>;P_6R>t#E#fRyzA>mGc3*()lA=?R=50a{i0zTuf_Ri)pPZ zK=2Pz^UisA!x zyaW`6iVE1Jh4K(}o1mg7_(a7Az7R!3MMUcVd`Z@{w1xhD>AC0o&UfD5Ip=%qwfi3e zaI9)qxc<^hH?4g~eXkX}$WDL7>m&8CzWS#6n427Q5|-zMzGKIO@tsPcN!bC0`4I2+LCnHz`8qU+IhZS7 zhbj0Qykl|r)Hf*+)f*43}A(bH^{EjO4^e($di*<7|p`0g`O54q~Z$UhSw9m z{%k=MS**fpk#-D?Z+0&-u|~o4+&onf$BBRySgUa4lo6aDMY}E{3Q1l%8D=CM<)$yu zjy*q!ldw*9Po{smPDZ!{u|B_as=^!^yS_K$CbFJ=w&e{3u_15WX$p&`PYDBW;f1tf zF+0PIT*;j5Z4lgahHYqgpT|3?y!09+c;pjJc$iSJ@HcxoEo1_EIl7#HU z*%Qh{*CiRxP8!%m&)I3->)L~ApG_@2>S|j_YOonwD$#$1b9u-6EGLmo+h@`bRzFjw zda8su4^feJJ}bo(3=M2!(hbT&f)$~5s#Ic-FGNoO7vOCSW1I!pqZPgRFvgfX3}aiu z%48^FLelC*s$io}Zdd=*PMhj78*r#hX;teQuvV{W?aC&DxJWG8jzsY~7OIGW)I^VJ z^$iTt{e6F~6mQ#$4JaHwWm*?Ykyx8XMuP0oT6-6D$ON$?Z|zQMHD1Kq+(d%uPVF)V znDUi&a?rb^gC`h^q9-(^tkDtgz&itYJKjao1Xn~noi?vw`PRubH>D?O-j2SH&ikjH`3}2l6wqlUA$Ol>P*}$HK<2w)-4L5X*n6Vjh>;%AU-GL zpT&Re3`0Jfbt9cODKErVdvK>@!snT4rO6n?7p0YK$6agyp1Z!Qt-ZZiKff#`%*9ve zKaLYl-z6K|ovDOt#oG$Aio%*HZrPhDwfEp&(dMg6=xplk&R~bk3DYI?K{I%8FLH8l zm}PZ5U}Vt3A>*`NF?%q7=kCk*pL{7E&D($R0N0u``tq50h)CLI!QR1YQ$Ky%DPE=^ zzJ^DH%h&0RqE@G7`}*v(9p7YIy7hgNQ7i7Xrv|fy%2eFmUu>HNgGxvYd~1rZ>7Mjh z0FUC^3gufiZw#+B@m+<+al#TF({{D*1#kf0my&kySYD;V{tp7!had97kW0LSLu7vt zPl?O+;YSo3OSl=X{6yx8efVkd#%eJo9{>4-jm-mTcV~VS`~{uT=4KP|x|HkH^-1Nb zky-jZe^UD7bA#!ZgWZ}GbTeuHNx%@W0;G2<-p z2f2BFR8Y+({!Dk!Nf|d4p^|@*zGr`Xh4vK0U&TGY#NVizn`usQ$}#bGjt!D>X_xwY ztf5D}sbPka|AChR?1TR-*8F@KlN&+z{aeAerR!ivEZO79|KOEMyo~=+wC8rXJK1~q zq8JxlN?#_&<_(m`}UVE04Vo5)=)QYwNE8S&ZoV9;bF=PfjXnPr5~^sRiLD1XZn?FO&;-(O$Q0sF1k8a=eYw zFF5hF2i2i!aX>9n9Ian^0 zvn*w*qu4z9^sd5*QzXpRX_I&&V@hsN%gI|c@|KLBX-{!8ogMV-`1oa2O(i2#`&lI$ z&7$4f3Bw1kGRuOYRmxTx;P^hj&dE@pI=(EOcpck`-fK411_r8)&uuEvdW8?Ra!!V{8Rc{5$)gP*3>F|CY#Q>prXinq0DPpc!6AH> zZzR^p^A&_k8l&5`h069~{))X=*t8dm!h5keRK6EWhH=C_kiU7T$C3GS=5op;cmK7G zqgWR0XdJ@A9F~t_MYOSJ7)=^onZvQwt^Ak6@xwTA2#az!WjBA;tjM8lH=227K7Wg% zIcyw3NA%1goD=QbkBUA1IVRTR6b_Z;kPVgRu zU`P}jp&5Jd+wR)Rid*r$kZ}NyHEF77#L(;vac~X~ig$k>E^_=v#2nR9LuM!tE`%bS zr(9V=$vDsA4kj_eikw##vXKv!zx3v@NiSK zXpzxV{R}M{!S8eUQ}uHP%_{DjJ=M=^i(fdnr6NXIt65v=dt0=%@@92Ht$F=x-Nh8( zZ?R@}cS(ODs4CfxM#?0>)h~|VU-#nG9Ftf1a;joCV~3}-&E?@5WzsO!IjREDiU)CV zG#V=JiTZ0)u&b;_&F(61t;nf)wG};G!|ITnTFA7?sU^FS5l3{28zM%COZC-{_t0lg zgbX@jR4paluv$iU{+I;&(GaSrQAbD2vIk*ABb9&tkkLhVSLW0T2J`98J($biB4M;7sqLVLmW{BejNuid<>6k_%jYf z0%d=M5%@0+SLG=utRu`+QG`w0}qv5sc z1`TgiBN{%Sp3v|K^`v?hP(M;X)%dgOIf1@weAoGBs}>CdD(t(_cZ`1^Q z^1ZBafr9_nU!ie<#QoL&1%hix96t3Hmfb5+_dlF#V3~o=S1@~wb6>zfxn4M3|9AEO z?FNS%1&pzZPfNfWjtavVV~wAd#=zyIdJS_8T%pwBG4_h8>G_dJWcp{~XK1y|nMi*= zu1SucS@ZJ^+&_jZrzLVpM1`InL)r8+2KH&HUy5NfP(7_RI(cS|#@IC9AR4F1Zl0hs zPbRBz7$vLw3Wqt+aPKIFsJMsx4i#46Hbb?%3O}jDnd3CvDo{ZJTe{IQzEM`XAui8v zyo@8p*rChVrwfD}DdoE}pGpTe6!mH5+k27t7-w)C=qBA(?q5hhUdCbI3etUyirv8$ z|0)7%J*w0O1XVv~sU&9m)?tosGv@j(z&u|J)xLhz_%6jE{w~z|FT{L*91Hvo7Wxwi z`3JQezaBgM{|8V@2MF_%Q9{HF006QWlkqzolT>;|e_B^->*2<`Rq)hx@kmkeMi2!> zP!POKx6^Gjdm!1?3$YL4TX-RY7e0UwCC*kwLlJ}3-Hvn6h6?p9RF6#Gg zLk71LH{D$~Xt^~vNTO6}nW-f9qNGWz8`2~#@n&0EFKAP6Ydev3cUw|hs<~5z*XmxAy6(dWgh1&s z>6n0ylqP}2#DsomWK)xWXJnd^@lRr#Nv#*Y^I?9mA_fH}Z)8{cTE?M&-ngM4D`J@a zzQ&J}i2Wu``;1Eb+<%XSmQ=c9=!~qDArsZpZeN$nEWa&N!}}^$*@3|P(qDuB@bZ;F zVQKlwfrE(>iYPl6!RRQ4P;pSgSYAyD3?A|;p~6j(e`bIyrnsu)3}?aNV4T+(?&eV7 z0Lm-Z*Dsh{eMYtRjOiz!j~4nCg-=jR2MDI8gO6$f008Hc@H-uoBYZD^3w&GWRX?94 z`N}uS!*=Y%c{I0n+{lt;=dswS(wFU|tz+fsJfgBf1?)j2Ma2b}nT%Mu+sIZL~IKh9fCG6ERuFKu5=>#OAG7o84C0Ka@)* zF<_7Akxl3t>0vW%7+EttjL|bj*2Y;F-`2LJZChl}IMet6KM6s9YQL4sCX74Hq#f`kHr03aTWQfK0tn|;;)qfQfU!?t%5ssxoiE# zjT;3G&wIh5L$}AIGfk_V4=eVhYx^BW&Gwe-Y+he%dl;sF?Au|(=}GD~0ACwyDU&4! zw+HA3TE|w<1O>{ERj3gTG0vH`V@rb_4bXaOR;h_@ngKUgCxwE7>f~t7F_Y~*Rx$|` z0@=1gAwg9}D&vgCAWcwBNe{V_$Dl?lMN|q?8R`*UnbruJ3l^qSx&F+PwxS&1=^w$Mrv*TzxU;Gxj zmG=XgOJ*vr&>eyl)85Iq3s5&TFQP8$5p?fe(mUE97G=$W99u%$&}?te1}($Z(w3to zthA$>X-!X$VwtOxY1nPr&T|=bj6uz@v>`J+s2S&f^n{Zf)izD78*TH`PWWfY%BFOf z^yc7PlpLGqE^}7}=q|cjr55THwBd(@l|p@jnu6~MQyF8sRf^FbL0;Ru-;hY^4bVQ? z&xSgHP+!ncMf=z=gQcbZuU0yUBM}1Z+uoMB775T{I>M^FAM29lfS-;sBA{=}JjUp@ zEC*_T>Y3e8tl!bIpo;aI6uL*H6O68wnKnu5Ddr1@S!W&?-^(ZIf_A+(R`_^5%U7L3 zjW*9N+&3Yp9y!Gv8ZB{RPcdN$+By$P-rI=)c>mp9k{4|VIBA3`kB9}Ft(e~Zo zG|=DsH7q@d4J%*nS3p#1~@T7d+O@kUU4DDxIbK5mmX&pzc6-1yjAf zEcQp}1FX@5C2{gL2S>8jS$%-H@}IfL>-I0-D)9iWHl$5_aJ zkC(1hW|HolnH=O?@{=k(!bqx~UeSw$B=gKq!M2Wdw{gzhGY8UB5&bjt5tV+LewGUW zR2$AnfIde1ImkbbA;wY~7he{lLp>FsrpAv2rOoDto@kD+ZS-`qc!Zs?or#an~aNv-#VXZiE*tAVY8*!YB9c?dCWE-<(u~42a zk=vQETsD%bPff6QtReWy#0lkp<^!?!4!PDEU_fa(8|Klq1TKl|mM?A9Y{QUF(M-o? zYo9RzKycu%piZ5}+JRi!F;fOAI3vUR6#BJUnSMsT`ix4?(eo%nT=1b`cn6eI0$eiYO&qsrQu&ZUg3bUT!rq%ZLL-Y>7g@gHXe3XSbC#b|#G! zq#`nZm&=v~kWUPRx$&sm%H%`aNF$3Nq3ht#?ArQH8z?jS8oIz1?zE+`GZ-VUroAyTZ}L>ehtN|tq(~?U|E80`k^=rO8yc3u}XhPf5IoD4y;U_ zM)iQZ{<%vze*vB>IiWi@G{i)(H|LaPlD`tPvfNEGXa8EI*V!)()1EC~P{iEdsPr2B zEvieII;Um@wFhJKo33=3nRyNOd4s;muKhcBWxfLy`g_3bEYdE24E~Rt)&7CL%|9RJ zT}WE0gd$T!GC-fBD~!;8DbJ#N%L3_N@e=5Q1PKJ? zf58X~KI#;DhwCqEI6(iy5%}NqePoXVU=yY(KNX-DY*Q>00(cz*Di4VY45I|bBiV2g zBMZe(+Hl$r9q5&R@v|6G_JLK?j{B}&7HpYSn2AcE!1Kb-?gtiqZ5h;gez6D`+fhcv zez6$E&~@ITidYJCGb|5fQ5M}0oTbgoZa`Fv8dWS4wX+iLf~9*|!WDHexu`Ea;fgX9 zu@dS#)}aHjvWvQtF&wx`tX4&XSTl25Oc6H#iAYVH>C*0hBMyW*Yyb2dBx&MCRjdi`xeXzJ9Ahx?xx1cr* zE*RS4HePc(oH;DdaB%OKTi}T<6nL2Ip7AzEg=#PmcL4aPwHfyA&}`0jN8!mk#a*h{ zDelGw)8@)Eo6TiV9R$QK5F%#!e8m5j5#c1{+~F*LVv?W2MtaVlfM!R;`W?oQo=ZBV z{=Qk;asFPhkL|dB=HF!gw}KSWkJMHwobXU{a(2%ME^5evf7dSd#vyT76$ix;(8d&O z`Yj}slHaC@PQ*c8Q}xqX-PX)$)3o`;F_qq;=b<a&fg1oZw`FGF?2%YnMlNbOt z$_Ye&)^C0RjcSTjX;gFEleM5<3~_}%Pkmn=_9Gnj;1*BHZt;uLfU*viPO9F%t2m*3Ls{tjXk;4fRU9WRE=by!22G2`KbzD)%+JO*#>Aa zS_QCJLQ6@A40;=|-ivm1D1LmLYOc`oc;7gG)rDT572y}Cq4fn?eM!Qpiq_Ctca!)M zwp5~B6b|L-#v^&!aFNsrYVRAP+rxR<67PGND#r@n4PBwmcx;@uUAxWG;jQzoeVW#W z>b#rdQD2_6Um!KyfREdcocD^c!W-ef(2ImPxImisDkbp`mQ z0wXbaBnt&XaCjv)?!)K^gq?x6J_4~%U~~-Y-T*M(!kz-wRgpnMMX&NaL+2~4FO&CD z&Bz3$_gtY&Jn9XPlU==xKJSnE8ocbX2jU%-Pf$&y!RM)~%+m+Q;BNYOU1i08lkE4` zBMsg>ozK%xVE-f7KTeN&I(&7$$hD`bEmG&(QcZ;iC+MT`C^kO^gD-0EF58%=Pac7I z3_X72ybp-@S}V(WGQKBIPhWsa;dq{&0otC8DeRT_@u=4m>i35GeXaeKk^Y)rZScA- zdM*wJ{raTTViFdpqg60D0l`gwvTecd)+vX5j8xydRIkt}g)$1|3bc|Wg`!JBp@#}= zURd09;?z30>uvHEAic6|GN&Nm2{jUTiw-VMLf|9p(!}gGb2~kH#0y%=_1;+1s&#i01u<{y)d?>tTGY~&PFJ2^npXa&r6|m_y zvGSScuv5spFDB3TsYao3vGQ$*tm1mI2#05jO!D*9;vXU*;G+kB{FM z2(MS;d-yP*B$B5;n4mwELH1`CXerzOFOQ5BzB)$7S|eBJHD398oIx~BUvKb@(>L<; zt*E!!I}2Km)6x>OzB5*T_;w^-#M7JjKUVlqUkE3?IoX=0f4am!lVCFySLv2UTQ1ub zq{+6Cnq?cL4%yyJx5;)V?UHSb_R97E9hdEKIthal=?DvMN63=uee1Eugg1&nxz9$sFObr}{;gdE0K2G05_#nV) z{u4i~#qYQAgE-66yTzrElPGa{t?*1uP2w;DBr3rjE_T2%cPi*r3$O6G$9oNJJnL)&cya?5b){}X$`LgK9i>Um)H81Xn z`l^G#-tN5U>F`!{`l~wC24AZLVE|m_Oo-mRh+U+6>(zRHe_i0=eP>fqJ#h`|x8IX+@--2aQhuWpMyQ^=e+czd>pB)Zx0{VF{gTr+=*QR9}M<^^TEU zY@=7`t$3|CJ}&N=3^ynZzQ|>9qE_6C>z7cEl;sbzsX{Pk;>aZ=+O2)OjqL`z)(Qg_ z1$BxQwPF~5pAmV*Q?(-LS~@f?tjTi8FOi?4?RC>{$E%%?L&&WQv+<%@f$v(H-e~~6-pIh#~L|>MDZn^&r z`j+f-%YD2tWuII0g$Hji^kvKaR#fcV=a%~k@tD+q(+$h-(UJm=Qe}8GF*l=d(nR&OQ{7OL_2E=Vm2~MJX9`-SZSXeEFD}Wr5B5U8nD2AgzO2JB1RsOKwrp| zQ9+&%9{^BG2MBjW_x58D003kklkqzolXHtTe}Te6DU?D%5Kvqd+tTd+0E=b=XuYWoSE;xzkUO- ziY11l!^7w0w`!dmd%|s~>#DJ%7FEM@e9PvM<++;UH3aE_umukVEjD?m8BJmAg|QQ= zf9pHk4n|^y zT)JB-YYlOrz8e5zNY=bKFvKIv77Wu~VCrVT8@AA22i*5XpjSQ96oG;S!{{zQ;JVFS zQ-50D6-K0>pCNmuJ|x0z@VYG&3^4TVf5(=H7}z#L|9#7~q6Z9#+;)D8p*NS`N+E@j zBow4mNMdLZeaO&??U@V{x$2p3Et31FNbXz>wKriT90e1^croRfXd#xTKco1FD8Zdd z3Rf^Sh)GN{jCTl7FvFnuQn1|==8#Qd7T2g`ezF~grSr9HG}8hQOQ?3e{H_P zpkIdkQ{+5UnfE5cN>_GsvuncT%b^Y_7i7vi)cD*+SLdm}YaI*<(qNIgxCMQd(>>{iBFSw8J6KV=ooCr>Y&{ zbUK#D6MxFu;BS6WYE8f;!W)xC6Dxygm5GV2(K>pIcrZE{1zv<}{@ez}p!1NGR^qkN z$lx%uu^(FzY4jhh$aA#*ohXt^=P(U5+7{Fq>@USy_*$6QzYUitixxB)G|!b$#RY?d z{>@K7Wq!5w?7th#8PxiNc^BHy=|Bs17}T%m3o6iq2HC0@oi=P!-zC>0t&uj4-k|&X z8>qk*)V={wO9u$HjWB8?0RRAMlkhtolZKB&e-2P4PC`p5lv2gUpcq0zq!*0Pi!D;Y z2B-v!sTZ6~PLhGi%y?!7%2K=92Y*ESppSj+Q_{*>_Q5yb{SE#GUyS<2}pIOwBWFD^<0NoaBO= ze_V4pDJzw?!{iKcTa?pfp%qP@-V~bS zaFM<%YAoUf2mpJ^kQL+>z;y6hBIaE<+fapSDT&;7vkB# z+OX3SW@=>T=zE5lp4XfyhDfVkfy&TnxI1aJ$4Bl*5J8uUFitY`HGQXT)1=5$o2#Ik zA;hbWw?&8yr{jl%M9_mXDo&%9p|`1O=BeN;g}rK6hIc&(doO}>7*NrV^9=p1e;LkM zj_>6>!L_P_H)OO!1qQBfsu;uth7Qx#iVWwPMlJqe5_&yvkb4f ze!<;Mp)WpnY!08`j^c}0f;a2U(H!(9PtC~579LsrF zLUeP0&xd)~lsq;NIVi^14|c^ac}6=}p5!k~Q2%v}7lsErGUTnvA$f5&XasePPJ_sg z6hwO2?$YipnbOVRboPAd-8-(a?jjcxrEaP=73lUf=x_LpwkWxrOtgUq2iuJf27CDI z$Zo!&;JFpGF;C}KyUq56H9w}UsDoGCm~uO-bmp~{q}<>S6#vc^sy<<)K_NX?&~$+# zSpV|%XBcFILUM~0EhMqI6MYf0HD`iqU8Mrn0^)^REIRsgKJYE%DE&TzM-V{|BR5(o-FtXIUIdAvAp_2i%4*$iNCzjVTipiOx8IZ6E?+t$V#^sGm;;^uj zWpcCr=t@o85&cLcr`~n_G8R`gHLdoW15WR=V+IriwkY!f;}gQ}^mt6qnyH>1LFMr-$to}%T!%YB^nUi- zk0IWBMZdM27T5(8(V^vBtn5beZtk-T#2}wu zwXtVIXPL+5JVO?DGbgg&?X3UmF$bNGGNs6smHpPp;+AyU>&)@kzIGhdER2 zUn9LuaFny*!&Q#r0h*&$wdn@Z|^T$|5vZPCZGYKVMbd-*A-OTE2$aT zvElV9QO9#Wb-!~c>Ro$^i1^IP>tk_F$`b2aCqAlbefKEalH)n0E_>0zY@?%Kd8!Vb z)eh6~UhMYI;pL5&H(fQ*-vU?Ogn$gF!R_& zG*`?yg&5hECwPSDBgezFU0OYchl>aZ_O#1As$3DLs?6DVQ{+Bgf)qXOt?i!a-QsZ%Qyak$I+*LVKW3LN868lw&Abn1?M8woaWLO$jR z$1o+N+loH#L^Er>=GCPgsT1^R0=X}s#h!PvnZFcfc zPt^$bFspHAPSw5*d+fTlT0DcKG-OCmeGp&5%#xVc(qXh_!{LV4Fy&pGr2278^s7Hd zG0OA~n))|Zn3$VO=t^_#qRjpIIm&kCB^Mks z5%5*{`o~*6j@yuj;WK9LU!7(f7@qD&a9f}U_ezFf?*k~2TwalyDA{Me7+?!XX85W8~2Gkn7tkMi(Y#9wua=HjEN6b!4F;~fq2 zN+=n_OYt$sP&~H8bAIx}a8=fAeC)y3XSNNE)@wvGrmw_A2?_6(5dH4Ay$$3eKnpls zQ9p2NjNR;IS2XA*j@uavp?DKu^d$E794+V23Ft`Vk@33@+vnrt10H+~EM|8CvEjZ0 zsbjngycb@L8_MfVT`Xnnuk>x^`U%`CUB!Uzxi*3x3TY=eP}a67_st`3LM%MRB2@IF z--lqT%Cn#eoc*(yV-@o_=s>T9rI^|8Sn#Mxp@^^<0&VtemQx&)8jQ7o21p%?cZhY= z2$L+PviXU>b&m1-87KE7;kWh`u#fdL$UD*xi>MUO^=5ux-13*`xP76LtA@2zUB^ms zSP{pq)Oc4=?5KT7jGFsk9qwwUux!x@N8#C3{jzMRcrJ}`@d6sRivaGYm`CCXmL6|fuFcBWxDev6Dq94<*BsW}T zUkMa>wwY(#q>&x))jD6u=f}0nXH*SBq(iHCV2gJ)&{Y3)R1aG6HdSi6xrrL+dp_=o zTnPHdBA;++kh;9JI$dVv-Z^nm2UM>VT`TKi3#7P}DGpQ3hHyot_%Ga5v(0Q0Xw^BQ zrB9sE+=kH-nx;d_Bwn5&zP(`iND^1RUcgx6*Ieq^p5Ygbprub6b$UW5=&;iph_RJX zv<=!^MO&MGLRP?LAeXM#O}yx{*)e_8fczM2xhtfJUEEenScK&7Hm`>;^Z!hT>)+_| zotD^E!|*`-9xk8Mw9oTqyVn;=CubXG)F|FKXuGWzYg<+^{7hV|$;^Yn&0ElR`rJL} z@vE~it;yE0dG*)jM%UBw6e>Tu^*xu9&HUkCUX1ntJ{WCAJasOvA3ufatZs5*DI-p- zxNA`D)n(2siM^MSVtP0)tHIk@)Xyyz(ho#&Rr)o@W(78Dad7&wf4-@MOtE?N z?#5=EP9XfsK%DG|mFk0QoA#XR{LtbZ@XFbt-?!L<9(NTEGPBG}T`ZcX-L#^jM zq2;S+?;XXN4s!~p7D#pnf~~zMgH`2|dUL}P=UuB`{<@O=I98hMSI++L66r4FY2r<< z%0Bf0xHUihoNG6;)RcCV(`@{S-4gawQv?%S?=6Wh<;jH!587HZv1BDpGAo@Ha#KkB zjix+Lg`FvSr!`ja1%F;iIbo1XspRa=d+)|5G{2lHURUXkxe35IPELIvv7a zc|*l*t#Q=As}vi>RC7aRxdsm%)g@4h`#6*)7T$V$Dlxt=ej+c%c-+ArC9|ex{2@7| zu4c+$vYSIihTmODqeJ{JH$%> z-CFQ!lh+{2vP;+tewX9brpOL9Ne7)_0gn)ROwklwW4VTNQqE#prrjg3HjNst&{(RS| zGk*}mpX;P2#HZfT)Hx8EbQ~u0Zdek{Znhq#>yfJt;^%*@YT~1O1FKn5tErRueVR-L@n%;Fhr|EP^GW)F`mDjn z=f0ShV<4J&+CF9AoFQJ zAblnPmu*LPX`s(O6$An`00LxqfK$b-aNX%sw zpzWo1N+A9djuA~ekCB0ytR#>%SDb(3=lj+RM5vxPT~s84Fn~p_xj;(RQ+jKn06+}e zhLfE?!%Y+s1X%=LHV4X#WPK~b_KXgOb1;2;_b{P*DdDF8YJI?#iBmj46lRX{+Svix3yprmvW z;urmpc*u~|x~H*62?NkVap+;Z!rxsq(F6gka7~idft^3G?K)&yFSPe4J|I;~fiw&U zF7QP16d5_83uqVFK}lZZ#3mgj0&-*k3;_aa^iGlr9(pSOT~O3;kKzR6iw&WNzOo>Y z5}DTG=|2=5;9)FG()?c!GGQ{>&g>5j2KY+^srL=5v`V-r2#k#CzWIj&1J}a%NtF+GV?iJxGCC#V z4^0cKl?p-+x6(i$K{C=TX`hV4l76?)gN-9%3&=0^U0|OSNDv@ZKU^AuK(b_-5vluR tb|UG5rrMiG19Iiulsp;xC-#?+`!a`jC=f`JOy*MdA6k~?a^c>+=|A-;lequ@ delta 35551 zcmYJZV|bna)5V*{Y~1X)L1WvtZQHhXxMQoaZ98df+je97^#6O#xz79h)jhM;%=fb< zejogP5xmysJ1}Y-zK;P#^eNya^!*RyrWsaa*o?`cG4E0x(uI5*J=Ql{I8pVHbrf*&ViJbv&0$Zx^9HzKJYQ+2@eUCip7Q~vv%wZxh=X(hybkQ-d%4h08A3r-BgR1yDQOhGU!yc)KY_R) z<~z-KN~9P>0@{5up2;>ZO7$o~VmdL?8yt&VFrbN!Ax~@SD^gB(*;lok#cYX1yF0ri zTfoNS4~q_qcA&~muAcevb&3QXO?~0wIJt9T@@k%iwWyg|@`P{EtB0FDW2TTpJ449e zuN$b!Af;6128-YK{g=RgMOrWWfwmiBb%I9~ClxAv$Tv$EFuBIYWT39uPZWMY_)u>-6QS>Dpp%(#NEFIeU zjJN#v$j{|sq!va#kM7Uh3#%b(XnIqbX?K%PlWA%C!0rz)hR9!_CvWd*YWqemcDG<_ ztH|`aB23nP=k&Rwy!(xW{j|Wn?pi2hNM1G%1t1en-wK?TTrRDhBR7g@m1Q#C7R_i_ zL3gbJo7pkkx%%3RHtl+`z|2k&Q(IqCA$2glZe)H(AF@Q`UUFJnn$##p$J+Wg29V06 z^$W;@!nT*;@Fm6WWuq~~ZbeD|5ihjEEcv%uhGHE&8e;#tPwF|FJFRb1H*J)HAb-%_ zATZ3|un`ABE3ffkn8#v4L?T+D&Ath57i3+NL7H6VrjcSx00}9XLCoNTea8^xLS$ul zj~YlyyKT+NZn9!<(nGF`y+z)ulWL?2y{qJxmB*f{ug(}O0}n4IaigLNKcqBbBr*t= zAbGz_({CW|vYA*MC0CMUm#7EfqwiX&)Q#eM9U657>_Z_=xQ_KLM zO%6h`rx~)x-7(vp@br}&k(TFMBXDg~(68W~7Id{DO7>I%!1Is@@Z$NA0*S#kM~}+M zO;#+U>;QsYyR6@9itLyZXt?aMAe&1UyFw@2JH?lLl_gE+<6YSM)@Ls;5 zX&SY^f>-?i>qi@tYFRsQFtCPi5dY~o7hMQ=A%`xA!7Ch4v_2OI`%GK?^Fs@VApw2} zQc^|&han&EY+T$iZ))h?oVJ-iFcS2P_&EdlYjyzUIxot79StR&<&wfumAu}Bs9%YpbNZ+1Q6_U5E>>Jo(Gcc?vo73mT|MU zjZUVk4qN7C;+OIaIiiV369ED#h6Bf;tb$G|3w$vB9@Xu`$R4ZvbCmXCj*}^O+=%@F z?=UU%P|G2nihG9%jS$(?h*>v|@=Mlj^g-^oXqx>TK_|sk=2c$Oy!7?DbCN)O^j5Ja zz{rC@_R^7N3(lv$2dGRhkafdoB)-0To|uCK*;$MQWvw&`~J&*b;AnbCAg8}xm^Q^Ypo+fh_OqPzc* zWPK%OH*$E-|C-La5++UiU(+>1{?~KIM86Uve~<&^=M6CY^aS9WD6nq)uraZ1sL^LQ zf3yG5CeC$~Vv=FGYEP}28=rH_Wqf6pxo_YXK*uDxxt$y!H09AXhZG#cTCTkC-a5{_ z%N+N9-9Ij&2NQD)+FiUmcCVLTBwkJp)>R@`@l}*9Yd2O!N_+zuTc;?ak-CRawvt;k z^zi~^YhZmxD>SpY>PBSc3m2?38$48*!Epy=%tQ!zr8U^!w1IVI>7>_GI=Fd7wc{Y# zVCxmr1UiIe5`EI?@3BbcO$i!mIZXkKBc3HkXM5>}@Sv#ulzG$CRGIiCSrXn0jUO%2 z%qFL7?!3E?^5LSxzZ%b9UbO1!=<`B$bqax(RaPih2k`E=37ylvM0v@1i!}hfFH2}w zvN4&MnPa5&YkDRf!YI&JbZMmYxkFo?CzP#){V*K`yvg4bB12^1P-ArAWn@og8pJ7{ zy>T8}r;g02H$f}sj9NjTvesSpv8>v?J?qC)J#KIT40LBAhIPXy_OX~v?1ArOJy zS?%=pXOb4ddE_iQcSy{>LEg!ldXtnK!TlE;VI+vU8O^`&j4kL8atsZ4XSD~#g`Oy7 zGeqF!ev<8TyfzmZbk;|X0~V2gb_O) z_@8OloSoSzC5RX0@CzBks;Dq5iQ0hyOD%F5+l^6>C-0{ET4N;K8!XeeGZ%@J-Dk7enSJ zxiQ``wpU9n8nmzC5P}3s(FoeBXGkf+k{S-V&gy@9;e{_NBv0L=|T!{Qb zcmbg?KO`F&&H99L0;=@mYUbvJw@i%PP!!X7-kRqpAVkrW}Z(P}X7Kut#HlOn0( z9;4KaiG_OrL*-N#+++{f|Fi@p@qK^}0t`$y5e3H*cP^%2H{CvQuOlDf63e=PD_TZ*Er2A}3kqg z;SOi^KKTtFvm~xW?E-yT+S`VA&i2P9?e^Ep;W8N8{ud%WA#Z!l#p6tFI^TdS?E--m zatLuAurYb^6m)i$f<38)L*6!tRLzz7JyexEo#5zHSdQ;Jcr8?=e>Yx%4t=t`t(49O z(Qdt&vg?Iuu4z5uQP{KpX8?1h82cjLX5+DUWdfiQhQMoZTU_7Ogs() z$Y5@4-O?}G&H*$|%Z)z1Qf_vwu{LA8sm4|TOxMcfxlpwYT~GbXSf$v&PVWDfP*~Bf zBjj&*S2=|F_lS8UgH~Ar&gHZS$3gla3sqMKU1XLSYuBq zC|pj}*|05*nI|HNO3`8=>8mw3s@OgK3kzgS-~- zA4}J0_nB-EjHu~K>{aJWO{7RJ@p(q(?Zof=u+?*Q71nl9MNkhA>8$SNiaF>*kfe9-5ZZw9$5s?X_wRv+66j-AiQFTAX9C6boKn)z=SGf_R zs~dTH*P?QqE2LOcv3qjg9_gq)g*=!pQR~e%#vNv(;L4<1^$%3%xsZbL>dFQTTTB7L zYJX{FIgt1AxOn_SE#tU=ueLfv1x8GC!^TY4aWf6AO2AdhCKRXWJ54saLUsu}9e?UIF{9wu)__c$BjVfHHJV;A zhYVV#cIZ5%7iJAy*D|&hb93@El0wF)$Nce4RlU%4s}FbBKDa0lNj0b?i9*!eliscz zodbJd(Id6B#d8UVh-(`Q;ednhCz)^jlD5p2xStUJkK;xI@Xh<>1S@qFad|%OkqbW8 znVl68ZQ*?W*2Pk+^~|laLAs~x#?dbF3&$%-@9lZgq1rG%{)bP1H0d|CU}c!^Dzb*B zmNfDgX?o{Rf5?QfzwnSI21 zkYHzU9R=B?O7mO6gH7q(FltF9hECeLF~*f%HF(3jjpO8j1^k%VLT4%(f70AKl7vuV zemQmc>s02~G!f*z)z$29iJA93EdehD1_jCx^f<^ub{-T7yt-^~5_>@qTbGwMJx7lP6}LNr(_prpAFt zWd~4xIkP1FMzdYf%d;^c2==XPj+g~5Pf#g-& zLgR>80`CNs$QgV}R+hyjnn!Tn^!A|Gzkt^;Sk(-{c6Ie$(>6cGjhBwRj57B;6MV6U zyBD+W@8+8^8|o~h6Ky`hPWl!mg*{7|`$dUGT&_U?A+-lycI%k=(ck3<-YA_u(K+?` z6GhRf$0LMU#JLrFB1u0M2>KU(LKmH?S;g@*4R76n57qV%1 zSR+cm4zfql_dUk+8De}Do~3@VQP8`qqx@vav-B0=e}nJJ|1xs}8VtkQ-oc40NO4+*oMypQV@`FbPBrinn*))GcdlkzS`|6!Qz~ z=|xUIk$K-iz81%pmo}fF5wuA3zU1}IKF-W`zMR(I27;CL8a&tbeC6NBSvxw*k2E)z zr{Px>re&`;;S;Q7v*^^&j$9##Ukl6(>kT!v`N_ zo;v(qg(sg1qnFN$u!z%@WY=leHXC-yQ_d%dU3&h8Ab(Q!4#hKMUu)`vJOzd+1+D~d z1GFL1{z4#D1;d6N!6+}RhlFAD^OKEb=o9wk89C~RJ#*B#{M|a$oWi^ULxBqZwPtYvb9qofWYm z-n-zqIruA~1uuY#RX?v|oB?YR{DRCPM+~$?ob@BF53nk;>w1POhuK5?hCRzHe&qwM zMXV+PsT6T%4z2MHI8V07A{{rfr4j?zBOSz8P3yxlfoavEL2|fI&TorKhD?!WDIw8t z1oMR*Ex3k3vm{4R@^X#CjyxQWdqw(RqYe1?a?AdEt)%|%wIY}}PD%z;v6i1#0Qh~! zO^SBJX8)#`7iec=sslMBIznn8;Xorm`W%w!8meT$?X*TTFoJx;{w#=;DuNF5=O24^ zgE&m7l$G<&e)7zDa@u-)$|39li!uz@y&E0XdM!vle(iREKZ`2ADwR~FUxO(gy zaI5`|_# z0pHNAj-FHF0G+}T$qxU#SCB|GLd_;1Ae6I)axC>LhcSk&!ID55;6I*#p`(v?jrA51j3d%qd;tN)@r8pvbNX_tH_#~N z5tdENu+KVm=kWn;p}ypq)7i}U^BLwI=oNA`1bm-#febi8rK0G<49$NbP#c5ue&Pu7 z3U!x7=M5eWdkTg~)yy$~Vphfo_zx%}xy7tD@1{-JKC=bGXHb2BK| zo-7D9UqX>ZaO6L)B%_lnHJ?-+HR)fpaLFtR?Ren&uh_ZVli996H3AA|AMSWCx z(%F_pOiH)=nDY;2Bnmey!G4Ggjhn&>*HJ`&5JI%GG$*g%HVdXiP=tA+jsfi%t65SQ zq?8j@cE+Bp9a)o|x@%LWY-}k@^@y9xbBTQ@;wq`faHl|ph<=HXT*CvgeQIn9fN?2% zaEpawYPn71V2!CJwB!yHSs!4SG)S#!H4Q&Pi<3cJFx~KaN@k1S5p^P%5s52rhuHTF zak86IyZ%nd?z;0=;0KE<{D*@T%0noMMfj_;lmuARJFca#WQQIk9MRp(lG+~PWB@`V z+4RgO(x)k=C=3^Un!H2>C|fGO=^QV%dxpB7r^@yI{)&PCy-a8-zEqw7u*N0&MhT66 zEMb$K|H3WCKF!$lf`A7eMEnftQ zO|p_WO>P0~mBVF3!B32v0Sid^A&1v~MkGk1t%ND6K=chQUkS3bjKks1iySv-xud>I z@s|o;A+Q&&EYuH-Fa!|#(@Xey=h)N!$kXid^6L}A|9d6Fv$O9KHF|-vj)W!UleoL%#wE7t;Gp<9x6 zlP(A-RpHA9!+c%*&DDaTw7I)w8i(Oxdr~Jc)^YfG{30!>_gJmt$q4t0wN{w4p`(IB zE9;H8xVP*6{uue&OfU8s`uRl2_Ln zkaBW*#cY7M3ei&`b2Ann*n6F<+kn|pSeiChX8Tq>&TAc-^w3$NL zVYFD*2}8aZH2~m2)l9-}UWDObZ~L+RygAsbUt1|x4!X#at|TrttAK*=jZFZsSUB4) zRU%4i@vTj&!83g04C;0fVZ!elG=`UbQfnxws6c^Jj8ERma2K-1GpNYyuvMWm*e_<4 zFZ*8cHFyuU`W+4*NJb}|{D|QjO3g??e)Hd^q|@S#`u*Pk6aGKM8%ZMoRQx|(lM_ip zP*Os9o#jz~mrOQ=!lVEn_$E>$h59q_|I>9$XNCl9GV(4x2hqbHnEL{%AtHr1;=zOu zv!m$k6=vYqhbN>z(sSR=<>O%O>-PF~E1t-i}gF}=)MYQ*u}$xl{BrHy={Y@&GH zY^eOuJu2KnU|P@SAyt3zwtQgH6T~S?epQugU7ciG^Mg|lw?YKCW-QG4LB3p}Sfdg- z27dlz>5oBeYyKrI!6@OcCmIIm#qu2StheP>>R4nu?I zJX#965ONPvine}|{x#GkJ(VXCU&jpZc#1RD;cL%H2Oy@ntD)gkdXIEdy-(nFwKoA& zKEB<=tRiF#E-caJpS+XqIMj!Hk2aSQ6*il?8sOPCYI4A3=o};dsIC0( zl;d>jysNuE)hP4MbRhdd+hu^uS@@}u%YeU6Dti4f~w4u_y-OdV|-qWIxu4wxJi&zm+Z`*e%3g|;(`+{7XM!8 zI>6wx(N55j-A424OTn?gL$aU6?r{&=juA0SF-}bGgQQs&@?vkfyrVB7^;R1P{`ct5 zSYq8F_%0IAw_iq0m+B!tqZQeI@T!PqYd8Zc+YxT-&$81~?80r}3jq-Kw6m5GQFz^8bHe!Tw8p6A5v?|G&v4YC<_OFj`et8(kd3Zy1t&pix4_hUScI5e=LO z3Ip}sB1(fY?x&!wh;-;Ck><+Zp-m*ID!u3X_UZj1y~m;TX06SdGR*2ICyy+)El$_nQ&f5ED0iBF!_aW8}C03bB zAa-+d`AYlG4icGOUBO7x%i_lRnWIgu!D!?Or+Lh*8!JlH-Nhs#---JNS8Lu9xbyp( zi=3)7GVBc|dDnRrjbHs}eT1<4s=@^xP0O3eFoqkj=Gur3C;jZ*^LU-!G zr&*jKRJ`b)QNDABj-aK1i%9+LYQB-*YE`!mR=!E;-HA5HyAYuMj+w$8Vd$bQI+a`% zBNviFF7}{{4kf%^Ngs?MxJFSRickS!an?y$;TN1* znzYVm@a+xh<%(Q71yt=WF6&CM1l2?@r}UrI}22@E%dS9)9y=L2PL;JFofWk(y`JSpqLDX z8`jpc2kNx@96s@MrU8K6%hFvm5_0s8<170FhOtjByI{uf3{v9os)~n=NJAO_0g1Zh zVABd%%;0+$Tz4F}mq9k)JX0wBgj|4%_~q(CJ#F}89%9Yf=qMtvk%2?vD}Q|%b3zGl zuRRj}rUz--cqt4AEj&XE(cdfb_LxcXJCxE9Q>oZ0+TeqGW4`5SteqNH)ie2OE?)C> zGmdGj{J<(1dsjwkSByP8Qi#9nr;(Di{|6(bzlmkanv_1s{ln8=tZ?++&C+cm2V&O5 z5qnmhLjzB9DDMC$&+!g%fZpeQzOuivZ;UL0o8mz8{0y~V;R6+pC9%{iKNB#edaaM4 z0O6a;t(SwW!?E^?-!0{acYzJtJ+Q0c07uB*-=x8?))4$@F7Xvs$dausbVP~M16O-& z|LGHA!}v^{v?uZN2aQN*0yRKy=)_+8Z=3GlecZ=zBgaY!W2hW@i#*L zG3Vt0S*qV2a*$1-J?jyVvkLZtBa%WSA@W;JSQ831TF zHx5%;G(+9{m^RQELa{DUM!OL-xQAyL#DXlSTQTaf>*qxgf3xC_th+-(&IDA-Fu7b#_o*gJKFMg|~NnuNAh zv~7Qb&ksZTx6lS{m$%8YIk%vQr=fd@?-X;5+UIr21qNe-#=m~Wlewu4Wv=M7{m}Lfct-P!JypG))+PpVMO!;aoe!Ey2G4tIji181H9N%Z5*!>P0%&9)kd z^Hs!}Q*DKeliE$PiF>8T%{C7p38Rv)Q*BDz;;HcPC)3LCvY;AN)^sPbtSn?`2W5v9 zbOb1ejHL1uDHlqHfnn|nmmhW*d6qyWiAXM7L>n4^?n0tzyX65Bw9YCtV$MG$u5fnSPCIzPKdidn!{cKt=OInFY<O_65e(4m6jj>(r+GP9S`_g_21ajkkIIA~ZBwyHSPy2z}M zn-v^#)4X19DfwQOA7nVAW-Zhlih~Yps=Z|=$bhoF%G&98-|oR~g+Won(9v#}up5t z5i8fYQVE~dd_2`s{W<2wHGTIVT98YnqTQKJWg6`Rq!VeYU)UsVI>~b$L;jv3yKkg? ztY0kN-oAMgldw=*G!p_#cg_;zApXv~vrQG@4jOG4gih|S%_sE2zmM`D`h**C=B_#! z23%l_d`385|8cZPLsDtzQaCJP~T z9PjnVf7sCGNU)XXpRw%z3uf^XYq`0BlT!TxD4$E^Wlf)rXN$t$^NkQylaxeJdLu(3 z0(Trc(u%FwC0AwPi5~@h5Ri!}p27H%IA}fYm?oYYwkQ5RO%G%FLsTMkMh&x1lJ`(A z`p=Enzmy+ey--Pm)<$&9E#pj38SO{oTn3Ev+XWsZk#yoYdKMFhX0!RDf<(RpA$Uhm z2ng91dQrV?@2-4n7(j5#se(a7MRjuFm2$>r;wJdhM%`_|)@?*$oR?`+*nlxxH4V|! zwYWcOX8R1yOiUP51^w2R_@Y>v2_r04&U)q?nydYlf6jvNMrTG?zH@KFD7A%p2E4?x zKyd~{KdR6>+4ebG9~x_Syayv0lyEJ+r2S+3$JG(=Kd7%2Fg4zWuMFD)F;yxkj19jz zm%>fxU3Xb9TtCM`S)tpmg-hZrvx;RQkRR4oCsUN2y|7}cAgi*_+(>?H<~EQFT}Eo(2^iFDwC9AkZet# z5#q&Qmt?l+QFxYOt6#!xe7#%SG`XV;8*A;Vz`aJ#Yl%X9^HsR^sZ4YeN&bkonEJ*P6MVr|jJh2uo4C4RRoavA zop>D5G0n?cjd0Eq!X>n=8c|MhZ%a!)4Gz)n`cJxU?l5C;mDuGYOX@iWsgO8D9JF@2 z!hD_J@aFY8h}+A;)lYm9L+n$qEIoTc?1;DNB(a z8>2L)>6rAXg-qsq?TKuWs8Q}vEjPw1XyR4qY?8`HMrCKW!+i?^f6$K^!Gi{oMuFB{ z3sLRPcwGu}dw&7)N1aF%m$ezL5SztBv-fTH(|6vo{1|3W-SI*%5-ILg5L4aQ4$!7U zFWMOO_BkIBCS2lSZC~L2ZkEj76ma41B_qwF?sjU z|04y*)sb?(||E&lT#$>pD6CWnNH!Fw((H;ycad1NT?yqe5d^?Y^y0yDtE z1@Eb@=|QUL6Dg-$Rcs|JcWlKk=gF`nLC9LC7#AOCB@v!OPeeZ@VI^XHFg@!30M@Z& zH}`Aem^%G99V1y?$1UANu5|4Oe(cWypx;HrAm~Pm*U&g^mBo$^c&3efTJQYK0nru& zpE`jk7Qkugl9NO>Qir$>7P%}u?1(1X5lzcIM&-KE#iXjeSgf%mz3Fq1anZ<|vZbjM zoq({xgU*zx4JmaG>2YBMSR{BPFm&x~Pr|^^`MfgdSK}J&%#Rb(Tc$kpMDJHEE2@d2 zKSM{yYa+*vvLgdCy-V1U`hULZA+V^by46N3F{#agLYz4` zUG#=hr0u_hMPfT8T*J+se_{RTmzSh|(WqxzM; zSfBs7)+8`1DDJe-GCROPxx#p;_w=>Pl|mSC{~L-(!^0-=PBN&37@ZApI0@R-6gw)KsEY5($Mcyky-?|xirLHS zW9XR{=TXubo?YMKgF6Qrf($ifB(Mq*<UH0{XTb81#ye;beWBetn$eD6e+qycgClN!mf#Dg z%>N&YA5v93>ibvOg8wQjE-D6O9g4$}+-Y~HC8<&WPF#;R@QqaN-*M2Me{19L#REq} zLq%F0=g(Ur9|$bEpN=~a&lDo--@c)xTDrQbx=v0!5$gAR;~3HnK~7Djhq;eeFHOJ56K3EIa+d&YO$3sACzE^b)+nbAM_Ua^30JqT$TiegvS$OGq^n2tqs%Ie17$;kFs;gc zPESj9ydud2g$?iG9m)8BY8uw=dQCF}(PU_iCIVW{_?VYX(_c$DSzoJ+QRC~Gu6opX zdLa`ulUY2;(_Z5CUd*>hHecxHQV9m?M3j{9tQ3D+zRcJ9Z2z*?g+hcpl-w4d7z_7N z>ZJB`lBv#(d5X8=mr0!s&0=l5LssT$ue`Eup}(dt6n1pnVTTf8s6#ddnp~s*&l}HL z@A+c>6^G!z;_!+q02S@$)i6FU=N76QrKNBwRN@v3Xy9ap5rQiNkkmj)XiH^+qVZ&P zxNk#_=PSEwa`7mg*F*i;9)`&4``PhJO15)D=!wl=EEhTu1sPzIDL(%s*m2B#?9&Z= zf4HjwOS$IkcSk0uRKH5IwX=oWW=oZ=FrLa#n>p_wh~4-Dq<;X{R?vZ$zgCzrOAY;1 zL0wtJa2ays6zZM#oBd6$Z20Y$`k{q7Rpio~XW!V_`CZn^9R-S;r)7LfpSzAe?CI-w zQ5Yf6fauLx-)e}}=nsgyPgp?E7NU`5xb;8aY8Buz7IV-{KDM6l^d^*21HImjY{k3`_gibq~f&{L87;FV|hGZfi1^G{_&M|VK1UbXzE^}wXWXvHo@5ZjI(%@UW2 zNVlHFJC-tYoVeidFa;ByulY32ktG+^p7N^s?c1#ab3NtdKwpc9Eq`w^ z*CYoZNaB|IN|2UvK@((bk8)l|*v5M^s4IQH*fryjZRiDrWA9*EkyGl#I1G$|FDE_i zgH1ug8)VFKX&qrm%XAEK^0n3Hn)9{@xrFcUh1QLx-`CR~$)F+V?N@gzv zmuVq-oA4n}1`4|GlBvK0QGm<*(AMYg&zlEw|2E?0$Xx5apBLGKQ=O!~&H)r-dHlxp zedq0_{0#2zDM+4We*9aoQD6Yiti4@qch$SmuOs$k=dPW6kFEm8o+bO`@5Gov2BgZ^ z>Oa+`F*~9#?BN%$e~0<^ZvGs))DbAz;;?e(~n8zm1*Xb`ObOfp6K&Rm}pt}`QLsK%fjbE z^>4p8_`mb*Z_>iRb)|U)4Bb#|X;^jC0bCq~c_Hm@y-uhB#CrY#-wgj=@8Hb|<4PoY zB?Ly15bnV|N5!Nln&IWR48=Na?Cv!VVvh#jwpXnt{oo|kIrlK~R<7_ya zfT<$dX82?Phi!HT$DCLZWiPAG!)a8N$fq&rg!ea4`L5E`Y_gBVu&st<*6)X~weIV6 zERyq-kgLiSa;ac*^+Zvcno7k;gvGTyA~#&!@zSXBi*1=)PV?G&+CPzqkI2qyN%amx zqyuxVjx4~v91TZ7?b2}tRCKwE%P#SGZ#^pY@i%X?_mNnu6I zx|-<)3UwM0D4#ghZ~0u<3wttP?AT}T0g}Vch{Hw}ytK`&SuwQU-O8ncSnZe=t%Eaq z*;!*5YEmY3vVOd6DC+6B&7k*0eq=xs;v|girvzhi4nCc@x^AQE7IiV|B zmDv%?DdMv-99BR?9kaEuwR`d*6}I?=Wg<01qR7k3FR=O@Ngp%^A+9BB3zC$%+k3!s|8zvD=&uc?5seXWIj_r8qqOLD|z5uV7zRkK9=Xj|w4D zUSkg5YzZA7c-i_!!R;_cfH^ZRu)M2xw_thT#I%gB5mp#H<$I;NSw z@(Ybo(*#Duk{I({!QP#Oe1GOYNNE3tb%7`UUoi59dwP8IFBn0E`u~EFL~I<4L}xjA zpgNono+|cNj|n^XrXA60b3jpJ3{hU2+x$99fKZ|y5e!jAAsy|~=;gRs`evG`85>Np z*H1nF2yt3f#ZIb-HP}rSkz6ZFOk|N85z)anK82fnKYKIwO;YQ>@^|C*Julr)-TS`F zZ(GLG{Lc*jt{meI2RpslLlBq{QZB!(fprnZ5hn(szM?Af#S6hkW$iy?&KTufg2-Eq zoV4(iCJbD{#6u@t<|-|4RM5z3Y9t1OB!6M5ghU0%W-N&<+ZJ|-8OHz_vLsM?@st9s z;SRNQ7CG2eXyq1A?S2)8Gv%g-bp7&oexR-7k70QXNp_Ww>B{9jT6Nsq?=|I_^peapI zNvyZH2QoT6n7h^NwAJK-i@WI?^!P>vc)wfbEj77TIC8yV9B+R0BBUDzo(+}?u?9&u zjE+0i-!b`t2txd6MzOVgt>s+l9D&@3n z9E3$+Q`j}IRYN+r5sJkLjx#!v1Z!se;FEZy48OJ+Y=)Xl4Omj8k86Y4+ftjSr=fll z?8_H**ta6|(ID>D0;GQdV+$V*aQn+cCLC`qL$TKD=3(f6AXM4%>G&fIs&n@jC9MZp z@z^>f@UeBX+9E01l__>?KhIDm%tq6}x0WH^@(DMwu9XxjS)QC*j=xZcGCkiqB6|UT zD9ZFLlq6sz>7kY}yh@NNx}O#w_S=O%8ig)Z;mYa77cCpdYOH1ebrma#2=(^ReQ1&JHOs)BKK?l8&dw+`8|qy)nPosH{NTwW{{1YGuFiRZsibY+9*Xv)wRQ&)qmrJhxUU{rctQ`QrP*?8oHl>91P-P(P7?}mpv3Su``@mVTy^(5Zc3cq z?kz^?E^vdSo$+)zZFsbntf=UNUuN`|7|SBz26IM;z2Id`J(^}Olp6Mf>%n0y%2=g# zx*q%714I3L<^{?Idm^@LxtIOiS>WDSLF?b!f;&dZ{EXAhP(g zcAH&IB^6cHz>*E~1SL;(d;1ofH~nmUFwGKf4K)_cMHzx3&@XXwAG$HJlu44b-v?RE z!iNA?DPeqxNM540_3U)WjIz1jgZrpH2Z=ry0Qgs3qSrN1IaIptQ6@#r5`UC;7e_>_ z0ybQ~t8mw7vv!~F0rIg38Xuk0liu!#u?opCWD^+$@Pxo80Y0(Q+8Eyj!1xSlw&~$1 zjgbc9uo3wdKWe5Xfgu^@awCgNn)%ZhfywLo=Yz>EO~#1AgFe&nme?6zNNDHpp?(!D zlS4OJsXNkNkCG+*?oM26hr5eVg%@e$wEEq>Fz6Vg(Bj~fuZVoqQ?3!adu_+%nTp=& znS-{4Kz42diDx|F+3X+41mjLW60Ul&D2dD2@{#A8YTE=rmz>jXPo_MVgQ?e;V;|jH z_`PCq`mS_EDUQ+;p@$*w?InYuqFz8Y?Y!n>!NMy&0A zWPsg>tA!#h6#RISxT>{9K%c6t<~;4HOo@_9!~8GtMn^BHk>z`LrQHt-c7!#ugH0v= zVquYF5f<4RLOPtOB@W4=PvepS*ax1h&bx-ce^AHxbV%QcwKenN4>boXm!JpCb>v#r3gw^ZjH(-u!CnsbT?%7 zg~XQ2Cqg^T?BfCM>p4Gt&K1F}Xt zh)9g&_GHa&Nti>k+l=lM$yOug%U&WvXGmF{pQ%IZd~?q=K|8B^v_uqtA6=6yB&Z9a zDQ*c6B%o}_BOJHYkh>!Jrf!goWU6D_s%t;}c}?BOjY4yBEhK^@=+A;Q>rr(E!5bV2U!P}6@{1@%8Z zpZ<>Te2DLmXlj2DPV5wX#x@~*e*YpTW85X5mK7tGrTbEWj(z6WeMh;R2JXy~wR}bW z;lCp0QTqEO^gHYudx5Duv^>fpI@}L?r?;MzUiQ?Er`cO{6QVNx9`2o6p!PLi^7ME; zjkZlpGAF3OoUo>*3W00L{JI~G++vzTP&*jnpg{Q<&aR&bmtbg9E1#kum6Xqa|*7kYom2Kwr$%sJGPS@cWkqh z?AW$#+qP|WY<29M{=akT+^ktOYt5Tg>tfb;$9M*JV23Ql9vo_KYkASyx6Rtox9l1L zd@8uEkzyY~iq&8-h3lS*qR-m5Zr&mIS9)c|uQvwKzrFv-E_=lXB9LYcVEJomFcPv%WsO|wTLrX#D#BWQ@(!Pl0 z(OC99`(1v*g7REkKN1HziV&8B$32B8J**q~3V2j*Hd|v~`eTI*8my5<8|kJO3!Wl& zlopfFB6)00Q5crg&J}W%w&Z)NN(K*QnIxuR_@;$ed^X<4g48i;Lct>kJ9V|>-ntn* zI0Mvo{#~kk)1>ogX8ye^u9vs=1uBSBY95Df~Hqz8pjD&ak=m$4H>HI4#_CtJ!h!rpbp6mC@l;-t_vUqeyHI=>R_R7d)J}0!> z|J#s$@|M?s3h94hPPNio(t2V)004yZ#y4#iGJj%eOuVAYOkylHmDcIBY=B{iYtd23 z(A;dwY+^?+eb19~qZ(h>&aUIzW(n<&LeKg6b>S_5)oHks-*7e z)*oJd42G4t`OaLIZx}CG`g2u#b?NDaeg%1BAUI=|4 z*-Hp<&2RHtYhMT6lmjx^ z@w2<0!ln%K8+IEkQAVq3wlsOvVoYQX#VZ}OxlKqtE>jb6PEW}p&;XXa$~ikI;U$^M zPPz0)kx{yfbR~GxGUU;gh&PIiH^r5Mnvh9Mu~MR|l4q<;kL>87AOn8-CeIY!r+2Bk zn{@b%o8oqN@|x$lg4)vPl`WvcCKb3&s0|+WrwiQ1qYstQ7AP#Yq^2ywCa26_7$*B- zYvvnmaZRF1cKEn3L)1fj>(PKVKbunIGm9sy3)pf zgzO6StB^#n$_GPPTc4sPYb+MaC9^%7T7k-z82vsB(gz{c@av9Q(VPRoVm+#?#h*D* zYQLa{c~}-Qd|~9ddXi={b19(N572cliB{8csAg8LWCJ7=GlBZ&$lw{4jq*)8vS<1m zR<-^5*PjThmgz^ZwxM9`@TTzKq3Lstu&(~KQG!WJKb1@y<|aB=Pg3@ZvQXUT6!Kr` z(lv7MP-L?R`w#6l_iP=50=ir#OB9Ktm&QiFj=EG}jUH4JL2Dh3DTWAIL~uL4OE+0e#Eq(~z#-O)uKPtE!u z;nDejaT`8BO^FE9T~*WwE7@aPKnHE84*qK8;qcayJ$~4L47TfoaTLItB!_(~r$2$W z&*Op>w5K1bclDB`EJPrK{D#(DeNsHt3Hjra}({;;pkN3_H2ic~7A%JSZ`pYuF zDjc;;OHp2#AdWbZIoDVsp9Lc~3nxzKf|mY+2T7-MG` z^sZ4^qEaaEEvmG0166~k!qFu;hcDs}j$(x8GmqIcK3GD1PMpAO#rZ*6fuFf%38Eyy z3P9Fi{rk2QUudl{N!I8H5N^$Ep@Ic$0odvw(f1llL8a0;^V@_4IrP=4R6?w+rFoj9 z5Stn%9fzB9L-Tc;Pi-$1VIX4qs#K~}=QF-+pLK*4T2_Gp{yPLOgW41NVg``VpoEDu z6Jrg-cRs;C2n%Y~KUIaXM{c(4f#MCe3wu1SvzEvlaZ=S#KledOwdmf1?@Q%0p z!PQIQ^c-&>mCs!Dq!oM&m@mz-z!1znvjmuN{?fMV6`O^#>x~38a->UZ_VD?!Zq0KZ zKz-s+`t(y{$Y4uWs7`hZDZT;@J0A>mZ*=%;ZojlRY(0KF%`v> ze)U$D>dS~*!FLKwo5^I9v1W{qihO&QMJEF9t5x$-ZlbiC2bL;}iJ1=P2E&toGJGn; zy%-!KE!J^$KS0fobx8q(>gULa88DYGiiH*>gUs|Bnh-eS#;6@ zHNN~v4Dx&7=sv+%anI}u=de7^fKhX|V#oo*}Yv zlo=Ig5JpbsfvKh%YHp2^)aVgCAG%$}5}au^Oly%9ea>n6?snX)vtpuQa&%+Cpuee@ zZg0J7=s9PKL0C1*bs3yExahoh=y{ZfV2%CCjNy@sm_r~(mF&E9w51jsfhnH}x-+sk zg~J3<^92=I8m1#*dm|(aju%-clHL090^u3= z+U8>Y#qJ7$9)Z4{i1lb@n`?oi9dfjD;4-&!r+_i$B^&%IebvNl!3nh9mGI1CQMmNuwpfl88ttWh0JF5r68@ z>H}dY`Ms3a>#&jDy!bIUsri>M`S+_8d!Xq|BsLh>zF&92>1FflX6>DzAhFp_VVH2+ zu1NfK22P@^JPv9w&^k7zFzr(uY}n`4E8a{aWqI`B(j>RM65m)&kPE+8$p0LW5L-g9 zY}S9snvosn5r;;YXPls|3t3JOsI@S+&q_7PXUtQ|Xe+gSyNJ_3DoYSk;Z_uL02d(+?X zV55OIw}}SUL2WjA#cqm2!En8*F`H8|u?Qk`bMRZOCzA!D-OJq`v07CNUXXZ`*9P`R zM=R#IM}r9%cY`4#%;I_yvOo5khrG2)Yqk9OVI<-VEYiA~+eYGSp@igJEU}}2o)Wxn z8}=VV$83+i2Lpv#jNx0ejQ8&*RC_i4h&#>6LGLBRWI%W7|0qAUUT!GUrV|U+XS!_*a zaOH|~G#JTYmnN>0r$bsWddlt=KPWcos_5{SViV$<9cl+>Z#C5tUMrcc#8};=_GnLBtooYi|QZ_gkW!1xjoi?a3y~aFr`l6 zbwU|&Ce8GcshcEr2$B~7GeLmKvt=JZB$&oXHb|sL8B`Jieg>WhePs&)&xv+^Qi$%C^~M^G8Lu5L$uX?{{hXgFiik;j~YENafq6g zAu9sgmwZ0l%yuHCEhZBs@CnmHn_e$Z=0sMuYsu)lLuss`_Cai%eobRe7OPw(IjGzO z@jL{Yb<=H;sq#`CzfBiF0w4Cbh?h?At*<{OgW@uWDC?7-hI$#+1)fgUs6IqgHfzc0 zY>jxssdEtPNu}r?;lL1+bv^>PYB3GhE^QTu8%)T2^fIv(G`WBaQJC{6P$0_%g&@^Y z4u9msMy)77SNI&sH!qP1ir6h@rBW^m&~Y+WhNY0bh$lxo8yq1a&wDhLm|Cw*kqu$B z40LIy4W@vXu1O0MuXPEA4x_b1Qyn!qmy2LB?{Jm0tK?8pb2ikOtPuv1>gnbHc){p2 zO*A>FQI9FOoakZS*!3q*OW|vWd8DmUdFS}0GL_+BKkM3BHH)hE$&At`%V}Ea7C2pg zEVz}7fOsQ$kAg`y1;G&0y(=!A`6`B`cW6T_dUwQLpaM*hLBrv(kSAvOoG%uqG3WuIBy|iIT!O1oJ)03*MIhZGB1s3Fr zbadADOCGwu`F2r^zk@iL#U;v|X1O^eJJ0W$ER!}a$SThxZgg(#bxeyI_!K)O%DEIZ zH-TgaOOWmHV`V)cBTbCz9fh{D|F{lkoMhjmg+?BaWYk>=P9e(|%A=rc?3w(m39 z153$)_r?usuh94dxK!v7e>V5b^ZU_67jhzI)FQS6#5wR~EZw~BODiXbTfsMPTxsUy z^RAy?AiK0SM32mzuJzeFsFz3aj}5BdGRS8O0^rI?-}>{-JEw;#E(YZ69aBY^ zn1@Q_v*9CFW zVh|ffv3|fiEhVmZy@Q8eOE)}PuNTU1@;Sb_r9$D|r6evnUrt%x;v%-3`kw_vOiZDA zHI&7GzhZi|JMZVxy_En*eLC`L4SMCl2yqP>5^J`5Cv0M03V2X5bA^5d08JxPr0TE6 zJ9Q8X3~W!czn$YZ;HsDS#?8O8u0c);b(Pa6@3(+xmy`Dc($=cx;nhA})U%O=@)H70 z!gKe36Zj39%nzrWePz*mFUvH7*c9&&mhfv4qV+HkKF^91Iutoe6m(0eY%X2n1oEfx2Syu zr)+`0y|-9KvbitV)g$Kuq!@Q!w&QX|1$P8Twi_>J8Z~tDNJZJuF=|}}cX%cQjPZlv zfA!zcYVY~X+l^^?3KW!66Zo=6-EnxX#PH?do@lWHgk~lS3h{}K{L#G2tg}=>kd||I z>FHTUBoSlo5Dq>|vTE z!a0fUkIj;o$q~}7_A6DKHpn?q)VZcOcm&Uq%~I$Uvgp*-!hBLyxTS^`Y1SZA`m6!g znSK%FUt1lZ1(s24tLo=SGAqlXArV!9Y=|5dTGY z@tM;>6O=!xIx#7HqCaJ02L2^IU~q!1L?`jr>kOC=f$R2q8Uqq#n29=I%3|7c8#1^UYA zTl^7Mhhs$z5Wox};Hltx!_dL9_6E%v0R3 zEEUgfvPN|S?PG)MbNjKE=vIrH{FIe3;3&WygUORaIo`A15ez?Nt)Ps-8`2)3*^z>| z=maa{GXs@Pb!1-L<~-%O;U#$RQRC53xfQuB8NOAyRat!ka9{JXbFl}upmnW5Ks)*Vvm|Rkw5j^@z+1mSAjW75|q*R@;jajWKYd0_I$vf zHc!TMpiq~|CC+`IR+k2rmI1sHFnLqvJYzr@oT`X>3sYv?+2?;r;_2LRH`c18fUt;?rN)Vs#o3wXCbq-q>HD0ZkXnKV= z4~0ZDvDfpN!tuYM{wJ-Ds)LA8V1R&3(EKN+4?3~{5xjNOF~0v4P5<`sdAI0vlYL%x z#dEP;vkNQgj z780N;EaC!$GQ54N#JHH_TF{&GuQdq`(t+y1T!)jbd#~u<}pFG zqBD9ID8YtV@uUg$yW*lU(5-1U0z1ZZ)LWU)WWi%ADotXbXk4Fc5AG?WKRVomUHR&U zg%qZ-r-SJ-64ysC($s~EiwTy|uAuoZ#rmhfxKt1%YIle|O1&Aq&9EGs-S7Z=$9NQ# z6jn5oC3lTcIFpH8MUPrA@*MA_3BN^66KP2w5T1|F4t_LRX~^a>7SG4WtgD_Q#UV<{ zWQP<20yL2eJ2Pq|3Eu|+Hy#hbi^bnUXUiUGuGFyv zs=_dlRSRfv4U2-NCW4bz*a3wN1SZNIiv zc}k*sE^#t)Yf8e%L@I?j5#UC=T2~+nd>$>c{6KrP?ue02n=)X7*y8A_g>U4bE<>fx zn^XNLS)#YV1BM)C=UfB@c!Hu0lr&BNcLU{eR}L>ns!Dld`s;Cz3ndKC%f=8xov)jU zFksRhA)0Z|wYo+3H=@gUb^;!pP>;pH;H-~-Y8&|@q5cqzkusWkzuo=CB?(hPz`cOPUU@{ z45M()PR?OM;zsDv36}4{XVExZD%+_zU}|UTdxQ`agJey^tjDMu8x|PL4zLu$YN#Gg zac^JT1)9~8(h)Q)vlp23<5n>MMWJSj`F4!8;!U>rBliu1XiR19DW*K3>ssz%XzrlZ z>T(ilVxdTbppRZv!VzCpPZu11FculZqk!-oio3sI2PW~mL@}U{#S>!~Cukrhz)*U< zxCP%sG5j&rFpOtuFI$Ed@FG%oFk7y$u$qAmQi%D5op{MqZbv(24&Lx!*2v}}34c;b-T$3oHSoDKtKWgWd49pek zLt5`4Qs$&G#?tYz)%`$9orWSPjDFtp-FZ21nU^{^iD}BF!L^ne!z=uimewXs-5E|? z@OIlw`dih7KMW-Wc!%tnx$FgKC>@Q;%wH}cxmX@_QCM$Z(K28Kqgp?cY-naQc9=nh zh&|$=)|T=u*mLA3QEGFWmidEUg@_(j=Y!nrpQdoI8&} zLX*#V{^7zuO0pT8o48>(q%b$e)P}PbY>*Ji;Kqtt5wWfSR7VPw!`Kerp#>$FSjVD1 zyEn1oWI_Lk*w111nre0&Xwc?3*tPJUG8mY|^^N`$MR&3;3mkI#(&^#pMMFlQ)u%Wa zI|?GWPmHfMb(FZ)UBqjBU#vbRYNJe7C~-OU2rR540+MH5{S=GhMaBRYB+R5^w2rfc z_FbhFTCtA-i&}46Bsk8qZGvSF(5N{7VKe-!ZAbg9lG!Br{tW+#yyfcRYT=Y=hy9X< zq(6p_U(K ztjidkM$kB>?`bO@Z}U57#IO6Bxt+m99z6_(Jkcw%ZE%=mbvf!T(S=1??l_skWfC!6 z<0npNUtLzRE@7FZ^|E+-+1wC1OL7HFdW!S(De8$!WBaormcH_MW=SlK2|2qJHzJ>q zDq5onP)IK=bZ^YF^t~eAnY5$w`{N=FpK4^T$%kvgIr}1H9wbR zZmn7R{e)BH=}nr+*H|{Eeb+A{h8wz(m#j2nfK~?CQ9K$;{65Zemx)n)zz2|bpvTXvK-q%!c}2fB;1?K4va&bR+O*|=0usSt&VXNHWTOV*m^?9ezvJe$rFiV1}DnC2tXn) z1KE;xekCl(%Bgs@|8SUpW0lLtdWPM%vg{2#t=i~&d)x^iC@b6aw|wMNI@|Qe*%=^6 z;|St;_Wzbqif%vi3Eq^Zl6E)H+9z$EWWKo(lD`fh_p$;9TFS&9pihdDCZ83#eg2e4&ym1V(me zr1td8c?L5=B6giGe^hAtfEZv(0d<+`Fh>8bu7VTh$GvbgeBxhGqz3ruTFnDGZ?4bby{>^hk5gC?Yc3$5#XC@0}(3o=(- zyUzILDQMeTTxKDsEcr=eDla3q z838_;pIx}C*~QLY_)yLWyUwN`yw6O^-5D}u6LG8$sKevXS4>Yk(1ddng?WkG(k~7y z&`UzSKchFWBsJ)3yg2HDl#~2mdYSmZahducZ$*^mE7hDzy{sj_0HfBE2Goe)NzjNyqY%)p zN@1sc8>-w#cZ_e7S*RRtPS9s+k@afCPI(}y*Iek{_pB#EW{OB9?=|QeUUH4Tkaz~K z*Igi;-`}|IP`{H)@11rnJxpg6+Qm)cS3M5ZMUu&(x#!c1mHM~Dw&%qC+st+9CiN_t zx^eC%`M305c>y*59R$uk`u{ulo!_Z+Cl~IX+D4a_n&bgGwFtw{m6zbBxhn^{tI$@D z2=Q>pRODU)rHKmt2L!_%rOX#xo?ep0zlw1njkqA~6c8d^!;yB`0YXtjETdtLYZj7@#K9xF=i2+v$$dNTYGsQ!T&38wBw;Nw0khstDzRxOlfbe&PprTCN@8W( zR@S!sxFjEId`Y!k(%BqXN@!!pW{oR!e^s+WzZUawzNLa+kv3MwZPF|`a;IIz#o5A% zs~_q04~8L{=bi2%FDxmO*yr?1REWKyc)XX5Ret=1s(!j?MfT4tbFUW4AgC%=1CEncd;5chU88@|&4Ln&HFSRj$tr>U-(rdEPNy(THTacB4qxv+? zOu%42c&+mmLtftxwUwG$1Lo$hsIv_=vs}L)0BkLE!T-Me&m2Bb>%?e3B_NCk-l(gu z7zlV<0AfOc$!Xncl7&CF6afm2SPMR3gFH$Bx{9RXcuHztfG*6MsT)>;#j4E4m}N|h zC2DDS(umXcii-|aGytZk@aH*3r|V*o3~_sUlBs*J8$)6^~?WvqIGH{l?F&T>**Cj+Wxqo1m)h$_7E5 zu_NZ)DC@trr{~9MM&}*2X~x(B)tiVj11~i(1O%P?IG-*TXg^Q`l7J|chNX}1(OHZZ z*`~3sG3x-zQumzt=5UzpYkXz`&B>#WLyV^LA~(Rrl;yG3iT`|}*T$o2civkT2WQD< zzzUUhmEy$sb^s{OMO1oYQ&e7bGx+=DBC=j-uKWpXj3eNDIZ@#vrqO_n!*im0ITB%U z*;aMZ)r@2X$`0k}8QEz3B1{P>JrvUiR0;P8U^wxco#NQB~W?;3S{_^?2n+>C|3 z3)+kYw}hxx8B>f7a03!~y_aj}FE3#i5i{5m6IH{g_~E`>v=GxYMfI-qXJ_a(dtR(m z2aH(h*ImwSOP|RNo*xcQ2%K%8q$)Rdequ&)rEUs_(7e0J0o~u7G7g}v5L-2`D4^V- z&fGcztMg!CHHa=sHMoBYS##HrAv`I?ajIsDW}Y&NFsL-`;nGX zB^B8avzBcu-c0p$D5a`2)8FSdR zY0*mkKJyKJJNqG`(<2G~YAHNda*Ic*60(>l`c6$Vc7YvxhRO~mf?EJ)(-RnWPBE?7 zk^y$0W%c!K-D!jm)6_T$wSlEWE){ypTsZ(9$0h;xpfLjTU|VYxr9bJEU&2{W6cOE) zfuOP01)NqKMdzJKv(B|gQ=MevXp>{+aQJ}EbrGHG;gUcms$KV9)}}A#(AewA$m5VA zl5lGf1^OIqkz1G}Bz4uJ{dkXu`n|vD?gjyksLLddFQ8Y4;NIXYbP5->Y9DomPi_p& zpQckVEGOoz6U{d1Th?nGgg}zRt-kQ;vEc^^6 zVCJ&NK~2CiFa$Ap(P9#tFAfkz%$8uspk&Q}%l=Hm#ooP|Ss=H*!ya1XnVb)N0Lvo6 z_X6F=DQDsYmwkjhyLv!O`RtEaQRlj5z;1^(4|b<@$?;#{reg71B4r!tG~`|NQWDYu z02`s}8-KjpdButf$=w{O#dP!&AT7ks{fOBk8b%fy9{S`AddI9~qzjPWQ52f#@D^6` zwnSp6zZ2`aqbWjJtvK!A)m2^2&5NzOl;pAQs`i_pmcmLmdOtI^5nfVaw0ZlB$|J;J zK~cBJcCOVPQ0W|kxWLvmNcl#itO*P<0@@at;*o2y z%1LplUjKo=h9*tsm2;r9%XK-*LIQW2)6?UiS-XBN+mvY_s$$C#YU4l02@vd|Pb4}A<}n(yG-)6}xaE>UQ`6mh{ebJYoH7`hFHRr*e9cq$ z7n3EA$5+*|9}cU37+5A#fx@8}R1cU9+A+^y5UsRKA3b@S72E8u-4da@V}vFMJ2Sz(bh8Z;F$$ z-n`oTS+p+LcIkK}6Us4&v((d6oP1z3ZNn@r@o8H@9H^DwSIR36@bB)C7UJ9=I8^9* z;E-Obx6SLBjxN2nvB(?e=%UbKFEJK;AYPga=!1RoA)Swl#a7FVMIrpnx8JWid7f>k zvtDf4Z|QHn>?$NRh`Vo5LJY>7&W=n%1KK*d?JItMequ0do)#f!4UX*vI8XI9ACc|g zcNk&OB^E{y6@yW5;6$6>zuvS@bv1ls-zDBw5A`>3FvD370UNvkJ0zw#GhZ(1l<+)K z^m=cR0lfy+TA8+A6j|gN>V(Ee0-psi=bbBidnU``vWe38ZGa}~0`02wUivev)*l5@ z@>yq73uFjE9fqG<_-+8I6*^LKPCw9FkMm`GvTaq6y+99HV7Xb%UG71c;k}A>s}3pD0Es!IpL3IFo{|(9*-Septi8N<-q3U@qrBYx;PO3e73Hj2JP8 zIqS2Z*Zc*FfUJNLdK7d%S=GFf<~<5y{mWnJoqJO(o*|LHsbnE?)}ld?5}&7j!;m() zK<*QQ5EZiz_OLg_P01GC9%hQil3t^AYZ-FudTzKGfi8A+ZZ)7j;G%HoKYuf)1AY{fKg2R8|= z4to{$D&xO7DK?22Brl-gHRfa-j-?-3gm)s{e8^qBGcs!C&zE-Dn}60UY@DjY4%aNa zO`-}SH2HI;V1`506%k%FSQJUQ6EZBML>5gc0lgg}t|Kumb*yepD{?zttH(Gt;$;*T zGiz@Cx_Ihz;pG-b$79|+sSRirUBeaq6nk0odFaxV+xF(*#rBNfp+5yJ--30H7#X9*$cN&u@Sw^Zk6e0- z=ihx{bP%W(T3Q&YFsOACnw&dwieB|i`*CNRc29YTOD&(?pnSnHoAWMuX?mw`H!-7R zcZ!={9>m2fZ*Q$Do(uCY7tf?~DOXYX1+=t^2=&fMc_S4Ngs@%=1)N_n*01+sB6&u- z)JO>hJ)YG2X5>7$yaK%cUd*aUb`7@{#@pp&=06vsYJC{D-896xFRzgL+)}rU&V|P2 zJol3rMEn)RQV|n>8;4V($)H`J;C^2(%8gFo&AIg=CEGa-W8zdHBC>o-k83r_2cD?Z z&CYJe0k-@g02TySL(`nZ0?wN;f3h2&06$=eE+2oaU0`@~IlSsgm@}F2TXd2x7&x-` zj@fNow!4d=x32f)ME~Tn2{kr9y%WFl)aN#U+BOJ0EXJDX6R%fman$7D&FPlVR4xBh zYSb!HWV^OwzMeTaScM?IZ(l;b0m3hiMm}V+JwU)@G3nslX#ZWURORZ$QB2N$!2MF(_8v6^r|Nbi(jIJ0lYx9OiI4u z)^1>!dpDWvrGFNAE3=XHRo+E1L~C^2jj>m=31jIsi3*%wga4d9T2dl+4Hk`RIt?$e zS6KY>gQQPsQD~P+GO#a!$PV+dxVos4k$`~+oo}8Vl-p9GiaKH>0`VerZOf2x z&&WL@NR!-K#e^XspgZHXQRhcoZG+^ngaqGy#CIt-<50GEeY^ISYXS8y&7qY7kHn8F z#)zK-tJop;&sf9VdOIQ4!eXtccf;hc0bxq+5)T-|pIB$}91|JBvcTK%gY6&Hc)7TO z8j(KVdKX0{y8oX+fO{`Mhv0yPe}w>$eS8 z&Hgge!-^tDPw#^Z9sutm3a3d`8(d5PQQKuZuN1J%TeHDk9}u-&nC&7YxP^(o)UX?T zzv4SSxbnW;ycC|=kG}37VE(tCTQu1)%ka$O)&B2kP%t|w*t+%2 z>m&BRS1zbQ{_VaEkm0s7>0FQgY`t`z{A}`&IoFPeB%{pxX6QR7Q=>{aM6rAbHYw-5 z^Zu`ml!Y`v_Vr&6hzI_E+Jr?s2e7_RlqN+*xGt~Fw>j99L1ID4_?Ohb{z8rw!^1x= zztw4i1huiO!>tkr_ zr0r#_b3amg@^w1jBJ3daM;%Qs!F%=~81_A+7{|jr8W_k1trDAwDD;c$FM%>#1sL7N zcsZBYF%$E;2DMt&iduLYvoG62t~|)i#majmuPp~?!7=vE4{-xw-Q4VY)(q{?X-3TE%R#`451jj5O$j7WB3@xozn}|((q0-a=%-J|?xJ$Sv zR#;3#_@d13!n`i*j2+VGjmF)I(AHccEYBMJy+9Teq(*5Vy8VGu~Xr<|8-|v~nx<7K>hG?US%2io{O1CsLl;#^^8j@TB26 zIz7S@U6$by>qx4f@=@m7f3xpPm=6g4fBAmG|I4?S<3vil@r6!gPND$He-8n~bA{Jc z>Ey-eQk4F&`x5i0A9~j15^cFM>oQjY*P#9~@WT*#gAmDNg%M^2zrOgsPt(7@K7RcG zF+3+(+M=%eNjp+X|0H}Q=+YOklf6t&?uLpL5z+f&nB-0wMCE00h` zCjVb!3J|S`-kHfXDY*Vvolf7TYm7mW+}Q3P654J;4g0me9>w?pc70;12Uu^VO@2GU z&mk&llq#nKZMi{_Py=_SOrKyL!h~e50#Q%+&I3M@$Hc2{8KzT0fxRC?Uo4w|MIXNt zx8)iv_a`2)+gsIR!YpI6C;4lR$%^_@rdgZl6Q7hvW!X8g(U)h#XG<~Jhy$D?Lr?(s%o1P zf*2B4*7ik7!kQJ{3K^b)pOW<-FdZtiQ5{Z%df!&Zs;fl)mxM)d5RyBIVQNT?(2#4NL_kU*= zUW?W(ZPzSOVIOjZuP6$z{^hLvQhk&VHbEe&;$MQjfmF_3RIXmaME*=L?rNz=c!h^2OB71la2QL2`%{ZHxS!+OsSa@rfm4VOdg$N%2AHGvogv5MhPk` zzq+MUrJ*|}*45%Ah~$#M!HPQwFLbTdx@M1Ze*M1vq1$wk2~BZdk_98tZjX&XHOuudfQb#TY!Rkk9O+&)~NYe*^h>!0;i&i}ZZkoDph|&B)$|RncOvF|_0( z)@Ief?%k^RRWh?xmZ2eH8*qd3R$Am@;!;R|S@w&!yzshTO+1nvc~x}mdop^7syHt& z&`hALB}Tq6;VssVa3Vm4CclbU4)`ePEsc*>F5RG(G81yXr0*d+3QOD6jd<+bQ|=qe zEg)^3(vekM&8t~`7_6&u?JvtM4X!Tq3r+Na`9rvL6*>X(g+Y1njA|~Y@O_=r%c=bm zb7xD!z|M_2UDk#KFv!Qz)f(Nub;S_(_ZH5(k2%xZKNg$NI7_gGQMgwEar<7ypmoq@Xyp^l5ENeZnT>EQJPd zGy}S|R<)6>1>6&zOhaVb3!3f&DF7%r9~+wFB?NhX68cj7Wfn&+5X`wTFyxliNA^aE zn)m>|@%5i>tw;H0{{;4rfcgaa{{y*t^-u}*_=(mTSU{aT4dEoJWbomp0ROl++s!?j7<0K zNWbD!X3_wdslzJbS!l9=YDT)HBn}Sk#R>Qm*AiwcW_XSAczSj1vnh)uc*k~8jKJw| zR~qfYM_|#EGkW8?3r%AXK;YyyIiz4WNV#~N9WkADoYuIbN{0LQj0@Q6!0Xn>fH$MI z*~z{n5i;mkz{;HLWqTDfsIq*jN`k^9tgPN?lfJpvdA2DRM>DA`LU*${lLs`o;u()T zjastG?_pI9*6uk)Vd}|{^2uSyRTSvU7ByNnRp9$;Hb&9L0iK5;=-xIk9hUNsW9c;l zM+9|jZq=Vi67F<_8f*bO==TUDG1y8hvDO?xe4gsyTBk&`HUJ;!bn&f&Lix_@z>$kAsnBnnC@W{OA4LQa}zN`~Z8PGRtJX7&;-g92K*81-14G zw?}^c6?#H)6e5ZLkxwUhwrlC`z0l8A^HLDV)P4|&nBzKJivJPMCwR2Wqv^fTPt0Id*@-!WtqVF=%Ao*Ju~%rebC9~ew+)m|AH_Cvt!HR z^K9sS^e~i)h;`sVv49&&^j9LTDQ0URO>Za(Sp)(C7Q1FJ7;&;NLn+AciH`rGkY#d$ z+Dc2acu>bl2QR8n(!=42F)&;l;Bm&+>|~5mHAaY{jntv*D~i>Wm?S&vX{fUEO}GYn z&wE?nj~uT!1jIrrwDn{2D>GD%zA|d>!T*p~6j$j;Qt~j7OJ&8Wk$mEFI^m8rmzQ_X zPXHRtqgbj%P$y(WJRlP6IW7iUu_n)REU=r}G1H$lxHgnj{d_AqZe^yYw%}2~;?8Km zL@{0{i?Oy+QD9+rnKd(1=R(Dz^gGFH?L!Eqf&)SBvhFas66s|{~4NB0J3VH08}LoC;7pt{?To`2Wj z`tA$Q7yTsRX9CqaC80xNomy>AS`%T`+pMI6cSVTSgLo?}Df>TNoq1Ff*B-}XOj#5H z7KjB#mas1ZPY`5_2LiGNN}E7{00o4SO3+{{V1UT>s9_TZ;)W;+h><0c3If6dMB)Mn z0?I>u8huqGgrz7_+&URO!6E0&ADR2f?|1K=$;{k)?tH)VIO}^qHKNAV^sWyPd|vRx z^PQ$DH*BAJ8f5n|)rfn7hV8vB{gNC}QJ((1_2)EGi*HRnd0-?)KQQ(EJ&T>MvFW}_ z)31p-$TQ z?1>6awB;{splC~gq5Mv}yp%dMY?UvWIOX~f7<*m1&T;5+16_AC!1{;paBQb-#5m&l zW0RasrJ9ljtyp7k(;zw}0bLPIb>qJE;Zz>+CrHXus|yyR1{;F!j@aPJ zbEL=tCb_4i^guP{L+C_J!hvF8+5kQHj%}{f9}Q*m7f*;c7Y&@APWtF>u>`$sFKLd7 z9e3ztUaGm~?D?C>^Hr1&i5=({|92Pj%$}9T?>}C>S{UMzs@S{@^NF3WtTa7!%+5n{ zO+41j+K1jdGGJY=UYm9zn$ElhzvB~z5w+L}5?!EJ%dahDUj4(FtI{RiitxOpbiFQgP& zc=l+yxHpdVlEjI>7ixc|;EEwAqcD&3A$|UHwi`8LpV>9iBRzO^+Vz zTkxY!WNb8vsb~{%-jMA)Gput>7QzzH=Vxi>#?cAFxT}Y;uct1l$TQLu3|h(i2Dw7! zE$(@7l(#A+i|t~ju*pcn@aUtypT&QLTe>5(XV4*|I&x{8xQ+C7|9!gNO#SgBi1`g;_u?vqs!SA8IR|x`u}_qz3xPR zbBM3YP)l3xGqZ3xRuTXH;^fIO0VTJwRlrJ~?6PaZx0CoI9)|r>=5uEcru{iF5<$*u zY9i#D+n*{*;?L%O)ay!8ak_PAb(GW?RqETL zj{;dWUW!~gc7_FgEeCJcxC7`u%ws$>UfTz4|3X3PDYDNJ7A&m=KyMX2@JzF+cH-_P zQWA7GYk`CxjS=7>@JOvYu%|)(csNwv3O(@IBFg>L;6UAKcxfO&W>_wdLb)J7RooX) z9%R+o0bd)ux*|YGT2>j1i)@xP@fJ%skR|1&$W=%iEpVTjf#;v zErH)(z@Zzq%E}5ZH~_2OBy0PeYx4z^E92<`GOGcoOOeN>W;^K2bNdFC$Op4{8faH1 zXa^qb;28m{GU036vgi!H;{^aRiE5|~ZiqHS?t}nsNLAbokf|L*5CH*2xPgx@h5|Ch zT?nv70Odq*Q?mvb>1ibG1?^Q?(Y5J*2ZI`LAiq%oq=IPXtq9057=}8j25{=tHzOdaAq04U3WJGF zHb8)Eu@nl0M?mix5VQrHXwn1Vg*{Np7tn@G>2wf+yn)qeO%zHG5k)Z_0swIEkP2L< z)fp=kN*4i!7Ql64mukSEYkgE#5e4TZ8oL`*D!!E(Nx_UaSv j+6D+geLfC^M|+mQ*Ow$yL@ceNaI6S{mE76Panj42;u diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d4081da476bb..2e1113280ef1 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 23d15a936707..adff685a0348 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -172,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" diff --git a/gradlew.bat b/gradlew.bat index 5eed7ee84528..e509b2dd8fe5 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,11 +70,10 @@ goto fail :execute @rem Setup the command line -set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell From 0cc79ba3661020cd1dbaf99caac3a604b01ffa41 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Fri, 19 Sep 2025 17:49:12 +0200 Subject: [PATCH 237/591] Catch EOFException in IntrospectingClientHttpResponse Prior to this commit, the `IntrospectingClientHttpResponse` would try and read the HTTP response stream in order to check for the presence of a non-empty message body. Developers reported that in some cases, an `EOFException` is thrown instead of returning -1 from the `read()` method. This commit ensures that this case is taken into account and that we report the response as an empty body in these cases. Closes gh-35361 --- .../IntrospectingClientHttpResponse.java | 53 +++++++++-------- .../IntrospectingClientHttpResponseTests.java | 57 +++++++++++++++---- 2 files changed, 77 insertions(+), 33 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/web/client/IntrospectingClientHttpResponse.java b/spring-web/src/main/java/org/springframework/web/client/IntrospectingClientHttpResponse.java index d40ea625e772..8ea3e9bc8d20 100644 --- a/spring-web/src/main/java/org/springframework/web/client/IntrospectingClientHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/web/client/IntrospectingClientHttpResponse.java @@ -16,6 +16,7 @@ package org.springframework.web.client; +import java.io.EOFException; import java.io.IOException; import java.io.InputStream; import java.io.PushbackInputStream; @@ -33,7 +34,6 @@ * @author Brian Clozel * @author Rossen Stoyanchev * @since 4.1.5 - * @see RFC 7230 Section 3.3.3 */ class IntrospectingClientHttpResponse extends ClientHttpResponseDecorator { @@ -47,14 +47,18 @@ public IntrospectingClientHttpResponse(ClientHttpResponse response) { /** - * Indicates whether the response has a message body. + * Indicates whether the response might have a message body. *

      Implementation returns {@code false} for: *

        *
      • a response status of {@code 1XX}, {@code 204} or {@code 304}
      • *
      • a {@code Content-Length} header of {@code 0}
      • *
      - * @return {@code true} if the response has a message body, {@code false} otherwise + *

      In other cases, the server could use a {@code Transfer-Encoding} header or just + * write the body and close the response. Reading the message body is then the only way + * to check for the presence of a body. + * @return {@code true} if the response might have a message body, {@code false} otherwise * @throws IOException in case of I/O errors + * @see RFC 7230 Section 3.3.3 */ public boolean hasMessageBody() throws IOException { HttpStatusCode statusCode = getStatusCode(); @@ -62,10 +66,7 @@ public boolean hasMessageBody() throws IOException { statusCode == HttpStatus.NOT_MODIFIED) { return false; } - if (getHeaders().getContentLength() == 0) { - return false; - } - return true; + return getHeaders().getContentLength() != 0; } /** @@ -73,6 +74,7 @@ public boolean hasMessageBody() throws IOException { *

      Implementation tries to read the first bytes of the response stream: *

        *
      • if no bytes are available, the message body is empty
      • + *
      • if an {@link EOFException} is thrown, the body is considered empty
      • *
      • otherwise it is not empty and the stream is reset to its start for further reading
      • *
      * @return {@code true} if the response has a zero-length message body, {@code false} otherwise @@ -85,26 +87,31 @@ public boolean hasEmptyMessageBody() throws IOException { if (body == null) { return true; } - if (body.markSupported()) { - body.mark(1); - if (body.read() == -1) { - return true; + try { + if (body.markSupported()) { + body.mark(1); + if (body.read() == -1) { + return true; + } + else { + body.reset(); + return false; + } } else { - body.reset(); - return false; + this.pushbackInputStream = new PushbackInputStream(body); + int b = this.pushbackInputStream.read(); + if (b == -1) { + return true; + } + else { + this.pushbackInputStream.unread(b); + return false; + } } } - else { - this.pushbackInputStream = new PushbackInputStream(body); - int b = this.pushbackInputStream.read(); - if (b == -1) { - return true; - } - else { - this.pushbackInputStream.unread(b); - return false; - } + catch (EOFException exc) { + return true; } } diff --git a/spring-web/src/test/java/org/springframework/web/client/IntrospectingClientHttpResponseTests.java b/spring-web/src/test/java/org/springframework/web/client/IntrospectingClientHttpResponseTests.java index 55e5b048d585..c34881d228de 100644 --- a/spring-web/src/test/java/org/springframework/web/client/IntrospectingClientHttpResponseTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/IntrospectingClientHttpResponseTests.java @@ -17,11 +17,18 @@ package org.springframework.web.client; import java.io.ByteArrayInputStream; +import java.io.EOFException; import java.io.InputStream; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; import org.springframework.http.client.ClientHttpResponse; +import org.springframework.web.testfixture.http.client.MockClientHttpResponse; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; @@ -30,27 +37,57 @@ /** * Tests for {@link IntrospectingClientHttpResponse}. * - * @since 5.3.10 - * @author Yin-Jui Liao + * @author Brian Clozel */ class IntrospectingClientHttpResponseTests { - private final ClientHttpResponse response = mock(); - private final IntrospectingClientHttpResponse wrappedResponse = new IntrospectingClientHttpResponse(response); + @ParameterizedTest + @MethodSource("noBodyHttpStatus") + void noMessageBodyWhenStatus(HttpStatus status) throws Exception { + var response = new MockClientHttpResponse(new byte[0], status); + var wrapped = new IntrospectingClientHttpResponse(response); + assertThat(wrapped.hasMessageBody()).isFalse(); + } + + static Stream noBodyHttpStatus() { + return Stream.of(HttpStatus.NO_CONTENT, HttpStatus.EARLY_HINTS, HttpStatus.NOT_MODIFIED); + } + + @Test + void noMessageBodyWhenContentLength0() throws Exception { + var response = new MockClientHttpResponse(new byte[0], HttpStatus.OK); + response.getHeaders().setContentLength(0); + var wrapped = new IntrospectingClientHttpResponse(response); + + assertThat(wrapped.hasMessageBody()).isFalse(); + } @Test - void messageBodyDoesNotExist() throws Exception { - given(response.getBody()).willReturn(null); - assertThat(wrappedResponse.hasEmptyMessageBody()).isTrue(); + void emptyMessageWhenNullInputStream() throws Exception { + ClientHttpResponse mockResponse = mock(); + given(mockResponse.getBody()).willReturn(null); + var wrappedMock = new IntrospectingClientHttpResponse(mockResponse); + assertThat(wrappedMock.hasEmptyMessageBody()).isTrue(); } @Test void messageBodyExists() throws Exception { - InputStream stream = new ByteArrayInputStream("content".getBytes()); - given(response.getBody()).willReturn(stream); - assertThat(wrappedResponse.hasEmptyMessageBody()).isFalse(); + var stream = new ByteArrayInputStream("content".getBytes()); + var response = new MockClientHttpResponse(stream, HttpStatus.OK); + var wrapped = new IntrospectingClientHttpResponse(response); + assertThat(wrapped.hasEmptyMessageBody()).isFalse(); + } + + @Test + void emptyMessageWhenEOFException() throws Exception { + ClientHttpResponse mockResponse = mock(); + InputStream stream = mock(); + given(mockResponse.getBody()).willReturn(stream); + given(stream.read()).willThrow(new EOFException()); + var wrappedMock = new IntrospectingClientHttpResponse(mockResponse); + assertThat(wrappedMock.hasEmptyMessageBody()).isTrue(); } } From d85a020e4eda65eb497d91098bcfc02ea1578e7a Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Fri, 19 Sep 2025 18:05:02 +0200 Subject: [PATCH 238/591] Improve Task Javadoc about Runnable wrapping Closes gh-35394 --- .../main/java/org/springframework/scheduling/config/Task.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spring-context/src/main/java/org/springframework/scheduling/config/Task.java b/spring-context/src/main/java/org/springframework/scheduling/config/Task.java index 04efa345808b..a0cb29c0cd09 100644 --- a/spring-context/src/main/java/org/springframework/scheduling/config/Task.java +++ b/spring-context/src/main/java/org/springframework/scheduling/config/Task.java @@ -50,7 +50,9 @@ public Task(Runnable runnable) { /** - * Return the underlying task. + * Return a {@link Runnable} that executes the underlying task. + *

      Note, this does not necessarily return the {@link Task#Task(Runnable) original runnable} + * as it can be wrapped by the Framework for additional support. */ public Runnable getRunnable() { return this.runnable; From 79738d921ad8824ee1c312711ccb63b912403f56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Mon, 22 Sep 2025 11:16:25 +0200 Subject: [PATCH 239/591] Upgrade to Jackson 3.0.0-rc10 See gh-35521 --- framework-platform/framework-platform.gradle | 2 +- .../JacksonJsonMessageConverterTests.java | 6 ++--- .../simp/user/MultiServerUserRegistry.java | 2 +- ...ServerSentEventHttpMessageWriterTests.java | 14 ++++++------ .../codec/json/JacksonJsonEncoderTests.java | 22 +++++++++---------- .../client/RestClientIntegrationTests.java | 2 +- .../reactive/function/BodyInsertersTests.java | 6 ++--- .../client/WebClientIntegrationTests.java | 2 +- 8 files changed, 28 insertions(+), 28 deletions(-) diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index f098c9f7bb2c..1b8ddebf99ac 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -21,7 +21,7 @@ dependencies { api(platform("org.jetbrains.kotlinx:kotlinx-serialization-bom:1.9.0")) api(platform("org.junit:junit-bom:5.13.4")) api(platform("org.mockito:mockito-bom:5.19.0")) - api(platform("tools.jackson:jackson-bom:3.0.0-rc9")) + api(platform("tools.jackson:jackson-bom:3.0.0-rc10")) constraints { api("com.fasterxml:aalto-xml:1.3.3") diff --git a/spring-jms/src/test/java/org/springframework/jms/support/converter/JacksonJsonMessageConverterTests.java b/spring-jms/src/test/java/org/springframework/jms/support/converter/JacksonJsonMessageConverterTests.java index 8530d594d0cf..b01f421512ec 100644 --- a/spring-jms/src/test/java/org/springframework/jms/support/converter/JacksonJsonMessageConverterTests.java +++ b/spring-jms/src/test/java/org/springframework/jms/support/converter/JacksonJsonMessageConverterTests.java @@ -181,7 +181,7 @@ void toTextMessageWithReturnType() throws JMSException, NoSuchMethodException { @Test void toTextMessageWithNullReturnType() throws JMSException, NoSuchMethodException { testToTextMessageWithReturnType(null); - verify(sessionMock).createTextMessage("{\"description\":\"lengthy description\",\"name\":\"test\"}"); + verify(sessionMock).createTextMessage("{\"name\":\"test\",\"description\":\"lengthy description\"}"); } @Test @@ -190,7 +190,7 @@ void toTextMessageWithReturnTypeAndNoJsonView() throws JMSException, NoSuchMetho MethodParameter returnType = new MethodParameter(method, -1); testToTextMessageWithReturnType(returnType); - verify(sessionMock).createTextMessage("{\"description\":\"lengthy description\",\"name\":\"test\"}"); + verify(sessionMock).createTextMessage("{\"name\":\"test\",\"description\":\"lengthy description\"}"); } @Test @@ -237,7 +237,7 @@ void toTextMessageWithAnotherJsonViewClass() throws JMSException { converter.toMessage(bean, sessionMock, Full.class); verify(textMessageMock).setStringProperty("__typeid__", MyAnotherBean.class.getName()); - verify(sessionMock).createTextMessage("{\"description\":\"lengthy description\",\"name\":\"test\"}"); + verify(sessionMock).createTextMessage("{\"name\":\"test\",\"description\":\"lengthy description\"}"); } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/user/MultiServerUserRegistry.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/user/MultiServerUserRegistry.java index ee3eca77959c..b066c76857da 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/user/MultiServerUserRegistry.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/user/MultiServerUserRegistry.java @@ -209,7 +209,7 @@ public UserRegistrySnapshot() { /** * Constructor to create DTO from a local user registry. */ - public UserRegistrySnapshot(String id, SimpUserRegistry registry) { + UserRegistrySnapshot(String id, SimpUserRegistry registry) { this.id = id; Set users = registry.getUsers(); this.users = CollectionUtils.newHashMap(users.size()); diff --git a/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventHttpMessageWriterTests.java b/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventHttpMessageWriterTests.java index 87f4a9e9c3fb..59aa10ce09b3 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventHttpMessageWriterTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventHttpMessageWriterTests.java @@ -151,10 +151,10 @@ void writePojo(DataBufferFactory bufferFactory) { StepVerifier.create(outputMessage.getBody()) .consumeNextWith(stringConsumer("data:")) - .consumeNextWith(stringConsumer("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}")) + .consumeNextWith(stringConsumer("{\"foo\":\"foofoo\",\"bar\":\"barbar\"}")) .consumeNextWith(stringConsumer("\n\n")) .consumeNextWith(stringConsumer("data:")) - .consumeNextWith(stringConsumer("{\"bar\":\"barbarbar\",\"foo\":\"foofoofoo\"}")) + .consumeNextWith(stringConsumer("{\"foo\":\"foofoofoo\",\"bar\":\"barbarbar\"}")) .consumeNextWith(stringConsumer("\n\n")) .expectComplete() .verify(); @@ -175,15 +175,15 @@ void writePojoWithPrettyPrint(DataBufferFactory bufferFactory) { .consumeNextWith(stringConsumer("data:")) .consumeNextWith(stringConsumer(""" { - data: "bar" : "barbar", - data: "foo" : "foofoo" + data: "foo" : "foofoo", + data: "bar" : "barbar" data:}""")) .consumeNextWith(stringConsumer("\n\n")) .consumeNextWith(stringConsumer("data:")) .consumeNextWith(stringConsumer(""" { - data: "bar" : "barbarbar", - data: "foo" : "foofoofoo" + data: "foo" : "foofoofoo", + data: "bar" : "barbarbar" data:}""")) .consumeNextWith(stringConsumer("\n\n")) .expectComplete() @@ -203,7 +203,7 @@ void writePojoWithCustomEncoding(DataBufferFactory bufferFactory) { assertThat(outputMessage.getHeaders().getContentType()).isEqualTo(mediaType); StepVerifier.create(outputMessage.getBody()) .consumeNextWith(stringConsumer("data:", charset)) - .consumeNextWith(stringConsumer("{\"bar\":\"bar\uD834\uDD1E\",\"foo\":\"foo\uD834\uDD1E\"}", charset)) + .consumeNextWith(stringConsumer("{\"foo\":\"foo\uD834\uDD1E\",\"bar\":\"bar\uD834\uDD1E\"}", charset)) .consumeNextWith(stringConsumer("\n\n", charset)) .expectComplete() .verify(); diff --git a/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonEncoderTests.java b/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonEncoderTests.java index 229fb9fed27b..3c2208c0aa14 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonEncoderTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/json/JacksonJsonEncoderTests.java @@ -98,9 +98,9 @@ public void encode() throws Exception { new Pojo("foofoofoo", "barbarbar")); testEncodeAll(input, ResolvableType.forClass(Pojo.class), APPLICATION_NDJSON, null, step -> step - .consumeNextWith(expectString("{\"bar\":\"bar\",\"foo\":\"foo\"}\n")) - .consumeNextWith(expectString("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}\n")) - .consumeNextWith(expectString("{\"bar\":\"barbarbar\",\"foo\":\"foofoofoo\"}\n")) + .consumeNextWith(expectString("{\"foo\":\"foo\",\"bar\":\"bar\"}\n")) + .consumeNextWith(expectString("{\"foo\":\"foofoo\",\"bar\":\"barbar\"}\n")) + .consumeNextWith(expectString("{\"foo\":\"foofoofoo\",\"bar\":\"barbarbar\"}\n")) .verifyComplete() ); } @@ -137,9 +137,9 @@ void encodeNonStream() { ); testEncode(input, Pojo.class, step -> step - .consumeNextWith(expectString("[{\"bar\":\"bar\",\"foo\":\"foo\"}")) - .consumeNextWith(expectString(",{\"bar\":\"barbar\",\"foo\":\"foofoo\"}")) - .consumeNextWith(expectString(",{\"bar\":\"barbarbar\",\"foo\":\"foofoofoo\"}")) + .consumeNextWith(expectString("[{\"foo\":\"foo\",\"bar\":\"bar\"}")) + .consumeNextWith(expectString(",{\"foo\":\"foofoo\",\"bar\":\"barbar\"}")) + .consumeNextWith(expectString(",{\"foo\":\"foofoofoo\",\"bar\":\"barbarbar\"}")) .consumeNextWith(expectString("]")) .verifyComplete()); } @@ -187,9 +187,9 @@ public void encodeStreamWithCustomStreamingType() { ); testEncode(input, ResolvableType.forClass(Pojo.class), barMediaType, null, step -> step - .consumeNextWith(expectString("{\"bar\":\"bar\",\"foo\":\"foo\"}\n")) - .consumeNextWith(expectString("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}\n")) - .consumeNextWith(expectString("{\"bar\":\"barbarbar\",\"foo\":\"foofoofoo\"}\n")) + .consumeNextWith(expectString("{\"foo\":\"foo\",\"bar\":\"bar\"}\n")) + .consumeNextWith(expectString("{\"foo\":\"foofoo\",\"bar\":\"barbar\"}\n")) + .consumeNextWith(expectString("{\"foo\":\"foofoofoo\",\"bar\":\"barbarbar\"}\n")) .verifyComplete() ); } @@ -237,7 +237,7 @@ public void encodeWithFlushAfterWriteOff() { ResolvableType.forClass(Pojo.class), MimeTypeUtils.APPLICATION_JSON, Collections.emptyMap()); StepVerifier.create(result) - .consumeNextWith(expectString("[{\"bar\":\"bar\",\"foo\":\"foo\"}")) + .consumeNextWith(expectString("[{\"foo\":\"foo\",\"bar\":\"bar\"}")) .consumeNextWith(expectString("]")) .expectComplete() .verify(Duration.ofSeconds(5)); @@ -249,7 +249,7 @@ void encodeAscii() { MimeType mimeType = new MimeType("application", "json", StandardCharsets.US_ASCII); testEncode(input, ResolvableType.forClass(Pojo.class), mimeType, null, step -> step - .consumeNextWith(expectString("{\"bar\":\"bar\",\"foo\":\"foo\"}")) + .consumeNextWith(expectString("{\"foo\":\"foo\",\"bar\":\"bar\"}")) .verifyComplete() ); } diff --git a/spring-web/src/test/java/org/springframework/web/client/RestClientIntegrationTests.java b/spring-web/src/test/java/org/springframework/web/client/RestClientIntegrationTests.java index 77c2bfd87659..8ac257ccbac3 100644 --- a/spring-web/src/test/java/org/springframework/web/client/RestClientIntegrationTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/RestClientIntegrationTests.java @@ -528,7 +528,7 @@ void postPojoAsJson(ClientHttpRequestFactory requestFactory) throws IOException expectRequestCount(1); expectRequest(request -> { assertThat(request.getTarget()).isEqualTo("/pojo/capitalize"); - assertThat(request.getBody().utf8()).isEqualTo("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}"); + assertThat(request.getBody().utf8()).isEqualTo("{\"foo\":\"foofoo\",\"bar\":\"barbar\"}"); assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); assertThat(request.getHeaders().get(HttpHeaders.CONTENT_TYPE)).isEqualTo("application/json"); }); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/BodyInsertersTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/BodyInsertersTests.java index d91049ad5bfb..c0a87e81dfae 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/BodyInsertersTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/BodyInsertersTests.java @@ -140,7 +140,7 @@ void ofObject() { StepVerifier.create(result).expectComplete().verify(); StepVerifier.create(response.getBodyAsString()) - .expectNext("{\"password\":\"bar\",\"username\":\"foo\"}") + .expectNext("{\"username\":\"foo\",\"password\":\"bar\"}") .expectComplete() .verify(); } @@ -169,7 +169,7 @@ void ofProducerWithMono() { Mono result = inserter.insert(response, this.context); StepVerifier.create(result).expectComplete().verify(); StepVerifier.create(response.getBodyAsString()) - .expectNext("{\"password\":\"bar\",\"username\":\"foo\"}") + .expectNext("{\"username\":\"foo\",\"password\":\"bar\"}") .expectComplete() .verify(); } @@ -200,7 +200,7 @@ void ofProducerWithSingle() { Mono result = inserter.insert(response, this.context); StepVerifier.create(result).expectComplete().verify(); StepVerifier.create(response.getBodyAsString()) - .expectNext("{\"password\":\"bar\",\"username\":\"foo\"}") + .expectNext("{\"username\":\"foo\",\"password\":\"bar\"}") .expectComplete() .verify(); } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientIntegrationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientIntegrationTests.java index 6a1c36197c91..e870c28a6eb0 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientIntegrationTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientIntegrationTests.java @@ -779,7 +779,7 @@ void postPojoAsJson(ClientHttpConnector connector) throws IOException { expectRequestCount(1); expectRequest(request -> { assertThat(request.getTarget()).isEqualTo("/pojo/capitalize"); - assertThat(request.getBody().utf8()).isEqualTo("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}"); + assertThat(request.getBody().utf8()).isEqualTo("{\"foo\":\"foofoo\",\"bar\":\"barbar\"}"); assertThat(request.getHeaders().get(HttpHeaders.CONTENT_LENGTH)).isEqualTo("31"); assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); assertThat(request.getHeaders().get(HttpHeaders.CONTENT_TYPE)).isEqualTo("application/json"); From e9e19f5ed739eb478ca851804f56db9190559172 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Mon, 22 Sep 2025 11:31:32 +0100 Subject: [PATCH 240/591] Update references to HTTP service clients in docs Closes gh-35522 --- framework-docs/modules/ROOT/nav.adoc | 2 +- .../ROOT/pages/integration/rest-clients.adoc | 22 +++++++++--------- ....adoc => webflux-http-service-client.adoc} | 6 ++--- .../ROOT/pages/web/webflux-versioning.adoc | 2 +- .../controller/ann-requestmapping.adoc | 23 +++++++++---------- .../modules/ROOT/pages/web/webmvc-client.adoc | 6 ++--- .../ROOT/pages/web/webmvc-versioning.adoc | 2 +- .../mvc-controller/ann-requestmapping.adoc | 9 ++++---- .../CustomHttpServiceArgumentResolver.java | 6 ++--- .../CustomHttpServiceArgumentResolver.kt | 6 ++--- .../service/invoker/HttpRequestValues.java | 2 +- .../registry/HttpServiceProxyRegistry.java | 4 ++-- 12 files changed, 44 insertions(+), 46 deletions(-) rename framework-docs/modules/ROOT/pages/web/{webflux-http-interface-client.adoc => webflux-http-service-client.adoc} (68%) rename framework-docs/src/main/java/org/springframework/docs/integration/{resthttpinterface => resthttpserviceclient}/customresolver/CustomHttpServiceArgumentResolver.java (95%) rename framework-docs/src/main/kotlin/org/springframework/docs/integration/{resthttpinterface => resthttpserviceclient}/customresolver/CustomHttpServiceArgumentResolver.kt (94%) diff --git a/framework-docs/modules/ROOT/nav.adoc b/framework-docs/modules/ROOT/nav.adoc index 06d105e209be..276c84ee6638 100644 --- a/framework-docs/modules/ROOT/nav.adoc +++ b/framework-docs/modules/ROOT/nav.adoc @@ -314,7 +314,7 @@ *** xref:web/webflux-webclient/client-context.adoc[] *** xref:web/webflux-webclient/client-synchronous.adoc[] *** xref:web/webflux-webclient/client-testing.adoc[] -** xref:web/webflux-http-interface-client.adoc[] +** xref:web/webflux-http-service-client.adoc[] ** xref:web/webflux-websocket.adoc[] ** xref:web/webflux-test.adoc[] ** xref:rsocket.adoc[] diff --git a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc index 0417613f496e..1cf4363b0e5b 100644 --- a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc +++ b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc @@ -6,7 +6,7 @@ The Spring Framework provides the following choices for making calls to REST end * xref:integration/rest-clients.adoc#rest-restclient[`RestClient`] -- synchronous client with a fluent API * xref:integration/rest-clients.adoc#rest-webclient[`WebClient`] -- non-blocking, reactive client with fluent API * xref:integration/rest-clients.adoc#rest-resttemplate[`RestTemplate`] -- synchronous client with template method API -* xref:integration/rest-clients.adoc#rest-http-interface[HTTP Interface Clients] -- annotated interface backed by generated proxy +* xref:integration/rest-clients.adoc#rest-http-service-client[HTTP Service Clients] -- annotated interface backed by generated proxy [[rest-restclient]] @@ -855,8 +855,8 @@ It can be used to migrate from the latter to the former. |=== -[[rest-http-interface]] -== HTTP Interface Clients +[[rest-http-service-client]] +== HTTP Service Clients You can define an HTTP Service as a Java interface with `@HttpExchange` methods, and use `HttpServiceProxyFactory` to create a client proxy from it for remote access over HTTP via @@ -928,7 +928,7 @@ Now, you're ready to create client proxies: // Use service methods for remote calls... ---- -[[rest-http-interface-method-parameters]] +[[rest-http-service-client-method-parameters]] === Method Parameters `@HttpExchange` methods support flexible method signatures with the following inputs: @@ -1000,13 +1000,13 @@ parameter annotation) is set to `false`, or the parameter is marked optional as `StreamingHttpOutputMessage.Body` that allows sending the request body by writing to an `OutputStream`. -[[rest-http-interface.custom-resolver]] +[[rest-http-service-client.custom-resolver]] === Custom Arguments You can configure a custom `HttpServiceArgumentResolver`. The example interface below uses a custom `Search` method parameter type: -include-code::./CustomHttpServiceArgumentResolver[tag=httpinterface,indent=0] +include-code::./CustomHttpServiceArgumentResolver[tag=httpserviceclient,indent=0] A custom argument resolver could be implemented like this: @@ -1021,7 +1021,7 @@ the use of more fine-grained method parameters for individual parts of the reque -[[rest-http-interface-return-values]] +[[rest-http-service-client-return-values]] === Return Values The supported return values depend on the underlying client. @@ -1097,7 +1097,7 @@ underlying HTTP client, which operates at a lower level and provides more contro `InputStream` or `ResponseEntity` that provides access to the raw response body content. -[[rest-http-interface-exceptions]] +[[rest-http-service-client-exceptions]] === Error Handling To customize error handling for HTTP Service client proxies, you can configure the @@ -1134,7 +1134,7 @@ documentation for each client, as well as the Javadoc of `defaultStatusHandler` -[[rest-http-interface-adapter-decorator]] +[[rest-http-service-client-adapter-decorator]] === Decorating the Adapter `HttpExchangeAdapter` and `ReactorHttpExchangeAdapter` are contracts that decouple HTTP @@ -1162,8 +1162,8 @@ built-in decorators to suppress 404 exceptions and return a `ResponseEntity` wit -[[rest-http-interface-group-config]] -=== HTTP Interface Groups +[[rest-http-service-client-group-config]] +=== HTTP Service Groups It's trivial to create client proxies with `HttpServiceProxyFactory`, but to have them declared as beans leads to repetitive configuration. You may also have multiple diff --git a/framework-docs/modules/ROOT/pages/web/webflux-http-interface-client.adoc b/framework-docs/modules/ROOT/pages/web/webflux-http-service-client.adoc similarity index 68% rename from framework-docs/modules/ROOT/pages/web/webflux-http-interface-client.adoc rename to framework-docs/modules/ROOT/pages/web/webflux-http-service-client.adoc index 890478d79bb0..55280b4a3440 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux-http-interface-client.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux-http-service-client.adoc @@ -1,9 +1,9 @@ -[[webflux-http-interface-client]] -= HTTP Interface Client +[[webflux-http-service-client]] += HTTP Service Client The Spring Frameworks lets you define an HTTP service as a Java interface with HTTP exchange methods. You can then generate a proxy that implements this interface and performs the exchanges. This helps to simplify HTTP remote access and provides additional flexibility for to choose an API style such as synchronous or reactive. -See xref:integration/rest-clients.adoc#rest-http-interface[REST Endpoints] for details. +See xref:integration/rest-clients.adoc#rest-http-service-client[HTTP Service Clients] for details. diff --git a/framework-docs/modules/ROOT/pages/web/webflux-versioning.adoc b/framework-docs/modules/ROOT/pages/web/webflux-versioning.adoc index 2065a962f1e7..070bc7798c74 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux-versioning.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux-versioning.adoc @@ -17,7 +17,7 @@ to annotated controller methods with an API version to functional endpoints with an API version Client support for API versioning is available also in `RestClient`, `WebClient`, and -xref:integration/rest-clients.adoc#rest-http-interface[HTTP Service] clients, as well as +xref:integration/rest-clients.adoc#rest-http-service-client[HTTP Service] clients, as well as for testing in `WebTestClient`. diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc index 2ddc29f80a8d..c385221e306a 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc @@ -629,17 +629,16 @@ Kotlin:: == `@HttpExchange` [.small]#xref:web/webmvc/mvc-controller/ann-requestmapping.adoc#mvc-ann-httpexchange-annotation[See equivalent in the Servlet stack]# -While the main purpose of `@HttpExchange` is to abstract HTTP client code with a -generated proxy, the -xref:integration/rest-clients.adoc#rest-http-interface[HTTP Interface] on which -such annotations are placed is a contract neutral to client vs server use. -In addition to simplifying client code, there are also cases where an HTTP Interface -may be a convenient way for servers to expose their API for client access. This leads -to increased coupling between client and server and is often not a good choice, -especially for public API's, but may be exactly the goal for an internal API. -It is an approach commonly used in Spring Cloud, and it is why `@HttpExchange` is -supported as an alternative to `@RequestMapping` for server side handling in -controller classes. +While the main purpose of `@HttpExchange` is for an HTTP Service +xref:integration/rest-clients.adoc#rest-http-service-client[client with a generated proxy], +the HTTP Service interface on which such annotations are placed is a contract neutral +to client vs server use. In addition to simplifying client code, there are also cases +where an HTTP Service interface may be a convenient way for servers to expose their +API for client access. This leads to increased coupling between client and server and +is often not a good choice, especially for public API's, but may be exactly the goal +for an internal API. It is an approach commonly used in Spring Cloud, and it is why +`@HttpExchange` is supported as an alternative to `@RequestMapping` for server side +handling in controller classes. For example: @@ -710,5 +709,5 @@ path, and content types. For method parameters and returns values, generally, `@HttpExchange` supports a subset of the method parameters that `@RequestMapping` does. Notably, it excludes any server-side specific parameter types. For details, see the list for -xref:integration/rest-clients.adoc#rest-http-interface-method-parameters[@HttpExchange] and +xref:integration/rest-clients.adoc#rest-http-service-client-method-parameters[@HttpExchange] and xref:web/webflux/controller/ann-methods/arguments.adoc[@RequestMapping]. diff --git a/framework-docs/modules/ROOT/pages/web/webmvc-client.adoc b/framework-docs/modules/ROOT/pages/web/webmvc-client.adoc index e22a36120212..03b8950d7e40 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc-client.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc-client.adoc @@ -30,12 +30,12 @@ libraries. See xref:integration/rest-clients.adoc#rest-resttemplate[`RestTemplate`] for details. -[[webmvc-http-interface]] -== HTTP Interface +[[webmvc-http-service-client]] +== HTTP Service Client The Spring Framework lets you define an HTTP service as a Java interface with HTTP exchange methods. You can then generate a proxy that implements this interface and performs the exchanges. This helps to simplify HTTP remote access and provides additional flexibility for choosing an API style such as synchronous or reactive. -See xref:integration/rest-clients.adoc#rest-http-interface[HTTP Interface] for details. +See xref:integration/rest-clients.adoc#rest-http-service-client[HTTP Service Client] for details. diff --git a/framework-docs/modules/ROOT/pages/web/webmvc-versioning.adoc b/framework-docs/modules/ROOT/pages/web/webmvc-versioning.adoc index 4cb7dd94797b..6d548ad356b9 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc-versioning.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc-versioning.adoc @@ -16,7 +16,7 @@ to annotated controller methods with an API version to functional endpoints with an API version Client support for API versioning is available also in `RestClient`, `WebClient`, and -xref:integration/rest-clients.adoc#rest-http-interface[HTTP Service] clients, as well as +xref:integration/rest-clients.adoc#rest-http-service-client[HTTP Service] clients, as well as for testing in MockMvc and `WebTestClient`. diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc index 1590753e000b..77c64535a271 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc @@ -659,10 +659,9 @@ Kotlin:: [.small]#xref:web/webflux/controller/ann-requestmapping.adoc#webflux-ann-httpexchange-annotation[See equivalent in the Reactive stack]# While the main purpose of `@HttpExchange` is to abstract HTTP client code with a -generated proxy, the -xref:integration/rest-clients.adoc#rest-http-interface[HTTP Interface] on which -such annotations are placed is a contract neutral to client vs server use. -In addition to simplifying client code, there are also cases where an HTTP Interface +generated proxy, the interface on which such annotations are placed is a contract neutral +to client vs server use. In addition to simplifying client code, there are also cases +where an xref:integration/rest-clients.adoc#rest-http-service-client[HTTP Service Client] may be a convenient way for servers to expose their API for client access. This leads to increased coupling between client and server and is often not a good choice, especially for public API's, but may be exactly the goal for an internal API. @@ -739,7 +738,7 @@ path, and content types. For method parameters and returns values, generally, `@HttpExchange` supports a subset of the method parameters that `@RequestMapping` does. Notably, it excludes any server-side specific parameter types. For details, see the list for -xref:integration/rest-clients.adoc#rest-http-interface-method-parameters[@HttpExchange] and +xref:integration/rest-clients.adoc#rest-http-service-client-method-parameters[@HttpExchange] and xref:web/webmvc/mvc-controller/ann-methods/arguments.adoc[@RequestMapping]. `@HttpExchange` also supports a `headers()` parameter which accepts `"name=value"`-like diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/resthttpinterface/customresolver/CustomHttpServiceArgumentResolver.java b/framework-docs/src/main/java/org/springframework/docs/integration/resthttpserviceclient/customresolver/CustomHttpServiceArgumentResolver.java similarity index 95% rename from framework-docs/src/main/java/org/springframework/docs/integration/resthttpinterface/customresolver/CustomHttpServiceArgumentResolver.java rename to framework-docs/src/main/java/org/springframework/docs/integration/resthttpserviceclient/customresolver/CustomHttpServiceArgumentResolver.java index 3702dc51048b..6982f682d0c4 100644 --- a/framework-docs/src/main/java/org/springframework/docs/integration/resthttpinterface/customresolver/CustomHttpServiceArgumentResolver.java +++ b/framework-docs/src/main/java/org/springframework/docs/integration/resthttpserviceclient/customresolver/CustomHttpServiceArgumentResolver.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.docs.integration.resthttpinterface.customresolver; +package org.springframework.docs.integration.resthttpserviceclient.customresolver; import java.util.List; @@ -28,14 +28,14 @@ public class CustomHttpServiceArgumentResolver { - // tag::httpinterface[] + // tag::httpserviceclient[] public interface RepositoryService { @GetExchange("/repos/search") List searchRepository(Search search); } - // end::httpinterface[] + // end::httpserviceclient[] class Sample { diff --git a/framework-docs/src/main/kotlin/org/springframework/docs/integration/resthttpinterface/customresolver/CustomHttpServiceArgumentResolver.kt b/framework-docs/src/main/kotlin/org/springframework/docs/integration/resthttpserviceclient/customresolver/CustomHttpServiceArgumentResolver.kt similarity index 94% rename from framework-docs/src/main/kotlin/org/springframework/docs/integration/resthttpinterface/customresolver/CustomHttpServiceArgumentResolver.kt rename to framework-docs/src/main/kotlin/org/springframework/docs/integration/resthttpserviceclient/customresolver/CustomHttpServiceArgumentResolver.kt index b1412985d7e7..8f608e2d182c 100644 --- a/framework-docs/src/main/kotlin/org/springframework/docs/integration/resthttpinterface/customresolver/CustomHttpServiceArgumentResolver.kt +++ b/framework-docs/src/main/kotlin/org/springframework/docs/integration/resthttpserviceclient/customresolver/CustomHttpServiceArgumentResolver.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.docs.integration.resthttpinterface.customresolver +package org.springframework.docs.integration.resthttpserviceclient.customresolver import org.springframework.core.MethodParameter import org.springframework.web.client.RestClient @@ -26,14 +26,14 @@ import org.springframework.web.service.invoker.HttpServiceProxyFactory class CustomHttpServiceArgumentResolver { - // tag::httpinterface[] + // tag::httpserviceclient[] interface RepositoryService { @GetExchange("/repos/search") fun searchRepository(search: Search): List } - // end::httpinterface[] + // end::httpserviceclient[] class Sample { fun sample() { diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java index d5b20179a242..3e4dcb5a0a5f 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpRequestValues.java @@ -127,7 +127,7 @@ protected HttpRequestValues(@Nullable HttpMethod httpMethod, /** * Return the {@link UriBuilderFactory} to expand * the {@link HttpRequestValues#uriTemplate} and {@link #getUriVariables()} with. - *

      The {@link UriBuilderFactory} is passed into the HTTP interface method + *

      The {@link UriBuilderFactory} is passed into the HTTP Service client method * in order to override the UriBuilderFactory (and its baseUrl) used by the * underlying client. * @since 6.1 diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceProxyRegistry.java b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceProxyRegistry.java index 774628663917..f6266cf1aa86 100644 --- a/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceProxyRegistry.java +++ b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceProxyRegistry.java @@ -33,7 +33,7 @@ public interface HttpServiceProxyRegistry { * Return an HTTP service client from any group as long as there is only one * client of this type across all groups. * @param httpServiceType the type of client - * @param

      the type of HTTP interface client + * @param

      the type of HTTP service client * @return the matched client * @throws IllegalArgumentException if there is no client of the given type, * or there is more than one client of the given type. @@ -44,7 +44,7 @@ public interface HttpServiceProxyRegistry { * Return an HTTP service client from the named group. * @param groupName the name of the group * @param httpServiceType the type of client - * @param

      the type of HTTP interface client + * @param

      the type of HTTP service client * @return the matched client * @throws IllegalArgumentException if there is no matching client. */ From f504d051abc498446b2e543c76875ef574ad7fd3 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Mon, 22 Sep 2025 11:35:27 +0100 Subject: [PATCH 241/591] Polishing in HTTP interface clients docs --- .../modules/ROOT/pages/integration/rest-clients.adoc | 7 +++++++ .../ROOT/pages/web/webflux-http-service-client.adoc | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc index 1cf4363b0e5b..73d2fcf728af 100644 --- a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc +++ b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc @@ -928,6 +928,13 @@ Now, you're ready to create client proxies: // Use service methods for remote calls... ---- +HTTP service clients is a powerful and expressive choice for remote access over HTTP. +It allows one team to own the knowledge of how a REST API works, what parts are relevant +to a client application, what input and output types to create, what endpoint method +signatures are needed, what Javadoc to have, and so on. The resulting Java API guides and +is ready to use. + + [[rest-http-service-client-method-parameters]] === Method Parameters diff --git a/framework-docs/modules/ROOT/pages/web/webflux-http-service-client.adoc b/framework-docs/modules/ROOT/pages/web/webflux-http-service-client.adoc index 55280b4a3440..f083a87545ab 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux-http-service-client.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux-http-service-client.adoc @@ -4,6 +4,6 @@ The Spring Frameworks lets you define an HTTP service as a Java interface with HTTP exchange methods. You can then generate a proxy that implements this interface and performs the exchanges. This helps to simplify HTTP remote access and provides additional -flexibility for to choose an API style such as synchronous or reactive. +flexibility in choosing an API style such as synchronous or reactive. See xref:integration/rest-clients.adoc#rest-http-service-client[HTTP Service Clients] for details. From 0c61ac956bb8053403d298c0c3d11bc5df177a5e Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Mon, 22 Sep 2025 13:20:37 +0200 Subject: [PATCH 242/591] =?UTF-8?q?Add=20missing=20@=E2=81=A0Override=20an?= =?UTF-8?q?notations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ... and suppress "serial" warnings --- .../org/springframework/expression/ExpressionException.java | 3 ++- .../java/org/springframework/jms/core/DefaultJmsClient.java | 2 ++ .../messaging/core/MessageSendingTemplateTests.java | 1 + .../orm/jpa/hibernate/LocalSessionFactoryBuilder.java | 1 + .../org/springframework/mock/web/MockHttpServletResponse.java | 2 +- .../test/web/servlet/client/DefaultRestTestClientBuilder.java | 1 + .../test/web/servlet/client/samples/bind/FilterTests.java | 1 + .../interceptor/AbstractTransactionAspectTests.java | 1 + .../http/client/reactive/JdkResponseCookieParser.java | 2 +- .../springframework/web/accept/DefaultApiVersionStrategy.java | 1 + .../web/client/DefaultApiVersionInserterBuilder.java | 1 + .../context/request/async/StandardServletAsyncWebRequest.java | 1 + .../support/RestClientProxyRegistryIntegrationTests.java | 1 + .../web/context/request/async/WebAsyncManagerTests.java | 1 + .../springframework/web/filter/OncePerRequestFilterTests.java | 1 + .../web/testfixture/servlet/MockHttpServletResponse.java | 2 +- .../web/reactive/accept/DefaultApiVersionStrategy.java | 1 + .../web/servlet/function/DefaultServerRequest.java | 2 +- .../web/servlet/handler/HandlerMappingIntrospector.java | 1 + .../springframework/web/servlet/DispatcherServletTests.java | 1 + .../annotation/WebMvcConfigurationSupportExtensionTests.java | 1 + .../web/servlet/mvc/annotation/CglibProxyControllerTests.java | 1 + .../web/servlet/mvc/annotation/JdkProxyControllerTests.java | 1 + .../method/annotation/AbstractServletHandlerMethodTests.java | 1 + .../org/springframework/web/servlet/tags/BindTagTests.java | 1 + .../springframework/web/servlet/tags/HtmlEscapeTagTests.java | 1 + .../org/springframework/web/servlet/tags/MessageTagTests.java | 1 + .../springframework/web/servlet/tags/form/ButtonTagTests.java | 1 + .../web/servlet/tags/form/CheckboxTagTests.java | 1 + .../web/servlet/tags/form/CheckboxesTagTests.java | 1 + .../springframework/web/servlet/tags/form/ErrorsTagTests.java | 1 + .../springframework/web/servlet/tags/form/FormTagTests.java | 1 + .../web/servlet/tags/form/HiddenInputTagTests.java | 1 + .../springframework/web/servlet/tags/form/InputTagTests.java | 1 + .../springframework/web/servlet/tags/form/LabelTagTests.java | 1 + .../web/servlet/tags/form/OptionTagEnumTests.java | 1 + .../springframework/web/servlet/tags/form/OptionTagTests.java | 1 + .../springframework/web/servlet/tags/form/OptionsTagTests.java | 1 + .../web/servlet/tags/form/RadioButtonTagTests.java | 1 + .../web/servlet/tags/form/RadioButtonsTagTests.java | 1 + .../springframework/web/servlet/tags/form/SelectTagTests.java | 1 + .../web/servlet/tags/form/TextareaTagTests.java | 1 + 42 files changed, 44 insertions(+), 5 deletions(-) diff --git a/spring-expression/src/main/java/org/springframework/expression/ExpressionException.java b/spring-expression/src/main/java/org/springframework/expression/ExpressionException.java index 2d63f21eb1a8..f4cb9173abe7 100644 --- a/spring-expression/src/main/java/org/springframework/expression/ExpressionException.java +++ b/spring-expression/src/main/java/org/springframework/expression/ExpressionException.java @@ -117,10 +117,11 @@ public final int getPosition() { /** * Return the exception message. - * As of Spring 4.0, this method returns the same result as {@link #toDetailedString()}. + *

      This method returns the same result as {@link #toDetailedString()}. * @see #getSimpleMessage() * @see java.lang.Throwable#getMessage() */ + @Override public String getMessage() { return toDetailedString(); } diff --git a/spring-jms/src/main/java/org/springframework/jms/core/DefaultJmsClient.java b/spring-jms/src/main/java/org/springframework/jms/core/DefaultJmsClient.java index ac8d13af8016..06677d492e43 100644 --- a/spring-jms/src/main/java/org/springframework/jms/core/DefaultJmsClient.java +++ b/spring-jms/src/main/java/org/springframework/jms/core/DefaultJmsClient.java @@ -70,10 +70,12 @@ void setMessagePostProcessor(MessagePostProcessor messagePostProcessor) { } + @Override public OperationSpec destination(Destination destination) { return new DefaultOperationSpec(destination); } + @Override public OperationSpec destination(String destinationName) { return new DefaultOperationSpec(destinationName); } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/core/MessageSendingTemplateTests.java b/spring-messaging/src/test/java/org/springframework/messaging/core/MessageSendingTemplateTests.java index d544ee1b8243..89542bf44686 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/core/MessageSendingTemplateTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/core/MessageSendingTemplateTests.java @@ -50,6 +50,7 @@ class MessageSendingTemplateTests { private final TestMessagePostProcessor postProcessor = new TestMessagePostProcessor(); + @SuppressWarnings("serial") private final Map headers = new HashMap<>() {{ put("key", "value"); }}; diff --git a/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/LocalSessionFactoryBuilder.java b/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/LocalSessionFactoryBuilder.java index 338f235752d9..928c1d520644 100644 --- a/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/LocalSessionFactoryBuilder.java +++ b/spring-orm/src/main/java/org/springframework/orm/jpa/hibernate/LocalSessionFactoryBuilder.java @@ -284,6 +284,7 @@ public LocalSessionFactoryBuilder setEntityTypeFilters(TypeFilter... entityTypeF * @see #addPackage * @see #scanPackages */ + @Override public LocalSessionFactoryBuilder addPackages(String... annotatedPackages) { for (String annotatedPackage : annotatedPackages) { addPackage(annotatedPackage); diff --git a/spring-test/src/main/java/org/springframework/mock/web/MockHttpServletResponse.java b/spring-test/src/main/java/org/springframework/mock/web/MockHttpServletResponse.java index c2aa11fbb899..75db6c99eae7 100644 --- a/spring-test/src/main/java/org/springframework/mock/web/MockHttpServletResponse.java +++ b/spring-test/src/main/java/org/springframework/mock/web/MockHttpServletResponse.java @@ -642,7 +642,7 @@ public void sendRedirect(String url) throws IOException { sendRedirect(url, HttpServletResponse.SC_MOVED_TEMPORARILY, true); } - // @Override - on Servlet 6.1 + @Override public void sendRedirect(String url, int sc, boolean clearBuffer) throws IOException { Assert.state(!isCommitted(), "Cannot send redirect - response is already committed"); Assert.notNull(url, "Redirect URL must not be null"); diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java index 484ebbf698a2..86daf7812313 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClientBuilder.java @@ -173,6 +173,7 @@ public AbstractMockMvcSetupBuilder(M mockMvcBuilder) { this.mockMvcBuilder = mockMvcBuilder; } + @Override public T configureServer(Consumer consumer) { consumer.accept(this.mockMvcBuilder); return self(); diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/FilterTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/FilterTests.java index c59fdd5f8c13..59f02d7fcc55 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/FilterTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/samples/bind/FilterTests.java @@ -41,6 +41,7 @@ class FilterTests { @Test void filter() { + @SuppressWarnings("serial") Filter filter = new HttpFilter() { @Override protected void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException { diff --git a/spring-tx/src/test/java/org/springframework/transaction/interceptor/AbstractTransactionAspectTests.java b/spring-tx/src/test/java/org/springframework/transaction/interceptor/AbstractTransactionAspectTests.java index 070f2eef9f58..e5fdb0aaaa7f 100644 --- a/spring-tx/src/test/java/org/springframework/transaction/interceptor/AbstractTransactionAspectTests.java +++ b/spring-tx/src/test/java/org/springframework/transaction/interceptor/AbstractTransactionAspectTests.java @@ -370,6 +370,7 @@ void noRollbackOnUncheckedExceptionWithRollbackException() throws Throwable { protected void doTestRollbackOnException( final Exception ex, final boolean shouldRollback, boolean rollbackException) throws Exception { + @SuppressWarnings("serial") TransactionAttribute txatt = new DefaultTransactionAttribute() { @Override public boolean rollbackOn(Throwable t) { diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/JdkResponseCookieParser.java b/spring-web/src/main/java/org/springframework/http/client/reactive/JdkResponseCookieParser.java index d162962a54e0..d5d1ee4114bb 100644 --- a/spring-web/src/main/java/org/springframework/http/client/reactive/JdkResponseCookieParser.java +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/JdkResponseCookieParser.java @@ -23,7 +23,6 @@ import org.springframework.http.ResponseCookie; - /** * Parser that delegates to {@link java.net.HttpCookie#parse(String)} for parsing, * but also extracts and sets {@code sameSite}. @@ -39,6 +38,7 @@ final class JdkResponseCookieParser implements ResponseCookie.Parser { /** * Parse the given headers. */ + @Override public List parse(String header) { Matcher matcher = SAME_SITE_PATTERN.matcher(header); String sameSite = (matcher.matches() ? matcher.group(1) : null); diff --git a/spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java b/spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java index bb65d4c0a317..070433f777f7 100644 --- a/spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java +++ b/spring-web/src/main/java/org/springframework/web/accept/DefaultApiVersionStrategy.java @@ -163,6 +163,7 @@ public Comparable parseVersion(String version) { return this.versionParser.parseVersion(version); } + @Override public void validateVersion(@Nullable Comparable requestVersion, HttpServletRequest request) throws MissingApiVersionException, InvalidApiVersionException { diff --git a/spring-web/src/main/java/org/springframework/web/client/DefaultApiVersionInserterBuilder.java b/spring-web/src/main/java/org/springframework/web/client/DefaultApiVersionInserterBuilder.java index 5b8e60176203..b0e306d9cb6f 100644 --- a/spring-web/src/main/java/org/springframework/web/client/DefaultApiVersionInserterBuilder.java +++ b/spring-web/src/main/java/org/springframework/web/client/DefaultApiVersionInserterBuilder.java @@ -84,6 +84,7 @@ public ApiVersionInserter.Builder withVersionFormatter(ApiVersionFormatter versi return this; } + @Override public ApiVersionInserter build() { return new DefaultApiVersionInserter( this.header, this.queryParam, this.mediaTypeParam, this.pathSegmentIndex, diff --git a/spring-web/src/main/java/org/springframework/web/context/request/async/StandardServletAsyncWebRequest.java b/spring-web/src/main/java/org/springframework/web/context/request/async/StandardServletAsyncWebRequest.java index c28d1c2413a6..5197e9971f85 100644 --- a/spring-web/src/main/java/org/springframework/web/context/request/async/StandardServletAsyncWebRequest.java +++ b/spring-web/src/main/java/org/springframework/web/context/request/async/StandardServletAsyncWebRequest.java @@ -389,6 +389,7 @@ public void write(int b) throws IOException { } } + @Override public void write(byte[] buf, int offset, int len) throws IOException { int level = this.response.obtainLockOrRaiseException(); try { diff --git a/spring-web/src/test/java/org/springframework/web/client/support/RestClientProxyRegistryIntegrationTests.java b/spring-web/src/test/java/org/springframework/web/client/support/RestClientProxyRegistryIntegrationTests.java index 566196991e7a..1d415f724c12 100644 --- a/spring-web/src/test/java/org/springframework/web/client/support/RestClientProxyRegistryIntegrationTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/support/RestClientProxyRegistryIntegrationTests.java @@ -117,6 +117,7 @@ void basic(Class configClass) throws InterruptedException { void beansAreCreatedUsingBeanClassLoader() { ClassLoader beanClassLoader = new OverridingClassLoader(getClass().getClassLoader()) { + @Override protected boolean isEligibleForOverriding(String className) { return className.contains("EchoA"); }; diff --git a/spring-web/src/test/java/org/springframework/web/context/request/async/WebAsyncManagerTests.java b/spring-web/src/test/java/org/springframework/web/context/request/async/WebAsyncManagerTests.java index ef3270bda15d..d38c3e73403c 100644 --- a/spring-web/src/test/java/org/springframework/web/context/request/async/WebAsyncManagerTests.java +++ b/spring-web/src/test/java/org/springframework/web/context/request/async/WebAsyncManagerTests.java @@ -137,6 +137,7 @@ void startCallableProcessingCallableException() throws Exception { } @Test // gh-30232 + @SuppressWarnings("serial") void startCallableProcessingSubmitException() throws Exception { RuntimeException ex = new RuntimeException(); diff --git a/spring-web/src/test/java/org/springframework/web/filter/OncePerRequestFilterTests.java b/spring-web/src/test/java/org/springframework/web/filter/OncePerRequestFilterTests.java index 9eb2c80968e6..ab2a206c5278 100644 --- a/spring-web/src/test/java/org/springframework/web/filter/OncePerRequestFilterTests.java +++ b/spring-web/src/test/java/org/springframework/web/filter/OncePerRequestFilterTests.java @@ -50,6 +50,7 @@ class OncePerRequestFilterTests { @BeforeEach + @SuppressWarnings("serial") public void setup() throws Exception { this.request = new MockHttpServletRequest(); this.request.setScheme("http"); diff --git a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockHttpServletResponse.java b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockHttpServletResponse.java index 5e22d218f6c6..f36bea5ef855 100644 --- a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockHttpServletResponse.java +++ b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockHttpServletResponse.java @@ -642,7 +642,7 @@ public void sendRedirect(String url) throws IOException { sendRedirect(url, HttpServletResponse.SC_MOVED_TEMPORARILY, true); } - // @Override - on Servlet 6.1 + @Override public void sendRedirect(String url, int sc, boolean clearBuffer) throws IOException { Assert.state(!isCommitted(), "Cannot send redirect - response is already committed"); Assert.notNull(url, "Redirect URL must not be null"); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java index da7ce06b2ddf..709e75c80269 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/accept/DefaultApiVersionStrategy.java @@ -164,6 +164,7 @@ public Comparable parseVersion(String version) { return this.versionParser.parseVersion(version); } + @Override public void validateVersion(@Nullable Comparable requestVersion, ServerWebExchange exchange) throws MissingApiVersionException, InvalidApiVersionException { diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequest.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequest.java index ca4e100bab8a..2342b0ced90d 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequest.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/DefaultServerRequest.java @@ -693,7 +693,7 @@ public void sendRedirect(String location) throws IOException { throw new UnsupportedOperationException(); } - // @Override - on Servlet 6.1 + @Override public void sendRedirect(String location, int sc, boolean clearBuffer) throws IOException { throw new UnsupportedOperationException(); } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/HandlerMappingIntrospector.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/HandlerMappingIntrospector.java index 239864b8bf78..47d28d847e15 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/HandlerMappingIntrospector.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/HandlerMappingIntrospector.java @@ -222,6 +222,7 @@ public boolean allHandlerMappingsUsePathPatternParser() { * @throws NoHandlerFoundException if no handler matches the request * @since 6.2 */ + @Override public void handlePreFlight(HttpServletRequest request, HttpServletResponse response) throws Exception { Assert.state(this.handlerMappings != null, "Not yet initialized via afterPropertiesSet."); Assert.state(CorsUtils.isPreFlightRequest(request), "Not a pre-flight request."); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/DispatcherServletTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/DispatcherServletTests.java index baf454092c64..f7a9e83b9ad8 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/DispatcherServletTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/DispatcherServletTests.java @@ -791,6 +791,7 @@ void environmentOperations() { assertThat(servlet.getEnvironment()).isSameAs(env1); assertThatIllegalArgumentException().isThrownBy(() -> servlet.setEnvironment(mock(Environment.class))); class CustomServletEnvironment extends StandardServletEnvironment { } + @SuppressWarnings("serial") DispatcherServlet custom = new DispatcherServlet() { @Override protected ConfigurableWebEnvironment createEnvironment() { diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportExtensionTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportExtensionTests.java index 8ab56bdafde9..aaa55448b034 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportExtensionTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportExtensionTests.java @@ -439,6 +439,7 @@ public void addInterceptors(InterceptorRegistry registry) { } @Override + @SuppressWarnings("serial") public MessageCodesResolver getMessageCodesResolver() { return new DefaultMessageCodesResolver() { @Override diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/annotation/CglibProxyControllerTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/annotation/CglibProxyControllerTests.java index 4edb671798dc..17f25934314f 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/annotation/CglibProxyControllerTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/annotation/CglibProxyControllerTests.java @@ -80,6 +80,7 @@ void typeAndMethodLevel() throws Exception { } + @SuppressWarnings("serial") private void initServlet(final Class controllerClass) throws ServletException { servlet = new DispatcherServlet() { @Override diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/annotation/JdkProxyControllerTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/annotation/JdkProxyControllerTests.java index 4f13168d5130..06220f4ba44d 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/annotation/JdkProxyControllerTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/annotation/JdkProxyControllerTests.java @@ -78,6 +78,7 @@ void typeAndMethodLevel() throws Exception { } + @SuppressWarnings("serial") private void initServlet(final Class controllerclass) throws ServletException { servlet = new DispatcherServlet() { @Override diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/AbstractServletHandlerMethodTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/AbstractServletHandlerMethodTests.java index 5236b0d7ccd3..a2c66bd1a3c4 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/AbstractServletHandlerMethodTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/AbstractServletHandlerMethodTests.java @@ -67,6 +67,7 @@ protected WebApplicationContext initDispatcherServlet( return initDispatcherServlet(controllerClass, usePathPatterns, null); } + @SuppressWarnings("serial") WebApplicationContext initDispatcherServlet( @Nullable Class controllerClass, boolean usePathPatterns, @Nullable ApplicationContextInitializer initializer) diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/tags/BindTagTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/tags/BindTagTests.java index 13d1012ee0f4..a34ddcc7e978 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/tags/BindTagTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/tags/BindTagTests.java @@ -1004,6 +1004,7 @@ void nestingInFormTag() throws JspException { binder.registerCustomEditor(Date.class, l); pc.getRequest().setAttribute(BindingResult.MODEL_KEY_PREFIX + "tb", binder.getBindingResult()); + @SuppressWarnings("serial") FormTag formTag = new FormTag() { @Override protected TagWriter createTagWriter() { diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/tags/HtmlEscapeTagTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/tags/HtmlEscapeTagTests.java index a400d553c2b0..a07a6b065c38 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/tags/HtmlEscapeTagTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/tags/HtmlEscapeTagTests.java @@ -31,6 +31,7 @@ * @author Juergen Hoeller * @author Alef Arendsen */ +@SuppressWarnings("serial") class HtmlEscapeTagTests extends AbstractTagTests { @Test diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/tags/MessageTagTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/tags/MessageTagTests.java index 65763704c6f8..6db01e4fd5c1 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/tags/MessageTagTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/tags/MessageTagTests.java @@ -39,6 +39,7 @@ * @author Alef Arendsen * @author Nicholas Williams */ +@SuppressWarnings("serial") class MessageTagTests extends AbstractTagTests { @Test diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/tags/form/ButtonTagTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/tags/form/ButtonTagTests.java index c79175ba4df6..c4c3431fccb3 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/tags/form/ButtonTagTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/tags/form/ButtonTagTests.java @@ -85,6 +85,7 @@ protected final void assertTagOpened(String output) { assertThat(output).as("Tag not opened properly").startsWith("