From f86034b528abd0400cede89969c3e1f8d7f138e0 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Tue, 15 Jul 2025 15:59:23 +0200 Subject: [PATCH 001/156] Drop final declaration from doFilter entry point (for CGLIB proxying) Closes gh-35198 --- .../org/springframework/web/filter/OncePerRequestFilter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spring-web/src/main/java/org/springframework/web/filter/OncePerRequestFilter.java b/spring-web/src/main/java/org/springframework/web/filter/OncePerRequestFilter.java index b0fe2a71ed..a40e0925de 100644 --- a/spring-web/src/main/java/org/springframework/web/filter/OncePerRequestFilter.java +++ b/spring-web/src/main/java/org/springframework/web/filter/OncePerRequestFilter.java @@ -86,7 +86,7 @@ public abstract class OncePerRequestFilter extends GenericFilterBean { * @see #doFilterInternal */ @Override - public final void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) + public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException { if (!((request instanceof HttpServletRequest httpRequest) && (response instanceof HttpServletResponse httpResponse))) { From 12a6098eae12d4871dd2c0bef2e6cca1ba7545c3 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Tue, 15 Jul 2025 16:00:00 +0200 Subject: [PATCH 002/156] Upgrade to Reactor 2024.0.8 and Micrometer 1.14.9 Closes gh-35201 Closes gh-35202 --- 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 f7f5778f15..b19f220ec2 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")) - api(platform("io.micrometer:micrometer-bom:1.14.8")) + api(platform("io.micrometer:micrometer-bom:1.14.9")) api(platform("io.netty:netty-bom:4.1.122.Final")) api(platform("io.netty:netty5-bom:5.0.0.Alpha5")) - api(platform("io.projectreactor:reactor-bom:2024.0.7")) + api(platform("io.projectreactor:reactor-bom:2024.0.8")) api(platform("io.rsocket:rsocket-bom:1.1.5")) api(platform("org.apache.groovy:groovy-bom:4.0.27")) api(platform("org.apache.logging.log4j:log4j-bom:2.21.1")) From 19086835a737881a16772574531df0d893c1a4a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Tue, 15 Jul 2025 16:20:01 +0200 Subject: [PATCH 003/156] Upgrade tests to OkHttp 5.x See https://square.github.io/okhttp/changelogs/changelog/#version-500. Closes gh-35183 --- framework-platform/framework-platform.gradle | 2 +- spring-core/spring-core.gradle | 2 +- .../core/io/ResourceTests.java | 38 +- spring-web/spring-web.gradle | 2 +- .../client/AbstractMockWebServerTests.java | 77 ++-- .../reactive/ClientHttpConnectorTests.java | 56 ++- .../client/AbstractMockWebServerTests.java | 157 ++++--- .../client/RestClientIntegrationTests.java | 412 ++++++++--------- .../web/client/RestClientVersionTests.java | 36 +- .../web/client/RestTemplateTests.java | 20 +- .../support/RestClientAdapterTests.java | 98 ++-- ...stClientProxyRegistryIntegrationTests.java | 22 +- ...KotlinRestTemplateHttpServiceProxyTests.kt | 48 +- spring-webflux/spring-webflux.gradle | 2 +- .../WebClientDataBufferAllocatingTests.java | 61 +-- .../client/WebClientIntegrationTests.java | 433 +++++++++--------- .../client/WebClientVersionTests.java | 33 +- .../client/support/WebClientAdapterTests.java | 68 +-- ...ebClientProxyRegistryIntegrationTests.java | 22 +- .../WebClientHttpServiceProxyKotlinTests.kt | 67 +-- 20 files changed, 838 insertions(+), 818 deletions(-) diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index 90953f3e48..14a76638d5 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -37,7 +37,7 @@ dependencies { api("com.networknt:json-schema-validator:1.5.3") api("com.oracle.database.jdbc:ojdbc11:21.9.0.0") api("com.rometools:rome:1.19.0") - api("com.squareup.okhttp3:mockwebserver:3.14.9") + api("com.squareup.okhttp3:mockwebserver3:5.1.0") api("com.sun.activation:jakarta.activation:2.0.1") api("com.sun.xml.bind:jaxb-core:3.0.2") api("com.sun.xml.bind:jaxb-impl:3.0.2") diff --git a/spring-core/spring-core.gradle b/spring-core/spring-core.gradle index eb5384ae5c..1fa54d0aed 100644 --- a/spring-core/spring-core.gradle +++ b/spring-core/spring-core.gradle @@ -96,7 +96,7 @@ dependencies { testImplementation("com.fasterxml.jackson.core:jackson-databind") testImplementation("com.fasterxml.woodstox:woodstox-core") testImplementation("com.google.code.findbugs:jsr305") - testImplementation("com.squareup.okhttp3:mockwebserver") + testImplementation("com.squareup.okhttp3:mockwebserver3") testImplementation("io.projectreactor:reactor-test") testImplementation("io.projectreactor.tools:blockhound") testImplementation("jakarta.annotation:jakarta.annotation-api") 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 c174609df1..ffb08bc50c 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 @@ -37,10 +37,10 @@ import java.util.Base64; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Stream; -import okhttp3.mockwebserver.Dispatcher; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; +import mockwebserver3.Dispatcher; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import mockwebserver3.RecordedRequest; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -419,7 +419,7 @@ class ResourceTests { assertThat(resource.exists()).isTrue(); RecordedRequest request = this.server.takeRequest(); assertThat(request.getMethod()).isEqualTo("HEAD"); - assertThat(request.getHeader("Framework-Name")).isEqualTo("Spring"); + assertThat(request.getHeaders().get("Framework-Name")).isEqualTo("Spring"); } @Test @@ -429,7 +429,7 @@ class ResourceTests { assertThat(resource.exists()).isTrue(); RecordedRequest request = this.server.takeRequest(); assertThat(request.getMethod()).isEqualTo("HEAD"); - assertThat(request.getHeader("Framework-Name")).isEqualTo("Spring"); + assertThat(request.getHeaders().get("Framework-Name")).isEqualTo("Spring"); } @Test @@ -439,7 +439,7 @@ class ResourceTests { assertThat(resource.getInputStream()).hasContent("Spring"); RecordedRequest request = this.server.takeRequest(); assertThat(request.getMethod()).isEqualTo("GET"); - assertThat(request.getHeader("Framework-Name")).isEqualTo("Spring"); + assertThat(request.getHeaders().get("Framework-Name")).isEqualTo("Spring"); } @Test @@ -449,15 +449,15 @@ class ResourceTests { "http://alice:secret@localhost:" + this.server.getPort() + "/resource"); assertThat(resource.getInputStream()).hasContent("Spring"); RecordedRequest request = this.server.takeRequest(); - String authorization = request.getHeader("Authorization"); + String authorization = request.getHeaders().get("Authorization"); assertThat(authorization).isNotNull().startsWith("Basic "); assertThat(new String(Base64.getDecoder().decode(authorization.substring(6)), StandardCharsets.ISO_8859_1)).isEqualTo("alice:secret"); } @AfterEach - void shutdown() throws Exception { - this.server.shutdown(); + void shutdown() { + this.server.close(); } private String startServer(boolean withHeadSupport) throws Exception { @@ -488,23 +488,25 @@ class ResourceTests { @Override public MockResponse dispatch(RecordedRequest request) { - if (request.getPath().equals("/resource")) { + if (request.getTarget().equals("/resource")) { return switch (request.getMethod()) { case "HEAD" -> (this.withHeadSupport ? - new MockResponse() + new MockResponse.Builder() .addHeader("Content-Type", "text/plain") .addHeader("Content-Length", "6") - .addHeader("Last-Modified", LAST_MODIFIED) : - new MockResponse().setResponseCode(405)); - case "GET" -> new MockResponse() + .addHeader("Last-Modified", LAST_MODIFIED) + .build() : + new MockResponse.Builder().code(405).build()); + case "GET" -> new MockResponse.Builder() .addHeader("Content-Type", "text/plain") .addHeader("Content-Length", "6") .addHeader("Last-Modified", LAST_MODIFIED) - .setBody("Spring"); - default -> new MockResponse().setResponseCode(404); + .body("Spring") + .build(); + default -> new MockResponse.Builder().code(404).build(); }; } - return new MockResponse().setResponseCode(404); + return new MockResponse.Builder().code(404).build(); } } } diff --git a/spring-web/spring-web.gradle b/spring-web/spring-web.gradle index c12ddef4ed..58b566fa37 100644 --- a/spring-web/spring-web.gradle +++ b/spring-web/spring-web.gradle @@ -82,7 +82,7 @@ dependencies { testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin") testImplementation("com.fasterxml.jackson.module:jackson-module-parameter-names") testImplementation("com.fasterxml.jackson.dataformat:jackson-dataformat-csv") - testImplementation("com.squareup.okhttp3:mockwebserver") + testImplementation("com.squareup.okhttp3:mockwebserver3") testImplementation("io.micrometer:micrometer-observation-test") testImplementation("io.projectreactor:reactor-test") testImplementation("io.projectreactor.tools:blockhound") 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 89a1c2da2a..28e83978e1 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,10 +16,10 @@ package org.springframework.http.client; -import okhttp3.mockwebserver.Dispatcher; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; +import mockwebserver3.Dispatcher; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import mockwebserver3.RecordedRequest; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -49,8 +49,8 @@ public abstract class AbstractMockWebServerTests { } @AfterEach - void tearDown() throws Exception { - this.server.shutdown(); + void tearDown() { + this.server.close(); } @@ -59,56 +59,57 @@ public abstract class AbstractMockWebServerTests { @Override public MockResponse dispatch(RecordedRequest request) { try { - if (request.getPath().equals("/echo")) { - assertThat(request.getHeader("Host")).contains("localhost:" + port); - MockResponse response = new MockResponse() - .setHeaders(request.getHeaders()) - .setHeader("Content-Length", request.getBody().size()) - .setResponseCode(200) - .setBody(request.getBody()); - request.getBody().flush(); - return response; + if (request.getTarget().equals("/echo")) { + assertThat(request.getHeaders().get("Host")).contains("localhost:" + port); + MockResponse.Builder builder = new MockResponse.Builder().headers(request.getHeaders()); + if (request.getBody() != null) { + builder = builder.body(request.getBody().utf8()); + } + else { + builder.setHeader("Content-Length", 0); + } + return builder.code(200).build(); } - else if(request.getPath().equals("/status/ok")) { - return new MockResponse(); + else if(request.getTarget().equals("/status/ok")) { + return new MockResponse.Builder().build(); } - else if(request.getPath().equals("/status/notfound")) { - return new MockResponse().setResponseCode(404); + else if(request.getTarget().equals("/status/notfound")) { + return new MockResponse.Builder().code(404).build(); } - else if (request.getPath().equals("/status/299")) { - assertThat(request.getHeader("Expect")).contains("299"); - return new MockResponse().setResponseCode(299); + else if (request.getTarget().equals("/status/299")) { + assertThat(request.getHeaders().get("Expect")).contains("299"); + return new MockResponse.Builder().code(299).build(); } - else if(request.getPath().startsWith("/params")) { - assertThat(request.getPath()).contains("param1=value"); - assertThat(request.getPath()).contains("param2=value1¶m2=value2"); - return new MockResponse(); + else if(request.getTarget().startsWith("/params")) { + assertThat(request.getTarget()).contains("param1=value"); + assertThat(request.getTarget()).contains("param2=value1¶m2=value2"); + return new MockResponse.Builder().build(); } - else if(request.getPath().equals("/methods/post")) { + else if(request.getTarget().equals("/methods/post")) { assertThat(request.getMethod()).isEqualTo("POST"); - String transferEncoding = request.getHeader("Transfer-Encoding"); + String transferEncoding = request.getHeaders().get("Transfer-Encoding"); if(StringUtils.hasLength(transferEncoding)) { assertThat(transferEncoding).isEqualTo("chunked"); } else { - long contentLength = Long.parseLong(request.getHeader("Content-Length")); + long contentLength = Long.parseLong(request.getHeaders().get("Content-Length")); assertThat(request.getBody().size()).isEqualTo(contentLength); } - return new MockResponse().setResponseCode(200); + return new MockResponse.Builder().code(200).build(); } - else if(request.getPath().startsWith("/methods/")) { - String expectedMethod = request.getPath().replace("/methods/","").toUpperCase(); + else if(request.getTarget().startsWith("/methods/")) { + String expectedMethod = request.getTarget().replace("/methods/","").toUpperCase(); assertThat(request.getMethod()).isEqualTo(expectedMethod); - return new MockResponse(); + return new MockResponse.Builder().build(); } - else if(request.getPath().startsWith("/header/")) { - String headerName = request.getPath().replace("/header/",""); - return new MockResponse().setBody(headerName + ":" + request.getHeader(headerName)).setResponseCode(200); + 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(); } - return new MockResponse().setResponseCode(404); + return new MockResponse.Builder().code(404).build(); } catch (Throwable ex) { - return new MockResponse().setResponseCode(500).setBody(ex.toString()); + return new MockResponse.Builder().code(500).body(ex.toString()).build(); } } } diff --git a/spring-web/src/test/java/org/springframework/http/client/reactive/ClientHttpConnectorTests.java b/spring-web/src/test/java/org/springframework/http/client/reactive/ClientHttpConnectorTests.java index a695f934f0..aa2f0f17f0 100644 --- a/spring-web/src/test/java/org/springframework/http/client/reactive/ClientHttpConnectorTests.java +++ b/spring-web/src/test/java/org/springframework/http/client/reactive/ClientHttpConnectorTests.java @@ -34,10 +34,11 @@ import java.util.Random; import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.function.Consumer; +import java.util.function.Function; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import mockwebserver3.RecordedRequest; import okio.Buffer; import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; import org.junit.jupiter.api.AfterEach; @@ -84,8 +85,8 @@ class ClientHttpConnectorTests { } @AfterEach - void stopServer() throws IOException { - server.shutdown(); + void stopServer() { + server.close(); } // Do not auto-close arguments since HttpComponentsClientHttpConnector implements @@ -96,11 +97,10 @@ class ClientHttpConnectorTests { URI uri = this.server.url("/").uri(); String responseBody = "bar\r\n"; - prepareResponse(response -> { - response.setResponseCode(200); - response.addHeader("Baz", "Qux"); - response.setBody(responseBody); - }); + prepareResponse(builder -> builder + .code(200) + .addHeader("Baz", "Qux") + .body(responseBody)); String requestBody = "foo\r\n"; boolean requestHasBody = METHODS_WITH_BODY.contains(method); @@ -144,9 +144,9 @@ class ClientHttpConnectorTests { expectRequest(request -> { assertThat(request.getMethod()).isEqualTo(method.name()); - assertThat(request.getHeader("Foo")).isEqualTo("Bar"); + assertThat(request.getHeaders().get("Foo")).isEqualTo("Bar"); if (requestHasBody) { - assertThat(request.getBody().readUtf8()).isEqualTo(requestBody); + assertThat(request.getBody().utf8()).isEqualTo(requestBody); } }); } @@ -158,7 +158,7 @@ class ClientHttpConnectorTests { stringBuffer("foo"), Mono.error(error) ); - prepareResponse(response -> response.setResponseCode(200)); + prepareResponse(builder -> builder.code(200)); Mono futureResponse = connector.connect(HttpMethod.POST, this.server.url("/").uri(), request -> request.writeWith(body)); StepVerifier.create(futureResponse) @@ -169,7 +169,7 @@ class ClientHttpConnectorTests { @ParameterizedConnectorTest void cancelResponseBody(ClientHttpConnector connector) { Buffer responseBody = randomBody(100); - prepareResponse(response -> response.setBody(responseBody)); + prepareResponse(builder -> builder.body(responseBody)); ClientHttpResponse response = connector.connect(HttpMethod.POST, this.server.url("/").uri(), ReactiveHttpOutputMessage::setComplete).block(); @@ -187,10 +187,9 @@ class ClientHttpConnectorTests { ZonedDateTime tomorrow = ZonedDateTime.now(ZoneId.of("UTC")).plusDays(1); String formattedDate = tomorrow.format(DateTimeFormatter.RFC_1123_DATE_TIME); - prepareResponse(response -> { - response.setResponseCode(200); - response.addHeader("Set-Cookie", "id=test; Expires= " + formattedDate + ";"); - }); + prepareResponse(builder -> builder + .code(200) + .addHeader("Set-Cookie", "id=test; Expires= " + formattedDate + ";")); Mono futureResponse = connector.connect(HttpMethod.GET, this.server.url("/").uri(), ReactiveHttpOutputMessage::setComplete); StepVerifier.create(futureResponse) @@ -206,10 +205,9 @@ class ClientHttpConnectorTests { void partitionedCookieSupport(ClientHttpConnector connector) { Assumptions.assumeFalse(connector instanceof JettyClientHttpConnector, "Jetty client does not support partitioned cookies"); Assumptions.assumeFalse(connector instanceof JdkClientHttpConnector, "JDK client does not support partitioned cookies"); - prepareResponse(response -> { - response.setResponseCode(200); - response.addHeader("Set-Cookie", "id=test; Partitioned;"); - }); + prepareResponse(builder -> builder + .code(200) + .addHeader("Set-Cookie", "id=test; Partitioned;")); Mono futureResponse = connector.connect(HttpMethod.GET, this.server.url("/").uri(), ReactiveHttpOutputMessage::setComplete); StepVerifier.create(futureResponse) @@ -227,10 +225,9 @@ class ClientHttpConnectorTests { HttpAsyncClientBuilder.create().disableCookieManagement().build() ); - prepareResponse(response -> { - response.setResponseCode(200); - response.addHeader("Set-Cookie", "id=test;"); - }); + prepareResponse(builder -> builder + .code(200) + .addHeader("Set-Cookie", "id=test;")); Mono futureResponse = connector.connect(HttpMethod.GET, this.server.url("/").uri(), ReactiveHttpOutputMessage::setComplete); StepVerifier.create(futureResponse) @@ -254,10 +251,9 @@ class ClientHttpConnectorTests { return responseBody; } - private void prepareResponse(Consumer consumer) { - MockResponse response = new MockResponse(); - consumer.accept(response); - this.server.enqueue(response); + private void prepareResponse(Function f) { + MockResponse.Builder builder = new MockResponse.Builder(); + this.server.enqueue(f.apply(builder).build()); } private void expectRequest(Consumer consumer) { diff --git a/spring-web/src/test/java/org/springframework/web/client/AbstractMockWebServerTests.java b/spring-web/src/test/java/org/springframework/web/client/AbstractMockWebServerTests.java index bd1f89c85d..5d33f8266b 100644 --- a/spring-web/src/test/java/org/springframework/web/client/AbstractMockWebServerTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/AbstractMockWebServerTests.java @@ -21,11 +21,12 @@ import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.Collections; -import okhttp3.mockwebserver.Dispatcher; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; +import mockwebserver3.Dispatcher; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import mockwebserver3.RecordedRequest; import okio.Buffer; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -68,96 +69,102 @@ abstract class AbstractMockWebServerTests { @AfterEach void tearDown() throws Exception { - this.server.shutdown(); + this.server.close(); } - private MockResponse getRequest(RecordedRequest request, byte[] body, String contentType) { + private MockResponse getRequest(RecordedRequest request, byte[] body, @Nullable String contentType) { if (request.getMethod().equals("OPTIONS")) { - return new MockResponse().setResponseCode(200).setHeader("Allow", "GET, OPTIONS, HEAD, TRACE"); + return new MockResponse.Builder().code(200).setHeader("Allow", "GET, OPTIONS, HEAD, TRACE").build(); } Buffer buf = new Buffer(); buf.write(body); - MockResponse response = new MockResponse() + MockResponse.Builder builder = new MockResponse.Builder() .setHeader(CONTENT_LENGTH, body.length) - .setBody(buf) - .setResponseCode(200); + .body(buf) + .code(200); if (contentType != null) { - response = response.setHeader(CONTENT_TYPE, contentType); + return builder.setHeader(CONTENT_TYPE, contentType).build(); + } + else { + return builder.build(); } - return response; } private MockResponse postRequest(RecordedRequest request, String expectedRequestContent, String location, String contentType, byte[] responseBody) { assertThat(request.getHeaders().values(CONTENT_LENGTH)).hasSize(1); - assertThat(Integer.parseInt(request.getHeader(CONTENT_LENGTH))).as("Invalid request content-length").isGreaterThan(0); - String requestContentType = request.getHeader(CONTENT_TYPE); + assertThat(Integer.parseInt(request.getHeaders().get(CONTENT_LENGTH))).as("Invalid request content-length").isGreaterThan(0); + String requestContentType = request.getHeaders().get(CONTENT_TYPE); assertThat(requestContentType).as("No content-type").isNotNull(); Charset charset = StandardCharsets.ISO_8859_1; if (requestContentType.contains("charset=")) { String charsetName = requestContentType.split("charset=")[1]; charset = Charset.forName(charsetName); } - assertThat(request.getBody().readString(charset)).as("Invalid request body").isEqualTo(expectedRequestContent); + assertThat(request.getBody().string(charset)).as("Invalid request body").isEqualTo(expectedRequestContent); Buffer buf = new Buffer(); buf.write(responseBody); - return new MockResponse() + return new MockResponse.Builder() .setHeader(LOCATION, baseUrl + location) .setHeader(CONTENT_TYPE, contentType) .setHeader(CONTENT_LENGTH, responseBody.length) - .setBody(buf) - .setResponseCode(201); + .body(buf) + .code(201) + .build(); } private MockResponse jsonPostRequest(RecordedRequest request, String location, String contentType) { if (request.getBodySize() > 0) { - String contentLength = request.getHeader(CONTENT_LENGTH); + String contentLength = request.getHeaders().get(CONTENT_LENGTH); if (contentLength != null) { assertThat(Integer.parseInt(contentLength)).as("Invalid request content-length").isGreaterThan(0); } - assertThat(request.getHeader(CONTENT_TYPE)).as("No content-type").isNotNull(); + assertThat(request.getHeaders().get(CONTENT_TYPE)).as("No content-type").isNotNull(); } - return new MockResponse() + return new MockResponse.Builder() .setHeader(LOCATION, baseUrl + location) .setHeader(CONTENT_TYPE, contentType) .setHeader(CONTENT_LENGTH, request.getBody().size()) - .setBody(request.getBody()) - .setResponseCode(201); + .body(request.getBody().utf8()) + .code(201) + .build(); } private MockResponse multipartFormDataRequest(RecordedRequest request) { - MediaType mediaType = MediaType.parseMediaType(request.getHeader(CONTENT_TYPE)); + MediaType mediaType = MediaType.parseMediaType(request.getHeaders().get(CONTENT_TYPE)); assertThat(mediaType.isCompatibleWith(MULTIPART_FORM_DATA)).as(MULTIPART_FORM_DATA.toString()).isTrue(); assertMultipart(request, mediaType); - return new MockResponse().setResponseCode(200); + return new MockResponse.Builder().code(200).build(); } private MockResponse multipartMixedRequest(RecordedRequest request) { - MediaType mediaType = MediaType.parseMediaType(request.getHeader(CONTENT_TYPE)); + MediaType mediaType = MediaType.parseMediaType(request.getHeaders().get(CONTENT_TYPE)); assertThat(mediaType.isCompatibleWith(MULTIPART_MIXED)).as(MULTIPART_MIXED.toString()).isTrue(); assertMultipart(request, mediaType); - return new MockResponse().setResponseCode(200); + return new MockResponse.Builder().code(200).build(); } private MockResponse multipartRelatedRequest(RecordedRequest request) { - MediaType mediaType = MediaType.parseMediaType(request.getHeader(CONTENT_TYPE)); + MediaType mediaType = MediaType.parseMediaType(request.getHeaders().get(CONTENT_TYPE)); assertThat(mediaType.isCompatibleWith(MULTIPART_RELATED)).as(MULTIPART_RELATED.toString()).isTrue(); assertMultipart(request, mediaType); - return new MockResponse().setResponseCode(200); + return new MockResponse.Builder().code(200).build(); } private void assertMultipart(RecordedRequest request, MediaType mediaType) { assertThat(mediaType.isCompatibleWith(new MediaType("multipart", "*"))).as("multipart/*").isTrue(); String boundary = mediaType.getParameter("boundary"); assertThat(boundary).as("boundary").isNotBlank(); - Buffer body = request.getBody(); - try { - assertPart(body, "form-data", boundary, "name 1", "text/plain", "value 1"); - assertPart(body, "form-data", boundary, "name 2", "text/plain", "value 2+1"); - assertPart(body, "form-data", boundary, "name 2", "text/plain", "value 2+2"); - assertFilePart(body, "form-data", boundary, "logo", "logo.jpg", "image/jpeg"); + + try (Buffer buffer = new Buffer()) { + assertThat(request.getBody()).isNotNull(); + buffer.write(request.getBody()); + assertPart(buffer, "form-data", boundary, "name 1", "text/plain", "value 1"); + assertPart(buffer, "form-data", boundary, "name 2", "text/plain", "value 2+1"); + assertPart(buffer, "form-data", boundary, "name 2", "text/plain", "value 2+2"); + assertFilePart(buffer, "form-data", boundary, "logo", "logo.jpg", "image/jpeg"); } catch (EOFException ex) { throw new AssertionError(ex); @@ -192,43 +199,45 @@ abstract class AbstractMockWebServerTests { } private MockResponse formRequest(RecordedRequest request) { - assertThat(request.getHeader(CONTENT_TYPE)).isEqualTo("application/x-www-form-urlencoded"); - assertThat(request.getBody().readUtf8()).contains("name+1=value+1", "name+2=value+2%2B1", "name+2=value+2%2B2"); - return new MockResponse().setResponseCode(200); + assertThat(request.getHeaders().get(CONTENT_TYPE)).isEqualTo("application/x-www-form-urlencoded"); + assertThat(request.getBody().utf8()).contains("name+1=value+1", "name+2=value+2%2B1", "name+2=value+2%2B2"); + return new MockResponse.Builder().code(200).build(); } private MockResponse patchRequest(RecordedRequest request, String expectedRequestContent, String contentType, byte[] responseBody) { assertThat(request.getMethod()).isEqualTo("PATCH"); - assertThat(Integer.parseInt(request.getHeader(CONTENT_LENGTH))).as("Invalid request content-length").isGreaterThan(0); - String requestContentType = request.getHeader(CONTENT_TYPE); + assertThat(Integer.parseInt(request.getHeaders().get(CONTENT_LENGTH))).as("Invalid request content-length").isGreaterThan(0); + String requestContentType = request.getHeaders().get(CONTENT_TYPE); assertThat(requestContentType).as("No content-type").isNotNull(); Charset charset = StandardCharsets.ISO_8859_1; if (requestContentType.contains("charset=")) { String charsetName = requestContentType.split("charset=")[1]; charset = Charset.forName(charsetName); } - assertThat(request.getBody().readString(charset)).as("Invalid request body").isEqualTo(expectedRequestContent); + assertThat(request.getBody().string(charset)).as("Invalid request body").isEqualTo(expectedRequestContent); Buffer buf = new Buffer(); buf.write(responseBody); - return new MockResponse().setResponseCode(201) + return new MockResponse.Builder() + .code(201) .setHeader(CONTENT_LENGTH, responseBody.length) .setHeader(CONTENT_TYPE, contentType) - .setBody(buf); + .body(buf) + .build(); } private MockResponse putRequest(RecordedRequest request, String expectedRequestContent) { - assertThat(Integer.parseInt(request.getHeader(CONTENT_LENGTH))).as("Invalid request content-length").isGreaterThan(0); - String requestContentType = request.getHeader(CONTENT_TYPE); + assertThat(Integer.parseInt(request.getHeaders().get(CONTENT_LENGTH))).as("Invalid request content-length").isGreaterThan(0); + String requestContentType = request.getHeaders().get(CONTENT_TYPE); assertThat(requestContentType).as("No content-type").isNotNull(); Charset charset = StandardCharsets.ISO_8859_1; if (requestContentType.contains("charset=")) { String charsetName = requestContentType.split("charset=")[1]; charset = Charset.forName(charsetName); } - assertThat(request.getBody().readString(charset)).as("Invalid request body").isEqualTo(expectedRequestContent); - return new MockResponse().setResponseCode(202); + assertThat(request.getBody().string(charset)).as("Invalid request body").isEqualTo(expectedRequestContent); + return new MockResponse.Builder().code(202).build(); } @@ -239,64 +248,64 @@ abstract class AbstractMockWebServerTests { try { byte[] helloWorldBytes = helloWorld.getBytes(StandardCharsets.UTF_8); - if (request.getPath().equals("/get")) { + if (request.getTarget().equals("/get")) { return getRequest(request, helloWorldBytes, textContentType.toString()); } - else if (request.getPath().equals("/get/nothing")) { + else if (request.getTarget().equals("/get/nothing")) { return getRequest(request, new byte[0], textContentType.toString()); } - else if (request.getPath().equals("/get/nocontenttype")) { + else if (request.getTarget().equals("/get/nocontenttype")) { return getRequest(request, helloWorldBytes, null); } - else if (request.getPath().equals("/post")) { + else if (request.getTarget().equals("/post")) { return postRequest(request, helloWorld, "/post/1", textContentType.toString(), helloWorldBytes); } - else if (request.getPath().equals("/jsonpost")) { + else if (request.getTarget().equals("/jsonpost")) { return jsonPostRequest(request, "/jsonpost/1", "application/json; charset=utf-8"); } - else if (request.getPath().equals("/status/nocontent")) { - return new MockResponse().setResponseCode(204); + else if (request.getTarget().equals("/status/nocontent")) { + return new MockResponse.Builder().code(204).build(); } - else if (request.getPath().equals("/status/notmodified")) { - return new MockResponse().setResponseCode(304); + else if (request.getTarget().equals("/status/notmodified")) { + return new MockResponse.Builder().code(304).build(); } - else if (request.getPath().equals("/status/notfound")) { - return new MockResponse().setResponseCode(404); + else if (request.getTarget().equals("/status/notfound")) { + return new MockResponse.Builder().code(404).build(); } - else if (request.getPath().equals("/status/badrequest")) { - return new MockResponse().setResponseCode(400); + else if (request.getTarget().equals("/status/badrequest")) { + return new MockResponse.Builder().code(400).build(); } - else if (request.getPath().equals("/status/server")) { - return new MockResponse().setResponseCode(500); + else if (request.getTarget().equals("/status/server")) { + return new MockResponse.Builder().code(500).build(); } - else if (request.getPath().contains("/uri/")) { - return new MockResponse().setBody(request.getPath()).setHeader(CONTENT_TYPE, "text/plain"); + else if (request.getTarget().contains("/uri/")) { + return new MockResponse.Builder().body(request.getTarget()).setHeader(CONTENT_TYPE, "text/plain").build(); } - else if (request.getPath().equals("/multipartFormData")) { + else if (request.getTarget().equals("/multipartFormData")) { return multipartFormDataRequest(request); } - else if (request.getPath().equals("/multipartMixed")) { + else if (request.getTarget().equals("/multipartMixed")) { return multipartMixedRequest(request); } - else if (request.getPath().equals("/multipartRelated")) { + else if (request.getTarget().equals("/multipartRelated")) { return multipartRelatedRequest(request); } - else if (request.getPath().equals("/form")) { + else if (request.getTarget().equals("/form")) { return formRequest(request); } - else if (request.getPath().equals("/delete")) { - return new MockResponse().setResponseCode(200); + else if (request.getTarget().equals("/delete")) { + return new MockResponse.Builder().code(200).build(); } - else if (request.getPath().equals("/patch")) { + else if (request.getTarget().equals("/patch")) { return patchRequest(request, helloWorld, textContentType.toString(), helloWorldBytes); } - else if (request.getPath().equals("/put")) { + else if (request.getTarget().equals("/put")) { return putRequest(request, helloWorld); } - return new MockResponse().setResponseCode(404); + return new MockResponse.Builder().code(404).build(); } catch (Throwable ex) { - return new MockResponse().setResponseCode(500).setBody(ex.toString()); + return new MockResponse.Builder().code(500).body(ex.toString()).build(); } } } 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 4e435902e8..77c2bfd876 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 @@ -27,12 +27,13 @@ import java.net.URISyntaxException; import java.util.List; import java.util.Map; import java.util.function.Consumer; +import java.util.function.Function; import java.util.stream.Stream; import com.fasterxml.jackson.annotation.JsonView; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import mockwebserver3.RecordedRequest; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.params.ParameterizedTest; @@ -99,8 +100,9 @@ class RestClientIntegrationTests { private RestClient restClient; - private void startServer(ClientHttpRequestFactory requestFactory) { + private void startServer(ClientHttpRequestFactory requestFactory) throws IOException { this.server = new MockWebServer(); + this.server.start(); this.restClient = RestClient .builder() .requestFactory(requestFactory) @@ -111,17 +113,17 @@ class RestClientIntegrationTests { @AfterEach void shutdown() throws IOException { if (server != null) { - this.server.shutdown(); + this.server.close(); } } @ParameterizedRestClientTest - void retrieve(ClientHttpRequestFactory requestFactory) { + void retrieve(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + prepareResponse(builder -> builder + .setHeader("Content-Type", "text/plain").body("Hello Spring!")); String result = this.restClient.get() .uri("/greeting") @@ -133,18 +135,18 @@ class RestClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getHeader("X-Test-Header")).isEqualTo("testvalue"); - assertThat(request.getPath()).isEqualTo("/greeting"); + assertThat(request.getHeaders().get("X-Test-Header")).isEqualTo("testvalue"); + assertThat(request.getTarget()).isEqualTo("/greeting"); }); } @ParameterizedRestClientTest - void retrieveJson(ClientHttpRequestFactory requestFactory) { + void retrieveJson(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> response + prepareResponse(builder -> builder .setHeader("Content-Type", "application/json") - .setBody("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}")); + .body("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}")); Pojo result = this.restClient.get() .uri("/pojo") @@ -157,18 +159,18 @@ class RestClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/pojo"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/pojo"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedRestClientTest - void retrieveJsonWithParameterizedTypeReference(ClientHttpRequestFactory requestFactory) { + void retrieveJsonWithParameterizedTypeReference(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); String content = "{\"containerValue\":{\"bar\":\"barbar\",\"foo\":\"foofoo\"}}"; - prepareResponse(response -> response - .setHeader("Content-Type", "application/json").setBody(content)); + prepareResponse(builder -> builder + .setHeader("Content-Type", "application/json").body(content)); ValueContainer result = this.restClient.get() .uri("/json").accept(MediaType.APPLICATION_JSON) @@ -182,18 +184,18 @@ class RestClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/json"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/json"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedRestClientTest - void retrieveJsonWithListParameterizedTypeReference(ClientHttpRequestFactory requestFactory) { + void retrieveJsonWithListParameterizedTypeReference(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); String content = "{\"containerValue\":[{\"bar\":\"barbar\",\"foo\":\"foofoo\"}]}"; - prepareResponse(response -> response - .setHeader("Content-Type", "application/json").setBody(content)); + prepareResponse(builder -> builder + .setHeader("Content-Type", "application/json").body(content)); ValueContainer> result = this.restClient.get() .uri("/json").accept(MediaType.APPLICATION_JSON) @@ -205,18 +207,18 @@ class RestClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/json"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/json"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedRestClientTest - void retrieveJsonAsResponseEntity(ClientHttpRequestFactory requestFactory) { + void retrieveJsonAsResponseEntity(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); String content = "{\"bar\":\"barbar\",\"foo\":\"foofoo\"}"; - prepareResponse(response -> response - .setHeader("Content-Type", "application/json").setBody(content)); + prepareResponse(builder -> builder + .setHeader("Content-Type", "application/json").body(content)); ResponseEntity result = this.restClient.get() .uri("/json").accept(MediaType.APPLICATION_JSON) @@ -230,17 +232,17 @@ class RestClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/json"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/json"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedRestClientTest - void retrieveJsonAsBodilessEntity(ClientHttpRequestFactory requestFactory) { + void retrieveJsonAsBodilessEntity(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> response - .setHeader("Content-Type", "application/json").setBody("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}")); + prepareResponse(builder -> builder + .setHeader("Content-Type", "application/json").body("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}")); ResponseEntity result = this.restClient.get() .uri("/json").accept(MediaType.APPLICATION_JSON) @@ -254,18 +256,18 @@ class RestClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/json"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/json"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedRestClientTest - void retrieveJsonArray(ClientHttpRequestFactory requestFactory) { + void retrieveJsonArray(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> response + prepareResponse(builder -> builder .setHeader("Content-Type", "application/json") - .setBody("[{\"bar\":\"bar1\",\"foo\":\"foo1\"},{\"bar\":\"bar2\",\"foo\":\"foo2\"}]")); + .body("[{\"bar\":\"bar1\",\"foo\":\"foo1\"},{\"bar\":\"bar2\",\"foo\":\"foo2\"}]")); List result = this.restClient.get() .uri("/pojos") @@ -281,18 +283,18 @@ class RestClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/pojos"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/pojos"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedRestClientTest - void retrieveJsonArrayAsResponseEntityList(ClientHttpRequestFactory requestFactory) { + void retrieveJsonArrayAsResponseEntityList(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); String content = "[{\"bar\":\"bar1\",\"foo\":\"foo1\"}, {\"bar\":\"bar2\",\"foo\":\"foo2\"}]"; - prepareResponse(response -> response - .setHeader("Content-Type", "application/json").setBody(content)); + prepareResponse(builder -> builder + .setHeader("Content-Type", "application/json").body(content)); ResponseEntity> result = this.restClient.get() .uri("/json").accept(MediaType.APPLICATION_JSON) @@ -310,18 +312,18 @@ class RestClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/json"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/json"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedRestClientTest - void retrieveJsonAsSerializedText(ClientHttpRequestFactory requestFactory) { + void retrieveJsonAsSerializedText(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); String content = "{\"bar\":\"barbar\",\"foo\":\"foofoo\"}"; - prepareResponse(response -> response - .setHeader("Content-Type", "application/json").setBody(content)); + prepareResponse(builder -> builder + .setHeader("Content-Type", "application/json").body(content)); String result = this.restClient.get() .uri("/json").accept(MediaType.APPLICATION_JSON) @@ -332,20 +334,20 @@ class RestClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/json"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/json"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedRestClientTest @SuppressWarnings({ "rawtypes", "unchecked" }) - void retrieveJsonNull(ClientHttpRequestFactory requestFactory) { + void retrieveJsonNull(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> response - .setResponseCode(200) + prepareResponse(builder -> builder + .code(200) .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) - .setBody("null")); + .body("null")); Map result = this.restClient.get() .uri("/null") @@ -356,11 +358,11 @@ class RestClientIntegrationTests { } @ParameterizedRestClientTest - void retrieveJsonEmpty(ClientHttpRequestFactory requestFactory) { + void retrieveJsonEmpty(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> response - .setResponseCode(200) + prepareResponse(builder -> builder + .code(200) .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)); Pojo result = this.restClient.get() @@ -372,11 +374,10 @@ class RestClientIntegrationTests { } @ParameterizedRestClientTest - void retrieve404(ClientHttpRequestFactory requestFactory) { + void retrieve404(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> response.setResponseCode(404) - .setHeader("Content-Type", "text/plain")); + prepareResponse(builder -> builder.code(404).setHeader("Content-Type", "text/plain")); assertThatExceptionOfType(HttpClientErrorException.NotFound.class).isThrownBy(() -> this.restClient.get().uri("/greeting") @@ -385,16 +386,16 @@ class RestClientIntegrationTests { ); expectRequestCount(1); - expectRequest(request -> assertThat(request.getPath()).isEqualTo("/greeting")); + expectRequest(request -> assertThat(request.getTarget()).isEqualTo("/greeting")); } @ParameterizedRestClientTest - void retrieve404WithBody(ClientHttpRequestFactory requestFactory) { + void retrieve404WithBody(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> response.setResponseCode(404) - .setHeader("Content-Type", "text/plain").setBody("Not Found")); + prepareResponse(builder -> builder.code(404) + .setHeader("Content-Type", "text/plain").body("Not Found")); assertThatExceptionOfType(HttpClientErrorException.NotFound.class).isThrownBy(() -> this.restClient.get() @@ -404,16 +405,16 @@ class RestClientIntegrationTests { ); expectRequestCount(1); - expectRequest(request -> assertThat(request.getPath()).isEqualTo("/greeting")); + expectRequest(request -> assertThat(request.getTarget()).isEqualTo("/greeting")); } @ParameterizedRestClientTest - void retrieve500(ClientHttpRequestFactory requestFactory) { + void retrieve500(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); String errorMessage = "Internal Server error"; - prepareResponse(response -> response.setResponseCode(500) - .setHeader("Content-Type", "text/plain").setBody(errorMessage)); + prepareResponse(builder -> builder.code(500) + .setHeader("Content-Type", "text/plain").body(errorMessage)); String path = "/greeting"; try { @@ -431,15 +432,15 @@ class RestClientIntegrationTests { } expectRequestCount(1); - expectRequest(request -> assertThat(request.getPath()).isEqualTo(path)); + expectRequest(request -> assertThat(request.getTarget()).isEqualTo(path)); } @ParameterizedRestClientTest - void retrieve500AsEntity(ClientHttpRequestFactory requestFactory) { + void retrieve500AsEntity(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> response.setResponseCode(500) - .setHeader("Content-Type", "text/plain").setBody("Internal Server error")); + prepareResponse(builder -> builder.code(500) + .setHeader("Content-Type", "text/plain").body("Internal Server error")); assertThatExceptionOfType(HttpServerErrorException.InternalServerError.class).isThrownBy(() -> this.restClient.get() @@ -450,17 +451,17 @@ class RestClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedRestClientTest - void retrieve500AsBodilessEntity(ClientHttpRequestFactory requestFactory) { + void retrieve500AsBodilessEntity(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> response.setResponseCode(500) - .setHeader("Content-Type", "text/plain").setBody("Internal Server error")); + prepareResponse(builder -> builder.code(500) + .setHeader("Content-Type", "text/plain").body("Internal Server error")); assertThatExceptionOfType(HttpServerErrorException.InternalServerError.class).isThrownBy(() -> this.restClient.get() @@ -471,20 +472,20 @@ class RestClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedRestClientTest - void retrieve555UnknownStatus(ClientHttpRequestFactory requestFactory) { + void retrieve555UnknownStatus(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); int errorStatus = 555; assertThat(HttpStatus.resolve(errorStatus)).isNull(); String errorMessage = "Something went wrong"; - prepareResponse(response -> response.setResponseCode(errorStatus) - .setHeader("Content-Type", "text/plain").setBody(errorMessage)); + prepareResponse(builder -> builder.code(errorStatus) + .setHeader("Content-Type", "text/plain").body(errorMessage)); try { this.restClient.get() @@ -502,15 +503,15 @@ class RestClientIntegrationTests { } expectRequestCount(1); - expectRequest(request -> assertThat(request.getPath()).isEqualTo("/unknownPage")); + expectRequest(request -> assertThat(request.getTarget()).isEqualTo("/unknownPage")); } @ParameterizedRestClientTest - void postPojoAsJson(ClientHttpRequestFactory requestFactory) { + void postPojoAsJson(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> response.setHeader("Content-Type", "application/json") - .setBody("{\"bar\":\"BARBAR\",\"foo\":\"FOOFOO\"}")); + prepareResponse(builder -> builder.setHeader("Content-Type", "application/json") + .body("{\"bar\":\"BARBAR\",\"foo\":\"FOOFOO\"}")); Pojo result = this.restClient.post() .uri("/pojo/capitalize") @@ -526,19 +527,19 @@ class RestClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/pojo/capitalize"); - assertThat(request.getBody().readUtf8()).isEqualTo("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); - assertThat(request.getHeader(HttpHeaders.CONTENT_TYPE)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/pojo/capitalize"); + assertThat(request.getBody().utf8()).isEqualTo("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getHeaders().get(HttpHeaders.CONTENT_TYPE)).isEqualTo("application/json"); }); } @ParameterizedRestClientTest - void postUserAsJsonWithJsonView(ClientHttpRequestFactory requestFactory) { + void postUserAsJsonWithJsonView(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> response.setHeader("Content-Type", "application/json") - .setBody("{\"username\":\"USERNAME\"}")); + prepareResponse(builder -> builder.setHeader("Content-Type", "application/json") + .body("{\"username\":\"USERNAME\"}")); User result = this.restClient.post() .uri("/user/capitalize") @@ -555,18 +556,18 @@ class RestClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/user/capitalize"); - assertThat(request.getBody().readUtf8()).isEqualTo("{\"username\":\"username\"}"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); - assertThat(request.getHeader(HttpHeaders.CONTENT_TYPE)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/user/capitalize"); + assertThat(request.getBody().utf8()).isEqualTo("{\"username\":\"username\"}"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getHeaders().get(HttpHeaders.CONTENT_TYPE)).isEqualTo("application/json"); }); } @ParameterizedRestClientTest // gh-31361 - public void postForm(ClientHttpRequestFactory requestFactory) { + public void postForm(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> response.setResponseCode(200)); + prepareResponse(builder -> builder.code(200)); MultiValueMap formData = new LinkedMultiValueMap<>(); formData.add("foo", "bar"); @@ -583,10 +584,10 @@ class RestClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/form"); - String contentType = request.getHeader(HttpHeaders.CONTENT_TYPE); + assertThat(request.getTarget()).isEqualTo("/form"); + String contentType = request.getHeaders().get(HttpHeaders.CONTENT_TYPE); assertThat(contentType).startsWith(MediaType.MULTIPART_FORM_DATA_VALUE); - String[] lines = request.getBody().readUtf8().split("\r\n"); + String[] lines = request.getBody().utf8().split("\r\n"); assertThat(lines).hasSize(13); assertThat(lines[0]).startsWith("--"); // boundary assertThat(lines[1]).isEqualTo("Content-Disposition: form-data; name=\"foo\""); @@ -606,9 +607,9 @@ class RestClientIntegrationTests { } @ParameterizedRestClientTest // gh-35102 - void postStreamingBody(ClientHttpRequestFactory requestFactory) { + void postStreamingBody(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> response.setResponseCode(200)); + prepareResponse(builder -> builder.code(200)); StreamingHttpOutputMessage.Body testBody = out -> { assertThat(out).as("Not a streaming response").isNotInstanceOf(FastByteArrayOutputStream.class); @@ -625,17 +626,17 @@ class RestClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/streaming/body"); - assertThat(request.getBody().readUtf8()).isEqualTo("test-data"); + assertThat(request.getTarget()).isEqualTo("/streaming/body"); + assertThat(request.getBody().utf8()).isEqualTo("test-data"); }); } @ParameterizedRestClientTest - void statusHandler(ClientHttpRequestFactory requestFactory) { + void statusHandler(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> response.setResponseCode(500) - .setHeader("Content-Type", "text/plain").setBody("Internal Server error")); + prepareResponse(builder -> builder.code(500) + .setHeader("Content-Type", "text/plain").body("Internal Server error")); assertThatExceptionOfType(MyException.class).isThrownBy(() -> this.restClient.get() @@ -648,15 +649,15 @@ class RestClientIntegrationTests { ); expectRequestCount(1); - expectRequest(request -> assertThat(request.getPath()).isEqualTo("/greeting")); + expectRequest(request -> assertThat(request.getTarget()).isEqualTo("/greeting")); } @ParameterizedRestClientTest - void statusHandlerIOException(ClientHttpRequestFactory requestFactory) { + void statusHandlerIOException(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> response.setResponseCode(500) - .setHeader("Content-Type", "text/plain").setBody("Internal Server error")); + prepareResponse(builder -> builder.code(500) + .setHeader("Content-Type", "text/plain").body("Internal Server error")); assertThatExceptionOfType(RestClientException.class).isThrownBy(() -> this.restClient.get() @@ -669,15 +670,15 @@ class RestClientIntegrationTests { ).withCauseInstanceOf(IOException.class); expectRequestCount(1); - expectRequest(request -> assertThat(request.getPath()).isEqualTo("/greeting")); + expectRequest(request -> assertThat(request.getTarget()).isEqualTo("/greeting")); } @ParameterizedRestClientTest - void statusHandlerParameterizedTypeReference(ClientHttpRequestFactory requestFactory) { + void statusHandlerParameterizedTypeReference(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> response.setResponseCode(500) - .setHeader("Content-Type", "text/plain").setBody("Internal Server error")); + prepareResponse(builder -> builder.code(500) + .setHeader("Content-Type", "text/plain").body("Internal Server error")); assertThatExceptionOfType(MyException.class).isThrownBy(() -> this.restClient.get() @@ -691,15 +692,15 @@ class RestClientIntegrationTests { ); expectRequestCount(1); - expectRequest(request -> assertThat(request.getPath()).isEqualTo("/greeting")); + expectRequest(request -> assertThat(request.getTarget()).isEqualTo("/greeting")); } @ParameterizedRestClientTest - void statusHandlerSuppressedErrorSignal(ClientHttpRequestFactory requestFactory) { + void statusHandlerSuppressedErrorSignal(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> response.setResponseCode(500) - .setHeader("Content-Type", "text/plain").setBody("Internal Server error")); + prepareResponse(builder -> builder.code(500) + .setHeader("Content-Type", "text/plain").body("Internal Server error")); String result = this.restClient.get() .uri("/greeting") @@ -710,16 +711,16 @@ class RestClientIntegrationTests { assertThat(result).isEqualTo("Internal Server error"); expectRequestCount(1); - expectRequest(request -> assertThat(request.getPath()).isEqualTo("/greeting")); + expectRequest(request -> assertThat(request.getTarget()).isEqualTo("/greeting")); } @ParameterizedRestClientTest - void statusHandlerSuppressedErrorSignalWithEntity(ClientHttpRequestFactory requestFactory) { + void statusHandlerSuppressedErrorSignalWithEntity(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); String content = "Internal Server error"; - prepareResponse(response -> - response.setResponseCode(500).setHeader("Content-Type", "text/plain").setBody(content)); + prepareResponse(builder -> builder.code(500) + .setHeader("Content-Type", "text/plain").body(content)); ResponseEntity result = this.restClient.get() .uri("/").accept(MediaType.APPLICATION_JSON) @@ -733,16 +734,16 @@ class RestClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedRestClientTest - void exchangeForPlainText(ClientHttpRequestFactory requestFactory) { + void exchangeForPlainText(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> response.setBody("Hello Spring!")); + prepareResponse(builder -> builder.body("Hello Spring!")); String result = this.restClient.get() .uri("/greeting") @@ -753,18 +754,18 @@ class RestClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getHeader("X-Test-Header")).isEqualTo("testvalue"); - assertThat(request.getPath()).isEqualTo("/greeting"); + assertThat(request.getHeaders().get("X-Test-Header")).isEqualTo("testvalue"); + assertThat(request.getTarget()).isEqualTo("/greeting"); }); } @ParameterizedRestClientTest - void exchangeForJson(ClientHttpRequestFactory requestFactory) { + void exchangeForJson(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> response + prepareResponse(builder -> builder .setHeader("Content-Type", "application/json") - .setBody("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}")); + .body("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}")); Pojo result = this.restClient.get() .uri("/pojo") @@ -776,18 +777,18 @@ class RestClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/pojo"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/pojo"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedRestClientTest - void exchangeForJsonArray(ClientHttpRequestFactory requestFactory) { + void exchangeForJsonArray(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> response + prepareResponse(builder -> builder .setHeader("Content-Type", "application/json") - .setBody("[{\"bar\":\"bar1\",\"foo\":\"foo1\"},{\"bar\":\"bar2\",\"foo\":\"foo2\"}]")); + .body("[{\"bar\":\"bar1\",\"foo\":\"foo1\"},{\"bar\":\"bar2\",\"foo\":\"foo2\"}]")); List result = this.restClient.get() .uri("/pojo") @@ -802,17 +803,17 @@ class RestClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/pojo"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/pojo"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedRestClientTest - void exchangeFor404(ClientHttpRequestFactory requestFactory) { + void exchangeFor404(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> - response.setResponseCode(404).setHeader("Content-Type", "text/plain").setBody("Not Found")); + prepareResponse(builder -> + builder.code(404).setHeader("Content-Type", "text/plain").body("Not Found")); String result = this.restClient.get() .uri("/greeting") @@ -821,14 +822,14 @@ class RestClientIntegrationTests { assertThat(result).isEqualTo("Not Found"); expectRequestCount(1); - expectRequest(request -> assertThat(request.getPath()).isEqualTo("/greeting")); + expectRequest(request -> assertThat(request.getTarget()).isEqualTo("/greeting")); } @ParameterizedRestClientTest - void exchangeForRequiredValue(ClientHttpRequestFactory requestFactory) { + void exchangeForRequiredValue(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> response.setBody("Hello Spring!")); + prepareResponse(builder -> builder.body("Hello Spring!")); String result = this.restClient.get() .uri("/greeting") @@ -839,17 +840,17 @@ class RestClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getHeader("X-Test-Header")).isEqualTo("testvalue"); - assertThat(request.getPath()).isEqualTo("/greeting"); + assertThat(request.getHeaders().get("X-Test-Header")).isEqualTo("testvalue"); + assertThat(request.getTarget()).isEqualTo("/greeting"); }); } @ParameterizedRestClientTest @SuppressWarnings("DataFlowIssue") - void exchangeForNullRequiredValue(ClientHttpRequestFactory requestFactory) { + void exchangeForNullRequiredValue(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> response.setBody("Hello Spring!")); + prepareResponse(builder -> builder.body("Hello Spring!")); assertThatIllegalStateException().isThrownBy(() -> this.restClient.get() .uri("/greeting") @@ -858,11 +859,11 @@ class RestClientIntegrationTests { } @ParameterizedRestClientTest - void requestInitializer(ClientHttpRequestFactory requestFactory) { + void requestInitializer(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + prepareResponse(builder -> + builder.setHeader("Content-Type", "text/plain").body("Hello Spring!")); RestClient initializedClient = this.restClient.mutate() .requestInitializer(request -> request.getHeaders().add("foo", "bar")) @@ -876,15 +877,15 @@ class RestClientIntegrationTests { assertThat(result).isEqualTo("Hello Spring!"); expectRequestCount(1); - expectRequest(request -> assertThat(request.getHeader("foo")).isEqualTo("bar")); + expectRequest(request -> assertThat(request.getHeaders().get("foo")).isEqualTo("bar")); } @ParameterizedRestClientTest - void requestInterceptor(ClientHttpRequestFactory requestFactory) { + void requestInterceptor(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + prepareResponse(builder -> + builder.setHeader("Content-Type", "text/plain").body("Hello Spring!")); RestClient interceptedClient = this.restClient.mutate() .requestInterceptor((request, body, execution) -> { @@ -901,15 +902,15 @@ class RestClientIntegrationTests { assertThat(result).isEqualTo("Hello Spring!"); expectRequestCount(1); - expectRequest(request -> assertThat(request.getHeader("foo")).isEqualTo("bar")); + expectRequest(request -> assertThat(request.getHeaders().get("foo")).isEqualTo("bar")); } @ParameterizedRestClientTest - void requestInterceptorWithResponseBuffering(ClientHttpRequestFactory requestFactory) { + void requestInterceptorWithResponseBuffering(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + prepareResponse(builder -> + builder.setHeader("Content-Type", "text/plain").body("Hello Spring!")); RestClient interceptedClient = this.restClient.mutate() .requestInterceptor((request, body, execution) -> { @@ -931,11 +932,11 @@ class RestClientIntegrationTests { } @ParameterizedRestClientTest - void bufferContent(ClientHttpRequestFactory requestFactory) { + void bufferContent(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + prepareResponse(builder -> + builder.setHeader("Content-Type", "text/plain").body("Hello Spring!")); RestClient bufferingClient = this.restClient.mutate() .bufferContent((uri, httpMethod) -> true) @@ -956,10 +957,10 @@ class RestClientIntegrationTests { } @ParameterizedRestClientTest - void retrieveDefaultCookiesAsCookieHeader(ClientHttpRequestFactory requestFactory) { + void retrieveDefaultCookiesAsCookieHeader(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + prepareResponse(builder -> + builder.setHeader("Content-Type", "text/plain").body("Hello Spring!")); RestClient restClientWithCookies = this.restClient.mutate() .defaultCookie("testCookie", "firstValue", "secondValue") @@ -972,13 +973,13 @@ class RestClientIntegrationTests { .body(String.class); expectRequest(request -> - assertThat(request.getHeader(HttpHeaders.COOKIE)) + assertThat(request.getHeaders().get(HttpHeaders.COOKIE)) .isEqualTo("testCookie=firstValue; testCookie=secondValue") ); } @ParameterizedRestClientTest - void filterForErrorHandling(ClientHttpRequestFactory requestFactory) { + void filterForErrorHandling(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); ClientHttpRequestInterceptor interceptor = (request, body, execution) -> { @@ -995,8 +996,8 @@ class RestClientIntegrationTests { RestClient interceptedClient = this.restClient.mutate().requestInterceptor(interceptor).build(); // header not present - prepareResponse(response -> - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + prepareResponse(builder -> + builder.setHeader("Content-Type", "text/plain").body("Hello Spring!")); assertThatExceptionOfType(MyException.class).isThrownBy(() -> interceptedClient.get() @@ -1007,9 +1008,9 @@ class RestClientIntegrationTests { // header present - prepareResponse(response -> response.setHeader("Content-Type", "text/plain") + prepareResponse(builder -> builder.setHeader("Content-Type", "text/plain") .setHeader("Foo", "Bar") - .setBody("Hello Spring!")); + .body("Hello Spring!")); String result = interceptedClient.get() .uri("/greeting") @@ -1021,11 +1022,11 @@ class RestClientIntegrationTests { } @ParameterizedRestClientTest - void defaultHeaders(ClientHttpRequestFactory requestFactory) { + void defaultHeaders(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + prepareResponse(builder -> + builder.setHeader("Content-Type", "text/plain").body("Hello Spring!")); RestClient headersClient = this.restClient.mutate() .defaultHeaders(headers -> headers.add("foo", "bar")) @@ -1039,15 +1040,15 @@ class RestClientIntegrationTests { assertThat(result).isEqualTo("Hello Spring!"); expectRequestCount(1); - expectRequest(request -> assertThat(request.getHeader("foo")).isEqualTo("bar")); + expectRequest(request -> assertThat(request.getHeaders().get("foo")).isEqualTo("bar")); } @ParameterizedRestClientTest - void defaultRequest(ClientHttpRequestFactory requestFactory) { + void defaultRequest(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + prepareResponse(builder -> + builder.setHeader("Content-Type", "text/plain").body("Hello Spring!")); RestClient headersClient = this.restClient.mutate() .defaultRequest(request -> request.header("foo", "bar")) @@ -1061,15 +1062,15 @@ class RestClientIntegrationTests { assertThat(result).isEqualTo("Hello Spring!"); expectRequestCount(1); - expectRequest(request -> assertThat(request.getHeader("foo")).isEqualTo("bar")); + expectRequest(request -> assertThat(request.getHeaders().get("foo")).isEqualTo("bar")); } @ParameterizedRestClientTest - void defaultRequestOverride(ClientHttpRequestFactory requestFactory) { + void defaultRequestOverride(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + prepareResponse(builder -> + builder.setHeader("Content-Type", "text/plain").body("Hello Spring!")); RestClient headersClient = this.restClient.mutate() .defaultRequest(request -> request.accept(MediaType.APPLICATION_JSON)) @@ -1084,15 +1085,15 @@ class RestClientIntegrationTests { assertThat(result).isEqualTo("Hello Spring!"); expectRequestCount(1); - expectRequest(request -> assertThat(request.getHeader("Accept")).isEqualTo(MediaType.TEXT_PLAIN_VALUE)); + expectRequest(request -> assertThat(request.getHeaders().get("Accept")).isEqualTo(MediaType.TEXT_PLAIN_VALUE)); } @ParameterizedRestClientTest - void relativeUri(ClientHttpRequestFactory requestFactory) throws URISyntaxException { + void relativeUri(ClientHttpRequestFactory requestFactory) throws URISyntaxException, IOException { startServer(requestFactory); - prepareResponse(response -> - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + prepareResponse(builder -> + builder.setHeader("Content-Type", "text/plain").body("Hello Spring!")); URI uri = new URI(null, null, "/foo bar", null); @@ -1106,15 +1107,15 @@ class RestClientIntegrationTests { assertThat(result).isEqualTo("Hello Spring!"); expectRequestCount(1); - expectRequest(request -> assertThat(request.getPath()).isEqualTo("/foo%20bar")); + expectRequest(request -> assertThat(request.getTarget()).isEqualTo("/foo%20bar")); } @ParameterizedRestClientTest - void cookieAddsCookie(ClientHttpRequestFactory requestFactory) { + void cookieAddsCookie(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + prepareResponse(builder -> + builder.setHeader("Content-Type", "text/plain").body("Hello Spring!")); this.restClient.get() .uri("/greeting") @@ -1124,15 +1125,15 @@ class RestClientIntegrationTests { .retrieve() .body(String.class); - expectRequest(request -> assertThat(request.getHeader("Cookie")).isEqualTo("c1=v1a; c1=v1b; c2=v2a")); + expectRequest(request -> assertThat(request.getHeaders().get("Cookie")).isEqualTo("c1=v1a; c1=v1b; c2=v2a")); } @ParameterizedRestClientTest - void cookieOverridesDefaultCookie(ClientHttpRequestFactory requestFactory) { + void cookieOverridesDefaultCookie(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + prepareResponse(builder -> + builder.setHeader("Content-Type", "text/plain").body("Hello Spring!")); RestClient restClientWithCookies = this.restClient.mutate() .defaultCookie("testCookie", "firstValue", "secondValue") @@ -1144,15 +1145,15 @@ class RestClientIntegrationTests { .retrieve() .body(String.class); - expectRequest(request -> assertThat(request.getHeader("Cookie")).isEqualTo("testCookie=test")); + expectRequest(request -> assertThat(request.getHeaders().get("Cookie")).isEqualTo("testCookie=test")); } @ParameterizedRestClientTest - void cookiesCanRemoveCookie(ClientHttpRequestFactory requestFactory) { + void cookiesCanRemoveCookie(ClientHttpRequestFactory requestFactory) throws IOException { startServer(requestFactory); - prepareResponse(response -> - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + prepareResponse(builder -> + builder.setHeader("Content-Type", "text/plain").body("Hello Spring!")); this.restClient.get() .uri("/greeting") @@ -1162,13 +1163,12 @@ class RestClientIntegrationTests { .retrieve() .body(String.class); - expectRequest(request -> assertThat(request.getHeader("Cookie")).isEqualTo("test=Hello")); + expectRequest(request -> assertThat(request.getHeaders().get("Cookie")).isEqualTo("test=Hello")); } - private void prepareResponse(Consumer consumer) { - MockResponse response = new MockResponse(); - consumer.accept(response); - this.server.enqueue(response); + private void prepareResponse(Function f) { + MockResponse.Builder builder = new MockResponse.Builder(); + this.server.enqueue(f.apply(builder).build()); } private void expectRequest(Consumer consumer) { 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 00d823fa2c..adddf6e260 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 @@ -19,9 +19,9 @@ package org.springframework.web.client; import java.io.IOException; import java.util.function.Consumer; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import mockwebserver3.RecordedRequest; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -39,46 +39,50 @@ public class RestClientVersionTests { private final MockWebServer server = new MockWebServer(); - private final RestClient.Builder restClientBuilder = RestClient.builder() - .requestFactory(new JdkClientHttpRequestFactory()) - .baseUrl(this.server.url("/").toString()); + private RestClient.Builder restClientBuilder; @BeforeEach - void setUp() { - MockResponse response = new MockResponse(); - response.setHeader("Content-Type", "text/plain").setBody("body"); + void setUp() throws IOException { + this.server.start(); + this.restClientBuilder = RestClient.builder() + .requestFactory(new JdkClientHttpRequestFactory()) + .baseUrl(this.server.url("/").toString()); + MockResponse response = new MockResponse.Builder() + .setHeader("Content-Type", "text/plain") + .body("body") + .build(); this.server.enqueue(response); } @AfterEach - void shutdown() throws IOException { - this.server.shutdown(); + void shutdown() { + this.server.close(); } @Test void header() { performRequest(ApiVersionInserter.useHeader("X-API-Version")); - expectRequest(request -> assertThat(request.getHeader("X-API-Version")).isEqualTo("1.2")); + expectRequest(request -> assertThat(request.getHeaders().get("X-API-Version")).isEqualTo("1.2")); } @Test void queryParam() { performRequest(ApiVersionInserter.useQueryParam("api-version")); - expectRequest(request -> assertThat(request.getPath()).isEqualTo("/path?api-version=1.2")); + expectRequest(request -> assertThat(request.getTarget()).isEqualTo("/path?api-version=1.2")); } @Test void pathSegmentIndexLessThanSize() { performRequest(ApiVersionInserter.builder().usePathSegment(0).withVersionFormatter(v -> "v" + v).build()); - expectRequest(request -> assertThat(request.getPath()).isEqualTo("/v1.2/path")); + expectRequest(request -> assertThat(request.getTarget()).isEqualTo("/v1.2/path")); } @Test void pathSegmentIndexEqualToSize() { performRequest(ApiVersionInserter.builder().usePathSegment(1).withVersionFormatter(v -> "v" + v).build()); - expectRequest(request -> assertThat(request.getPath()).isEqualTo("/path/v1.2")); + expectRequest(request -> assertThat(request.getTarget()).isEqualTo("/path/v1.2")); } @Test @@ -94,7 +98,7 @@ public class RestClientVersionTests { RestClient restClient = restClientBuilder.defaultApiVersion(1.2).apiVersionInserter(inserter).build(); restClient.get().uri("/path").retrieve().body(String.class); - expectRequest(request -> assertThat(request.getHeader("X-API-Version")).isEqualTo("1.2")); + expectRequest(request -> assertThat(request.getHeaders().get("X-API-Version")).isEqualTo("1.2")); } private void performRequest(ApiVersionInserter versionInserter) { diff --git a/spring-web/src/test/java/org/springframework/web/client/RestTemplateTests.java b/spring-web/src/test/java/org/springframework/web/client/RestTemplateTests.java index f824ed0843..0e1ae35ce5 100644 --- a/spring-web/src/test/java/org/springframework/web/client/RestTemplateTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/RestTemplateTests.java @@ -28,9 +28,9 @@ import java.util.Map; import java.util.Map.Entry; import java.util.Set; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import mockwebserver3.RecordedRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -504,18 +504,18 @@ class RestTemplateTests { @Test // gh-23740 void headerAcceptAllOnPut() throws Exception { try (MockWebServer server = new MockWebServer()) { - server.enqueue(new MockResponse().setResponseCode(500).setBody("internal server error")); + server.enqueue(new MockResponse.Builder().code(500).body("internal server error").build()); server.start(); template.setRequestFactory(new SimpleClientHttpRequestFactory()); template.put(server.url("/internal/server/error").uri(), null); - assertThat(server.takeRequest().getHeader("Accept")).isEqualTo("*/*"); + assertThat(server.takeRequest().getHeaders().get("Accept")).isEqualTo("*/*"); } } @Test // gh-23740 void keepGivenAcceptHeaderOnPut() throws Exception { try (MockWebServer server = new MockWebServer()) { - server.enqueue(new MockResponse().setResponseCode(500).setBody("internal server error")); + server.enqueue(new MockResponse.Builder().code(500).body("internal server error").build()); server.start(); HttpHeaders headers = new HttpHeaders(); headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); @@ -582,11 +582,11 @@ class RestTemplateTests { @Test // gh-23740 void headerAcceptAllOnDelete() throws Exception { try (MockWebServer server = new MockWebServer()) { - server.enqueue(new MockResponse().setResponseCode(500).setBody("internal server error")); + server.enqueue(new MockResponse.Builder().code(500).body("internal server error").build()); server.start(); template.setRequestFactory(new SimpleClientHttpRequestFactory()); template.delete(server.url("/internal/server/error").uri()); - assertThat(server.takeRequest().getHeader("Accept")).isEqualTo("*/*"); + assertThat(server.takeRequest().getHeaders().get("Accept")).isEqualTo("*/*"); } } @@ -773,7 +773,7 @@ class RestTemplateTests { @Test void requestInterceptorWithBuffering() throws Exception { try (MockWebServer server = new MockWebServer()) { - server.enqueue(new MockResponse().setResponseCode(200).setBody("Hello Spring!")); + server.enqueue(new MockResponse.Builder().code(200).body("Hello Spring!").build()); server.start(); template.setRequestFactory(new SimpleClientHttpRequestFactory()); template.setInterceptors(List.of((request, body, execution) -> { @@ -793,7 +793,7 @@ class RestTemplateTests { @Test void buffering() throws Exception { try (MockWebServer server = new MockWebServer()) { - server.enqueue(new MockResponse().setResponseCode(200).setBody("Hello Spring!")); + server.enqueue(new MockResponse.Builder().code(200).body("Hello Spring!").build()); server.start(); template.setRequestFactory(new SimpleClientHttpRequestFactory()); template.setBufferingPredicate((uri, httpMethod) -> true); 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 4c3dfdedf9..b288a6e659 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 @@ -28,15 +28,16 @@ import java.util.LinkedHashSet; import java.util.Optional; import java.util.Set; import java.util.function.BiFunction; -import java.util.function.Consumer; +import java.util.function.Function; import java.util.stream.Stream; import io.micrometer.observation.tck.TestObservationRegistry; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import mockwebserver3.RecordedRequest; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; @@ -82,10 +83,14 @@ class RestClientAdapterTests { private final MockWebServer anotherServer = new MockWebServer(); + @BeforeEach + void setUp() throws IOException { + this.anotherServer.start(); + } @AfterEach - void shutdown() throws IOException { - this.anotherServer.shutdown(); + void shutdown() { + this.anotherServer.close(); } @@ -96,7 +101,7 @@ class RestClientAdapterTests { @interface ParameterizedAdapterTest { } - public static Stream arguments() { + public static Stream arguments() throws IOException { return Stream.of( createArgsForAdapter((url, or) -> { RestClient restClient = RestClient.builder().baseUrl(url).observationRegistry(or).build(); @@ -112,12 +117,15 @@ class RestClientAdapterTests { @SuppressWarnings("resource") private static Object[] createArgsForAdapter( - BiFunction adapterFactory) { + BiFunction adapterFactory) throws IOException { MockWebServer server = new MockWebServer(); + server.start(); - MockResponse response = new MockResponse(); - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!"); + MockResponse response = new MockResponse.Builder() + .setHeader("Content-Type", "text/plain") + .body("Hello Spring!") + .build(); server.enqueue(response); TestObservationRegistry registry = TestObservationRegistry.create(); @@ -137,7 +145,7 @@ class RestClientAdapterTests { RecordedRequest request = server.takeRequest(); assertThat(response).isEqualTo("Hello Spring!"); assertThat(request.getMethod()).isEqualTo("GET"); - assertThat(request.getPath()).isEqualTo("/greeting"); + assertThat(request.getTarget()).isEqualTo("/greeting"); assertThat(registry).hasObservationWithNameEqualTo("http.client.requests").that() .hasLowCardinalityKeyValue("uri", "/greeting"); } @@ -151,7 +159,7 @@ class RestClientAdapterTests { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getBody()).isEqualTo("Hello Spring!"); assertThat(request.getMethod()).isEqualTo("GET"); - assertThat(request.getPath()).isEqualTo("/greeting/456"); + assertThat(request.getTarget()).isEqualTo("/greeting/456"); assertThat(registry).hasObservationWithNameEqualTo("http.client.requests").that() .hasLowCardinalityKeyValue("uri", "/greeting/{id}"); } @@ -165,15 +173,15 @@ class RestClientAdapterTests { RecordedRequest request = server.takeRequest(); assertThat(response.orElse("empty")).isEqualTo("Hello Spring!"); assertThat(request.getMethod()).isEqualTo("GET"); - assertThat(request.getRequestUrl().uri()).isEqualTo(dynamicUri); + assertThat(request.getUrl().uri()).isEqualTo(dynamicUri); assertThat(registry).hasObservationWithNameEqualTo("http.client.requests").that() .hasLowCardinalityKeyValue("uri", "none"); } @Test void greetingWithApiVersion() throws Exception { - prepareResponse(response -> - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring 2!")); + prepareResponse(builder -> + builder.setHeader("Content-Type", "text/plain").body("Hello Spring 2!")); RestClient restClient = RestClient.builder() .baseUrl(anotherServer.url("/").toString()) @@ -186,14 +194,14 @@ class RestClientAdapterTests { String actualResponse = service.getGreetingWithVersion(); RecordedRequest request = anotherServer.takeRequest(); - assertThat(request.getHeader("X-API-Version")).isEqualTo("1.2"); + assertThat(request.getHeaders().get("X-API-Version")).isEqualTo("1.2"); assertThat(actualResponse).isEqualTo("Hello Spring 2!"); } @ParameterizedAdapterTest void getWithUriBuilderFactory(MockWebServer server, Service service) throws InterruptedException { - prepareResponse(response -> - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring 2!")); + prepareResponse(builder -> + builder.setHeader("Content-Type", "text/plain").body("Hello Spring 2!")); String url = this.anotherServer.url("/").toString(); UriBuilderFactory factory = new DefaultUriBuilderFactory(url); @@ -204,14 +212,14 @@ class RestClientAdapterTests { assertThat(actualResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(actualResponse.getBody()).isEqualTo("Hello Spring 2!"); assertThat(request.getMethod()).isEqualTo("GET"); - assertThat(request.getPath()).isEqualTo("/greeting"); + assertThat(request.getTarget()).isEqualTo("/greeting"); assertThat(server.getRequestCount()).isEqualTo(0); } @ParameterizedAdapterTest void getWithFactoryPathVariableAndRequestParam(MockWebServer server, Service service) throws InterruptedException { - prepareResponse(response -> - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring 2!")); + prepareResponse(builder -> + builder.setHeader("Content-Type", "text/plain").body("Hello Spring 2!")); String url = this.anotherServer.url("/").toString(); UriBuilderFactory factory = new DefaultUriBuilderFactory(url); @@ -222,14 +230,14 @@ class RestClientAdapterTests { assertThat(actualResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(actualResponse.getBody()).isEqualTo("Hello Spring 2!"); assertThat(request.getMethod()).isEqualTo("GET"); - assertThat(request.getPath()).isEqualTo("/greeting/123?param=test"); + assertThat(request.getTarget()).isEqualTo("/greeting/123?param=test"); assertThat(server.getRequestCount()).isEqualTo(0); } @ParameterizedAdapterTest void getWithIgnoredUriBuilderFactory(MockWebServer server, Service service) throws InterruptedException { - prepareResponse(response -> - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring 2!")); + prepareResponse(builder -> + builder.setHeader("Content-Type", "text/plain").body("Hello Spring 2!")); URI dynamicUri = server.url("/greeting/123").uri(); UriBuilderFactory factory = new DefaultUriBuilderFactory(this.anotherServer.url("/").toString()); @@ -240,7 +248,7 @@ class RestClientAdapterTests { assertThat(actualResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(actualResponse.getBody()).isEqualTo("Hello Spring!"); assertThat(request.getMethod()).isEqualTo("GET"); - assertThat(request.getPath()).isEqualTo("/greeting/123"); + assertThat(request.getTarget()).isEqualTo("/greeting/123"); assertThat(this.anotherServer.getRequestCount()).isEqualTo(0); } @@ -250,9 +258,9 @@ class RestClientAdapterTests { RecordedRequest request = server.takeRequest(); assertThat(request.getMethod()).isEqualTo("POST"); - assertThat(request.getPath()).isEqualTo("/greeting"); + assertThat(request.getTarget()).isEqualTo("/greeting"); assertThat(request.getHeaders().get("testHeaderName")).isEqualTo("testHeader"); - assertThat(request.getBody().readUtf8()).isEqualTo("testBody"); + assertThat(request.getBody().utf8()).isEqualTo("testBody"); } @ParameterizedAdapterTest @@ -265,7 +273,7 @@ class RestClientAdapterTests { RecordedRequest request = server.takeRequest(); assertThat(request.getHeaders().get("Content-Type")).isEqualTo("application/x-www-form-urlencoded"); - assertThat(request.getBody().readUtf8()).isEqualTo("param1=value+1¶m2=value+2"); + assertThat(request.getBody().utf8()).isEqualTo("param1=value+1¶m2=value+2"); } @ParameterizedAdapterTest // gh-30342 @@ -277,7 +285,7 @@ class RestClientAdapterTests { RecordedRequest request = server.takeRequest(); assertThat(request.getHeaders().get("Content-Type")).startsWith("multipart/form-data;boundary="); - assertThat(request.getBody().readUtf8()).containsSubsequence( + assertThat(request.getBody().utf8()).containsSubsequence( "Content-Disposition: form-data; name=\"file\"; filename=\"originalTestFileName\"", "Content-Type: application/json", "Content-Length: 4", "test", "Content-Disposition: form-data; name=\"anotherPart\"", "Content-Type: text/plain;charset=UTF-8", @@ -293,8 +301,8 @@ class RestClientAdapterTests { RecordedRequest request = server.takeRequest(); assertThat(request.getMethod()).isEqualTo("POST"); - assertThat(request.getPath()).isEqualTo("/persons"); - assertThat(request.getBody().readUtf8()).isEqualTo("[{\"name\":\"John\"},{\"name\":\"Richard\"}]"); + assertThat(request.getTarget()).isEqualTo("/persons"); + assertThat(request.getBody().utf8()).isEqualTo("[{\"name\":\"John\"},{\"name\":\"Richard\"}]"); } @ParameterizedAdapterTest @@ -303,7 +311,7 @@ class RestClientAdapterTests { RecordedRequest request = server.takeRequest(); assertThat(request.getMethod()).isEqualTo("PUT"); - assertThat(request.getHeader("Cookie")).isEqualTo("firstCookie=test1; secondCookie=test2"); + assertThat(request.getHeaders().get("Cookie")).isEqualTo("firstCookie=test1; secondCookie=test2"); } @ParameterizedAdapterTest @@ -312,38 +320,37 @@ class RestClientAdapterTests { RecordedRequest request = server.takeRequest(); assertThat(request.getMethod()).isEqualTo("PUT"); - assertThat(request.getHeader("Cookie")).isEqualTo("testCookie=test1; testCookie=test2"); + assertThat(request.getHeaders().get("Cookie")).isEqualTo("testCookie=test1; testCookie=test2"); } @Test void getInputStream() throws Exception { - prepareResponse(response -> - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring 2!")); + prepareResponse(builder -> + builder.setHeader("Content-Type", "text/plain").body("Hello Spring 2!")); InputStream inputStream = initService().getInputStream(); RecordedRequest request = this.anotherServer.takeRequest(); - assertThat(request.getPath()).isEqualTo("/input-stream"); + assertThat(request.getTarget()).isEqualTo("/input-stream"); assertThat(StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8)).isEqualTo("Hello Spring 2!"); } @Test void postOutputStream() throws Exception { - prepareResponse(response -> - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring 2!")); + prepareResponse(builder -> + builder.setHeader("Content-Type", "text/plain").body("Hello Spring 2!")); String body = "test stream"; initService().postOutputStream(outputStream -> outputStream.write(body.getBytes())); RecordedRequest request = this.anotherServer.takeRequest(); - assertThat(request.getPath()).isEqualTo("/output-stream"); - assertThat(request.getBody().readUtf8()).isEqualTo(body); + assertThat(request.getTarget()).isEqualTo("/output-stream"); + assertThat(request.getBody().utf8()).isEqualTo(body); } @Test void handleNotFoundException() { - MockResponse response = new MockResponse(); - response.setResponseCode(404); + MockResponse response = new MockResponse.Builder().code(404).build(); this.anotherServer.enqueue(response); RestClientAdapter clientAdapter = RestClientAdapter.create( @@ -366,10 +373,9 @@ class RestClientAdapterTests { return HttpServiceProxyFactory.builderFor(adapter).build().createClient(Service.class); } - private void prepareResponse(Consumer consumer) { - MockResponse response = new MockResponse(); - consumer.accept(response); - this.anotherServer.enqueue(response); + private void prepareResponse(Function f) { + MockResponse.Builder builder = new MockResponse.Builder(); + this.anotherServer.enqueue(f.apply(builder).build()); } 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 1e6e119bdc..566196991e 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 @@ -16,11 +16,9 @@ package org.springframework.web.client.support; -import java.io.IOException; - -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import mockwebserver3.RecordedRequest; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -61,8 +59,8 @@ public class RestClientProxyRegistryIntegrationTests { } @AfterEach - void shutdown() throws IOException { - this.server.shutdown(); + void shutdown() { + this.server.close(); } @@ -89,7 +87,7 @@ public class RestClientProxyRegistryIntegrationTests { assertThat(registry.getClient(GreetingB.class)).isSameAs(greetingB); for (int i = 0; i < 4; i++) { - this.server.enqueue(new MockResponse().setBody("body")); + this.server.enqueue(new MockResponse.Builder().body("body").build()); } echoA.handle("a"); @@ -97,22 +95,22 @@ public class RestClientProxyRegistryIntegrationTests { RecordedRequest request = this.server.takeRequest(); assertThat(request.getMethod()).isEqualTo("GET"); - assertThat(request.getPath()).isEqualTo("/echoA?input=a"); + assertThat(request.getTarget()).isEqualTo("/echoA?input=a"); request = this.server.takeRequest(); assertThat(request.getMethod()).isEqualTo("GET"); - assertThat(request.getPath()).isEqualTo("/echoB?input=b"); + assertThat(request.getTarget()).isEqualTo("/echoB?input=b"); greetingA.handle("a"); greetingB.handle("b"); request = this.server.takeRequest(); assertThat(request.getMethod()).isEqualTo("GET"); - assertThat(request.getPath()).isEqualTo("/greetingA?input=a"); + assertThat(request.getTarget()).isEqualTo("/greetingA?input=a"); request = this.server.takeRequest(); assertThat(request.getMethod()).isEqualTo("GET"); - assertThat(request.getPath()).isEqualTo("/greetingB?input=b"); + assertThat(request.getTarget()).isEqualTo("/greetingB?input=b"); } @Test diff --git a/spring-web/src/test/kotlin/org/springframework/web/client/support/KotlinRestTemplateHttpServiceProxyTests.kt b/spring-web/src/test/kotlin/org/springframework/web/client/support/KotlinRestTemplateHttpServiceProxyTests.kt index e3090c0731..4b11f2def0 100644 --- a/spring-web/src/test/kotlin/org/springframework/web/client/support/KotlinRestTemplateHttpServiceProxyTests.kt +++ b/spring-web/src/test/kotlin/org/springframework/web/client/support/KotlinRestTemplateHttpServiceProxyTests.kt @@ -16,8 +16,8 @@ package org.springframework.web.client.support -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach @@ -57,8 +57,10 @@ class KotlinRestTemplateHttpServiceProxyTests { @BeforeEach fun setUp() { server = MockWebServer() + server.start() prepareResponse() anotherServer = anotherServer() + anotherServer.start() testService = initTestService() } @@ -73,8 +75,8 @@ class KotlinRestTemplateHttpServiceProxyTests { @AfterEach fun shutDown() { - server.shutdown() - anotherServer.shutdown() + server.close() + anotherServer.close() } @Test @@ -85,7 +87,7 @@ class KotlinRestTemplateHttpServiceProxyTests { val request = server.takeRequest() assertThat(response).isEqualTo("Hello Spring!") assertThat(request.method).isEqualTo("GET") - assertThat(request.path).isEqualTo("/test") + assertThat(request.target).isEqualTo("/test") } @Test @@ -97,20 +99,20 @@ class KotlinRestTemplateHttpServiceProxyTests { assertThat(response.statusCode).isEqualTo(HttpStatus.OK) assertThat(response.body).isEqualTo("Hello Spring!") assertThat(request.method).isEqualTo("GET") - assertThat(request.path).isEqualTo("/test/456") + assertThat(request.target).isEqualTo("/test/456") } @Test @Throws(InterruptedException::class) fun getRequestWithDynamicUri() { - val dynamicUri = server.url("/greeting/123").uri() + val dynamicUri = server.url("/greeting/123").toUri() val response = testService.getRequestWithDynamicUri(dynamicUri, "456") val request = server.takeRequest() assertThat(response.orElse("empty")).isEqualTo("Hello Spring!") assertThat(request.method).isEqualTo("GET") - assertThat(request.requestUrl.uri()).isEqualTo(dynamicUri) + assertThat(request.url.toUri()).isEqualTo(dynamicUri) } @Test @@ -120,9 +122,9 @@ class KotlinRestTemplateHttpServiceProxyTests { val request = server.takeRequest() assertThat(request.method).isEqualTo("POST") - assertThat(request.path).isEqualTo("/test") + assertThat(request.target).isEqualTo("/test") assertThat(request.headers["testHeaderName"]).isEqualTo("testHeader") - assertThat(request.body.readUtf8()).isEqualTo("testBody") + assertThat(request.body!!.utf8()).isEqualTo("testBody") } @Test @@ -136,7 +138,7 @@ class KotlinRestTemplateHttpServiceProxyTests { val request = server.takeRequest() assertThat(request.headers["Content-Type"]).isEqualTo("application/x-www-form-urlencoded") - assertThat(request.body.readUtf8()).isEqualTo("param1=value+1¶m2=value+2") + assertThat(request.body!!.utf8()).isEqualTo("param1=value+1¶m2=value+2") } // gh-30342 @@ -152,7 +154,7 @@ class KotlinRestTemplateHttpServiceProxyTests { val request = server.takeRequest() assertThat(request.headers["Content-Type"]).startsWith("multipart/form-data;boundary=") - assertThat(request.body.readUtf8()).containsSubsequence( + assertThat(request.body!!.utf8()).containsSubsequence( "Content-Disposition: form-data; name=\"file\"; filename=\"originalTestFileName\"", "Content-Type: application/json", "Content-Length: 4", "test", "Content-Disposition: form-data; name=\"anotherPart\"", "Content-Type: text/plain;charset=UTF-8", @@ -166,7 +168,7 @@ class KotlinRestTemplateHttpServiceProxyTests { val request = server.takeRequest() assertThat(request.method).isEqualTo("PUT") - assertThat(request.getHeader("Cookie")) + assertThat(request.headers["Cookie"]) .isEqualTo("firstCookie=test1; secondCookie=test2") } @@ -177,7 +179,7 @@ class KotlinRestTemplateHttpServiceProxyTests { val request = server.takeRequest() assertThat(request.method).isEqualTo("PUT") - assertThat(request.getHeader("Cookie")) + assertThat(request.headers["Cookie"]) .isEqualTo("testCookie=test1; testCookie=test2") } @@ -194,7 +196,7 @@ class KotlinRestTemplateHttpServiceProxyTests { assertThat(actualResponse.statusCode).isEqualTo(HttpStatus.OK) assertThat(actualResponse.body).isEqualTo("Hello Spring 2!") assertThat(request.method).isEqualTo("GET") - assertThat(request.path).isEqualTo("/greeting") + assertThat(request.target).isEqualTo("/greeting") assertThat(server.requestCount).isEqualTo(0) } @@ -212,14 +214,14 @@ class KotlinRestTemplateHttpServiceProxyTests { assertThat(actualResponse.statusCode).isEqualTo(HttpStatus.OK) assertThat(actualResponse.body).isEqualTo("Hello Spring 2!") assertThat(request.method).isEqualTo("GET") - assertThat(request.path).isEqualTo("/greeting/123?param=test") + assertThat(request.target).isEqualTo("/greeting/123?param=test") assertThat(server.requestCount).isEqualTo(0) } @Test @Throws(InterruptedException::class) fun getWithIgnoredUriBuilderFactory() { - val dynamicUri = server.url("/greeting/123").uri() + val dynamicUri = server.url("/greeting/123").toUri() val factory: UriBuilderFactory = DefaultUriBuilderFactory(anotherServer.url("/") .toString()) @@ -230,21 +232,23 @@ class KotlinRestTemplateHttpServiceProxyTests { assertThat(actualResponse.statusCode).isEqualTo(HttpStatus.OK) assertThat(actualResponse.body).isEqualTo("Hello Spring!") assertThat(request.method).isEqualTo("GET") - assertThat(request.path).isEqualTo("/greeting/123") + assertThat(request.target).isEqualTo("/greeting/123") assertThat(anotherServer.requestCount).isEqualTo(0) } private fun prepareResponse() { - val response = MockResponse() - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!") + val response = MockResponse.Builder() + .setHeader("Content-Type", "text/plain").body("Hello Spring!").build() server.enqueue(response) } private fun anotherServer(): MockWebServer { val anotherServer = MockWebServer() - val response = MockResponse() - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring 2!") + val response = MockResponse.Builder() + .setHeader("Content-Type", "text/plain") + .body("Hello Spring 2!") + .build() anotherServer.enqueue(response) return anotherServer } diff --git a/spring-webflux/spring-webflux.gradle b/spring-webflux/spring-webflux.gradle index 7445a756f5..85bc6bcaf2 100644 --- a/spring-webflux/spring-webflux.gradle +++ b/spring-webflux/spring-webflux.gradle @@ -40,7 +40,7 @@ dependencies { testImplementation(testFixtures(project(":spring-core"))) testImplementation(testFixtures(project(":spring-web"))) testImplementation("com.fasterxml:aalto-xml") - testImplementation("com.squareup.okhttp3:mockwebserver") + testImplementation("com.squareup.okhttp3:mockwebserver3") testImplementation("io.micrometer:context-propagation") testImplementation("io.micrometer:micrometer-observation-test") testImplementation("io.projectreactor:reactor-test") diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientDataBufferAllocatingTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientDataBufferAllocatingTests.java index 17fe153a7e..58624dd8bc 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientDataBufferAllocatingTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/WebClientDataBufferAllocatingTests.java @@ -16,6 +16,7 @@ package org.springframework.web.reactive.function.client; +import java.io.IOException; import java.time.Duration; import java.util.Collections; import java.util.Map; @@ -23,8 +24,8 @@ import java.util.function.Function; import io.netty.buffer.ByteBufAllocator; import io.netty.channel.ChannelOption; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.TestInstance; @@ -71,9 +72,10 @@ class WebClientDataBufferAllocatingTests extends AbstractDataBufferAllocatingTes this.factory.destroy(); } - private void setUp(DataBufferFactory bufferFactory) { + private void setUp(DataBufferFactory bufferFactory) throws IOException { super.bufferFactory = bufferFactory; this.server = new MockWebServer(); + this.server.start(); this.webClient = WebClient .builder() .clientConnector(initConnector()) @@ -96,13 +98,14 @@ class WebClientDataBufferAllocatingTests extends AbstractDataBufferAllocatingTes @ParameterizedDataBufferAllocatingTest - void bodyToMonoVoid(DataBufferFactory bufferFactory) { + void bodyToMonoVoid(DataBufferFactory bufferFactory) throws IOException { setUp(bufferFactory); - this.server.enqueue(new MockResponse() - .setResponseCode(201) + this.server.enqueue(new MockResponse.Builder(). + code(201) .setHeader("Content-Type", "application/json") - .setChunkedBody("{\"foo\" : {\"bar\" : \"123\", \"baz\" : \"456\"}}", 5)); + .chunkedBody("{\"foo\" : {\"bar\" : \"123\", \"baz\" : \"456\"}}", 5) + .build()); Mono mono = this.webClient.get() .uri("/json").accept(MediaType.APPLICATION_JSON) @@ -114,12 +117,13 @@ class WebClientDataBufferAllocatingTests extends AbstractDataBufferAllocatingTes } @ParameterizedDataBufferAllocatingTest // SPR-17482 - void bodyToMonoVoidWithoutContentType(DataBufferFactory bufferFactory) { + void bodyToMonoVoidWithoutContentType(DataBufferFactory bufferFactory) throws IOException { setUp(bufferFactory); - this.server.enqueue(new MockResponse() - .setResponseCode(HttpStatus.ACCEPTED.value()) - .setChunkedBody("{\"foo\" : \"123\", \"baz\" : \"456\", \"baz\" : \"456\"}", 5)); + this.server.enqueue(new MockResponse.Builder() + .code(HttpStatus.ACCEPTED.value()) + .chunkedBody("{\"foo\" : \"123\", \"baz\" : \"456\", \"baz\" : \"456\"}", 5) + .build()); Mono> mono = this.webClient.get() .uri("/sample").accept(MediaType.APPLICATION_JSON) @@ -131,7 +135,7 @@ class WebClientDataBufferAllocatingTests extends AbstractDataBufferAllocatingTes } @ParameterizedDataBufferAllocatingTest - void onStatusWithBodyNotConsumed(DataBufferFactory bufferFactory) { + void onStatusWithBodyNotConsumed(DataBufferFactory bufferFactory) throws IOException { setUp(bufferFactory); RuntimeException ex = new RuntimeException("response error"); @@ -139,7 +143,7 @@ class WebClientDataBufferAllocatingTests extends AbstractDataBufferAllocatingTes } @ParameterizedDataBufferAllocatingTest - void onStatusWithBodyConsumed(DataBufferFactory bufferFactory) { + void onStatusWithBodyConsumed(DataBufferFactory bufferFactory) throws IOException { setUp(bufferFactory); RuntimeException ex = new RuntimeException("response error"); @@ -147,7 +151,7 @@ class WebClientDataBufferAllocatingTests extends AbstractDataBufferAllocatingTes } @ParameterizedDataBufferAllocatingTest // SPR-17473 - void onStatusWithMonoErrorAndBodyNotConsumed(DataBufferFactory bufferFactory) { + void onStatusWithMonoErrorAndBodyNotConsumed(DataBufferFactory bufferFactory) throws IOException { setUp(bufferFactory); RuntimeException ex = new RuntimeException("response error"); @@ -155,7 +159,7 @@ class WebClientDataBufferAllocatingTests extends AbstractDataBufferAllocatingTes } @ParameterizedDataBufferAllocatingTest - void onStatusWithMonoErrorAndBodyConsumed(DataBufferFactory bufferFactory) { + void onStatusWithMonoErrorAndBodyConsumed(DataBufferFactory bufferFactory) throws IOException { setUp(bufferFactory); RuntimeException ex = new RuntimeException("response error"); @@ -163,7 +167,7 @@ class WebClientDataBufferAllocatingTests extends AbstractDataBufferAllocatingTes } @ParameterizedDataBufferAllocatingTest // gh-23230 - void onStatusWithImmediateErrorAndBodyNotConsumed(DataBufferFactory bufferFactory) { + void onStatusWithImmediateErrorAndBodyNotConsumed(DataBufferFactory bufferFactory) throws IOException { setUp(bufferFactory); RuntimeException ex = new RuntimeException("response error"); @@ -173,13 +177,14 @@ class WebClientDataBufferAllocatingTests extends AbstractDataBufferAllocatingTes } @ParameterizedDataBufferAllocatingTest - void releaseBody(DataBufferFactory bufferFactory) { + void releaseBody(DataBufferFactory bufferFactory) throws IOException { setUp(bufferFactory); - this.server.enqueue(new MockResponse() - .setResponseCode(200) + this.server.enqueue(new MockResponse.Builder() + .code(200) .setHeader("Content-Type", "text/plain") - .setBody("foo bar")); + .body("foo bar") + .build()); Mono result = this.webClient.get() .exchangeToMono(ClientResponse::releaseBody); @@ -190,13 +195,14 @@ class WebClientDataBufferAllocatingTests extends AbstractDataBufferAllocatingTes } @ParameterizedDataBufferAllocatingTest - void exchangeToBodilessEntity(DataBufferFactory bufferFactory) { + void exchangeToBodilessEntity(DataBufferFactory bufferFactory) throws IOException { setUp(bufferFactory); - this.server.enqueue(new MockResponse() - .setResponseCode(201) + this.server.enqueue(new MockResponse.Builder() + .code(201) .setHeader("Foo", "bar") - .setBody("foo bar")); + .body("foo bar") + .build()); Mono> result = this.webClient.get() .exchangeToMono(ClientResponse::toBodilessEntity); @@ -217,10 +223,11 @@ class WebClientDataBufferAllocatingTests extends AbstractDataBufferAllocatingTes HttpStatus errorStatus = HttpStatus.BAD_GATEWAY; - this.server.enqueue(new MockResponse() - .setResponseCode(errorStatus.value()) + this.server.enqueue(new MockResponse.Builder() + .code(errorStatus.value()) .setHeader("Content-Type", "application/json") - .setChunkedBody("{\"error\" : {\"status\" : 502, \"message\" : \"Bad gateway.\"}}", 5)); + .chunkedBody("{\"error\" : {\"status\" : 502, \"message\" : \"Bad gateway.\"}}", 5) + .build()); Mono mono = this.webClient.get() .uri("/json").accept(MediaType.APPLICATION_JSON) 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 442d516690..6a1c36197c 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 @@ -16,7 +16,6 @@ package org.springframework.web.reactive.function.client; -import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -41,9 +40,9 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import io.netty.util.Attribute; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import mockwebserver3.RecordedRequest; import org.eclipse.jetty.client.Request; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -118,8 +117,9 @@ class WebClientIntegrationTests { private WebClient webClient; - private void startServer(ClientHttpConnector connector) { + private void startServer(ClientHttpConnector connector) throws IOException { this.server = new MockWebServer(); + this.server.start(); this.webClient = WebClient .builder() .clientConnector(connector) @@ -128,19 +128,20 @@ class WebClientIntegrationTests { } @AfterEach - void shutdown() throws IOException { + void shutdown() { if (server != null) { - this.server.shutdown(); + this.server.close(); } } @ParameterizedWebClientTest - void retrieve(ClientHttpConnector connector) { + void retrieve(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + prepareResponse(builder -> builder + .setHeader("Content-Type", "text/plain") + .body("Hello Spring!")); Mono result = this.webClient.get() .uri("/greeting") @@ -156,20 +157,20 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getHeader(HttpHeaders.COOKIE)).isEqualTo("testkey=testvalue"); - assertThat(request.getHeader("X-Test-Header")).isEqualTo("testvalue"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("*/*"); - assertThat(request.getPath()).isEqualTo("/greeting"); + assertThat(request.getHeaders().get(HttpHeaders.COOKIE)).isEqualTo("testkey=testvalue"); + assertThat(request.getHeaders().get("X-Test-Header")).isEqualTo("testvalue"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("*/*"); + assertThat(request.getTarget()).isEqualTo("/greeting"); }); } @ParameterizedWebClientTest - void retrieveJson(ClientHttpConnector connector) { + void retrieveJson(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> response + prepareResponse(builder -> builder .setHeader("Content-Type", "application/json") - .setBody("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}")); + .body("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}")); Mono result = this.webClient.get() .uri("/pojo") @@ -184,15 +185,15 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/pojo"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/pojo"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedWebClientTest - void applyAttributesToNativeRequest(ClientHttpConnector connector) { + void applyAttributesToNativeRequest(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> {}); + prepareResponse(Function.identity()); final AtomicReference nativeRequest = new AtomicReference<>(); Mono result = this.webClient.get() @@ -227,12 +228,13 @@ class WebClientIntegrationTests { @ParameterizedWebClientTest - void retrieveJsonWithParameterizedTypeReference(ClientHttpConnector connector) { + void retrieveJsonWithParameterizedTypeReference(ClientHttpConnector connector) throws IOException { startServer(connector); String content = "{\"containerValue\":{\"bar\":\"barbar\",\"foo\":\"foofoo\"}}"; - prepareResponse(response -> response - .setHeader("Content-Type", "application/json").setBody(content)); + prepareResponse(builder -> builder + .setHeader("Content-Type", "application/json") + .body(content)); Mono> result = this.webClient.get() .uri("/json").accept(MediaType.APPLICATION_JSON) @@ -245,18 +247,19 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/json"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/json"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedWebClientTest - void retrieveJsonAsResponseEntity(ClientHttpConnector connector) { + void retrieveJsonAsResponseEntity(ClientHttpConnector connector) throws IOException { startServer(connector); String content = "{\"bar\":\"barbar\",\"foo\":\"foofoo\"}"; - prepareResponse(response -> response - .setHeader("Content-Type", "application/json").setBody(content)); + prepareResponse(builder -> builder + .setHeader("Content-Type", "application/json") + .body(content)); Mono> result = this.webClient.get() .uri("/json").accept(MediaType.APPLICATION_JSON) @@ -274,17 +277,18 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/json"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/json"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedWebClientTest - void retrieveJsonAsBodilessEntity(ClientHttpConnector connector) { + void retrieveJsonAsBodilessEntity(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> response - .setHeader("Content-Type", "application/json").setBody("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}")); + prepareResponse(builder -> builder + .setHeader("Content-Type", "application/json") + .body("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}")); Mono> result = this.webClient.get() .uri("/json").accept(MediaType.APPLICATION_JSON) @@ -302,18 +306,18 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/json"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/json"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedWebClientTest - void retrieveJsonArray(ClientHttpConnector connector) { + void retrieveJsonArray(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> response + prepareResponse(builder -> builder .setHeader("Content-Type", "application/json") - .setBody("[{\"bar\":\"bar1\",\"foo\":\"foo1\"},{\"bar\":\"bar2\",\"foo\":\"foo2\"}]")); + .body("[{\"bar\":\"bar1\",\"foo\":\"foo1\"},{\"bar\":\"bar2\",\"foo\":\"foo2\"}]")); Flux result = this.webClient.get() .uri("/pojos") @@ -329,18 +333,19 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/pojos"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/pojos"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedWebClientTest - void retrieveJsonArrayAsResponseEntityList(ClientHttpConnector connector) { + void retrieveJsonArrayAsResponseEntityList(ClientHttpConnector connector) throws IOException { startServer(connector); String content = "[{\"bar\":\"bar1\",\"foo\":\"foo1\"}, {\"bar\":\"bar2\",\"foo\":\"foo2\"}]"; - prepareResponse(response -> response - .setHeader("Content-Type", "application/json").setBody(content)); + prepareResponse(builder -> builder + .setHeader("Content-Type", "application/json") + .body(content)); Mono>> result = this.webClient.get() .uri("/json").accept(MediaType.APPLICATION_JSON) @@ -360,18 +365,19 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/json"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/json"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedWebClientTest - void retrieveJsonArrayAsResponseEntityFlux(ClientHttpConnector connector) { + void retrieveJsonArrayAsResponseEntityFlux(ClientHttpConnector connector) throws IOException { startServer(connector); String content = "[{\"bar\":\"bar1\",\"foo\":\"foo1\"}, {\"bar\":\"bar2\",\"foo\":\"foo2\"}]"; - prepareResponse(response -> response - .setHeader("Content-Type", "application/json").setBody(content)); + prepareResponse(builder -> builder + .setHeader("Content-Type", "application/json") + .body(content)); ResponseEntity> entity = this.webClient.get() .uri("/json").accept(MediaType.APPLICATION_JSON) @@ -393,18 +399,19 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/json"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/json"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedWebClientTest - void retrieveJsonArrayAsResponseEntityFluxWithBodyExtractor(ClientHttpConnector connector) { + void retrieveJsonArrayAsResponseEntityFluxWithBodyExtractor(ClientHttpConnector connector) throws IOException { startServer(connector); String content = "[{\"bar\":\"bar1\",\"foo\":\"foo1\"}, {\"bar\":\"bar2\",\"foo\":\"foo2\"}]"; - prepareResponse(response -> response - .setHeader("Content-Type", "application/json").setBody(content)); + prepareResponse(builder -> builder + .setHeader("Content-Type", "application/json") + .body(content)); ResponseEntity> entity = this.webClient.get() .uri("/json").accept(MediaType.APPLICATION_JSON) @@ -426,13 +433,13 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/json"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/json"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @Test // gh-24788 - void retrieveJsonArrayAsBodilessEntityShouldReleasesConnection() { + void retrieveJsonArrayAsBodilessEntityShouldReleasesConnection() throws IOException { // Constrain connection pool and make consecutive requests. // 2nd request should hang if response was not drained. @@ -440,6 +447,7 @@ class WebClientIntegrationTests { ConnectionProvider connectionProvider = ConnectionProvider.create("test", 1); this.server = new MockWebServer(); + this.server.start(); WebClient webClient = WebClient .builder() .clientConnector(new ReactorClientHttpConnector(HttpClient.create(connectionProvider))) @@ -455,9 +463,9 @@ class WebClientIntegrationTests { .collect(Collectors.joining(",", "[", "]")) .block(); - prepareResponse(response -> response + prepareResponse(builder -> builder .setHeader("Content-Type", "application/json") - .setBody(json)); + .body(json)); Mono> result = webClient.get() .uri("/json").accept(MediaType.APPLICATION_JSON) @@ -476,19 +484,20 @@ class WebClientIntegrationTests { expectRequestCount(i); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/json"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/json"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } } @ParameterizedWebClientTest - void retrieveJsonAsSerializedText(ClientHttpConnector connector) { + void retrieveJsonAsSerializedText(ClientHttpConnector connector) throws IOException { startServer(connector); String content = "{\"bar\":\"barbar\",\"foo\":\"foofoo\"}"; - prepareResponse(response -> response - .setHeader("Content-Type", "application/json").setBody(content)); + prepareResponse(builder -> builder + .setHeader("Content-Type", "application/json") + .body(content)); Mono result = this.webClient.get() .uri("/json").accept(MediaType.APPLICATION_JSON) @@ -501,20 +510,20 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/json"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/json"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedWebClientTest @SuppressWarnings("rawtypes") - void retrieveJsonNull(ClientHttpConnector connector) { + void retrieveJsonNull(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> response - .setResponseCode(200) + prepareResponse(builder -> builder + .code(200) .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) - .setBody("null")); + .body("null")); Mono result = this.webClient.get() .uri("/null") @@ -525,10 +534,11 @@ class WebClientIntegrationTests { } @ParameterizedWebClientTest // SPR-15946 - void retrieve404(ClientHttpConnector connector) { + void retrieve404(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> response.setResponseCode(404) + prepareResponse(builder -> builder + .code(404) .setHeader("Content-Type", "text/plain")); Mono result = this.webClient.get().uri("/greeting") @@ -541,17 +551,19 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("*/*"); - assertThat(request.getPath()).isEqualTo("/greeting"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("*/*"); + assertThat(request.getTarget()).isEqualTo("/greeting"); }); } @ParameterizedWebClientTest - void retrieve404WithBody(ClientHttpConnector connector) { + void retrieve404WithBody(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> response.setResponseCode(404) - .setHeader("Content-Type", "text/plain").setBody("Not Found")); + prepareResponse(builder -> builder + .code(404) + .setHeader("Content-Type", "text/plain") + .body("Not Found")); Mono result = this.webClient.get() .uri("/greeting") @@ -564,19 +576,19 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("*/*"); - assertThat(request.getPath()).isEqualTo("/greeting"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("*/*"); + assertThat(request.getTarget()).isEqualTo("/greeting"); }); } @ParameterizedWebClientTest @SuppressWarnings("deprecation") - void retrieve500(ClientHttpConnector connector) { + void retrieve500(ClientHttpConnector connector) throws IOException { startServer(connector); String errorMessage = "Internal Server error"; - prepareResponse(response -> response.setResponseCode(500) - .setHeader("Content-Type", "text/plain").setBody(errorMessage)); + prepareResponse(builder -> builder.code(500) + .setHeader("Content-Type", "text/plain").body(errorMessage)); String path = "/greeting"; Mono result = this.webClient.get() @@ -602,17 +614,17 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("*/*"); - assertThat(request.getPath()).isEqualTo(path); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("*/*"); + assertThat(request.getTarget()).isEqualTo(path); }); } @ParameterizedWebClientTest - void retrieve500AsEntity(ClientHttpConnector connector) { + void retrieve500AsEntity(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> response.setResponseCode(500) - .setHeader("Content-Type", "text/plain").setBody("Internal Server error")); + prepareResponse(builder -> builder.code(500) + .setHeader("Content-Type", "text/plain").body("Internal Server error")); Mono> result = this.webClient.get() .uri("/").accept(MediaType.APPLICATION_JSON) @@ -625,17 +637,17 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedWebClientTest - void retrieve500AsEntityList(ClientHttpConnector connector) { + void retrieve500AsEntityList(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> response.setResponseCode(500) - .setHeader("Content-Type", "text/plain").setBody("Internal Server error")); + prepareResponse(builder -> builder.code(500) + .setHeader("Content-Type", "text/plain").body("Internal Server error")); Mono>> result = this.webClient.get() .uri("/").accept(MediaType.APPLICATION_JSON) @@ -648,17 +660,17 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedWebClientTest - void retrieve500AsBodilessEntity(ClientHttpConnector connector) { + void retrieve500AsBodilessEntity(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> response.setResponseCode(500) - .setHeader("Content-Type", "text/plain").setBody("Internal Server error")); + prepareResponse(builder -> builder.code(500) + .setHeader("Content-Type", "text/plain").body("Internal Server error")); Mono> result = this.webClient.get() .uri("/").accept(MediaType.APPLICATION_JSON) @@ -671,24 +683,24 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedWebClientTest @SuppressWarnings("deprecation") - void retrieve555UnknownStatus(ClientHttpConnector connector) { + void retrieve555UnknownStatus(ClientHttpConnector connector) throws IOException { startServer(connector); int errorStatus = 555; assertThat(HttpStatus.resolve(errorStatus)).isNull(); String errorMessage = "Something went wrong"; - prepareResponse(response -> - response.setResponseCode(errorStatus) - .setHeader("Content-Type", "text/plain") - .setBody(errorMessage)); + prepareResponse(builder -> builder + .code(errorStatus) + .setHeader("Content-Type", "text/plain") + .body(errorMessage)); Mono result = this.webClient.get().uri("/unknownPage").retrieve().bodyToMono(String.class); @@ -705,23 +717,23 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("*/*"); - assertThat(request.getPath()).isEqualTo("/unknownPage"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("*/*"); + assertThat(request.getTarget()).isEqualTo("/unknownPage"); }); } @ParameterizedWebClientTest // gh-31202 - void retrieve929UnknownStatusCode(ClientHttpConnector connector) { + void retrieve929UnknownStatusCode(ClientHttpConnector connector) throws IOException { startServer(connector); int errorStatus = 929; assertThat(HttpStatus.resolve(errorStatus)).isNull(); String errorMessage = "Something went wrong"; - prepareResponse(response -> - response.setResponseCode(errorStatus) + prepareResponse(builder -> + builder.code(errorStatus) .setHeader("Content-Type", "text/plain") - .setBody(errorMessage)); + .body(errorMessage)); Mono result = this.webClient.get().uri("/unknownPage").retrieve().bodyToMono(String.class); @@ -739,17 +751,17 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("*/*"); - assertThat(request.getPath()).isEqualTo("/unknownPage"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("*/*"); + assertThat(request.getTarget()).isEqualTo("/unknownPage"); }); } @ParameterizedWebClientTest - void postPojoAsJson(ClientHttpConnector connector) { + void postPojoAsJson(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> response.setHeader("Content-Type", "application/json") - .setBody("{\"bar\":\"BARBAR\",\"foo\":\"FOOFOO\"}")); + prepareResponse(builder -> builder.setHeader("Content-Type", "application/json") + .body("{\"bar\":\"BARBAR\",\"foo\":\"FOOFOO\"}")); Mono result = this.webClient.post() .uri("/pojo/capitalize") @@ -766,19 +778,19 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/pojo/capitalize"); - assertThat(request.getBody().readUtf8()).isEqualTo("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}"); - assertThat(request.getHeader(HttpHeaders.CONTENT_LENGTH)).isEqualTo("31"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); - assertThat(request.getHeader(HttpHeaders.CONTENT_TYPE)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/pojo/capitalize"); + assertThat(request.getBody().utf8()).isEqualTo("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}"); + 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"); }); } @ParameterizedWebClientTest // SPR-16246 - void postLargeTextFile(ClientHttpConnector connector) { + void postLargeTextFile(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> {}); + prepareResponse(Function.identity()); Resource resource = new ClassPathResource("largeTextFile.txt", getClass()); Flux body = DataBufferUtils.read(resource, DefaultDataBufferFactory.sharedInstance, 4096); @@ -794,10 +806,8 @@ class WebClientIntegrationTests { .verify(Duration.ofSeconds(5)); expectRequest(request -> { - ByteArrayOutputStream bos = new ByteArrayOutputStream(); try { - request.getBody().copyTo(bos); - String actual = bos.toString(StandardCharsets.UTF_8); + String actual = request.getBody().utf8(); String expected = Files.readString(resource.getFile().toPath(), StandardCharsets.UTF_8); assertThat(actual).isEqualTo(expected); } @@ -808,11 +818,11 @@ class WebClientIntegrationTests { } @ParameterizedWebClientTest - void statusHandler(ClientHttpConnector connector) { + void statusHandler(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> response.setResponseCode(500) - .setHeader("Content-Type", "text/plain").setBody("Internal Server error")); + prepareResponse(builder -> builder.code(500) + .setHeader("Content-Type", "text/plain").body("Internal Server error")); Mono result = this.webClient.get() .uri("/greeting") @@ -826,17 +836,17 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("*/*"); - assertThat(request.getPath()).isEqualTo("/greeting"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("*/*"); + assertThat(request.getTarget()).isEqualTo("/greeting"); }); } @ParameterizedWebClientTest - void statusHandlerParameterizedTypeReference(ClientHttpConnector connector) { + void statusHandlerParameterizedTypeReference(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> response.setResponseCode(500) - .setHeader("Content-Type", "text/plain").setBody("Internal Server error")); + prepareResponse(builder -> builder.code(500) + .setHeader("Content-Type", "text/plain").body("Internal Server error")); Mono result = this.webClient.get() .uri("/greeting") @@ -850,19 +860,19 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("*/*"); - assertThat(request.getPath()).isEqualTo("/greeting"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("*/*"); + assertThat(request.getTarget()).isEqualTo("/greeting"); }); } @ParameterizedWebClientTest - void statusHandlerWithErrorBodyTransformation(ClientHttpConnector connector) { + void statusHandlerWithErrorBodyTransformation(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> response - .setResponseCode(500) + prepareResponse(builder -> builder + .code(500) .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) - .setBody("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}") + .body("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}") ); Mono result = this.webClient.get() @@ -884,11 +894,12 @@ class WebClientIntegrationTests { } @ParameterizedWebClientTest - void statusHandlerRawStatus(ClientHttpConnector connector) { + void statusHandlerRawStatus(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> response.setResponseCode(500) - .setHeader("Content-Type", "text/plain").setBody("Internal Server error")); + prepareResponse(builder -> builder.code(500) + .setHeader("Content-Type", "text/plain").body("Internal Server error") + ); Mono result = this.webClient.get() .uri("/greeting") @@ -902,17 +913,17 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("*/*"); - assertThat(request.getPath()).isEqualTo("/greeting"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("*/*"); + assertThat(request.getTarget()).isEqualTo("/greeting"); }); } @ParameterizedWebClientTest - void statusHandlerSuppressedErrorSignal(ClientHttpConnector connector) { + void statusHandlerSuppressedErrorSignal(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> response.setResponseCode(500) - .setHeader("Content-Type", "text/plain").setBody("Internal Server error")); + prepareResponse(builder -> builder.code(500) + .setHeader("Content-Type", "text/plain").body("Internal Server error")); Mono result = this.webClient.get() .uri("/greeting") @@ -926,18 +937,18 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("*/*"); - assertThat(request.getPath()).isEqualTo("/greeting"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("*/*"); + assertThat(request.getTarget()).isEqualTo("/greeting"); }); } @ParameterizedWebClientTest - void statusHandlerSuppressedErrorSignalWithFlux(ClientHttpConnector connector) { + void statusHandlerSuppressedErrorSignalWithFlux(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> response.setResponseCode(500) - .setHeader("Content-Type", "text/plain").setBody("Internal Server error")); + prepareResponse(builder -> builder.code(500) + .setHeader("Content-Type", "text/plain").body("Internal Server error")); Flux result = this.webClient.get() .uri("/greeting") @@ -951,18 +962,18 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("*/*"); - assertThat(request.getPath()).isEqualTo("/greeting"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("*/*"); + assertThat(request.getTarget()).isEqualTo("/greeting"); }); } @ParameterizedWebClientTest - void statusHandlerSuppressedErrorSignalWithEntity(ClientHttpConnector connector) { + void statusHandlerSuppressedErrorSignalWithEntity(ClientHttpConnector connector) throws IOException { startServer(connector); String content = "Internal Server error"; - prepareResponse(response -> response.setResponseCode(500) - .setHeader("Content-Type", "text/plain").setBody(content)); + prepareResponse(builder -> builder.code(500) + .setHeader("Content-Type", "text/plain").body(content)); Mono> result = this.webClient.get() .uri("/").accept(MediaType.APPLICATION_JSON) @@ -980,16 +991,16 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedWebClientTest - void exchangeForPlainText(ClientHttpConnector connector) { + void exchangeForPlainText(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> response.setBody("Hello Spring!")); + prepareResponse(builder -> builder.body("Hello Spring!")); Mono result = this.webClient.get() .uri("/greeting") @@ -1002,19 +1013,19 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getHeader("X-Test-Header")).isEqualTo("testvalue"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("*/*"); - assertThat(request.getPath()).isEqualTo("/greeting"); + assertThat(request.getHeaders().get("X-Test-Header")).isEqualTo("testvalue"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("*/*"); + assertThat(request.getTarget()).isEqualTo("/greeting"); }); } @ParameterizedWebClientTest - void exchangeForJsonAsResponseEntity(ClientHttpConnector connector) { + void exchangeForJsonAsResponseEntity(ClientHttpConnector connector) throws IOException { startServer(connector); String content = "{\"bar\":\"barbar\",\"foo\":\"foofoo\"}"; - prepareResponse(response -> response - .setHeader("Content-Type", "application/json").setBody(content)); + prepareResponse(builder -> builder + .setHeader("Content-Type", "application/json").body(content)); Mono> result = this.webClient.get() .uri("/json").accept(MediaType.APPLICATION_JSON) @@ -1031,17 +1042,17 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/json"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/json"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedWebClientTest - void exchangeForJsonAsBodilessEntity(ClientHttpConnector connector) { + void exchangeForJsonAsBodilessEntity(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> response - .setHeader("Content-Type", "application/json").setBody("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}")); + prepareResponse(builder -> builder + .setHeader("Content-Type", "application/json").body("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}")); Mono> result = this.webClient.get() .uri("/json").accept(MediaType.APPLICATION_JSON) @@ -1058,18 +1069,18 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/json"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/json"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedWebClientTest - void exchangeForJsonArrayAsResponseEntity(ClientHttpConnector connector) { + void exchangeForJsonArrayAsResponseEntity(ClientHttpConnector connector) throws IOException { startServer(connector); String content = "[{\"bar\":\"bar1\",\"foo\":\"foo1\"}, {\"bar\":\"bar2\",\"foo\":\"foo2\"}]"; - prepareResponse(response -> response - .setHeader("Content-Type", "application/json").setBody(content)); + prepareResponse(builder -> builder + .setHeader("Content-Type", "application/json").body(content)); Mono>> result = this.webClient.get() .uri("/json").accept(MediaType.APPLICATION_JSON) @@ -1088,16 +1099,16 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getPath()).isEqualTo("/json"); - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("application/json"); + assertThat(request.getTarget()).isEqualTo("/json"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("application/json"); }); } @ParameterizedWebClientTest - void exchangeForEmptyBodyAsVoidEntity(ClientHttpConnector connector) { + void exchangeForEmptyBodyAsVoidEntity(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> response.setHeader("Content-Length", "0").setBody("")); + prepareResponse(builder -> builder.addHeader("Content-Length", "0").body("")); Mono> result = this.webClient.get() .uri("/noContent") @@ -1109,11 +1120,11 @@ class WebClientIntegrationTests { } @ParameterizedWebClientTest - void exchangeFor404(ClientHttpConnector connector) { + void exchangeFor404(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> response.setResponseCode(404) - .setHeader("Content-Type", "text/plain").setBody("Not Found")); + prepareResponse(builder -> builder.code(404) + .setHeader("Content-Type", "text/plain").body("Not Found")); Mono> result = this.webClient.get().uri("/greeting") .exchangeToMono(ClientResponse::toBodilessEntity); @@ -1125,20 +1136,20 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("*/*"); - assertThat(request.getPath()).isEqualTo("/greeting"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("*/*"); + assertThat(request.getTarget()).isEqualTo("/greeting"); }); } @ParameterizedWebClientTest - void exchangeForUnknownStatusCode(ClientHttpConnector connector) { + void exchangeForUnknownStatusCode(ClientHttpConnector connector) throws IOException { startServer(connector); int errorStatus = 555; assertThat(HttpStatus.resolve(errorStatus)).isNull(); String errorMessage = "Something went wrong"; - prepareResponse(response -> response.setResponseCode(errorStatus) - .setHeader("Content-Type", "text/plain").setBody(errorMessage)); + prepareResponse(builder -> builder.code(errorStatus) + .setHeader("Content-Type", "text/plain").body(errorMessage)); Mono> result = this.webClient.get() .uri("/unknownPage") @@ -1151,17 +1162,17 @@ class WebClientIntegrationTests { expectRequestCount(1); expectRequest(request -> { - assertThat(request.getHeader(HttpHeaders.ACCEPT)).isEqualTo("*/*"); - assertThat(request.getPath()).isEqualTo("/unknownPage"); + assertThat(request.getHeaders().get(HttpHeaders.ACCEPT)).isEqualTo("*/*"); + assertThat(request.getTarget()).isEqualTo("/unknownPage"); }); } @ParameterizedWebClientTest - void filter(ClientHttpConnector connector) { + void filter(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> response.setHeader("Content-Type", "text/plain") - .setBody("Hello Spring!")); + prepareResponse(builder -> builder.setHeader("Content-Type", "text/plain") + .body("Hello Spring!")); WebClient filteredClient = this.webClient.mutate() .filter((request, next) -> { @@ -1182,11 +1193,11 @@ class WebClientIntegrationTests { .verify(Duration.ofSeconds(3)); expectRequestCount(1); - expectRequest(request -> assertThat(request.getHeader("foo")).isEqualTo("bar")); + expectRequest(request -> assertThat(request.getHeaders().get("foo")).isEqualTo("bar")); } @ParameterizedWebClientTest - void filterForErrorHandling(ClientHttpConnector connector) { + void filterForErrorHandling(ClientHttpConnector connector) throws IOException { startServer(connector); ExchangeFilterFunction filter = ExchangeFilterFunction.ofResponseProcessor( @@ -1201,8 +1212,9 @@ class WebClientIntegrationTests { WebClient filteredClient = this.webClient.mutate().filter(filter).build(); // header not present - prepareResponse(response -> response - .setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + prepareResponse(builder -> builder + .setHeader("Content-Type", "text/plain") + .body("Hello Spring!")); Mono result = filteredClient.get() .uri("/greeting") @@ -1214,9 +1226,9 @@ class WebClientIntegrationTests { // header present - prepareResponse(response -> response.setHeader("Content-Type", "text/plain") + prepareResponse(builder -> builder.setHeader("Content-Type", "text/plain") .setHeader("Foo", "Bar") - .setBody("Hello Spring!")); + .body("Hello Spring!")); result = filteredClient.get() .uri("/greeting") @@ -1230,14 +1242,14 @@ class WebClientIntegrationTests { } @ParameterizedWebClientTest - void exchangeResponseCookies(ClientHttpConnector connector) { + void exchangeResponseCookies(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> response + prepareResponse(builder -> builder .setHeader("Content-Type", "text/plain") .addHeader("Set-Cookie", "testkey1=testvalue1") // TODO invalid ";" at the end .addHeader("Set-Cookie", "testkey2=testvalue2; Max-Age=42; HttpOnly; SameSite=Lax; Secure") - .setBody("test")); + .body("test")); this.webClient.get() .uri("/test") @@ -1289,12 +1301,12 @@ class WebClientIntegrationTests { } @ParameterizedWebClientTest - void retrieveTextDecodedToFlux(ClientHttpConnector connector) { + void retrieveTextDecodedToFlux(ClientHttpConnector connector) throws IOException { startServer(connector); - prepareResponse(response -> response - .setHeader("Content-Type", "text/plain") - .setBody("Hey now")); + prepareResponse(builder -> builder + .addHeader("Content-Type", "text/plain") + .body("Hey now")); Flux result = this.webClient.get() .uri("/") @@ -1350,10 +1362,9 @@ class WebClientIntegrationTests { }); } - private void prepareResponse(Consumer consumer) { - MockResponse response = new MockResponse(); - consumer.accept(response); - this.server.enqueue(response); + private void prepareResponse(Function f) { + MockResponse.Builder builder = new MockResponse.Builder(); + this.server.enqueue(f.apply(builder).build()); } private void expectRequest(Consumer consumer) { 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 e562098ce1..0daabd73cb 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 @@ -19,9 +19,9 @@ package org.springframework.web.reactive.function.client; import java.io.IOException; import java.util.function.Consumer; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import mockwebserver3.RecordedRequest; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -39,45 +39,48 @@ public class WebClientVersionTests { private final MockWebServer server = new MockWebServer(); - private final WebClient.Builder webClientBuilder = - WebClient.builder().baseUrl(this.server.url("/").toString()); + private WebClient.Builder webClientBuilder; @BeforeEach - void setUp() { - MockResponse response = new MockResponse(); - response.setHeader("Content-Type", "text/plain").setBody("body"); + void setUp() throws IOException { + this.server.start(); + this.webClientBuilder = WebClient.builder().baseUrl(this.server.url("/").toString()); + MockResponse response = new MockResponse.Builder() + .setHeader("Content-Type", "text/plain") + .body("body") + .build(); this.server.enqueue(response); } @AfterEach - void shutdown() throws IOException { - this.server.shutdown(); + void shutdown() { + this.server.close(); } @Test void header() { performRequest(ApiVersionInserter.useHeader("X-API-Version")); - expectRequest(request -> assertThat(request.getHeader("X-API-Version")).isEqualTo("1.2")); + expectRequest(request -> assertThat(request.getHeaders().get("X-API-Version")).isEqualTo("1.2")); } @Test void queryParam() { performRequest(ApiVersionInserter.useQueryParam("api-version")); - expectRequest(request -> assertThat(request.getPath()).isEqualTo("/path?api-version=1.2")); + expectRequest(request -> assertThat(request.getTarget()).isEqualTo("/path?api-version=1.2")); } @Test void pathSegmentIndexLessThanSize() { performRequest(ApiVersionInserter.builder().usePathSegment(0).withVersionFormatter(v -> "v" + v).build()); - expectRequest(request -> assertThat(request.getPath()).isEqualTo("/v1.2/path")); + expectRequest(request -> assertThat(request.getTarget()).isEqualTo("/v1.2/path")); } @Test void pathSegmentIndexEqualToSize() { performRequest(ApiVersionInserter.builder().usePathSegment(1).withVersionFormatter(v -> "v" + v).build()); - expectRequest(request -> assertThat(request.getPath()).isEqualTo("/path/v1.2")); + expectRequest(request -> assertThat(request.getTarget()).isEqualTo("/path/v1.2")); } @Test @@ -93,7 +96,7 @@ public class WebClientVersionTests { WebClient webClient = webClientBuilder.defaultApiVersion(1.2).apiVersionInserter(inserter).build(); webClient.get().uri("/path").retrieve().bodyToMono(String.class).block(); - expectRequest(request -> assertThat(request.getHeader("X-API-Version")).isEqualTo("1.2")); + expectRequest(request -> assertThat(request.getHeaders().get("X-API-Version")).isEqualTo("1.2")); } private void performRequest(ApiVersionInserter versionInserter) { diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java index 0f277fbfd8..babb6d0f60 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientAdapterTests.java @@ -24,11 +24,11 @@ import java.util.HashMap; import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; -import java.util.function.Consumer; +import java.util.function.Function; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import mockwebserver3.RecordedRequest; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -75,27 +75,29 @@ class WebClientAdapterTests { @BeforeEach - void setUp() { + void setUp() throws IOException { this.server = new MockWebServer(); + this.server.start(); this.anotherServer = anotherServer(); + this.anotherServer.start(); } @SuppressWarnings("ConstantConditions") @AfterEach - void shutdown() throws IOException { + void shutdown() { if (this.server != null) { - this.server.shutdown(); + this.server.close(); } if (this.anotherServer != null) { - this.anotherServer.shutdown(); + this.anotherServer.close(); } } @Test void greeting() { - prepareResponse(response -> response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + prepareResponse(builder -> builder.setHeader("Content-Type", "text/plain").body("Hello Spring!")); StepVerifier.create(initService().getGreeting()) .expectNext("Hello Spring!") @@ -115,7 +117,7 @@ class WebClientAdapterTests { }) .build(); - prepareResponse(response -> response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + prepareResponse(response -> response.setHeader("Content-Type", "text/plain").body("Hello Spring!")); StepVerifier.create(initService(webClient).getGreetingWithAttribute("myAttributeValue")) .expectNext("Hello Spring!") @@ -128,18 +130,18 @@ class WebClientAdapterTests { @Test // gh-29624 void uri() throws Exception { String expectedBody = "hello"; - prepareResponse(response -> response.setResponseCode(200).setBody(expectedBody)); + prepareResponse(response -> response.code(200).body(expectedBody)); URI dynamicUri = this.server.url("/greeting/123").uri(); String actualBody = initService().getGreetingById(dynamicUri, "456"); assertThat(actualBody).isEqualTo(expectedBody); - assertThat(this.server.takeRequest().getRequestUrl().uri()).isEqualTo(dynamicUri); + assertThat(this.server.takeRequest().getUrl().uri()).isEqualTo(dynamicUri); } @Test void formData() throws Exception { - prepareResponse(response -> response.setResponseCode(201)); + prepareResponse(response -> response.code(201)); MultiValueMap map = new LinkedMultiValueMap<>(); map.add("param1", "value 1"); @@ -149,12 +151,12 @@ class WebClientAdapterTests { RecordedRequest request = this.server.takeRequest(); assertThat(request.getHeaders().get("Content-Type")).isEqualTo("application/x-www-form-urlencoded"); - assertThat(request.getBody().readUtf8()).isEqualTo("param1=value+1¶m2=value+2"); + assertThat(request.getBody().utf8()).isEqualTo("param1=value+1¶m2=value+2"); } @Test // gh-30342 void multipart() throws InterruptedException { - prepareResponse(response -> response.setResponseCode(201)); + prepareResponse(response -> response.code(201)); String fileName = "testFileName"; String originalFileName = "originalTestFileName"; MultipartFile file = new MockMultipartFile( @@ -164,7 +166,7 @@ class WebClientAdapterTests { RecordedRequest request = this.server.takeRequest(); assertThat(request.getHeaders().get("Content-Type")).startsWith("multipart/form-data;boundary="); - assertThat(request.getBody().readUtf8()) + assertThat(request.getBody().utf8()) .containsSubsequence("Content-Disposition: form-data; name=\"file\"; filename=\"originalTestFileName\"", "Content-Type: application/json", "Content-Length: 4", "test", "Content-Disposition: form-data; name=\"anotherPart\"", @@ -173,7 +175,7 @@ class WebClientAdapterTests { @Test // gh-34793 void postSet() throws InterruptedException { - prepareResponse(response -> response.setResponseCode(201)); + prepareResponse(response -> response.code(201)); Set persons = new LinkedHashSet<>(); persons.add(new Person("John")); @@ -183,54 +185,53 @@ class WebClientAdapterTests { RecordedRequest request = server.takeRequest(); assertThat(request.getMethod()).isEqualTo("POST"); - assertThat(request.getPath()).isEqualTo("/persons"); - assertThat(request.getBody().readUtf8()).isEqualTo("[{\"name\":\"John\"},{\"name\":\"Richard\"}]"); + assertThat(request.getTarget()).isEqualTo("/persons"); + assertThat(request.getBody().utf8()).isEqualTo("[{\"name\":\"John\"},{\"name\":\"Richard\"}]"); } @Test void uriBuilderFactory() throws Exception { String ignoredResponseBody = "hello"; - prepareResponse(response -> response.setResponseCode(200).setBody(ignoredResponseBody)); + prepareResponse(response -> response.code(200).body(ignoredResponseBody)); UriBuilderFactory factory = new DefaultUriBuilderFactory(this.anotherServer.url("/").toString()); String actualBody = initService().getWithUriBuilderFactory(factory); assertThat(actualBody).isEqualTo(ANOTHER_SERVER_RESPONSE_BODY); - assertThat(this.anotherServer.takeRequest().getPath()).isEqualTo("/greeting"); + assertThat(this.anotherServer.takeRequest().getTarget()).isEqualTo("/greeting"); assertThat(this.server.getRequestCount()).isEqualTo(0); } @Test void uriBuilderFactoryWithPathVariableAndRequestParam() throws Exception { String ignoredResponseBody = "hello"; - prepareResponse(response -> response.setResponseCode(200).setBody(ignoredResponseBody)); + prepareResponse(response -> response.code(200).body(ignoredResponseBody)); UriBuilderFactory factory = new DefaultUriBuilderFactory(this.anotherServer.url("/").toString()); String actualBody = initService().getWithUriBuilderFactory(factory, "123", "test"); assertThat(actualBody).isEqualTo(ANOTHER_SERVER_RESPONSE_BODY); - assertThat(this.anotherServer.takeRequest().getPath()).isEqualTo("/greeting/123?param=test"); + assertThat(this.anotherServer.takeRequest().getTarget()).isEqualTo("/greeting/123?param=test"); assertThat(this.server.getRequestCount()).isEqualTo(0); } @Test void ignoredUriBuilderFactory() throws Exception { String expectedResponseBody = "hello"; - prepareResponse(response -> response.setResponseCode(200).setBody(expectedResponseBody)); + prepareResponse(response -> response.code(200).body(expectedResponseBody)); URI dynamicUri = this.server.url("/greeting/123").uri(); UriBuilderFactory factory = new DefaultUriBuilderFactory(this.anotherServer.url("/").toString()); String actualBody = initService().getWithIgnoredUriBuilderFactory(dynamicUri, factory); assertThat(actualBody).isEqualTo(expectedResponseBody); - assertThat(this.server.takeRequest().getRequestUrl().uri()).isEqualTo(dynamicUri); + assertThat(this.server.takeRequest().getUrl().uri()).isEqualTo(dynamicUri); assertThat(this.anotherServer.getRequestCount()).isEqualTo(0); } @Test void handleNotFoundException() { - MockResponse response = new MockResponse(); - response.setResponseCode(404); + MockResponse response = new MockResponse.Builder().code(404).build(); this.server.enqueue(response); this.server.enqueue(response); @@ -256,8 +257,10 @@ class WebClientAdapterTests { private static MockWebServer anotherServer() { MockWebServer anotherServer = new MockWebServer(); - MockResponse response = new MockResponse(); - response.setHeader("Content-Type", "text/plain").setBody(ANOTHER_SERVER_RESPONSE_BODY); + MockResponse response = new MockResponse.Builder() + .setHeader("Content-Type", "text/plain") + .body(ANOTHER_SERVER_RESPONSE_BODY) + .build(); anotherServer.enqueue(response); return anotherServer; } @@ -272,10 +275,9 @@ class WebClientAdapterTests { return HttpServiceProxyFactory.builderFor(adapter).build().createClient(Service.class); } - private void prepareResponse(Consumer consumer) { - MockResponse response = new MockResponse(); - consumer.accept(response); - this.server.enqueue(response); + private void prepareResponse(Function f) { + MockResponse.Builder builder = new MockResponse.Builder(); + this.server.enqueue(f.apply(builder).build()); } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientProxyRegistryIntegrationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientProxyRegistryIntegrationTests.java index bbf125d7be..689ddad69b 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientProxyRegistryIntegrationTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientProxyRegistryIntegrationTests.java @@ -16,11 +16,9 @@ package org.springframework.web.reactive.function.client.support; -import java.io.IOException; - -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import mockwebserver3.RecordedRequest; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.params.ParameterizedTest; @@ -59,8 +57,8 @@ public class WebClientProxyRegistryIntegrationTests { } @AfterEach - void shutdown() throws IOException { - this.server.shutdown(); + void shutdown() { + this.server.close(); } @@ -87,7 +85,7 @@ public class WebClientProxyRegistryIntegrationTests { assertThat(registry.getClient(GreetingB.class)).isSameAs(greetingB); for (int i = 0; i < 4; i++) { - this.server.enqueue(new MockResponse().setBody("body")); + this.server.enqueue(new MockResponse.Builder().body("body").build()); } echoA.handle("a"); @@ -95,22 +93,22 @@ public class WebClientProxyRegistryIntegrationTests { RecordedRequest request = this.server.takeRequest(); assertThat(request.getMethod()).isEqualTo("GET"); - assertThat(request.getPath()).isEqualTo("/echoA?input=a"); + assertThat(request.getTarget()).isEqualTo("/echoA?input=a"); request = this.server.takeRequest(); assertThat(request.getMethod()).isEqualTo("GET"); - assertThat(request.getPath()).isEqualTo("/echoB?input=b"); + assertThat(request.getTarget()).isEqualTo("/echoB?input=b"); greetingA.handle("a"); greetingB.handle("b"); request = this.server.takeRequest(); assertThat(request.getMethod()).isEqualTo("GET"); - assertThat(request.getPath()).isEqualTo("/greetingA?input=a"); + assertThat(request.getTarget()).isEqualTo("/greetingA?input=a"); request = this.server.takeRequest(); assertThat(request.getMethod()).isEqualTo("GET"); - assertThat(request.getPath()).isEqualTo("/greetingB?input=b"); + assertThat(request.getTarget()).isEqualTo("/greetingB?input=b"); } diff --git a/spring-webflux/src/test/kotlin/org/springframework/web/reactive/function/client/support/WebClientHttpServiceProxyKotlinTests.kt b/spring-webflux/src/test/kotlin/org/springframework/web/reactive/function/client/support/WebClientHttpServiceProxyKotlinTests.kt index 4b52fdbe8b..b879066556 100644 --- a/spring-webflux/src/test/kotlin/org/springframework/web/reactive/function/client/support/WebClientHttpServiceProxyKotlinTests.kt +++ b/spring-webflux/src/test/kotlin/org/springframework/web/reactive/function/client/support/WebClientHttpServiceProxyKotlinTests.kt @@ -16,8 +16,8 @@ package org.springframework.web.reactive.function.client.support import kotlinx.coroutines.runBlocking -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach @@ -39,7 +39,6 @@ import reactor.core.publisher.Mono import reactor.test.StepVerifier import java.net.URI import java.time.Duration -import java.util.function.Consumer /** * Kotlin integration tests for [HTTP Service proxy][HttpServiceProxyFactory] @@ -58,23 +57,20 @@ class KotlinWebClientHttpServiceGroupAdapterServiceProxyTests { @BeforeEach fun setUp() { server = MockWebServer() + server.start() anotherServer = anotherServer() + anotherServer.start() } @AfterEach fun shutdown() { - server.shutdown() - anotherServer.shutdown() + server.close() + anotherServer.close() } @Test fun greetingSuspending() { - prepareResponse { response: MockResponse -> - response.setHeader( - "Content-Type", - "text/plain" - ).setBody("Hello Spring!") - } + prepareResponse { it.setHeader("Content-Type", "text/plain").body("Hello Spring!") } runBlocking { val greeting = initHttpService().getGreetingSuspending() assertThat(greeting).isEqualTo("Hello Spring!") @@ -83,12 +79,7 @@ class KotlinWebClientHttpServiceGroupAdapterServiceProxyTests { @Test fun greetingMono() { - prepareResponse { response: MockResponse -> - response.setHeader( - "Content-Type", - "text/plain" - ).setBody("Hello Spring!") - } + prepareResponse { it.setHeader("Content-Type", "text/plain").body("Hello Spring!") } StepVerifier.create(initHttpService().getGreetingMono()) .expectNext("Hello Spring!") .expectComplete() @@ -97,12 +88,7 @@ class KotlinWebClientHttpServiceGroupAdapterServiceProxyTests { @Test fun greetingBlocking() { - prepareResponse { response: MockResponse -> - response.setHeader( - "Content-Type", - "text/plain" - ).setBody("Hello Spring!") - } + prepareResponse { it.setHeader("Content-Type", "text/plain").body("Hello Spring!") } val greeting = initHttpService().getGreetingBlocking() assertThat(greeting).isEqualTo("Hello Spring!") } @@ -117,12 +103,7 @@ class KotlinWebClientHttpServiceGroupAdapterServiceProxyTests { next.exchange(request) } .build() - prepareResponse { response: MockResponse -> - response.setHeader( - "Content-Type", - "text/plain" - ).setBody("Hello Spring!") - } + prepareResponse { it.setHeader("Content-Type", "text/plain").body("Hello Spring!") } val service = initHttpService(webClient) runBlocking { val greeting = service.getGreetingSuspendingWithAttribute("myAttributeValue") @@ -134,9 +115,7 @@ class KotlinWebClientHttpServiceGroupAdapterServiceProxyTests { @Test @Throws(InterruptedException::class) fun getWithFactoryPathVariableAndRequestParam() { - prepareResponse { response: MockResponse -> - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!") - } + prepareResponse { it.setHeader("Content-Type", "text/plain").body("Hello Spring!") } val factory: UriBuilderFactory = DefaultUriBuilderFactory(anotherServer.url("/").toString()) val actualResponse: ResponseEntity = @@ -146,17 +125,16 @@ class KotlinWebClientHttpServiceGroupAdapterServiceProxyTests { assertThat(actualResponse.statusCode).isEqualTo(HttpStatus.OK) assertThat(actualResponse.body).isEqualTo("Hello Spring 2!") assertThat(request.method).isEqualTo("GET") - assertThat(request.path).isEqualTo("/greeting/123?param=test") + assertThat(request.target).isEqualTo("/greeting/123?param=test") assertThat(server.requestCount).isEqualTo(0) } @Test @Throws(InterruptedException::class) fun getWithIgnoredUriBuilderFactory() { - prepareResponse { response: MockResponse -> - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring!") - } - val dynamicUri = server.url("/greeting/123").uri() + prepareResponse { + it.setHeader("Content-Type", "text/plain").body("Hello Spring!") } + val dynamicUri = server.url("/greeting/123").toUri() val factory: UriBuilderFactory = DefaultUriBuilderFactory(anotherServer.url("/").toString()) val actualResponse: ResponseEntity = @@ -166,7 +144,7 @@ class KotlinWebClientHttpServiceGroupAdapterServiceProxyTests { assertThat(actualResponse.statusCode).isEqualTo(HttpStatus.OK) assertThat(actualResponse.body).isEqualTo("Hello Spring!") assertThat(request.method).isEqualTo("GET") - assertThat(request.path).isEqualTo("/greeting/123") + assertThat(request.target).isEqualTo("/greeting/123") assertThat(anotherServer.requestCount).isEqualTo(0) } @@ -181,16 +159,17 @@ class KotlinWebClientHttpServiceGroupAdapterServiceProxyTests { return HttpServiceProxyFactory.builderFor(adapter).build().createClient() } - private fun prepareResponse(consumer: Consumer) { - val response = MockResponse() - consumer.accept(response) - server.enqueue(response) + private fun prepareResponse(f: (MockResponse.Builder) -> MockResponse.Builder) { + val builder = MockResponse.Builder() + server.enqueue(f.invoke(builder).build()) } private fun anotherServer(): MockWebServer { val anotherServer = MockWebServer() - val response = MockResponse() - response.setHeader("Content-Type", "text/plain").setBody("Hello Spring 2!") + val response = MockResponse.Builder() + .setHeader("Content-Type", "text/plain") + .body("Hello Spring 2!") + .build() anotherServer.enqueue(response) return anotherServer } From e3f1bc8422a63ff4bac5473daaeb1062c72620d3 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Fri, 11 Jul 2025 13:33:34 +0100 Subject: [PATCH 004/156] Allow resolution of optional HandlerMethod Closes gh-35067 --- .../web/method/support/InvocableHandlerMethod.java | 4 ++++ .../support/InvocableHandlerMethodTests.java | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/spring-web/src/main/java/org/springframework/web/method/support/InvocableHandlerMethod.java b/spring-web/src/main/java/org/springframework/web/method/support/InvocableHandlerMethod.java index 29b9606d63..c87cc74729 100644 --- a/spring-web/src/main/java/org/springframework/web/method/support/InvocableHandlerMethod.java +++ b/spring-web/src/main/java/org/springframework/web/method/support/InvocableHandlerMethod.java @@ -217,6 +217,10 @@ public class InvocableHandlerMethod extends HandlerMethod { if (args[i] != null) { continue; } + if (parameter.getParameterType().equals(HandlerMethod.class) && parameter.isOptional()) { + args[i] = null; + continue; + } if (!this.resolvers.supportsParameter(parameter)) { throw new IllegalStateException(formatArgumentError(parameter, "No suitable resolver")); } diff --git a/spring-web/src/test/java/org/springframework/web/method/support/InvocableHandlerMethodTests.java b/spring-web/src/test/java/org/springframework/web/method/support/InvocableHandlerMethodTests.java index a919a96425..a2d06b9bb1 100644 --- a/spring-web/src/test/java/org/springframework/web/method/support/InvocableHandlerMethodTests.java +++ b/spring-web/src/test/java/org/springframework/web/method/support/InvocableHandlerMethodTests.java @@ -18,6 +18,7 @@ package org.springframework.web.method.support; import java.lang.reflect.Method; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -25,6 +26,7 @@ import org.springframework.core.MethodParameter; import org.springframework.web.bind.support.WebDataBinderFactory; import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.method.HandlerMethod; import org.springframework.web.testfixture.method.ResolvableMethod; import org.springframework.web.testfixture.servlet.MockHttpServletRequest; import org.springframework.web.testfixture.servlet.MockHttpServletResponse; @@ -105,6 +107,14 @@ class InvocableHandlerMethodTests { assertThat(value).isEqualTo("2-value2"); } + @Test + void resolveHandlerMethodArgToNull() throws Exception { + Object value = getInvocable(HandlerMethod.class).invokeForRequest(request, null); + + assertThat(value).isNotNull(); + assertThat(value).isEqualTo("success"); + } + @Test void exceptionInResolvingArg() { this.composite.addResolver(new ExceptionRaisingArgumentResolver()); @@ -184,6 +194,10 @@ class InvocableHandlerMethodTests { public void handleWithException(Throwable ex) throws Throwable { throw ex; } + + public String handleHandlerMethod(@Nullable HandlerMethod handlerMethod) { + return "success"; + } } From aac61b86c87c1f007db34f4cb3787946aedb1ea9 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Tue, 15 Jul 2025 13:37:30 +0200 Subject: [PATCH 005/156] Revert "Disallow @org.jetbrains.annotations.Nullable imports" This reverts commit 6e6280a42c6ed3c9446f23bdf2981e6dd51c93ce. See gh-35114 See gh-35195 --- src/checkstyle/checkstyle.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/checkstyle/checkstyle.xml b/src/checkstyle/checkstyle.xml index a0e0b8d47f..d7ba423332 100644 --- a/src/checkstyle/checkstyle.xml +++ b/src/checkstyle/checkstyle.xml @@ -107,7 +107,7 @@ + value="^reactor\.core\.support\.Assert,^org\.slf4j\.LoggerFactory,^(?!org\.jspecify|\.annotations).*(NonNull|Nullable)$"/> From 4fdf40e39ec011103e316a52414ea4c56b5af4a7 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Tue, 15 Jul 2025 13:45:34 +0200 Subject: [PATCH 006/156] Fix Checkstyle configuration for nullability annotations While assessing #35195, I noticed the following issues with our Checkstyle configuration regarding nullability annotations. - "^(?!org\.jspecify|\.annotations).*(NonNull|Nullable)$" contains a "|". - "^(?!org\.jspecify|\.annotations).*(NonNull|Nullable)$" matches against NonNull but not against Nonnull, and therefore incorrectly permits usage of javax.annotation.Nonnull. - Some of the Checkstyle suppressions no longer apply. This commit addresses all of the above issues and updates several tests to use example annotations other than javax.annotation.Nonnull where feasible. See gh-35195 Closes gh-35205 --- .../AnnotatedElementUtilsTests.java | 38 ++++++++++--------- .../annotation/AnnotationFilterTests.java | 10 ++--- .../AnnotationTypeMappingsTests.java | 4 +- .../core/annotation/AnnotationUtilsTests.java | 9 +++-- src/checkstyle/checkstyle-suppressions.xml | 7 +--- src/checkstyle/checkstyle.xml | 16 ++++++-- 6 files changed, 45 insertions(+), 39 deletions(-) diff --git a/spring-core/src/test/java/org/springframework/core/annotation/AnnotatedElementUtilsTests.java b/spring-core/src/test/java/org/springframework/core/annotation/AnnotatedElementUtilsTests.java index 9490fb0712..21fe0ec480 100644 --- a/spring-core/src/test/java/org/springframework/core/annotation/AnnotatedElementUtilsTests.java +++ b/spring-core/src/test/java/org/springframework/core/annotation/AnnotatedElementUtilsTests.java @@ -31,8 +31,10 @@ import java.util.Date; import java.util.List; import java.util.Set; -import javax.annotation.Nonnull; -import javax.annotation.ParametersAreNonnullByDefault; +import javax.annotation.RegEx; +import javax.annotation.Syntax; +import javax.annotation.concurrent.ThreadSafe; +import javax.annotation.meta.TypeQualifierNickname; import javax.annotation.meta.When; import jakarta.annotation.Resource; @@ -46,6 +48,7 @@ import org.springframework.core.annotation.AnnotationUtilsTests.WebController; import org.springframework.core.annotation.AnnotationUtilsTests.WebMapping; import org.springframework.core.testfixture.stereotype.Component; import org.springframework.core.testfixture.stereotype.Indexed; +import org.springframework.lang.Contract; import org.springframework.util.MultiValueMap; import static java.util.Arrays.asList; @@ -237,9 +240,8 @@ class AnnotatedElementUtilsTests { @SuppressWarnings("deprecation") void isAnnotatedForPlainTypes() { assertThat(isAnnotated(Order.class, Documented.class)).isTrue(); - assertThat(isAnnotated(org.springframework.lang.NonNullApi.class, Documented.class)).isTrue(); - assertThat(isAnnotated(org.springframework.lang.NonNullApi.class, Nonnull.class)).isTrue(); - assertThat(isAnnotated(ParametersAreNonnullByDefault.class, Nonnull.class)).isTrue(); + assertThat(isAnnotated(Inherited.class, Documented.class)).isTrue(); + assertThat(isAnnotated(Contract.class, Documented.class)).isTrue(); } @Test @@ -278,9 +280,8 @@ class AnnotatedElementUtilsTests { @SuppressWarnings("deprecation") void hasAnnotationForPlainTypes() { assertThat(hasAnnotation(Order.class, Documented.class)).isTrue(); - assertThat(hasAnnotation(org.springframework.lang.NonNullApi.class, Documented.class)).isTrue(); - assertThat(hasAnnotation(org.springframework.lang.NonNullApi.class, Nonnull.class)).isTrue(); - assertThat(hasAnnotation(ParametersAreNonnullByDefault.class, Nonnull.class)).isTrue(); + assertThat(hasAnnotation(Inherited.class, Documented.class)).isTrue(); + assertThat(hasAnnotation(Contract.class, Documented.class)).isTrue(); } @Test @@ -346,17 +347,16 @@ class AnnotatedElementUtilsTests { @SuppressWarnings("deprecation") void getAllAnnotationAttributesOnLangType() { MultiValueMap attributes = getAllAnnotationAttributes( - org.springframework.lang.NonNullApi.class, Nonnull.class.getName()); - assertThat(attributes).as("Annotation attributes map for @Nonnull on NonNullApi").isNotNull(); - assertThat(attributes.get("when")).as("value for NonNullApi").isEqualTo(List.of(When.ALWAYS)); + org.springframework.lang.NonNullApi.class, javax.annotation.Nonnull.class.getName()); + assertThat(attributes).as("Annotation attributes map for @Nonnull on @NonNullApi").isNotNull(); + assertThat(attributes.get("when")).as("value for @NonNullApi").isEqualTo(List.of(When.ALWAYS)); } @Test void getAllAnnotationAttributesOnJavaxType() { - MultiValueMap attributes = getAllAnnotationAttributes( - ParametersAreNonnullByDefault.class, Nonnull.class.getName()); - assertThat(attributes).as("Annotation attributes map for @Nonnull on NonNullApi").isNotNull(); - assertThat(attributes.get("when")).as("value for NonNullApi").isEqualTo(List.of(When.ALWAYS)); + MultiValueMap attributes = getAllAnnotationAttributes(RegEx.class, Syntax.class.getName()); + assertThat(attributes).as("Annotation attributes map for @Syntax on @RegEx").isNotNull(); + assertThat(attributes.get("when")).as("value for @RegEx").isEqualTo(List.of(When.ALWAYS)); } @Test @@ -855,8 +855,10 @@ class AnnotatedElementUtilsTests { @Test void javaxMetaAnnotationTypeViaFindMergedAnnotation() { - assertThat(findMergedAnnotation(ParametersAreNonnullByDefault.class, Nonnull.class)).isEqualTo(ParametersAreNonnullByDefault.class.getAnnotation(Nonnull.class)); - assertThat(findMergedAnnotation(ResourceHolder.class, Nonnull.class)).isEqualTo(ParametersAreNonnullByDefault.class.getAnnotation(Nonnull.class)); + assertThat(findMergedAnnotation(ThreadSafe.class, Documented.class)) + .isEqualTo(ThreadSafe.class.getAnnotation(Documented.class)); + assertThat(findMergedAnnotation(ResourceHolder.class, TypeQualifierNickname.class)) + .isEqualTo(RegEx.class.getAnnotation(TypeQualifierNickname.class)); } @Test @@ -1536,7 +1538,7 @@ class AnnotatedElementUtilsTests { } @Resource(name = "x") - @ParametersAreNonnullByDefault + @RegEx static class ResourceHolder { } diff --git a/spring-core/src/test/java/org/springframework/core/annotation/AnnotationFilterTests.java b/spring-core/src/test/java/org/springframework/core/annotation/AnnotationFilterTests.java index 8a48353d62..6bf99d1578 100644 --- a/spring-core/src/test/java/org/springframework/core/annotation/AnnotationFilterTests.java +++ b/spring-core/src/test/java/org/springframework/core/annotation/AnnotationFilterTests.java @@ -19,9 +19,8 @@ package org.springframework.core.annotation; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; -import javax.annotation.Nonnull; +import javax.annotation.concurrent.ThreadSafe; -import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.springframework.lang.Contract; @@ -86,12 +85,13 @@ class AnnotationFilterTests { @Test void javaWhenJavaxAnnotationReturnsTrue() { - assertThat(AnnotationFilter.JAVA.matches(Nonnull.class)).isTrue(); + assertThat(AnnotationFilter.JAVA.matches(ThreadSafe.class)).isTrue(); } @Test + @SuppressWarnings("deprecation") void javaWhenSpringLangAnnotationReturnsFalse() { - assertThat(AnnotationFilter.JAVA.matches(Nullable.class)).isFalse(); + assertThat(AnnotationFilter.JAVA.matches(org.springframework.lang.Nullable.class)).isFalse(); } @Test @@ -103,7 +103,7 @@ class AnnotationFilterTests { @SuppressWarnings("deprecation") void noneReturnsFalse() { assertThat(AnnotationFilter.NONE.matches(Retention.class)).isFalse(); - assertThat(AnnotationFilter.NONE.matches(Nullable.class)).isFalse(); + assertThat(AnnotationFilter.NONE.matches(org.springframework.lang.Nullable.class)).isFalse(); assertThat(AnnotationFilter.NONE.matches(TestAnnotation.class)).isFalse(); } diff --git a/spring-core/src/test/java/org/springframework/core/annotation/AnnotationTypeMappingsTests.java b/spring-core/src/test/java/org/springframework/core/annotation/AnnotationTypeMappingsTests.java index a34e4f73ea..0eac833cc5 100644 --- a/spring-core/src/test/java/org/springframework/core/annotation/AnnotationTypeMappingsTests.java +++ b/spring-core/src/test/java/org/springframework/core/annotation/AnnotationTypeMappingsTests.java @@ -29,8 +29,6 @@ import java.util.List; import java.util.Map; import java.util.stream.IntStream; -import javax.annotation.Nullable; - import org.junit.jupiter.api.Test; import org.springframework.core.annotation.AnnotationTypeMapping.MirrorSets; @@ -442,7 +440,7 @@ class AnnotationTypeMappingsTests { return result; } - private @Nullable Method getAliasMapping(AnnotationTypeMapping mapping, int attributeIndex) { + private Method getAliasMapping(AnnotationTypeMapping mapping, int attributeIndex) { int mapped = mapping.getAliasMapping(attributeIndex); return mapped != -1 ? mapping.getRoot().getAttributes().get(mapped) : null; } diff --git a/spring-core/src/test/java/org/springframework/core/annotation/AnnotationUtilsTests.java b/spring-core/src/test/java/org/springframework/core/annotation/AnnotationUtilsTests.java index a38fba676b..f1364c73ce 100644 --- a/spring-core/src/test/java/org/springframework/core/annotation/AnnotationUtilsTests.java +++ b/spring-core/src/test/java/org/springframework/core/annotation/AnnotationUtilsTests.java @@ -30,8 +30,9 @@ import java.util.Map; import java.util.NoSuchElementException; import java.util.Set; -import javax.annotation.Nonnull; -import javax.annotation.ParametersAreNonnullByDefault; +import javax.annotation.RegEx; +import javax.annotation.Syntax; +import javax.annotation.concurrent.ThreadSafe; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -428,8 +429,8 @@ class AnnotationUtilsTests { @Test void isAnnotationMetaPresentForPlainType() { assertThat(isAnnotationMetaPresent(Order.class, Documented.class)).isTrue(); - assertThat(isAnnotationMetaPresent(ParametersAreNonnullByDefault.class, Documented.class)).isTrue(); - assertThat(isAnnotationMetaPresent(ParametersAreNonnullByDefault.class, Nonnull.class)).isTrue(); + assertThat(isAnnotationMetaPresent(ThreadSafe.class, Documented.class)).isTrue(); + assertThat(isAnnotationMetaPresent(RegEx.class, Syntax.class)).isTrue(); } @Test diff --git a/src/checkstyle/checkstyle-suppressions.xml b/src/checkstyle/checkstyle-suppressions.xml index ad2ca8f7b5..3fe0922a83 100644 --- a/src/checkstyle/checkstyle-suppressions.xml +++ b/src/checkstyle/checkstyle-suppressions.xml @@ -21,9 +21,6 @@ - - - @@ -40,9 +37,7 @@ - - - + diff --git a/src/checkstyle/checkstyle.xml b/src/checkstyle/checkstyle.xml index d7ba423332..e6947f3235 100644 --- a/src/checkstyle/checkstyle.xml +++ b/src/checkstyle/checkstyle.xml @@ -104,10 +104,20 @@ - + - + + + + + + + + + + + + From 20a1261574d74948360f09391a26a5ca4564a5d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Tue, 15 Jul 2025 17:48:49 +0200 Subject: [PATCH 007/156] Make rootObject parameter consistently nullable MethodBasedEvaluationContext and CacheEvaluationContext should allow a nullable rootObject constructor parameter like StandardEvaluationContext does. Closes gh-35206 --- .../cache/interceptor/CacheEvaluationContext.java | 2 +- .../context/expression/MethodBasedEvaluationContext.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spring-context/src/main/java/org/springframework/cache/interceptor/CacheEvaluationContext.java b/spring-context/src/main/java/org/springframework/cache/interceptor/CacheEvaluationContext.java index b63ae2d04a..291fdccd95 100644 --- a/spring-context/src/main/java/org/springframework/cache/interceptor/CacheEvaluationContext.java +++ b/spring-context/src/main/java/org/springframework/cache/interceptor/CacheEvaluationContext.java @@ -48,7 +48,7 @@ class CacheEvaluationContext extends MethodBasedEvaluationContext { private final Set unavailableVariables = new HashSet<>(1); - CacheEvaluationContext(Object rootObject, Method method, @Nullable Object[] arguments, + CacheEvaluationContext(@Nullable Object rootObject, Method method, @Nullable Object[] arguments, ParameterNameDiscoverer parameterNameDiscoverer) { super(rootObject, method, arguments, parameterNameDiscoverer); diff --git a/spring-context/src/main/java/org/springframework/context/expression/MethodBasedEvaluationContext.java b/spring-context/src/main/java/org/springframework/context/expression/MethodBasedEvaluationContext.java index 4efca52723..bde07a1e73 100644 --- a/spring-context/src/main/java/org/springframework/context/expression/MethodBasedEvaluationContext.java +++ b/spring-context/src/main/java/org/springframework/context/expression/MethodBasedEvaluationContext.java @@ -53,7 +53,7 @@ public class MethodBasedEvaluationContext extends StandardEvaluationContext { private boolean argumentsLoaded = false; - public MethodBasedEvaluationContext(Object rootObject, Method method, @Nullable Object[] arguments, + public MethodBasedEvaluationContext(@Nullable Object rootObject, Method method, @Nullable Object[] arguments, ParameterNameDiscoverer parameterNameDiscoverer) { super(rootObject); From eac490d3208d60fc9edcada357be6fef2434dfa5 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Tue, 15 Jul 2025 20:40:59 +0200 Subject: [PATCH 008/156] Upgrade to Reactor 2025.0.0-M5 and Micrometer 1.16.0-M1 Includes Netty 4.2.3 Closes gh-35169 Closes gh-35170 --- framework-platform/framework-platform.gradle | 6 +-- ...ultServerRequestObservationConvention.java | 14 ++++-- ...ultServerRequestObservationConvention.java | 14 ++++-- .../filter/ServerHttpObservationFilter.java | 48 ++++++++++++------- 4 files changed, 52 insertions(+), 30 deletions(-) diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index 14a76638d5..82c32e802c 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.18.4")) - api(platform("io.micrometer:micrometer-bom:1.15.1")) - api(platform("io.netty:netty-bom:4.2.2.Final")) - api(platform("io.projectreactor:reactor-bom:2025.0.0-M4")) + 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")) api(platform("io.rsocket:rsocket-bom:1.1.5")) api(platform("org.apache.groovy:groovy-bom:4.0.27")) api(platform("org.apache.logging.log4j:log4j-bom:3.0.0-beta3")) diff --git a/spring-web/src/main/java/org/springframework/http/server/observation/DefaultServerRequestObservationConvention.java b/spring-web/src/main/java/org/springframework/http/server/observation/DefaultServerRequestObservationConvention.java index fae5f121e8..10f3c7f609 100644 --- a/spring-web/src/main/java/org/springframework/http/server/observation/DefaultServerRequestObservationConvention.java +++ b/spring-web/src/main/java/org/springframework/http/server/observation/DefaultServerRequestObservationConvention.java @@ -23,6 +23,7 @@ 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; @@ -89,12 +90,15 @@ public class DefaultServerRequestObservationConvention implements ServerRequestO } @Override - public String getContextualName(ServerRequestObservationContext context) { - String httpMethod = context.getCarrier().getMethod().toLowerCase(Locale.ROOT); - if (context.getPathPattern() != null) { - return "http " + httpMethod + " " + context.getPathPattern(); + public @Nullable String getContextualName(ServerRequestObservationContext context) { + if (context.getCarrier() != null) { + String httpMethod = context.getCarrier().getMethod().toLowerCase(Locale.ROOT); + if (context.getPathPattern() != null) { + return "http " + httpMethod + " " + context.getPathPattern(); + } + return "http " + httpMethod; } - return "http " + httpMethod; + return null; } @Override diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/observation/DefaultServerRequestObservationConvention.java b/spring-web/src/main/java/org/springframework/http/server/reactive/observation/DefaultServerRequestObservationConvention.java index 9e3ddb423b..ec6b76fe22 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/observation/DefaultServerRequestObservationConvention.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/observation/DefaultServerRequestObservationConvention.java @@ -21,6 +21,7 @@ import java.util.Set; 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; @@ -87,12 +88,15 @@ public class DefaultServerRequestObservationConvention implements ServerRequestO } @Override - public String getContextualName(ServerRequestObservationContext context) { - String httpMethod = context.getCarrier().getMethod().name().toLowerCase(Locale.ROOT); - if (context.getPathPattern() != null) { - return "http " + httpMethod + " " + context.getPathPattern(); + public @Nullable String getContextualName(ServerRequestObservationContext context) { + if (context.getCarrier() != null) { + String httpMethod = context.getCarrier().getMethod().name().toLowerCase(Locale.ROOT); + if (context.getPathPattern() != null) { + return "http " + httpMethod + " " + context.getPathPattern(); + } + return "http " + httpMethod; } - return "http " + httpMethod; + return null; } @Override diff --git a/spring-web/src/main/java/org/springframework/web/filter/ServerHttpObservationFilter.java b/spring-web/src/main/java/org/springframework/web/filter/ServerHttpObservationFilter.java index be49f7cbe3..39f37a215f 100644 --- a/spring-web/src/main/java/org/springframework/web/filter/ServerHttpObservationFilter.java +++ b/spring-web/src/main/java/org/springframework/web/filter/ServerHttpObservationFilter.java @@ -55,19 +55,23 @@ import org.springframework.http.server.observation.ServerRequestObservationConve public class ServerHttpObservationFilter extends OncePerRequestFilter { /** - * Name of the request attribute holding the {@link ServerRequestObservationContext context} for the current observation. + * Name of the request attribute holding the {@link ServerRequestObservationContext} for the current observation. */ - public static final String CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE = ServerHttpObservationFilter.class.getName() + ".context"; + public static final String CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE = + ServerHttpObservationFilter.class.getName() + ".context"; - private static final ServerRequestObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultServerRequestObservationConvention(); + private static final String CURRENT_OBSERVATION_ATTRIBUTE = + ServerHttpObservationFilter.class.getName() + ".observation"; - private static final String CURRENT_OBSERVATION_ATTRIBUTE = ServerHttpObservationFilter.class.getName() + ".observation"; + private static final ServerRequestObservationConvention DEFAULT_OBSERVATION_CONVENTION = + new DefaultServerRequestObservationConvention(); private final ObservationRegistry observationRegistry; private final ServerRequestObservationConvention observationConvention; + /** * Create an {@code HttpRequestsObservationFilter} that records observations * against the given {@link ObservationRegistry}. The default @@ -89,14 +93,6 @@ public class ServerHttpObservationFilter extends OncePerRequestFilter { this.observationConvention = observationConvention; } - /** - * Get the current {@link ServerRequestObservationContext observation context} from the given request, if available. - * @param request the current request - * @return the current observation context - */ - public static Optional findObservationContext(HttpServletRequest request) { - return Optional.ofNullable((ServerRequestObservationContext) request.getAttribute(CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE)); - } @Override protected boolean shouldNotFilterAsyncDispatch() { @@ -150,8 +146,9 @@ public class ServerHttpObservationFilter extends OncePerRequestFilter { Observation observation = (Observation) request.getAttribute(CURRENT_OBSERVATION_ATTRIBUTE); if (observation == null) { ServerRequestObservationContext context = new ServerRequestObservationContext(request, response); - observation = ServerHttpObservationDocumentation.HTTP_SERVLET_SERVER_REQUESTS.observation(this.observationConvention, - DEFAULT_OBSERVATION_CONVENTION, () -> context, this.observationRegistry).start(); + observation = ServerHttpObservationDocumentation.HTTP_SERVLET_SERVER_REQUESTS.observation( + this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> context, this.observationRegistry) + .start(); request.setAttribute(CURRENT_OBSERVATION_ATTRIBUTE, observation); if (!observation.isNoop()) { request.setAttribute(CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE, observation.getContext()); @@ -160,14 +157,32 @@ public class ServerHttpObservationFilter extends OncePerRequestFilter { return observation; } - static @Nullable Throwable unwrapServletException(Throwable ex) { - return (ex instanceof ServletException) ? ex.getCause() : ex; + + /** + * Get the current {@link ServerRequestObservationContext observation context} from the given request, if available. + * @param request the current request + * @return the current observation context + */ + public static Optional findObservationContext(HttpServletRequest request) { + return Optional.ofNullable( + (ServerRequestObservationContext) request.getAttribute(CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE)); } static @Nullable Throwable fetchException(ServletRequest request) { return (Throwable) request.getAttribute(RequestDispatcher.ERROR_EXCEPTION); } + static Throwable unwrapServletException(Throwable ex) { + if (ex instanceof ServletException) { + Throwable cause = ex.getCause(); + if (cause != null) { + return cause; + } + } + return ex; + } + + private static class ObservationAsyncListener implements AsyncListener { private final Observation currentObservation; @@ -195,7 +210,6 @@ public class ServerHttpObservationFilter extends OncePerRequestFilter { public void onError(AsyncEvent event) { this.currentObservation.error(unwrapServletException(event.getThrowable())); } - } } From 54732605a5c8ec2528854919b2e512332bf88498 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Tue, 15 Jul 2025 20:56:36 +0200 Subject: [PATCH 009/156] Backport nullability refinements for Micrometer See gh-35170 --- ...ultServerRequestObservationConvention.java | 14 ++++-- ...ultServerRequestObservationConvention.java | 14 ++++-- .../filter/ServerHttpObservationFilter.java | 49 ++++++++++++------- 3 files changed, 49 insertions(+), 28 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/http/server/observation/DefaultServerRequestObservationConvention.java b/spring-web/src/main/java/org/springframework/http/server/observation/DefaultServerRequestObservationConvention.java index fae5f121e8..49b4f5139a 100644 --- a/spring-web/src/main/java/org/springframework/http/server/observation/DefaultServerRequestObservationConvention.java +++ b/spring-web/src/main/java/org/springframework/http/server/observation/DefaultServerRequestObservationConvention.java @@ -29,6 +29,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.server.observation.ServerHttpObservationDocumentation.HighCardinalityKeyNames; import org.springframework.http.server.observation.ServerHttpObservationDocumentation.LowCardinalityKeyNames; +import org.springframework.lang.Nullable; import org.springframework.util.StringUtils; /** @@ -89,12 +90,16 @@ public class DefaultServerRequestObservationConvention implements ServerRequestO } @Override + @Nullable public String getContextualName(ServerRequestObservationContext context) { - String httpMethod = context.getCarrier().getMethod().toLowerCase(Locale.ROOT); - if (context.getPathPattern() != null) { - return "http " + httpMethod + " " + context.getPathPattern(); + if (context.getCarrier() != null) { + String httpMethod = context.getCarrier().getMethod().toLowerCase(Locale.ROOT); + if (context.getPathPattern() != null) { + return "http " + httpMethod + " " + context.getPathPattern(); + } + return "http " + httpMethod; } - return "http " + httpMethod; + return null; } @Override @@ -193,7 +198,6 @@ public class DefaultServerRequestObservationConvention implements ServerRequestO return HTTP_OUTCOME_UNKNOWN; } } - } } diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/observation/DefaultServerRequestObservationConvention.java b/spring-web/src/main/java/org/springframework/http/server/reactive/observation/DefaultServerRequestObservationConvention.java index 9e3ddb423b..1b22791f3d 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/observation/DefaultServerRequestObservationConvention.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/observation/DefaultServerRequestObservationConvention.java @@ -27,6 +27,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.server.reactive.observation.ServerHttpObservationDocumentation.HighCardinalityKeyNames; import org.springframework.http.server.reactive.observation.ServerHttpObservationDocumentation.LowCardinalityKeyNames; +import org.springframework.lang.Nullable; import org.springframework.util.StringUtils; /** @@ -87,12 +88,16 @@ public class DefaultServerRequestObservationConvention implements ServerRequestO } @Override + @Nullable public String getContextualName(ServerRequestObservationContext context) { - String httpMethod = context.getCarrier().getMethod().name().toLowerCase(Locale.ROOT); - if (context.getPathPattern() != null) { - return "http " + httpMethod + " " + context.getPathPattern(); + if (context.getCarrier() != null) { + String httpMethod = context.getCarrier().getMethod().name().toLowerCase(Locale.ROOT); + if (context.getPathPattern() != null) { + return "http " + httpMethod + " " + context.getPathPattern(); + } + return "http " + httpMethod; } - return "http " + httpMethod; + return null; } @Override @@ -191,7 +196,6 @@ public class DefaultServerRequestObservationConvention implements ServerRequestO return HTTP_OUTCOME_UNKNOWN; } } - } } diff --git a/spring-web/src/main/java/org/springframework/web/filter/ServerHttpObservationFilter.java b/spring-web/src/main/java/org/springframework/web/filter/ServerHttpObservationFilter.java index 138407e29c..db4df4a175 100644 --- a/spring-web/src/main/java/org/springframework/web/filter/ServerHttpObservationFilter.java +++ b/spring-web/src/main/java/org/springframework/web/filter/ServerHttpObservationFilter.java @@ -55,19 +55,23 @@ import org.springframework.lang.Nullable; public class ServerHttpObservationFilter extends OncePerRequestFilter { /** - * Name of the request attribute holding the {@link ServerRequestObservationContext context} for the current observation. + * Name of the request attribute holding the {@link ServerRequestObservationContext} for the current observation. */ - public static final String CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE = ServerHttpObservationFilter.class.getName() + ".context"; + public static final String CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE = + ServerHttpObservationFilter.class.getName() + ".context"; - private static final ServerRequestObservationConvention DEFAULT_OBSERVATION_CONVENTION = new DefaultServerRequestObservationConvention(); + private static final String CURRENT_OBSERVATION_ATTRIBUTE = + ServerHttpObservationFilter.class.getName() + ".observation"; - private static final String CURRENT_OBSERVATION_ATTRIBUTE = ServerHttpObservationFilter.class.getName() + ".observation"; + private static final ServerRequestObservationConvention DEFAULT_OBSERVATION_CONVENTION = + new DefaultServerRequestObservationConvention(); private final ObservationRegistry observationRegistry; private final ServerRequestObservationConvention observationConvention; + /** * Create an {@code HttpRequestsObservationFilter} that records observations * against the given {@link ObservationRegistry}. The default @@ -89,14 +93,6 @@ public class ServerHttpObservationFilter extends OncePerRequestFilter { this.observationConvention = observationConvention; } - /** - * Get the current {@link ServerRequestObservationContext observation context} from the given request, if available. - * @param request the current request - * @return the current observation context - */ - public static Optional findObservationContext(HttpServletRequest request) { - return Optional.ofNullable((ServerRequestObservationContext) request.getAttribute(CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE)); - } @Override protected boolean shouldNotFilterAsyncDispatch() { @@ -150,8 +146,9 @@ public class ServerHttpObservationFilter extends OncePerRequestFilter { Observation observation = (Observation) request.getAttribute(CURRENT_OBSERVATION_ATTRIBUTE); if (observation == null) { ServerRequestObservationContext context = new ServerRequestObservationContext(request, response); - observation = ServerHttpObservationDocumentation.HTTP_SERVLET_SERVER_REQUESTS.observation(this.observationConvention, - DEFAULT_OBSERVATION_CONVENTION, () -> context, this.observationRegistry).start(); + observation = ServerHttpObservationDocumentation.HTTP_SERVLET_SERVER_REQUESTS.observation( + this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> context, this.observationRegistry) + .start(); request.setAttribute(CURRENT_OBSERVATION_ATTRIBUTE, observation); if (!observation.isNoop()) { request.setAttribute(CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE, observation.getContext()); @@ -160,9 +157,15 @@ public class ServerHttpObservationFilter extends OncePerRequestFilter { return observation; } - @Nullable - static Throwable unwrapServletException(Throwable ex) { - return (ex instanceof ServletException) ? ex.getCause() : ex; + + /** + * Get the current {@link ServerRequestObservationContext observation context} from the given request, if available. + * @param request the current request + * @return the current observation context + */ + public static Optional findObservationContext(HttpServletRequest request) { + return Optional.ofNullable( + (ServerRequestObservationContext) request.getAttribute(CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE)); } @Nullable @@ -170,6 +173,17 @@ public class ServerHttpObservationFilter extends OncePerRequestFilter { return (Throwable) request.getAttribute(RequestDispatcher.ERROR_EXCEPTION); } + static Throwable unwrapServletException(Throwable ex) { + if (ex instanceof ServletException) { + Throwable cause = ex.getCause(); + if (cause != null) { + return cause; + } + } + return ex; + } + + private static class ObservationAsyncListener implements AsyncListener { private final Observation currentObservation; @@ -197,7 +211,6 @@ public class ServerHttpObservationFilter extends OncePerRequestFilter { public void onError(AsyncEvent event) { this.currentObservation.error(unwrapServletException(event.getThrowable())); } - } } From bb88ec9ea59646a578fe87fca18bd2c7446482e9 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 15 Jul 2025 21:00:20 +0200 Subject: [PATCH 010/156] Configure Mockito Java agent in Test tasks Closes gh-35207 --- .../build/TestConventions.java | 22 +++++++++++++++++-- framework-platform/framework-platform.gradle | 2 +- gradle.properties | 1 + 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/buildSrc/src/main/java/org/springframework/build/TestConventions.java b/buildSrc/src/main/java/org/springframework/build/TestConventions.java index 6e977938c7..e9477f6408 100644 --- a/buildSrc/src/main/java/org/springframework/build/TestConventions.java +++ b/buildSrc/src/main/java/org/springframework/build/TestConventions.java @@ -16,9 +16,9 @@ package org.springframework.build; -import java.util.Map; - import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.artifacts.Dependency; import org.gradle.api.plugins.JavaBasePlugin; import org.gradle.api.tasks.testing.Test; import org.gradle.api.tasks.testing.TestFrameworkOptions; @@ -26,12 +26,16 @@ import org.gradle.api.tasks.testing.junitplatform.JUnitPlatformOptions; import org.gradle.testretry.TestRetryPlugin; import org.gradle.testretry.TestRetryTaskExtension; +import java.util.Map; + /** * Conventions that are applied in the presence of the {@link JavaBasePlugin}. When the * plugin is applied: *
    *
  • The {@link TestRetryPlugin Test Retry} plugin is applied so that flaky tests * are retried 3 times when running on the CI server. + *
  • Common test properties are configured + *
  • The Mockito Java agent is set on test tasks. *
* * @author Brian Clozel @@ -45,6 +49,7 @@ class TestConventions { } private void configureTestConventions(Project project) { + configureMockitoAgent(project); project.getTasks().withType(Test.class, test -> { configureTests(project, test); @@ -75,6 +80,19 @@ class TestConventions { ); } + private void configureMockitoAgent(Project project) { + if (project.hasProperty("mockitoVersion")) { + String mockitoVersion = (String) project.getProperties().get("mockitoVersion"); + Configuration mockitoAgentConfig = project.getConfigurations().create("mockitoAgent"); + mockitoAgentConfig.setTransitive(false); + Dependency mockitoCore = project.getDependencies().create("org.mockito:mockito-core:" + mockitoVersion); + mockitoAgentConfig.getDependencies().add(mockitoCore); + project.afterEvaluate(p -> { + p.getTasks().withType(Test.class, test -> test.jvmArgs("-javaagent:" + mockitoAgentConfig.getAsPath())); + }); + } + } + private void configureTestRetryPlugin(Project project, Test test) { project.getPlugins().withType(TestRetryPlugin.class, testRetryPlugin -> { TestRetryTaskExtension testRetry = test.getExtensions().getByType(TestRetryTaskExtension.class); diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index 82c32e802c..ad59b4f3e4 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -20,7 +20,7 @@ dependencies { 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.3")) - api(platform("org.mockito:mockito-bom:5.18.0")) + api(platform("org.mockito:mockito-bom:${mockitoVersion}")) api(platform("tools.jackson:jackson-bom:3.0.0-rc5")) constraints { diff --git a/gradle.properties b/gradle.properties index 0e4ef3c141..8efd1df600 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,6 +5,7 @@ org.gradle.jvmargs=-Xmx2048m org.gradle.parallel=true kotlinVersion=2.2.0 +mockitoVersion=5.18.0 kotlin.jvm.target.validation.mode=ignore kotlin.stdlib.default.dependency=false From 0fc043f762d61f190ccbcc905736f8e73737baf9 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Tue, 15 Jul 2025 21:15:16 +0200 Subject: [PATCH 011/156] Upgrade to Netty 4.1.123, Selenium 4.34, XMLUnit 2.10.3 --- 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 b19f220ec2..47e86e5bc1 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.9")) - api(platform("io.netty:netty-bom:4.1.122.Final")) + 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.rsocket:rsocket-bom:1.1.5")) @@ -138,15 +138,15 @@ dependencies { 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.33.0") + api("org.seleniumhq.selenium:selenium-java:4.34.0") api("org.skyscreamer:jsonassert:1.5.3") api("org.slf4j:slf4j-api:2.0.17") api("org.testng:testng:7.11.0") 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.0") - api("org.xmlunit:xmlunit-matchers:2.10.0") + api("org.xmlunit:xmlunit-assertj:2.10.3") + api("org.xmlunit:xmlunit-matchers:2.10.3") api("org.yaml:snakeyaml:2.4") } } From ebe1f65e2141f96fe96a7d3cf3f959af9285ac9d Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 15 Jul 2025 21:58:53 +0200 Subject: [PATCH 012/156] Apply Mockito Java Agent to Kotlin tests too See gh-35207 --- .../java/org/springframework/build/TestConventions.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/buildSrc/src/main/java/org/springframework/build/TestConventions.java b/buildSrc/src/main/java/org/springframework/build/TestConventions.java index e9477f6408..034d040622 100644 --- a/buildSrc/src/main/java/org/springframework/build/TestConventions.java +++ b/buildSrc/src/main/java/org/springframework/build/TestConventions.java @@ -25,6 +25,7 @@ import org.gradle.api.tasks.testing.TestFrameworkOptions; import org.gradle.api.tasks.testing.junitplatform.JUnitPlatformOptions; import org.gradle.testretry.TestRetryPlugin; import org.gradle.testretry.TestRetryTaskExtension; +import org.jetbrains.kotlin.gradle.targets.jvm.tasks.KotlinJvmTest; import java.util.Map; @@ -89,6 +90,11 @@ class TestConventions { mockitoAgentConfig.getDependencies().add(mockitoCore); project.afterEvaluate(p -> { p.getTasks().withType(Test.class, test -> test.jvmArgs("-javaagent:" + mockitoAgentConfig.getAsPath())); + project.getPlugins().withId("org.jetbrains.kotlin.jvm", plugin -> { + project.getTasks().withType(KotlinJvmTest.class, kotlinTest -> { + kotlinTest.jvmArgs("-javaagent:" + mockitoAgentConfig.getAsPath()); + }); + }); }); } } From 4bf048cfcfe1d60473e2e8469c21c5890d9bb53c Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Wed, 16 Jul 2025 11:34:30 +0200 Subject: [PATCH 013/156] Configure ByteBuddy agent on test tasks This commit replaces the Mockito agent configuration with a single bytebuddy agent configuration that addresses both Mockito and mockk on tests. Closes gh-35207 --- .../build/TestConventions.java | 27 ++++++++----------- framework-platform/framework-platform.gradle | 2 +- gradle.properties | 2 +- 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/buildSrc/src/main/java/org/springframework/build/TestConventions.java b/buildSrc/src/main/java/org/springframework/build/TestConventions.java index 034d040622..649204b932 100644 --- a/buildSrc/src/main/java/org/springframework/build/TestConventions.java +++ b/buildSrc/src/main/java/org/springframework/build/TestConventions.java @@ -25,7 +25,6 @@ import org.gradle.api.tasks.testing.TestFrameworkOptions; import org.gradle.api.tasks.testing.junitplatform.JUnitPlatformOptions; import org.gradle.testretry.TestRetryPlugin; import org.gradle.testretry.TestRetryTaskExtension; -import org.jetbrains.kotlin.gradle.targets.jvm.tasks.KotlinJvmTest; import java.util.Map; @@ -36,7 +35,7 @@ import java.util.Map; *
  • The {@link TestRetryPlugin Test Retry} plugin is applied so that flaky tests * are retried 3 times when running on the CI server. *
  • Common test properties are configured - *
  • The Mockito Java agent is set on test tasks. + *
  • The ByteBuddy Java agent is configured on test tasks. * * * @author Brian Clozel @@ -50,7 +49,7 @@ class TestConventions { } private void configureTestConventions(Project project) { - configureMockitoAgent(project); + configureByteBuddyAgent(project); project.getTasks().withType(Test.class, test -> { configureTests(project, test); @@ -81,20 +80,16 @@ class TestConventions { ); } - private void configureMockitoAgent(Project project) { - if (project.hasProperty("mockitoVersion")) { - String mockitoVersion = (String) project.getProperties().get("mockitoVersion"); - Configuration mockitoAgentConfig = project.getConfigurations().create("mockitoAgent"); - mockitoAgentConfig.setTransitive(false); - Dependency mockitoCore = project.getDependencies().create("org.mockito:mockito-core:" + mockitoVersion); - mockitoAgentConfig.getDependencies().add(mockitoCore); + private void configureByteBuddyAgent(Project project) { + if (project.hasProperty("byteBuddyVersion")) { + String byteBuddyVersion = (String) project.getProperties().get("byteBuddyVersion"); + Configuration byteBuddyAgentConfig = project.getConfigurations().create("byteBuddyAgentConfig"); + byteBuddyAgentConfig.setTransitive(false); + Dependency byteBuddyAgent = project.getDependencies().create("net.bytebuddy:byte-buddy-agent:" + byteBuddyVersion); + byteBuddyAgentConfig.getDependencies().add(byteBuddyAgent); project.afterEvaluate(p -> { - p.getTasks().withType(Test.class, test -> test.jvmArgs("-javaagent:" + mockitoAgentConfig.getAsPath())); - project.getPlugins().withId("org.jetbrains.kotlin.jvm", plugin -> { - project.getTasks().withType(KotlinJvmTest.class, kotlinTest -> { - kotlinTest.jvmArgs("-javaagent:" + mockitoAgentConfig.getAsPath()); - }); - }); + p.getTasks().withType(Test.class, test -> test + .jvmArgs("-javaagent:" + byteBuddyAgentConfig.getAsPath())); }); } } diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index 84c0c092c6..d45014d6ee 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -20,7 +20,7 @@ dependencies { 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.3")) - api(platform("org.mockito:mockito-bom:${mockitoVersion}")) + api(platform("org.mockito:mockito-bom:5.18.0")) api(platform("tools.jackson:jackson-bom:3.0.0-rc5")) constraints { diff --git a/gradle.properties b/gradle.properties index 8efd1df600..8c6ebdd9ea 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ org.gradle.jvmargs=-Xmx2048m org.gradle.parallel=true kotlinVersion=2.2.0 -mockitoVersion=5.18.0 +byteBuddyVersion=1.17.6 kotlin.jvm.target.validation.mode=ignore kotlin.stdlib.default.dependency=false From 6f5a7eed7602aca6fe300d4a71e42b25f19b8e55 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Mon, 14 Jul 2025 16:48:24 +0100 Subject: [PATCH 014/156] Add section on Range requests to reference Closes gh-35052 --- framework-docs/modules/ROOT/nav.adoc | 2 ++ .../modules/ROOT/pages/web/webflux/range.adoc | 20 +++++++++++++++++++ .../ROOT/pages/web/webmvc/mvc-range.adoc | 20 +++++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 framework-docs/modules/ROOT/pages/web/webflux/range.adoc create mode 100644 framework-docs/modules/ROOT/pages/web/webmvc/mvc-range.adoc diff --git a/framework-docs/modules/ROOT/nav.adoc b/framework-docs/modules/ROOT/nav.adoc index a34c38e8f5..7d8e6903e1 100644 --- a/framework-docs/modules/ROOT/nav.adoc +++ b/framework-docs/modules/ROOT/nav.adoc @@ -197,6 +197,7 @@ *** xref:web/webmvc-functional.adoc[] *** xref:web/webmvc/mvc-uri-building.adoc[] *** xref:web/webmvc/mvc-ann-async.adoc[] +*** xref:web/webmvc/mvc-range.adoc[] *** xref:web/webmvc-cors.adoc[] *** xref:web/webmvc-versioning.adoc[] *** xref:web/webmvc/mvc-ann-rest-exceptions.adoc[] @@ -294,6 +295,7 @@ **** xref:web/webflux/controller/ann-advice.adoc[] *** xref:web/webflux-functional.adoc[] *** xref:web/webflux/uri-building.adoc[] +*** xref:web/webflux/range.adoc[] *** xref:web/webflux-cors.adoc[] *** xref:web/webflux-versioning.adoc[] *** xref:web/webflux/ann-rest-exceptions.adoc[] diff --git a/framework-docs/modules/ROOT/pages/web/webflux/range.adoc b/framework-docs/modules/ROOT/pages/web/webflux/range.adoc new file mode 100644 index 0000000000..7e2d5417ff --- /dev/null +++ b/framework-docs/modules/ROOT/pages/web/webflux/range.adoc @@ -0,0 +1,20 @@ +[[webflux-range]] += Range Requests +:page-section-summary-toc: 1 + +[.small]#xref:web/webmvc/mvc-range.adoc[See equivalent in the Servlet stack]# + +Spring WebFlux supports https://datatracker.ietf.org/doc/html/rfc9110#section-14[RFC 9110] +range requests. For an overview, see the +https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Range_requests[Ranger Requests] +Mozilla guide. + +The `Range` header is parsed and handled transparently in WebFlux when an annotated +controller returns a `Resource` or `ResponseEntity`, or a functional endpoint +xref:web/webflux-functional.adoc#webflux-fn-resources[serves a `Resource`]. `Range` header +support is also transparently handled when serving +xref:web/webflux/config.adoc#webflux-config-static-resources[static resources]. + +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 new file mode 100644 index 0000000000..f47c85cb79 --- /dev/null +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-range.adoc @@ -0,0 +1,20 @@ +[[mvc-range]] += Range Requests +:page-section-summary-toc: 1 + +[.small]#xref:web/webflux/range.adoc[See equivalent in the Reactive stack]# + +Spring MVC supports https://datatracker.ietf.org/doc/html/rfc9110#section-14[RFC 9110] +range requests. For an overview, see the +https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Range_requests[Ranger Requests] +Mozilla guide. + +The `Range` header is parsed and handled transparently in Spring MVC when an annotated +controller returns a `Resource` or `ResponseEntity`, or a functional endpoint +xref:web/webmvc-functional.adoc#webmvc-fn-resources[serves a `Resource`]. `Range` header +support is also transparently handled when serving +xref:web/webmvc/mvc-config/static-resources.adoc[static resources]. + +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 17382fe07957bfb43bc34d22f20e63f00a3ddd6b Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Wed, 16 Jul 2025 15:21:49 +0200 Subject: [PATCH 015/156] Re-initialize Quartz ConnectionProvider on context restart Closes gh-35208 --- .../quartz/LocalDataSourceJobStore.java | 61 +++++++++++-------- .../quartz/SchedulerFactoryBean.java | 37 ++++++++--- .../scheduling/quartz/QuartzSupportTests.java | 2 + .../scheduling/quartz/databasePersistence.xml | 16 ++--- 4 files changed, 73 insertions(+), 43 deletions(-) diff --git a/spring-context-support/src/main/java/org/springframework/scheduling/quartz/LocalDataSourceJobStore.java b/spring-context-support/src/main/java/org/springframework/scheduling/quartz/LocalDataSourceJobStore.java index 2f36bf849d..69a14a6d69 100644 --- a/spring-context-support/src/main/java/org/springframework/scheduling/quartz/LocalDataSourceJobStore.java +++ b/spring-context-support/src/main/java/org/springframework/scheduling/quartz/LocalDataSourceJobStore.java @@ -35,6 +35,7 @@ import org.quartz.utils.DBConnectionManager; import org.springframework.jdbc.datasource.DataSourceUtils; import org.springframework.jdbc.support.JdbcUtils; import org.springframework.jdbc.support.MetaDataAccessException; +import org.springframework.util.Assert; /** * Subclass of Quartz's {@link JobStoreCMT} class that delegates to a Spring-managed @@ -88,6 +89,8 @@ public class LocalDataSourceJobStore extends JobStoreCMT { private @Nullable DataSource dataSource; + private @Nullable DataSource nonTransactionalDataSource; + @Override @SuppressWarnings("NullAway") // Dataflow analysis limitation @@ -98,11 +101,40 @@ public class LocalDataSourceJobStore extends JobStoreCMT { throw new SchedulerConfigException("No local DataSource found for configuration - " + "'dataSource' property must be set on SchedulerFactoryBean"); } + // Non-transactional DataSource is optional: fall back to default + // DataSource if not explicitly specified. + this.nonTransactionalDataSource = SchedulerFactoryBean.getConfigTimeNonTransactionalDataSource(); - // Configure transactional connection settings for Quartz. + // Configure connection settings for Quartz. setDataSource(TX_DATA_SOURCE_PREFIX + getInstanceName()); + setNonManagedTXDataSource(NON_TX_DATA_SOURCE_PREFIX + getInstanceName()); setDontSetAutoCommitFalse(true); + initializeConnectionProvider(); + + // No, if HSQL is the platform, we really don't want to use locks... + try { + String productName = JdbcUtils.extractDatabaseMetaData(this.dataSource, + DatabaseMetaData::getDatabaseProductName); + productName = JdbcUtils.commonDatabaseName(productName); + if (productName != null && productName.toLowerCase(Locale.ROOT).contains("hsql")) { + setUseDBLocks(false); + setLockHandler(new SimpleSemaphore()); + } + } + catch (MetaDataAccessException ex) { + logWarnIfNonZero(1, "Could not detect database type. Assuming locks can be taken."); + } + + super.initialize(loadHelper, signaler); + } + + void initializeConnectionProvider() { + final DataSource dataSourceToUse = this.dataSource; + Assert.state(dataSourceToUse != null, "DataSource must not be null"); + final DataSource nonTxDataSourceToUse = + (this.nonTransactionalDataSource != null ? this.nonTransactionalDataSource : dataSourceToUse); + // Register transactional ConnectionProvider for Quartz. DBConnectionManager.getInstance().addConnectionProvider( TX_DATA_SOURCE_PREFIX + getInstanceName(), @@ -110,7 +142,7 @@ public class LocalDataSourceJobStore extends JobStoreCMT { @Override public Connection getConnection() throws SQLException { // Return a transactional Connection, if any. - return DataSourceUtils.doGetConnection(dataSource); + return DataSourceUtils.doGetConnection(dataSourceToUse); } @Override public void shutdown() { @@ -123,14 +155,6 @@ public class LocalDataSourceJobStore extends JobStoreCMT { } ); - // Non-transactional DataSource is optional: fall back to default - // DataSource if not explicitly specified. - DataSource nonTxDataSource = SchedulerFactoryBean.getConfigTimeNonTransactionalDataSource(); - final DataSource nonTxDataSourceToUse = (nonTxDataSource != null ? nonTxDataSource : this.dataSource); - - // Configure non-transactional connection settings for Quartz. - setNonManagedTXDataSource(NON_TX_DATA_SOURCE_PREFIX + getInstanceName()); - // Register non-transactional ConnectionProvider for Quartz. DBConnectionManager.getInstance().addConnectionProvider( NON_TX_DATA_SOURCE_PREFIX + getInstanceName(), @@ -150,23 +174,6 @@ public class LocalDataSourceJobStore extends JobStoreCMT { } } ); - - // No, if HSQL is the platform, we really don't want to use locks... - try { - String productName = JdbcUtils.extractDatabaseMetaData(this.dataSource, - DatabaseMetaData::getDatabaseProductName); - productName = JdbcUtils.commonDatabaseName(productName); - if (productName != null && productName.toLowerCase(Locale.ROOT).contains("hsql")) { - setUseDBLocks(false); - setLockHandler(new SimpleSemaphore()); - } - } - catch (MetaDataAccessException ex) { - logWarnIfNonZero(1, "Could not detect database type. Assuming locks can be taken."); - } - - super.initialize(loadHelper, signaler); - } @Override diff --git a/spring-context-support/src/main/java/org/springframework/scheduling/quartz/SchedulerFactoryBean.java b/spring-context-support/src/main/java/org/springframework/scheduling/quartz/SchedulerFactoryBean.java index b4a17bdec0..69c19d04a6 100644 --- a/spring-context-support/src/main/java/org/springframework/scheduling/quartz/SchedulerFactoryBean.java +++ b/spring-context-support/src/main/java/org/springframework/scheduling/quartz/SchedulerFactoryBean.java @@ -28,6 +28,8 @@ import org.jspecify.annotations.Nullable; import org.quartz.Scheduler; import org.quartz.SchedulerException; import org.quartz.SchedulerFactory; +import org.quartz.core.QuartzScheduler; +import org.quartz.core.QuartzSchedulerResources; import org.quartz.impl.RemoteScheduler; import org.quartz.impl.SchedulerRepository; import org.quartz.impl.StdSchedulerFactory; @@ -165,7 +167,7 @@ public class SchedulerFactoryBean extends SchedulerAccessor implements FactoryBe private @Nullable SchedulerFactory schedulerFactory; - private Class schedulerFactoryClass = StdSchedulerFactory.class; + private Class schedulerFactoryClass = LocalSchedulerFactory.class; private @Nullable String schedulerName; @@ -203,6 +205,8 @@ public class SchedulerFactoryBean extends SchedulerAccessor implements FactoryBe private @Nullable Scheduler scheduler; + private @Nullable LocalDataSourceJobStore jobStore; + /** * Set an external Quartz {@link SchedulerFactory} instance to use. @@ -223,11 +227,12 @@ public class SchedulerFactoryBean extends SchedulerAccessor implements FactoryBe /** * Set the Quartz {@link SchedulerFactory} implementation to use. - *

    Default is the {@link StdSchedulerFactory} class, reading in the standard - * {@code quartz.properties} from {@code quartz.jar}. For applying custom Quartz - * properties, specify {@link #setConfigLocation "configLocation"} and/or - * {@link #setQuartzProperties "quartzProperties"} etc on this local - * {@code SchedulerFactoryBean} instance. + *

    Default is a Spring-internal subclass of the {@link StdSchedulerFactory} + * class, reading in the standard {@code quartz.properties} from + * {@code quartz.jar}. For applying custom Quartz properties, + * specify {@link #setConfigLocation "configLocation"} and/or + * {@link #setQuartzProperties "quartzProperties"} etc on this + * local {@code SchedulerFactoryBean} instance. * @see org.quartz.impl.StdSchedulerFactory * @see #setConfigLocation * @see #setQuartzProperties @@ -508,8 +513,9 @@ public class SchedulerFactoryBean extends SchedulerAccessor implements FactoryBe private SchedulerFactory prepareSchedulerFactory() throws SchedulerException, IOException { SchedulerFactory schedulerFactory = this.schedulerFactory; if (schedulerFactory == null) { - // Create local SchedulerFactory instance (typically a StdSchedulerFactory) - schedulerFactory = BeanUtils.instantiateClass(this.schedulerFactoryClass); + // Create local SchedulerFactory instance (typically a LocalSchedulerFactory) + schedulerFactory = (this.schedulerFactoryClass == LocalSchedulerFactory.class ? + new LocalSchedulerFactory() : BeanUtils.instantiateClass(this.schedulerFactoryClass)); if (schedulerFactory instanceof StdSchedulerFactory stdSchedulerFactory) { initSchedulerFactory(stdSchedulerFactory); } @@ -778,6 +784,9 @@ public class SchedulerFactoryBean extends SchedulerAccessor implements FactoryBe @Override public void start() throws SchedulingException { if (this.scheduler != null) { + if (this.jobStore != null) { + this.jobStore.initializeConnectionProvider(); + } try { startScheduler(this.scheduler, this.startupDelay); } @@ -829,4 +838,16 @@ public class SchedulerFactoryBean extends SchedulerAccessor implements FactoryBe } } + + private class LocalSchedulerFactory extends StdSchedulerFactory { + + @Override + protected Scheduler instantiate(QuartzSchedulerResources rsrcs, QuartzScheduler qs) { + if (rsrcs.getJobStore() instanceof LocalDataSourceJobStore ldsjs) { + SchedulerFactoryBean.this.jobStore = ldsjs; + } + return super.instantiate(rsrcs, qs); + } + } + } 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 c6aaabc945..87adeaed3e 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,6 +391,8 @@ class QuartzSupportTests { 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-support/src/test/resources/org/springframework/scheduling/quartz/databasePersistence.xml b/spring-context-support/src/test/resources/org/springframework/scheduling/quartz/databasePersistence.xml index 9b7b97c07c..c9591ab188 100644 --- a/spring-context-support/src/test/resources/org/springframework/scheduling/quartz/databasePersistence.xml +++ b/spring-context-support/src/test/resources/org/springframework/scheduling/quartz/databasePersistence.xml @@ -5,28 +5,28 @@ http://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans.xsd"> - - + + - - + + - + - - + + - + From a4ec25d9ec0466c0c4e9ca4eb9d1ef9df18820a5 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Wed, 16 Jul 2025 15:31:46 +0200 Subject: [PATCH 016/156] Upgrade to MockK 1.13.17 --- 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 47e86e5bc1..ecc9e1f149 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -47,7 +47,7 @@ dependencies { api("com.thoughtworks.xstream:xstream:1.4.21") api("commons-io:commons-io:2.15.0") api("de.bechte.junit:junit-hierarchicalcontextrunner:4.12.2") - api("io.mockk:mockk:1.13.4") + api("io.mockk:mockk:1.13.17") api("io.projectreactor.netty:reactor-netty5-http:2.0.0-M3") api("io.projectreactor.tools:blockhound:1.0.8.RELEASE") api("io.r2dbc:r2dbc-h2:1.0.0.RELEASE") From 6f725dc7aabcd2637b65419bd3a4bbe667b99461 Mon Sep 17 00:00:00 2001 From: Juergen Hoeller Date: Wed, 16 Jul 2025 15:34:11 +0200 Subject: [PATCH 017/156] Upgrade to MockK 1.14.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 b665809812..83a2031f5c 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -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.13.17") + api("io.mockk:mockk:1.14.4") 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") From adc64d5cbcd4286dd4c766269004347768c96afb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Wed, 16 Jul 2025 15:56:41 +0200 Subject: [PATCH 018/156] Upgrade to Dokka 2.0.0 See https://kotlinlang.org/docs/dokka-migration.html Closes gh-35211 --- build.gradle | 2 +- buildSrc/build.gradle | 1 + .../build/KotlinConventions.java | 44 ++++++++++++++++++- framework-api/framework-api.gradle | 19 ++++---- gradle.properties | 3 ++ gradle/docs-dokka.gradle | 32 -------------- gradle/spring-module.gradle | 5 --- 7 files changed, 55 insertions(+), 51 deletions(-) delete mode 100644 gradle/docs-dokka.gradle diff --git a/build.gradle b/build.gradle index 39bffc99e6..6fafc96e97 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ plugins { id 'io.freefair.aspectj' version '8.13.1' apply false // kotlinVersion is managed in gradle.properties id 'org.jetbrains.kotlin.plugin.serialization' version "${kotlinVersion}" apply false - id 'org.jetbrains.dokka' version '1.9.20' + 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 'me.champeau.jmh' version '0.7.2' apply false diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 7d0027111b..ed471b72ea 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -20,6 +20,7 @@ ext { dependencies { checkstyle "io.spring.javaformat:spring-javaformat-checkstyle:${javaFormatVersion}" implementation "org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}" + implementation "org.jetbrains.dokka:dokka-gradle-plugin:2.0.0" implementation "com.tngtech.archunit:archunit:1.4.0" implementation "org.gradle:test-retry-gradle-plugin:1.6.2" implementation "io.spring.javaformat:spring-javaformat-gradle-plugin:${javaFormatVersion}" diff --git a/buildSrc/src/main/java/org/springframework/build/KotlinConventions.java b/buildSrc/src/main/java/org/springframework/build/KotlinConventions.java index 59b2a3f401..3163f143fc 100644 --- a/buildSrc/src/main/java/org/springframework/build/KotlinConventions.java +++ b/buildSrc/src/main/java/org/springframework/build/KotlinConventions.java @@ -17,6 +17,10 @@ package org.springframework.build; import org.gradle.api.Project; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.SourceSetContainer; +import org.jetbrains.dokka.gradle.DokkaExtension; +import org.jetbrains.dokka.gradle.DokkaPlugin; import org.jetbrains.kotlin.gradle.dsl.JvmTarget; import org.jetbrains.kotlin.gradle.dsl.KotlinVersion; import org.jetbrains.kotlin.gradle.tasks.KotlinCompile; @@ -28,8 +32,14 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile; public class KotlinConventions { void apply(Project project) { - project.getPlugins().withId("org.jetbrains.kotlin.jvm", - (plugin) -> project.getTasks().withType(KotlinCompile.class, this::configure)); + project.getPlugins().withId("org.jetbrains.kotlin.jvm", _ -> { + project.getTasks().withType(KotlinCompile.class, this::configure); + if (project.getLayout().getProjectDirectory().dir("src/main/kotlin").getAsFile().exists()) { + project.getPlugins().apply(DokkaPlugin.class); + project.getExtensions().configure(DokkaExtension.class, dokka -> configure(project, dokka)); + project.project(":framework-api").getDependencies().add("dokka", project); + } + }); } private void configure(KotlinCompile compile) { @@ -49,4 +59,34 @@ public class KotlinConventions { }); } + private void configure(Project project, DokkaExtension dokka) { + dokka.getDokkaSourceSets().forEach(sourceSet -> { + sourceSet.getSourceRoots().setFrom(project.file("src/main/kotlin")); + sourceSet.getClasspath() + .from(project.getExtensions() + .getByType(SourceSetContainer.class) + .getByName(SourceSet.MAIN_SOURCE_SET_NAME) + .getOutput()); + var externalDocumentationLinks = sourceSet.getExternalDocumentationLinks(); + externalDocumentationLinks.register("spring-framework", spec -> { + spec.url("https://docs.spring.io/spring-framework/docs/current/javadoc-api/"); + spec.packageListUrl("https://docs.spring.io/spring-framework/docs/current/javadoc-api/element-list"); + }); + externalDocumentationLinks.register("reactor-core", spec -> + spec.url("https://projectreactor.io/docs/core/release/api/")); + externalDocumentationLinks.register("reactive-streams", spec -> + spec.url("https://www.reactive-streams.org/reactive-streams-1.0.3-javadoc/")); + externalDocumentationLinks.register("kotlinx-coroutines", spec -> + spec.url("https://kotlinlang.org/api/kotlinx.coroutines/")); + externalDocumentationLinks.register("hamcrest", spec -> + spec.url("https://javadoc.io/doc/org.hamcrest/hamcrest/2.1/")); + externalDocumentationLinks.register("jakarta-servlet", spec -> { + spec.url("https://javadoc.io/doc/jakarta.servlet/jakarta.servlet-api/latest/"); + spec.packageListUrl("https://javadoc.io/doc/jakarta.servlet/jakarta.servlet-api/latest/element-list"); + }); + externalDocumentationLinks.register("rsocket-core", spec -> + spec.url("https://javadoc.io/static/io.rsocket/rsocket-core/1.1.1/")); + }); + } + } diff --git a/framework-api/framework-api.gradle b/framework-api/framework-api.gradle index f9da96bcd0..fc3ecb8637 100644 --- a/framework-api/framework-api.gradle +++ b/framework-api/framework-api.gradle @@ -1,6 +1,7 @@ plugins { id 'java-platform' id 'io.freefair.aggregate-javadoc' version '8.13.1' + id 'org.jetbrains.dokka' } description = "Spring Framework API Docs" @@ -54,23 +55,19 @@ javadoc { } } -/** - * Produce KDoc for all Spring Framework modules in "build/docs/kdoc" - */ -rootProject.tasks.dokkaHtmlMultiModule.configure { - dependsOn { - tasks.named("javadoc") +dokka { + moduleName = "spring-framework" + dokkaPublications.html { + outputDirectory = project.java.docsDir.dir("kdoc-api") + includes.from("$rootProject.rootDir/framework-docs/src/docs/api/dokka-overview.md") } - moduleName.set("spring-framework") - outputDirectory.set(project.java.docsDir.dir("kdoc-api").get().asFile) - includes.from("$rootProject.rootDir/framework-docs/src/docs/api/dokka-overview.md") } /** * Zip all Java docs (javadoc & kdoc) into a single archive */ tasks.register('docsZip', Zip) { - dependsOn = ['javadoc', rootProject.tasks.dokkaHtmlMultiModule] + dependsOn = ['javadoc', 'dokkaGenerate'] group = "distribution" description = "Builds -${archiveClassifier} archive containing api and reference " + "for deployment at https://docs.spring.io/spring-framework/docs/." @@ -83,7 +80,7 @@ tasks.register('docsZip', Zip) { from(javadoc) { into "javadoc-api" } - from(rootProject.tasks.dokkaHtmlMultiModule.outputDirectory) { + from(project.java.docsDir.dir("kdoc-api")) { into "kdoc-api" } } diff --git a/gradle.properties b/gradle.properties index 8c6ebdd9ea..8d3de9c131 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,3 +9,6 @@ byteBuddyVersion=1.17.6 kotlin.jvm.target.validation.mode=ignore kotlin.stdlib.default.dependency=false + +org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true +org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled \ No newline at end of file diff --git a/gradle/docs-dokka.gradle b/gradle/docs-dokka.gradle deleted file mode 100644 index 7d593bf49d..0000000000 --- a/gradle/docs-dokka.gradle +++ /dev/null @@ -1,32 +0,0 @@ -tasks.findByName("dokkaHtmlPartial")?.configure { - outputDirectory.set(new File(buildDir, "docs/kdoc")) - dokkaSourceSets { - configureEach { - sourceRoots.setFrom(file("src/main/kotlin")) - classpath.from(sourceSets["main"].runtimeClasspath) - externalDocumentationLink { - url.set(new URL("https://docs.spring.io/spring-framework/docs/current/javadoc-api/")) - packageListUrl.set(new URL("https://docs.spring.io/spring-framework/docs/current/javadoc-api/element-list")) - } - externalDocumentationLink { - url.set(new URL("https://projectreactor.io/docs/core/release/api/")) - } - externalDocumentationLink { - url.set(new URL("https://www.reactive-streams.org/reactive-streams-1.0.3-javadoc/")) - } - externalDocumentationLink { - url.set(new URL("https://kotlin.github.io/kotlinx.coroutines/")) - } - externalDocumentationLink { - url.set(new URL("https://javadoc.io/doc/org.hamcrest/hamcrest/2.1/")) - } - externalDocumentationLink { - url.set(new URL("https://javadoc.io/doc/jakarta.servlet/jakarta.servlet-api/latest/")) - packageListUrl.set(new URL("https://javadoc.io/doc/jakarta.servlet/jakarta.servlet-api/latest/element-list")) - } - externalDocumentationLink { - url.set(new URL("https://javadoc.io/static/io.rsocket/rsocket-core/1.1.1/")) - } - } - } -} diff --git a/gradle/spring-module.gradle b/gradle/spring-module.gradle index 076a67b192..a899900abf 100644 --- a/gradle/spring-module.gradle +++ b/gradle/spring-module.gradle @@ -15,11 +15,6 @@ dependencies { jmh 'net.sf.jopt-simple:jopt-simple' } -pluginManager.withPlugin("kotlin") { - apply plugin: "org.jetbrains.dokka" - apply from: "${rootDir}/gradle/docs-dokka.gradle" -} - jmh { duplicateClassesStrategy = DuplicatesStrategy.EXCLUDE } From 6d97791f5e82da8babbeac8bce5693cc56a78161 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Thu, 17 Jul 2025 09:55:21 +0200 Subject: [PATCH 019/156] Next development version (v6.2.10-SNAPSHOT) --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index ba3a4f7293..8f00a33971 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=6.2.9-SNAPSHOT +version=6.2.10-SNAPSHOT org.gradle.caching=true org.gradle.jvmargs=-Xmx2048m From c2a162a48b1f83cbf9f85d7281b933385e994227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Thu, 17 Jul 2025 09:58:01 +0200 Subject: [PATCH 020/156] Link to the right Framework version in the KDoc See gh-35211 --- .../java/org/springframework/build/KotlinConventions.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/buildSrc/src/main/java/org/springframework/build/KotlinConventions.java b/buildSrc/src/main/java/org/springframework/build/KotlinConventions.java index 3163f143fc..08912c5bee 100644 --- a/buildSrc/src/main/java/org/springframework/build/KotlinConventions.java +++ b/buildSrc/src/main/java/org/springframework/build/KotlinConventions.java @@ -68,9 +68,10 @@ public class KotlinConventions { .getByName(SourceSet.MAIN_SOURCE_SET_NAME) .getOutput()); var externalDocumentationLinks = sourceSet.getExternalDocumentationLinks(); + var springVersion = project.getVersion(); externalDocumentationLinks.register("spring-framework", spec -> { - spec.url("https://docs.spring.io/spring-framework/docs/current/javadoc-api/"); - spec.packageListUrl("https://docs.spring.io/spring-framework/docs/current/javadoc-api/element-list"); + spec.url("https://docs.spring.io/spring-framework/docs/" + springVersion + "/javadoc-api/"); + spec.packageListUrl("https://docs.spring.io/spring-framework/docs/" + springVersion + "/javadoc-api/element-list"); }); externalDocumentationLinks.register("reactor-core", spec -> spec.url("https://projectreactor.io/docs/core/release/api/")); From 200b3ea023e2c6129d2306f06221cefe5de7ba7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Thu, 17 Jul 2025 12:13:19 +0200 Subject: [PATCH 021/156] Restore Java 17 compatibility in KotlinConventions See gh-35211 --- .../main/java/org/springframework/build/KotlinConventions.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/src/main/java/org/springframework/build/KotlinConventions.java b/buildSrc/src/main/java/org/springframework/build/KotlinConventions.java index 08912c5bee..db10992f25 100644 --- a/buildSrc/src/main/java/org/springframework/build/KotlinConventions.java +++ b/buildSrc/src/main/java/org/springframework/build/KotlinConventions.java @@ -32,7 +32,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile; public class KotlinConventions { void apply(Project project) { - project.getPlugins().withId("org.jetbrains.kotlin.jvm", _ -> { + project.getPlugins().withId("org.jetbrains.kotlin.jvm", plugin -> { project.getTasks().withType(KotlinCompile.class, this::configure); if (project.getLayout().getProjectDirectory().dir("src/main/kotlin").getAsFile().exists()) { project.getPlugins().apply(DokkaPlugin.class); From dd8313f200a726417d64aae223e468364d617913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Nicoll?= Date: Thu, 17 Jul 2025 16:25:46 +0200 Subject: [PATCH 022/156] Migrate to Palentir's JavaPoet Closes gh-35214 --- spring-core/spring-core.gradle | 8 ++++---- .../aot/generate/DefaultMethodReference.java | 13 +++++++------ .../aot/generate/GeneratedFiles.java | 4 ++-- .../aot/generate/GeneratedMethod.java | 2 +- .../aot/generate/GeneratedClassTests.java | 4 ++-- .../aot/generate/GeneratedMethodTests.java | 2 +- .../aot/generate/GeneratedMethodsTests.java | 2 +- 7 files changed, 18 insertions(+), 17 deletions(-) diff --git a/spring-core/spring-core.gradle b/spring-core/spring-core.gradle index 1fa54d0aed..24649ec50d 100644 --- a/spring-core/spring-core.gradle +++ b/spring-core/spring-core.gradle @@ -14,7 +14,7 @@ multiRelease { releaseVersions 21, 24 } -def javapoetVersion = "1.13.0" +def javapoetVersion = "0.7.0" def objenesisVersion = "3.4" configurations { @@ -30,12 +30,12 @@ tasks.register('javapoetRepackJar', ShadowJar) { archiveBaseName = 'spring-javapoet-repack' archiveVersion = javapoetVersion configurations = [project.configurations.javapoet] - relocate('com.squareup.javapoet', 'org.springframework.javapoet') + relocate('com.palantir.javapoet', 'org.springframework.javapoet') } tasks.register('javapoetSource', ShadowSource) { configurations = [project.configurations.javapoet] - relocate('com.squareup.javapoet', 'org.springframework.javapoet') + relocate('com.palantir.javapoet', 'org.springframework.javapoet') outputDirectory = file("build/shadow-source/javapoet") } @@ -67,7 +67,7 @@ tasks.register('objenesisSourceJar', Jar) { } dependencies { - javapoet("com.squareup:javapoet:${javapoetVersion}@jar") + javapoet("com.palantir.javapoet:javapoet:${javapoetVersion}@jar") objenesis("org.objenesis:objenesis:${objenesisVersion}@jar") api(files(javapoetRepackJar)) api(files(objenesisRepackJar)) diff --git a/spring-core/src/main/java/org/springframework/aot/generate/DefaultMethodReference.java b/spring-core/src/main/java/org/springframework/aot/generate/DefaultMethodReference.java index f08cfa0809..65c61ac5d9 100644 --- a/spring-core/src/main/java/org/springframework/aot/generate/DefaultMethodReference.java +++ b/spring-core/src/main/java/org/springframework/aot/generate/DefaultMethodReference.java @@ -26,6 +26,7 @@ import org.jspecify.annotations.Nullable; import org.springframework.javapoet.ClassName; import org.springframework.javapoet.CodeBlock; import org.springframework.javapoet.MethodSpec; +import org.springframework.javapoet.ParameterSpec; import org.springframework.javapoet.TypeName; import org.springframework.util.Assert; @@ -51,7 +52,7 @@ public class DefaultMethodReference implements MethodReference { @Override public CodeBlock toCodeBlock() { - String methodName = this.method.name; + String methodName = this.method.name(); if (isStatic()) { Assert.state(this.declaringClass != null, "Static method reference must define a declaring class"); return CodeBlock.of("$T::$L", this.declaringClass, methodName); @@ -65,7 +66,7 @@ public class DefaultMethodReference implements MethodReference { public CodeBlock toInvokeCodeBlock(ArgumentCodeGenerator argumentCodeGenerator, @Nullable ClassName targetClassName) { - String methodName = this.method.name; + String methodName = this.method.name(); CodeBlock.Builder code = CodeBlock.builder(); if (isStatic()) { Assert.state(this.declaringClass != null, "Static method reference must define a declaring class"); @@ -96,8 +97,8 @@ public class DefaultMethodReference implements MethodReference { */ protected void addArguments(CodeBlock.Builder code, ArgumentCodeGenerator argumentCodeGenerator) { List arguments = new ArrayList<>(); - TypeName[] argumentTypes = this.method.parameters.stream() - .map(parameter -> parameter.type).toArray(TypeName[]::new); + TypeName[] argumentTypes = this.method.parameters().stream() + .map(ParameterSpec::type).toArray(TypeName[]::new); for (int i = 0; i < argumentTypes.length; i++) { TypeName argumentType = argumentTypes[i]; CodeBlock argumentCode = argumentCodeGenerator.generateCode(argumentType); @@ -115,12 +116,12 @@ public class DefaultMethodReference implements MethodReference { } private boolean isStatic() { - return this.method.modifiers.contains(Modifier.STATIC); + return this.method.modifiers().contains(Modifier.STATIC); } @Override public String toString() { - String methodName = this.method.name; + String methodName = this.method.name(); if (isStatic()) { return this.declaringClass + "::" + methodName; } diff --git a/spring-core/src/main/java/org/springframework/aot/generate/GeneratedFiles.java b/spring-core/src/main/java/org/springframework/aot/generate/GeneratedFiles.java index 93ce2f291a..79d29ea1dc 100644 --- a/spring-core/src/main/java/org/springframework/aot/generate/GeneratedFiles.java +++ b/spring-core/src/main/java/org/springframework/aot/generate/GeneratedFiles.java @@ -48,8 +48,8 @@ public interface GeneratedFiles { * @param javaFile the java file to add */ default void addSourceFile(JavaFile javaFile) { - validatePackage(javaFile.packageName, javaFile.typeSpec.name); - String className = javaFile.packageName + "." + javaFile.typeSpec.name; + validatePackage(javaFile.packageName(), javaFile.typeSpec().name()); + String className = javaFile.packageName() + "." + javaFile.typeSpec().name(); addSourceFile(className, javaFile::writeTo); } diff --git a/spring-core/src/main/java/org/springframework/aot/generate/GeneratedMethod.java b/spring-core/src/main/java/org/springframework/aot/generate/GeneratedMethod.java index 5c6c861510..878a1c1ecd 100644 --- a/spring-core/src/main/java/org/springframework/aot/generate/GeneratedMethod.java +++ b/spring-core/src/main/java/org/springframework/aot/generate/GeneratedMethod.java @@ -53,7 +53,7 @@ public final class GeneratedMethod { MethodSpec.Builder builder = MethodSpec.methodBuilder(this.name); method.accept(builder); this.methodSpec = builder.build(); - Assert.state(this.name.equals(this.methodSpec.name), + Assert.state(this.name.equals(this.methodSpec.name()), "'method' consumer must not change the generated method name"); } diff --git a/spring-core/src/test/java/org/springframework/aot/generate/GeneratedClassTests.java b/spring-core/src/test/java/org/springframework/aot/generate/GeneratedClassTests.java index 5122ab0522..8220ac2995 100644 --- a/spring-core/src/test/java/org/springframework/aot/generate/GeneratedClassTests.java +++ b/spring-core/src/test/java/org/springframework/aot/generate/GeneratedClassTests.java @@ -112,8 +112,8 @@ class GeneratedClassTests { @Test void generateJavaFileIncludesDeclaredClasses() { GeneratedClass generatedClass = createGeneratedClass(TEST_CLASS_NAME); - generatedClass.getOrAdd("First", type -> type.modifiers.add(Modifier.STATIC)); - generatedClass.getOrAdd("Second", type -> type.modifiers.add(Modifier.PRIVATE)); + generatedClass.getOrAdd("First", type -> type.addModifiers(Modifier.STATIC)); + generatedClass.getOrAdd("Second", type -> type.addModifiers(Modifier.PRIVATE)); assertThat(generatedClass.generateJavaFile().toString()) .contains("static class First").contains("private class Second"); } diff --git a/spring-core/src/test/java/org/springframework/aot/generate/GeneratedMethodTests.java b/spring-core/src/test/java/org/springframework/aot/generate/GeneratedMethodTests.java index d08c3cdc3a..77809d1f5b 100644 --- a/spring-core/src/test/java/org/springframework/aot/generate/GeneratedMethodTests.java +++ b/spring-core/src/test/java/org/springframework/aot/generate/GeneratedMethodTests.java @@ -53,7 +53,7 @@ class GeneratedMethodTests { @Test void generateMethodSpecReturnsMethodSpec() { GeneratedMethod generatedMethod = create(method -> method.addJavadoc("Test")); - assertThat(generatedMethod.getMethodSpec().javadoc).asString().contains("Test"); + assertThat(generatedMethod.getMethodSpec().javadoc()).asString().contains("Test"); } @Test diff --git a/spring-core/src/test/java/org/springframework/aot/generate/GeneratedMethodsTests.java b/spring-core/src/test/java/org/springframework/aot/generate/GeneratedMethodsTests.java index b15a394832..1cc2b617ba 100644 --- a/spring-core/src/test/java/org/springframework/aot/generate/GeneratedMethodsTests.java +++ b/spring-core/src/test/java/org/springframework/aot/generate/GeneratedMethodsTests.java @@ -119,7 +119,7 @@ class GeneratedMethodsTests { this.methods.add("springBeans", methodSpecCustomizer); this.methods.add("springContext", methodSpecCustomizer); List names = new ArrayList<>(); - this.methods.doWithMethodSpecs(methodSpec -> names.add(methodSpec.name)); + this.methods.doWithMethodSpecs(methodSpec -> names.add(methodSpec.name())); assertThat(names).containsExactly("springBeans", "springContext"); } From fbc5ff80f434e7963af9dc947c7f6f52c7f20283 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Fri, 18 Jul 2025 15:45:59 +0300 Subject: [PATCH 023/156] Implement AutoCloseable in GzippedFiles --- .../web/reactive/resource/GzipSupport.java | 29 +++++++++---------- .../web/servlet/resource/GzipSupport.java | 29 +++++++++---------- .../ResourceHttpRequestHandlerTests.java | 3 +- 3 files changed, 30 insertions(+), 31 deletions(-) diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/resource/GzipSupport.java b/spring-webflux/src/test/java/org/springframework/web/reactive/resource/GzipSupport.java index 6ee6fadad4..6846108501 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/resource/GzipSupport.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/resource/GzipSupport.java @@ -19,6 +19,7 @@ package org.springframework.web.reactive.resource; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; +import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -26,7 +27,6 @@ import java.util.HashSet; import java.util.Set; import java.util.zip.GZIPOutputStream; -import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.ExtensionContext.Namespace; import org.junit.jupiter.api.extension.ExtensionContext.Store; @@ -40,21 +40,13 @@ import org.springframework.util.FileCopyUtils; /** * @author Andy Wilkinson + * @author Sam Brannen * @since 5.2.2 */ -class GzipSupport implements AfterEachCallback, ParameterResolver { +class GzipSupport implements ParameterResolver { private static final Namespace namespace = Namespace.create(GzipSupport.class); - @Override - public void afterEach(ExtensionContext context) { - GzippedFiles gzippedFiles = getStore(context).remove(GzippedFiles.class, GzippedFiles.class); - if (gzippedFiles != null) { - for (File gzippedFile: gzippedFiles.created) { - gzippedFile.delete(); - } - } - } @Override public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { @@ -70,13 +62,14 @@ class GzipSupport implements AfterEachCallback, ParameterResolver { return extensionContext.getStore(namespace); } - static class GzippedFiles { + + static class GzippedFiles implements AutoCloseable { private final Set created = new HashSet<>(); void create(String filePath) { try { - Resource location = new ClassPathResource("test/", EncodedResourceResolverTests.class); + Resource location = new ClassPathResource("test/", getClass()); Resource resource = new FileSystemResource(location.createRelative(filePath).getFile()); Path gzFilePath = Paths.get(resource.getFile().getAbsolutePath() + ".gz"); @@ -85,13 +78,19 @@ class GzipSupport implements AfterEachCallback, ParameterResolver { File gzFile = Files.createFile(gzFilePath).toFile(); GZIPOutputStream out = new GZIPOutputStream(new FileOutputStream(gzFile)); FileCopyUtils.copy(resource.getInputStream(), out); - created.add(gzFile); + this.created.add(gzFile); } catch (IOException ex) { - throw new RuntimeException(ex); + throw new UncheckedIOException(ex); } } + @Override + public void close() { + for (File file: this.created) { + file.delete(); + } + } } } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/GzipSupport.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/GzipSupport.java index 93087c7915..7fe45d7e7b 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/GzipSupport.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/GzipSupport.java @@ -19,6 +19,7 @@ package org.springframework.web.servlet.resource; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; +import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -26,7 +27,6 @@ import java.util.HashSet; import java.util.Set; import java.util.zip.GZIPOutputStream; -import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.ExtensionContext.Namespace; import org.junit.jupiter.api.extension.ExtensionContext.Store; @@ -40,21 +40,13 @@ import org.springframework.util.FileCopyUtils; /** * @author Andy Wilkinson + * @author Sam Brannen * @since 5.2.2 */ -class GzipSupport implements AfterEachCallback, ParameterResolver { +class GzipSupport implements ParameterResolver { private static final Namespace namespace = Namespace.create(GzipSupport.class); - @Override - public void afterEach(ExtensionContext context) { - GzippedFiles gzippedFiles = getStore(context).remove(GzippedFiles.class, GzippedFiles.class); - if (gzippedFiles != null) { - for (File gzippedFile: gzippedFiles.created) { - gzippedFile.delete(); - } - } - } @Override public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { @@ -70,13 +62,14 @@ class GzipSupport implements AfterEachCallback, ParameterResolver { return extensionContext.getStore(namespace); } - static class GzippedFiles { + + static class GzippedFiles implements AutoCloseable { private final Set created = new HashSet<>(); void create(String filePath) { try { - Resource location = new ClassPathResource("test/", EncodedResourceResolverTests.class); + Resource location = new ClassPathResource("test/", getClass()); Resource resource = new FileSystemResource(location.createRelative(filePath).getFile()); Path gzFilePath = Paths.get(resource.getFile().getAbsolutePath() + ".gz"); @@ -85,13 +78,19 @@ class GzipSupport implements AfterEachCallback, ParameterResolver { File gzFile = Files.createFile(gzFilePath).toFile(); GZIPOutputStream out = new GZIPOutputStream(new FileOutputStream(gzFile)); FileCopyUtils.copy(resource.getInputStream(), out); - created.add(gzFile); + this.created.add(gzFile); } catch (IOException ex) { - throw new RuntimeException(ex); + throw new UncheckedIOException(ex); } } + @Override + public void close() { + for (File file: this.created) { + file.delete(); + } + } } } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerTests.java index 077496f7b8..eeae66ae5f 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerTests.java @@ -37,6 +37,7 @@ import org.springframework.util.StringUtils; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.context.support.StaticWebApplicationContext; import org.springframework.web.servlet.HandlerMapping; +import org.springframework.web.servlet.resource.GzipSupport.GzippedFiles; import org.springframework.web.testfixture.servlet.MockHttpServletRequest; import org.springframework.web.testfixture.servlet.MockHttpServletResponse; import org.springframework.web.testfixture.servlet.MockServletContext; @@ -357,7 +358,7 @@ class ResourceHttpRequestHandlerTests { } @Test // gh-25976 - void partialContentByteRangeWithEncodedResource(GzipSupport.GzippedFiles gzippedFiles) throws Exception { + void partialContentByteRangeWithEncodedResource(GzippedFiles gzippedFiles) throws Exception { String path = "js/foo.js"; gzippedFiles.create(path); 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 024/156] 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 83a2031f5c..fde721cb02 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 025/156] 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 372b2d8328..1dae7ba3d3 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 ecc9e1f149..e83712dbed 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 026/156] 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 b988157e60..a554d8325a 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 027/156] 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 509aed0712..df6218d3d5 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 9756570a92..ee8fd7ddda 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 @@ public abstract class AbstractApplicationContext extends DefaultResourceLoader @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 ae42985a4a..9abd0e44da 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 @@ public abstract class AbstractMessageSource extends MessageSourceSupport impleme @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 abstract class AbstractMessageSource extends MessageSourceSupport impleme } @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 abstract class AbstractMessageSource extends MessageSourceSupport impleme 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 abstract class AbstractMessageSource extends MessageSourceSupport impleme 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 @@ public abstract class AbstractMessageSource extends MessageSourceSupport impleme * @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 @@ public abstract class AbstractMessageSource extends MessageSourceSupport impleme * @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 6d15a713e0..952990a9e3 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 class DelegatingMessageSource extends MessageSourceSupport implements Hie @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 @@ public class DelegatingMessageSource extends MessageSourceSupport implements Hie } @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 class DelegatingMessageSource extends MessageSourceSupport implements Hie } 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 6f1bba4f6f..5ce2c329fe 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 @@ public abstract class MessageSourceSupport { * @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 @@ public abstract class MessageSourceSupport { * @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 @@ public abstract class MessageSourceSupport { * @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 @@ public abstract class MessageSourceSupport { * @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 e1bdca74cd..f3661f093a 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 @@ class StubWebApplicationContext implements WebApplicationContext { @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 b8722a2e1f..aca6d7c635 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 @@ public abstract class BindErrorUtils { @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 028/156] 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 | 84 ++- ...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, 864 insertions(+), 640 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 5b4e756899..67874085f0 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 94ff1b4f42..cd15384c34 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: +`@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. -* `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 +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. +You can map requests by using glob patterns and wildcards: -`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]. +[cols="2,3,5"] +|=== +|Pattern |Description |Example -Some example patterns: +| `spring` +| Literal pattern +| `+"/spring"+` matches `+"/spring"+` -* `+"/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 +| `+?+` +| 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 11cefc0c24..7133cebd8e 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.util.MultiValueMap; 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 @@ class CaptureTheRestPathElement extends PathElement { @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 0f9579a028..7011545ed3 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 @@ import org.springframework.web.util.pattern.PatternParseException.PatternMessage * {@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 @@ class InternalPathPatternParser { 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 @@ class InternalPathPatternParser { 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 @@ class InternalPathPatternParser { if (this.pathElementStart != -1) { pushPathElement(createPathElement()); } + verifyPatternElements(this.headPE); return new PathPattern(pathPattern, this.parser, this.headPE); } @@ -232,23 +233,28 @@ class InternalPathPatternParser { 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 @@ class InternalPathPatternParser { * @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 @@ class InternalPathPatternParser { 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 @@ class InternalPathPatternParser { 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 @@ class InternalPathPatternParser { } } else { - if (this.isCaptureTheRestVariable) { + if (this.isCaptureSegmentsVariable) { throw new PatternParseException(this.pathElementStart, this.pathPatternData, PatternMessage.CAPTURE_ALL_IS_STANDALONE_CONSTRUCT); } @@ -403,7 +413,7 @@ class InternalPathPatternParser { this.insideVariableCapture = false; this.variableCaptureCount = 0; this.wildcard = false; - this.isCaptureTheRestVariable = false; + this.isCaptureSegmentsVariable = false; this.variableCaptureStart = -1; } @@ -421,4 +431,22 @@ class InternalPathPatternParser { 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 2cc9c24ff5..a154cd5962 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 @@ abstract class PathElement { } /** - * 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 e3aa9d28ee..ddd8ce9fc8 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 class PathPattern implements Comparable { 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 @@ public class PathPattern implements Comparable { 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 9cd3583092..d9dee2780d 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 @@ import java.text.MessageFormat; * 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 class PatternParseException extends IllegalArgumentException { 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 65ae88dee5..80c3413ed7 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 @@ class WildcardTheRestPathElement extends PathElement { @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 4abc53cd17..99c3db0154 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.ArrayList; 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 @@ import static org.assertj.core.api.Assertions.fail; * * @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 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 singleCharWildcardPatterns() { - pathPattern = checkStructure("?"); - assertPathElements(pathPattern, SingleCharWildcardedPathElement.class); - checkStructure("/?/"); - checkStructure("/?abc?/"); + @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 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(/**)"); - } - - @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); + 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 @@ class PathPatternParserTests { 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 @@ class PathPatternParserTests { 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 @@ class PathPatternParserTests { } @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 @@ class PathPatternParserTests { 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 @@ class PathPatternParserTests { 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 aca89c2687..9238fcd77d 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.HashMap; 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.assertThat; 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 @@ class PathPatternTests { 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 @@ class PathPatternTests { 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 @@ class PathPatternTests { 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 @@ class PathPatternTests { 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 @@ class PathPatternTests { assertMatches(p,"bAb"); } - @Test - void extractPathWithinPattern_spr15259() { + @Test // SPR-15259 + void extractPathWithinPatternWildards() { checkExtractPathWithinPattern("/**","//",""); checkExtractPathWithinPattern("/**","/",""); checkExtractPathWithinPattern("/**","",""); @@ -553,9 +598,8 @@ class PathPatternTests { 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 @@ class PathPatternTests { 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 @@ class PathPatternTests { 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 029/156] 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 aa59dae0a0..4f4ab4cc82 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 @@ import org.springframework.web.server.ResponseStatusException; 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 d62bec9271..3704dcb8d0 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 class ResourceWebHandler implements WebHandler, InitializingBean { 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 0000000000..38ef980068 --- /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 9e791269da..593e33ef73 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 a89edd5c33..8949b91d41 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 class ResourceHttpRequestHandler extends WebContentGenerator 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 81378a9921..46bc91fd61 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 @@ class ResponseEntityExceptionHandlerTests { @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 3221ccfeea..bfd81a2386 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 @@ class DefaultHandlerExceptionResolverTests { @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 0000000000..56cf687c1f --- /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 030/156] 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 4173274a39..cbe60f9a52 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 @@ public class DefaultListableBeanFactory extends AbstractAutowireCapableBeanFacto } } + @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 b05c53d87a..dc964ad5ff 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 @@ class DefaultListableBeanFactoryTests { 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 @@ class DefaultListableBeanFactoryTests { } public TestBean create() { - TestBean tb = new TestBean(); + DerivedTestBean tb = new DerivedTestBean(); tb.setName(this.name); return tb; } @@ -3646,11 +3669,11 @@ class DefaultListableBeanFactoryTests { 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 031/156] 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 39b7287a79..2aed7cc696 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 abstract class AbstractFileResolvingResource extends AbstractResource { * @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 300b5be4a3..1878432af6 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 class FileUrlResource extends UrlResource implements WritableResource { @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 80ce168059..8ca9b80c0d 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 @@ public class UrlResource extends AbstractFileResolvingResource { 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 @@ public class UrlResource extends AbstractFileResolvingResource { } } + @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 class UrlResource extends AbstractFileResolvingResource { */ @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 1634796d15..0ecabb7e19 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 class PathMatchingResourcePatternResolver implements ResourcePatternResol * 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 class PathMatchingResourcePatternResolver implements ResourcePatternResol @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 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol } } 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 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol 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 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol 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 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol 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 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol } 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 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol 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 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol 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 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol 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 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol } } } - 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 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol 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 @@ public class PathMatchingResourcePatternResolver implements ResourcePatternResol * @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 032/156] 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 e0dd66c786..48cf379740 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 @@ class PathResourceResolverTests { 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 033/156] 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 bfaa262735..af41150cdf 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 034/156] 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 b066e00e56..0be9eecb94 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 31a65520dc..004ccc5768 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 @@ import org.springframework.web.socket.server.support.WebSocketHandlerMapping; * @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 @@ public abstract class WebSocketMessageBrokerConfigurationSupport extends Abstrac @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 0000000000..1b9fce7bff --- /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 035/156] 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 5d701f28f3..45302f7af2 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 @@ public final class VersionRequestCondition extends AbstractRequestCondition 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 2103ccc354..0f57ce25d2 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 @@ public class VersionRequestConditionTests { 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 fd824ee830..dce48a3b65 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 @@ public final class VersionRequestCondition extends AbstractRequestCondition 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 27c7096008..543de9e18f 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 @@ public class VersionRequestConditionTests { 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 036/156] 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 45302f7af2..b762aec91d 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 @@ public final class VersionRequestCondition extends AbstractRequestCondition 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 0f57ce25d2..06940692aa 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 @@ package org.springframework.web.reactive.result.condition; 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 @@ public class VersionRequestConditionTests { 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 dce48a3b65..2bc8f0a33e 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 @@ public final class VersionRequestCondition extends AbstractRequestCondition 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 543de9e18f..5ee4ddff5c 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 @@ package org.springframework.web.servlet.mvc.condition; 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 @@ public class VersionRequestConditionTests { 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 037/156] 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 b762aec91d..1d0843f289 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 @@ public final class VersionRequestCondition extends AbstractRequestCondition 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 2bc8f0a33e..cc71d473bf 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 @@ public final class VersionRequestCondition extends AbstractRequestCondition 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 038/156] 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 c01516b127..a694b7500f 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 @@ public class GenericTableMetaDataProvider implements TableMetaDataProvider { } } + /** + * 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 @@ public class GenericTableMetaDataProvider implements TableMetaDataProvider { 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 fa9338a7d2..cfea217acf 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 @@ package org.springframework.jdbc.core.metadata; 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 class PostgresTableMetaDataProvider extends GenericTableMetaDataProvider } + @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 039/156] 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 cfea217acf..614c532b88 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 @@ package org.springframework.jdbc.core.metadata; 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 class PostgresTableMetaDataProvider extends GenericTableMetaDataProvider @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 040/156] 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 a694b7500f..56701af1e2 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.ResultSet; 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 @@ public class GenericTableMetaDataProvider implements TableMetaDataProvider { @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 041/156] 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 46672b4b0a..fd796de79c 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 @@ import org.springframework.lang.Nullable; * *

    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 e5f3f42dee..97b1587c55 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 @@ import org.springframework.lang.Nullable; * *

    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 042/156] 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 8b2553ce27..760fc3da87 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.Collection; 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 abstract class StringUtils { } /** - * 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 0b89fb3bd0..7711470fa9 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 abstract class UriUtils { } /** - * 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 53587c51ba..ff3159a5e2 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 @@ class UriUtilsTests { 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 043/156] 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 6db5a2f389..0000000000 --- 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 0aaca79d93..0000000000 --- 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 47a6692dc9..8f69dac873 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 @@ public interface MockMvcWebTestClient { * @since 6.2 */ static RouterFunctionSpec bindToRouterFunction(RouterFunction... routerFunctions) { - return new RouterFunctionMockMvcSpec(routerFunctions); + return new MockMvcWebTestClientSpecs.RouterFunctionMockMvcSpec(routerFunctions); } /** @@ -112,7 +112,7 @@ public interface MockMvcWebTestClient { * 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 0000000000..6b59f956df --- /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 8cccb127ff..0000000000 --- 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 9b96287e76..0000000000 --- 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 044/156] 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 7d8e6903e1..06d105e209 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 0000000000..ddae5fb295 --- /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 5075de0a2a..d8906052ff 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.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; @@ -31,6 +34,8 @@ 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.client.RestTestClient; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -41,7 +46,9 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder * * @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 @@ public class MockMvcClientHttpRequestFactory implements ClientHttpRequestFactory 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 @@ public class MockMvcClientHttpRequestFactory implements ClientHttpRequestFactory } } + 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 0000000000..3bfb787cd4 --- /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 0000000000..9cfdf87f9f --- /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 0000000000..10bff023a6 --- /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 0000000000..dcd05e779b --- /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 0000000000..f9c9702f8b --- /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 0000000000..bb899d2a8b --- /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 0000000000..577e6abef9 --- /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 0000000000..cf6174caa3 --- /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 0000000000..509443698e --- /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 0000000000..23314716fe --- /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 0000000000..3fb58d6dd8 --- /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 0000000000..f52ea100a2 --- /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 2dc9e47394..948f2d8333 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 @@ import static org.assertj.core.api.Assertions.assertThatExceptionOfType; @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 0000000000..51783fd3bc --- /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 0000000000..200210e6ee --- /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 0000000000..c4993e0a1f --- /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 0000000000..2de075d88a --- /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 0000000000..ed2a836d29 --- /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 0000000000..9c28d6ee55 --- /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 0000000000..df60e53ff1 --- /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 0000000000..cefb95be67 --- /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 0000000000..e056b64488 --- /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 0000000000..20d2c5385a --- /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 0000000000..5477ec670c --- /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 0000000000..a9f433c21e --- /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 0000000000..8950f51bed --- /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 0000000000..e49c223ecc --- /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 0000000000..2f2aaee064 --- /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 0000000000..fe6bb3d7ed --- /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 0000000000..12e45899cd --- /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 0000000000..c17ef24645 --- /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 3fe0922a83..92021b5959 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 045/156] 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 0efd957834..6deca1e015 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; + super(exchangeResult, 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; + @Override + protected void assertWithDiagnostics(Runnable assertion) { + exchangeResult.assertWithDiagnostics(assertion); } - /** - * 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; + @Override + protected MultiValueMap getResponseCookies() { + return exchangeResult.getResponseCookies(); } - - /** - * 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; - } - - /** - * 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; - } - - /** - * 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; - } - - /** - * 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 ce008c059d..5cf42730b5 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 @@ import static org.springframework.test.util.AssertionErrors.fail; * @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; + super(result, 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)); + @Override + protected void assertWithDiagnostics(Runnable assertion) { + exchangeResult.assertWithDiagnostics(assertion); } - /** - * 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)); + @Override + protected HttpHeaders getResponseHeaders() { + return exchangeResult.getResponseHeaders(); } - - /** - * 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; - } - - /** - * 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; - } - - /** - * 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; - } - - /** - * 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 7de0e7ae8e..5e5e5da789 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); + super(spec, content, 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; - } - - /** - * 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 e34204e4a3..0c0d87e82f 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; + super(result, 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; + @Override + protected void assertWithDiagnostics(Runnable assertion) { + exchangeResult.assertWithDiagnostics(assertion); } - /** - * Assert the response status as an integer. - */ - public WebTestClient.ResponseSpec isEqualTo(int status) { - return isEqualTo(HttpStatusCode.valueOf(status)); + @Override + protected HttpStatusCode getStatus() { + return exchangeResult.getStatus(); } - - /** - * Assert the response status code is {@code HttpStatus.OK} (200). - */ - public WebTestClient.ResponseSpec isOk() { - return assertStatusAndReturn(HttpStatus.OK); - } - - /** - * 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 56c89bd4bf..1f7846928a 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); + String expression, @Nullable Map namespaces, Object... args) { + super(spec, 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); - } + @Override + protected Optional getResponseHeaders() { + return Optional.of(bodySpec.returnResult()) + .map(ExchangeResult::getResponseHeaders); } - - /** - * 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)); - } - - /** - * 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; - } - - 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 3bfb787cd4..8ca598d30b 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; + super(exchangeResult, 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; + @Override + protected void assertWithDiagnostics(Runnable assertion) { + exchangeResult.assertWithDiagnostics(assertion); } - /** - * 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 + "'"; + @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 577e6abef9..9429ae85b3 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 @@ import static org.springframework.test.util.AssertionErrors.fail; * @author Rob Worsnop * @see RestTestClient.ResponseSpec#expectHeader() */ -public class HeaderAssertions { +public class HeaderAssertions extends AbstractHeaderAssertions { - private final ExchangeResult exchangeResult; - - private final RestTestClient.ResponseSpec responseSpec; public HeaderAssertions(ExchangeResult exchangeResult, RestTestClient.ResponseSpec responseSpec) { - this.exchangeResult = exchangeResult; - this.responseSpec = responseSpec; + super(exchangeResult, 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)); + @Override + protected void assertWithDiagnostics(Runnable assertion) { + exchangeResult.assertWithDiagnostics(assertion); } - /** - * 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 + "'"; + @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 cf6174caa3..cb487bdd4c 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 @@ import org.springframework.util.Assert; * @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); + super(spec, content, 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/StatusAssertions.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/StatusAssertions.java index 3fb58d6dd8..debc3a4d3e 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 @@ import static org.springframework.test.util.AssertionErrors.assertNotNull; * * @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; + super(exchangeResult, 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; + @Override + protected void assertWithDiagnostics(Runnable assertion) { + exchangeResult.assertWithDiagnostics(assertion); } - /** - * 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; + @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 f52ea100a2..d4bbaa2740 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); + super(spec, 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); - } + @Override + protected Optional getResponseHeaders() { + return Optional.of(bodySpec.returnResult()) + .map(ExchangeResult::getResponseHeaders); } - - /** - * 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() { + @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 0000000000..0527572610 --- /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 0000000000..a10aada91c --- /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 0000000000..a34facd138 --- /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 0000000000..719ba8cedf --- /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 0000000000..138c2de92d --- /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 0000000000..c6ee357969 --- /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 92021b5959..7b93a7ca85 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 046/156] 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 | 211 +++--- .../client/DefaultRestTestClientBuilder.java | 35 +- .../web/servlet/client/HeaderAssertions.java | 1 + .../servlet/client/JsonPathAssertions.java | 2 +- .../web/servlet/client/RestTestClient.java | 611 +++++++++--------- .../web/servlet/client/StatusAssertions.java | 2 +- .../web/servlet/client/XpathAssertions.java | 1 + 10 files changed, 468 insertions(+), 423 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 e033410cd7..0659241cae 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 @@ class DefaultWebTestClient implements WebTestClient { 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 @@ class DefaultWebTestClient implements WebTestClient { 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 16127ae8d1..210146bc83 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 @@ public interface WebTestClient { interface RequestHeadersUriSpec> extends UriSpec, RequestHeadersSpec { } + /** * Specification for providing the body and the URI of a request. */ @@ -932,7 +933,6 @@ public interface WebTestClient { @FunctionalInterface interface ResponseSpecConsumer extends Consumer { } - } @@ -1014,7 +1014,6 @@ public interface WebTestClient { * 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 8ca598d30b..b4f8cced07 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 @@ import org.springframework.util.MultiValueMap; * 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 10bff023a6..d831a68af0 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 @@ import org.springframework.web.util.UriBuilder; * 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,41 +108,30 @@ class DefaultRestTestClient implements RestTestClient { 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; - - 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); - 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); @@ -158,14 +151,8 @@ class DefaultRestTestClient implements RestTestClient { } @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); + public RequestBodySpec uri(URI uri) { + this.requestBodySpec = this.requestHeadersUriSpec.uri(uri); return this; } @@ -176,14 +163,14 @@ class DefaultRestTestClient implements RestTestClient { } @Override - public RequestBodySpec contentType(MediaType contentType) { - this.requestBodySpec = this.requestHeadersUriSpec.contentType(contentType); + public RequestBodySpec headers(Consumer headersConsumer) { + this.requestBodySpec = this.requestHeadersUriSpec.headers(headersConsumer); return this; } @Override - public RequestHeadersSpec body(Object body) { - this.requestHeadersUriSpec.body(body); + public RequestBodySpec accept(MediaType... acceptableMediaTypes) { + this.requestBodySpec = this.requestHeadersUriSpec.accept(acceptableMediaTypes); return this; } @@ -193,6 +180,24 @@ class DefaultRestTestClient implements RestTestClient { return this; } + @Override + public RequestBodySpec contentType(MediaType contentType) { + this.requestBodySpec = this.requestHeadersUriSpec.contentType(contentType); + 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 ifModifiedSince(ZonedDateTime ifModifiedSince) { this.requestBodySpec = this.requestHeadersUriSpec.ifModifiedSince(ifModifiedSince); @@ -205,12 +210,6 @@ class DefaultRestTestClient implements RestTestClient { 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 @@ class DefaultRestTestClient implements RestTestClient { 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 @@ class DefaultRestTestClient implements RestTestClient { } } + private static class DefaultResponseSpec implements ResponseSpec { private final ExchangeResult exchangeResult; - public DefaultResponseSpec(ExchangeResult exchangeResult) { + DefaultResponseSpec(ExchangeResult exchangeResult) { this.exchangeResult = exchangeResult; } @@ -247,9 +253,13 @@ class DefaultRestTestClient implements RestTestClient { } @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 @@ class DefaultRestTestClient implements RestTestClient { } @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 @@ class DefaultRestTestClient implements RestTestClient { } return this; } + } - @Override - public EntityExchangeResult returnResult(Class elementClass) { - return new EntityExchangeResult<>(this.exchangeResult, this.exchangeResult.getBody(elementClass)); + + 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(ParameterizedTypeReference elementTypeRef) { - return new EntityExchangeResult<>(this.exchangeResult, this.exchangeResult.getBody(elementTypeRef)); + 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; + } + + @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; } @@ -373,57 +430,15 @@ class DefaultRestTestClient implements RestTestClient { return new String(body, charset); } + @Override + public BodyContentSpec consumeWith(Consumer> consumer) { + this.result.assertWithDiagnostics(() -> consumer.accept(this.result)); + return this; + } + @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 index dcd05e779b..4e4b722e30 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 @@ import org.springframework.web.util.UriBuilderFactory; /** * 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,11 +44,6 @@ class DefaultRestTestClientBuilder> implemen this.restClientBuilder = restClientBuilder; } - @Override - public RestTestClient.Builder apply(Consumer> builderConsumer) { - builderConsumer.accept(this); - return this; - } @Override public RestTestClient.Builder baseUrl(String baseUrl) { @@ -53,14 +52,8 @@ class DefaultRestTestClientBuilder> implemen } @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); + public RestTestClient.Builder uriBuilderFactory(UriBuilderFactory uriFactory) { + this.restClientBuilder.uriBuilderFactory(uriFactory); return this; } @@ -77,8 +70,20 @@ class DefaultRestTestClientBuilder> implemen } @Override - public RestTestClient.Builder uriBuilderFactory(UriBuilderFactory uriFactory) { - this.restClientBuilder.uriBuilderFactory(uriFactory); + 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 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 9429ae85b3..89e557b93e 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 @@ import org.springframework.test.web.support.AbstractHeaderAssertions; * 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 cb487bdd4c..b5eda5ba59 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 @@ import org.springframework.test.web.support.AbstractJsonPathAssertions; * 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 23314716fe..4dcd74026f 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 @@ public interface RestTestClient { 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 { + + interface Builder> { + + /** + * Configure a base URI as described in + * {@link RestClient#create(String) + * WebClient.create(String)}. + */ + Builder baseUrl(String baseUrl); + + /** + * Provide a pre-configured {@link UriBuilderFactory} instance as an + * alternative to and effectively overriding {@link #baseUrl(String)}. + */ + Builder 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); + + /** + * 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); + + /** + * 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); + + /** + * 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); + + /** + * Build the {@link RestTestClient} instance. + */ + RestTestClient build(); } - /** - * Specification for providing the body and the URI of a request. - */ - interface RequestBodyUriSpec extends RequestBodySpec, RequestHeadersUriSpec { + + interface MockServerBuilder extends Builder> { + + MockServerBuilder configureServer(Consumer consumer); + } - /** - * 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. @@ -424,6 +266,7 @@ public interface RestTestClient { * @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 @@ public interface RestTestClient { * @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 @@ public interface RestTestClient { ResponseSpec exchange(); } + /** * Specification for providing body of a request. */ @@ -587,70 +428,252 @@ public interface RestTestClient { 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); + /** + * Specification for providing request headers and the URI of a request. + * + * @param a self reference to the spec type + */ + interface RequestHeadersUriSpec> extends UriSpec, RequestHeadersSpec { } - interface MockServerBuilder extends Builder> { - MockServerBuilder configureServer(Consumer consumer); + + /** + * 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 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); + + /** + * Assertions on the response status. + */ + StatusAssertions expectStatus(); + + /** + * Assertions on the headers of the response. + */ + HeaderAssertions expectHeader(); + + /** + * Assertions on the cookies of the response. + */ + CookieAssertions expectCookie(); + + /** + * 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); + + /** + * Consume and decode the response body to {@code byte[]} and then apply + * assertions on the raw content (for example, isEmpty, JSONPath, etc.). + */ + BodyContentSpec expectBody(); + + /** + * 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 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 debc3a4d3e..c2f76ef22b 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 @@ import org.springframework.test.web.support.AbstractStatusAssertions; * 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 d4bbaa2740..cfec64a17e 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 @@ import org.springframework.util.Assert; * 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 047/156] 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 | 58 +++++----- .../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, 131 insertions(+), 100 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 210146bc83..4d9c30fbc1 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 9cfdf87f9f..0000000000 --- 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 4e4b722e30..48c1d255b0 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 @@ package org.springframework.test.web.servlet.client; 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 @@ import org.springframework.web.util.UriBuilderFactory; */ 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 4dcd74026f..32bbed7535 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.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; @@ -51,6 +50,8 @@ import org.springframework.web.util.UriBuilderFactory; * 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 @@ public interface RestTestClient { * {@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 @@ public interface RestTestClient { * {@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 @@ public interface RestTestClient { * * @return chained API to customize client config */ - static > Builder bindToServer() { + static Builder bindToServer() { return new DefaultRestTestClientBuilder<>(); } @@ -183,7 +181,7 @@ public interface RestTestClient { * 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 @@ public interface RestTestClient { * {@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 @@ public interface RestTestClient { * @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 @@ public interface RestTestClient { * @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 MockServerBuilder extends Builder> { - - MockServerBuilder configureServer(Consumer consumer); - + interface StandaloneSetupBuilder extends MockMvcSetupBuilder { } + interface RouterFunctionSetupBuilder extends MockMvcSetupBuilder { + } + + + 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 c4993e0a1f..a63e649418 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 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. 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 9c28d6ee55..656da349f8 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 @@ import org.springframework.web.bind.annotation.RestController; */ 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 df60e53ff1..28a3c99cac 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 @@ import org.springframework.web.bind.annotation.RestController; */ 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 cefb95be67..fc035c1e80 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 @@ import static org.hamcrest.Matchers.containsString; */ 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 20d2c5385a..12596a2593 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 @@ import static org.hamcrest.Matchers.startsWith; * @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 5477ec670c..381c520ca7 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 a9f433c21e..d13154e857 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 @@ import static org.assertj.core.api.Assertions.assertThatExceptionOfType; */ 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 8950f51bed..f9af1bcaa4 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 2f2aaee064..227bdc0d3d 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 048/156] 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 | 82 ++++++----- .../web/servlet/client/HeaderAssertions.java | 2 +- .../servlet/client/JsonPathAssertions.java | 8 +- .../web/servlet/client/RestTestClient.java | 130 +++++++++++------- .../web/servlet/client/StatusAssertions.java | 4 +- .../web/servlet/client/XpathAssertions.java | 8 +- .../client/samples/RestTestClientTests.java | 2 +- 16 files changed, 269 insertions(+), 196 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 6deca1e015..dd47cec18b 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 @@ import org.springframework.util.MultiValueMap; */ 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 0659241cae..78e287b424 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 @@ class DefaultWebTestClient implements WebTestClient { 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 @@ class DefaultWebTestClient implements WebTestClient { @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 @@ class DefaultWebTestClient implements WebTestClient { 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 5cf42730b5..d8ca7dadab 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 @@ import org.springframework.test.web.support.AbstractHeaderAssertions; */ 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 5e5e5da789..44602c185a 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 @@ import org.springframework.test.web.support.AbstractJsonPathAssertions; */ 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 0c0d87e82f..91fa59ee52 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 @@ import org.springframework.test.web.support.AbstractStatusAssertions; */ 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 4d9c30fbc1..2ffaa16168 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 @@ import org.springframework.web.util.UriBuilderFactory; * 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 @@ import org.springframework.web.util.UriBuilderFactory; * @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 @@ public interface WebTestClient { } /** - * 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 @@ public interface WebTestClient { } /** - * 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 @@ public interface WebTestClient {
       
       
       	/**
      -	 * 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 @@ public interface WebTestClient {
       		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 @@ public interface WebTestClient {
       	 * 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 @@ public interface WebTestClient {
       
       		/**
       		 * 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 @@ public interface WebTestClient {
       
       		/**
       		 * 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 @@ public interface WebTestClient {
       
       		/**
       		 * 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 @@ public interface WebTestClient {
       		/**
       		 * 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 @@ public interface WebTestClient {
       		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 b4f8cced07..e5a033f825 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 @@ import org.springframework.util.MultiValueMap; */ 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 d831a68af0..4e69ffdc13 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.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; @@ -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 @@ class DefaultRestTestClient implements RestTestClient { } @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 @@ class DefaultRestTestClient implements RestTestClient { @Override public > Builder mutate() { - return new DefaultRestTestClientBuilder<>(this.restClientBuilder); + return new DefaultRestTestClientBuilder<>(this.restClient.mutate()); } @@ -122,103 +118,105 @@ class DefaultRestTestClient implements RestTestClient { 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 @@ class DefaultRestTestClient implements RestTestClient { @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 @@ class DefaultRestTestClient implements RestTestClient { private final ExchangeResult exchangeResult; - DefaultResponseSpec(ExchangeResult exchangeResult) { - this.exchangeResult = exchangeResult; + DefaultResponseSpec(ExchangeResult result) { + this.exchangeResult = result; } @Override @@ -265,19 +261,22 @@ class DefaultRestTestClient implements RestTestClient { @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 @@ class DefaultRestTestClient implements RestTestClient { 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 @@ class DefaultRestTestClient implements RestTestClient { } @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 @@ class DefaultRestTestClient implements RestTestClient { @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 48c1d255b0..748deb67aa 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 @@ package org.springframework.test.web.servlet.client; 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 @@ import org.springframework.web.util.UriBuilderFactory; * * @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 @@ class DefaultRestTestClientBuilder> implemen 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 @@ class DefaultRestTestClientBuilder> implemen } + /** + * 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 @@ class DefaultRestTestClientBuilder> implemen } - 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 @@ class DefaultRestTestClientBuilder> implemen } - static class DefaultRouterFunctionSetupBuilder 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 @@ class DefaultRestTestClientBuilder> implemen } - 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 bb899d2a8b..a440f016d4 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 @@ 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; @@ -32,6 +31,7 @@ import org.springframework.core.ParameterizedTypeReference; 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 @@ import org.springframework.web.client.RestClient.RequestHeadersSpec.ConvertibleC * {@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); - /** 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"); + /** Ensure single logging; for example, for expectAll. */ + private boolean diagnosticsLogged; + + + 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,39 +83,13 @@ public class ExchangeResult { } } + /** + * Return the response headers received from the server. + */ 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. */ @@ -132,4 +117,33 @@ public class ExchangeResult { .partitioned(partitioned) .build(); } + + @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; + } + } + } 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 89e557b93e..777097e67d 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 @@ import org.springframework.test.web.support.AbstractHeaderAssertions; 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 b5eda5ba59..efe57d0b3d 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 @@ import org.springframework.test.web.support.AbstractJsonPathAssertions; * 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 32bbed7535..2394060227 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.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; @@ -47,7 +48,19 @@ import org.springframework.web.util.UriBuilder; 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 @@ public interface RestTestClient { } /** - * 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 @@ public interface RestTestClient {
       	}
       
       
      +	/**
      +	 * 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 @@ public interface RestTestClient {
       
       		/**
       		 * 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 @@ public interface RestTestClient {
       		 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 @@ public interface RestTestClient {
       		 */
       		 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 { - - T configureServer(Consumer consumer); } + /** + * 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); + } + + + /** + * 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 @@ public interface RestTestClient { * 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 @@ public interface RestTestClient { * 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 @@ public interface RestTestClient { * 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 @@ public interface RestTestClient { /** * 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 @@ public interface RestTestClient { interface ResponseSpec { /** - * Apply multiple assertions to a response with the given - * {@linkplain RestTestClient.ResponseSpec.ResponseSpecConsumer consumers}, with the guarantee that +s * Apply multiple assertions to a response with the given + * {@linkplain 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, @@ -522,8 +545,7 @@ public interface RestTestClient { 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 @@ public interface RestTestClient { 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 @@ public interface RestTestClient { /** * 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 c2f76ef22b..f29df0975a 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 @@ import org.springframework.test.web.support.AbstractStatusAssertions; */ 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 cfec64a17e..253bb0fbc6 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 @@ import org.springframework.util.Assert; */ 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 381c520ca7..af1be6b11b 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 RestTestClientTests { @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 049/156] 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 4e69ffdc13..1289c8af44 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 @@ class DefaultRestTestClient implements RestTestClient { 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 @@ class DefaultRestTestClient implements RestTestClient { @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 @@ class DefaultRestTestClient implements RestTestClient { @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 a440f016d4..def8eee72b 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 @@ package org.springframework.test.web.servlet.client; 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.jspecify.annotations.Nullable; 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 class ExchangeResult { } } + @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 51783fd3bc..4412adba02 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 @@ public class CookieAssertionsTests { } - 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 200210e6ee..3dd307acab 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.junit.jupiter.api.Test; 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 @@ class HeaderAssertionTests { } 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 ed2a836d29..099ca06f5b 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 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; @@ -255,7 +257,8 @@ class StatusAssertionTests { 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 050/156] 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 1289c8af44..45177fb058 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 @@ class DefaultRestTestClient implements RestTestClient { 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 @@ class DefaultRestTestClient implements RestTestClient { @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 748deb67aa..14b37ff044 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.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.ApiVersionInserter; import org.springframework.web.client.RestClient; import org.springframework.web.context.WebApplicationContext; import org.springframework.web.servlet.function.RouterFunction; @@ -94,6 +95,18 @@ class DefaultRestTestClientBuilder> implemen 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 2394060227..836979aa25 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.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.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 @@ public interface RestTestClient { */ 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 @@ public interface RestTestClient { */ 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 0000000000..4ea6e81a1f --- /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 051/156] 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 | 12 +- .../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, 561 insertions(+), 1005 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 dd47cec18b..e3328c8f5c 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 d8ca7dadab..c2c3a3a7fb 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 e5a033f825..8f9bd624ab 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 f9c9702f8b..2999fffcf5 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 @@ import org.jspecify.annotations.Nullable; * 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 777097e67d..1a3dfab4ad 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 ed071659d1..94c3686561 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 0527572610..e564b8b911 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 @@ import static org.springframework.test.util.AssertionErrors.fail; * 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 abstract class AbstractCookieAssertions { 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 a10aada91c..166de6e386 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 @@ import static org.springframework.test.util.AssertionErrors.fail; * 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 abstract class AbstractHeaderAssertions { 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 a34facd138..2254d39abb 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.core.ParameterizedTypeReference; 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 @@ public abstract class AbstractJsonPathAssertions { 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 719ba8cedf..9573e11920 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 @@ import static org.springframework.test.util.AssertionErrors.assertNotNull; * 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 abstract class AbstractStatusAssertions { 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 138c2de92d..4614d7dec3 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.http.HttpHeaders; 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 @@ public abstract class AbstractXpathAssertions { } + /** + * 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 abstract class AbstractXpathAssertions { 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 41246500bb..0000000000 --- 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 aa795f13b2..0000000000 --- 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 bbb7842bca..0000000000 --- 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 099ca06f5b..0000000000 --- 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 4412adba02..6762e1cb8a 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 @@ public class CookieAssertionsTests { @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 @@ public class CookieAssertionsTests { assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.sameSite("foo", "Strict")); } + private TestCookieAssertions initCookieAssertions(ResponseCookie cookie) throws IOException { + return new TestCookieAssertions(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); - when(response.getStatusCode()).thenReturn(HttpStatusCode.valueOf(200)); - ExchangeResult result = new ExchangeResult(new MockClientHttpRequest(), response, null); - return new CookieAssertions(result, mock()); + + 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 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 3dd307acab..01e6138ade 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.junit.jupiter.api.Test; 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 @@ class HeaderAssertionTests { 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 @@ class HeaderAssertionTests { 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 @@ class HeaderAssertionTests { 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 @@ class HeaderAssertionTests { 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 @@ class HeaderAssertionTests { 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 @@ class HeaderAssertionTests { 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 @@ class HeaderAssertionTests { 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 @@ class HeaderAssertionTests { 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 @@ class HeaderAssertionTests { // 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 @@ class HeaderAssertionTests { 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 @@ class HeaderAssertionTests { 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 @@ class HeaderAssertionTests { 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 0000000000..72b3904ca0 --- /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 7b93a7ca85..da63723d68 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 052/156] 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 af1be6b11b..6c72769348 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.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; @@ -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 @@ class RestTestClientTests { } + @Nested class Mutation { @@ -149,6 +151,7 @@ class RestTestClientTests { } } + @Nested class Uris { @@ -193,6 +196,7 @@ class RestTestClientTests { } } + @Nested class Cookies { @Test @@ -214,6 +218,7 @@ class RestTestClientTests { } } + @Nested class Headers { @Test @@ -271,6 +276,7 @@ class RestTestClientTests { } } + @Nested class Expectations { @Test @@ -281,6 +287,7 @@ class RestTestClientTests { } } + @Nested class ReturnResults { @Test @@ -312,6 +319,7 @@ class RestTestClientTests { } } + @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 4ea6e81a1f..c23d60b8c8 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 @@ public class ApiVersionTests { @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 656da349f8..d35f4b9999 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 @@ class ErrorTests { .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 28a3c99cac..17329742f7 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 @@ class HeaderAndCookieTests { .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 fc035c1e80..58d47dcebc 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 @@ class JsonContentTests { @RestController @RequestMapping("/persons") - static class PersonController { + private static class PersonController { @GetMapping List getPersons() { @@ -143,11 +143,13 @@ class JsonContentTests { @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 12596a2593..40dfb6d49f 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 @@ import static org.hamcrest.Matchers.startsWith; * @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 @@ class ResponseEntityTests { @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 d13154e857..d9f141df3b 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 @@ class SoftAssertionTests { @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 f9af1bcaa4..d960406c73 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 @@ class XmlContentTests { } } + @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 e49c223ecc..4829d904e1 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 @@ import org.springframework.web.context.WebApplicationContext; 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 fe6bb3d7ed..5be11ab68b 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 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; @@ -44,7 +43,7 @@ class FilterTests { 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 12e45899cd..44c5ef28ee 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 c17ef24645..ab0a4ab013 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 053/156] 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 ddae5fb295..5fb725a43f 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 054/156] 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 acab4ffb92..27a115663e 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 @@ import java.util.Set; 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 fe4b3a9e47..3785f6834d 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 @@ import org.springframework.web.service.annotation.HttpExchange; @Retention(RetentionPolicy.RUNTIME) @Documented @Repeatable(ImportHttpServices.Container.class) -@Import(AnnotationHttpServiceRegistrar.class) +@Import(ImportHttpServicesRegistrar.class) public @interface ImportHttpServices { /** @@ -106,7 +106,7 @@ public @interface ImportHttpServices { @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 8cc29cc04d..c23845a378 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 @@ import org.springframework.core.type.AnnotationMetadata; * @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 84188de380..d3f45d3c3f 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 org.springframework.web.service.registry.greeting.GreetingB; 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 @@ public class AnnotationHttpServiceRegistrarTests { 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 @@ public class AnnotationHttpServiceRegistrarTests { 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 @@ public class AnnotationHttpServiceRegistrarTests { 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 @@ public class AnnotationHttpServiceRegistrarTests { @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 0000000000..d4ed645a0b --- /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 0000000000..2f059d0674 --- /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 055/156] 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 570a2d3cd9..d7d9b461b0 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 @@ package org.springframework.web.service.registry; import java.util.Arrays; import java.util.Objects; +import java.util.stream.Stream; import org.jspecify.annotations.Nullable; @@ -186,7 +187,8 @@ public abstract class AbstractHttpServiceRegistrar implements 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 @@ public abstract class AbstractHttpServiceRegistrar implements 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 @@ public abstract class AbstractHttpServiceRegistrar implements 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 @@ public abstract class AbstractHttpServiceRegistrar implements * Variant of {@link #detectInBasePackages(Class[])} with a String package name. */ GroupSpec detectInBasePackages(String... packageNames); - } } @@ -288,6 +294,12 @@ public abstract class AbstractHttpServiceRegistrar implements 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 abstract class AbstractHttpServiceRegistrar implements } 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 0000000000..56fe47a93f --- /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 0000000000..ad9d22d2e5 --- /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 0000000000..ef5b11a952 --- /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 0000000000..197f950873 --- /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 0000000000..e339f9a35d --- /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 0000000000..ed28b908ff --- /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 056/156] 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 049e3f7908..21bdef7e00 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 class WebFluxConfigurationSupport implements ApplicationContextAware { @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 @@ public class WebFluxConfigurationSupport implements ApplicationContextAware { * @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 ac574bcd92..c3bc8b75ea 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 @@ public class DelegatingWebFluxConfigurationTests { 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 057/156] 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 d7d9b461b0..099e01f13f 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 @@ public abstract class AbstractHttpServiceRegistrar implements 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 @@ public abstract class AbstractHttpServiceRegistrar implements /** * 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 abstract class AbstractHttpServiceRegistrar implements 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 3785f6834d..4dbd745077 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 @@ public @interface ImportHttpServices { 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 ef5b11a952..3045eb9795 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.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 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 197f950873..6b81e0e779 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.annotation.GetExchange; 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 e339f9a35d..7ab473f584 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 ed28b908ff..88368f0c93 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 058/156] Revise method order in AbstractHttpServiceRegistrar See gh-35244 --- .../AbstractHttpServiceRegistrar.java | 55 +++++++++---------- 1 file changed, 27 insertions(+), 28 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 099e01f13f..4b55e80910 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 abstract class AbstractHttpServiceRegistrar implements })); } + /** + * 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,34 +187,6 @@ public abstract class AbstractHttpServiceRegistrar implements } } - /** - * 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); - - - /** - * 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"); - Assert.state(this.resourceLoader != null, "ResourceLoader has not been set"); - this.scanner = new HttpExchangeClassPathScanningCandidateComponentProvider(); - this.scanner.setEnvironment(this.environment); - this.scanner.setResourceLoader(this.resourceLoader); - } - return this.scanner.findCandidateComponents(basePackage).stream(); - } - private void mergeGroups(RootBeanDefinition proxyRegistryBeanDef) { ConstructorArgumentValues args = proxyRegistryBeanDef.getConstructorArgumentValues(); ConstructorArgumentValues.ValueHolder valueHolder = args.getArgumentValue(0, GroupsMetadata.class); @@ -220,6 +202,23 @@ public abstract class AbstractHttpServiceRegistrar implements return registry.getClient(groupName, ClassUtils.resolveClassName(httpServiceType, this.beanClassLoader)); } + /** + * Find HTTP Service types under the given base package, looking for + * interfaces with type or method {@link HttpExchange} annotations. + * @param basePackage the names of 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"); + Assert.state(this.resourceLoader != null, "ResourceLoader has not been set"); + this.scanner = new HttpExchangeClassPathScanningCandidateComponentProvider(); + this.scanner.setEnvironment(this.environment); + this.scanner.setResourceLoader(this.resourceLoader); + } + return this.scanner.findCandidateComponents(basePackage).stream(); + } + /** * 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 059/156] 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 ad9d22d2e5..e3fa444cc3 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.annotation.MergedAnnotations; 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 4b55e80910..9fc72368d5 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 @@ public abstract class AbstractHttpServiceRegistrar implements 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 @@ public abstract class AbstractHttpServiceRegistrar implements 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 @@ public abstract class AbstractHttpServiceRegistrar implements * 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 56fe47a93f..03c285b762 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 @@ 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}. + * 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 c23845a378..81f165e59d 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 @@ import org.springframework.core.type.AnnotationMetadata; * @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 4dbd745077..b1abea0323 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 @@ import org.springframework.web.service.annotation.HttpExchange; @Retention(RetentionPolicy.RUNTIME) @Documented @Repeatable(ImportHttpServices.Container.class) -@Import(ImportHttpServicesRegistrar.class) +@Import(ImportHttpServiceRegistrar.class) public @interface ImportHttpServices { /** @@ -80,7 +80,7 @@ public @interface ImportHttpServices { * 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 @@ public @interface ImportHttpServices { @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 3045eb9795..7f60b77753 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.assertj.core.api.Assertions.assertThat; 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 @@ public class HttpServiceClientRegistrarSupportTests { 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 d3f45d3c3f..8959cb2f2d 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 org.springframework.web.service.registry.greeting.GreetingB; 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 060/156] 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 0417613f49..d81436315a 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 061/156] 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 836979aa25..426b8e5d8f 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 @@ public interface RestTestClient { interface ResponseSpec { /** -s * Apply multiple assertions to a response with the given + * Apply multiple assertions to a response with the given * {@linkplain ResponseSpecConsumer consumers}, with the guarantee that * all assertions will be applied even if one or more assertions fails * with an exception. From 18ee8adaebfaa8b4f14ffc71339d5b313f60681c Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Thu, 31 Jul 2025 12:26:15 +0100 Subject: [PATCH 062/156] 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 22119b0f14..b6c351d903 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 java.util.Set; 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 class ApiVersionConfigurer { } 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 93e54192b5..d1aea97d8a 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 java.util.Set; 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 class ApiVersionConfigurer { } 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 class ApiVersionConfigurer { 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 063/156] 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 6c446bcb14..917fcc95bd 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 2be54cd833..f9f0afd77c 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 5fd592ce50..6efcc987ca 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 @@ public class ApiVersionTests { 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 0000000000..e9d7eb2275 --- /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 0000000000..62886a7899 --- /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 b6c351d903..049069acfc 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 class ApiVersionConfigurer { } /** - * 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 d1aea97d8a..5042170e6a 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.DefaultApiVersionStrategy; 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 class ApiVersionConfigurer { } /** - * 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 064/156] 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 8ff0fbe5a9..2e61e54bec 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 @@ public interface ApiVersionInserter { * @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 @@ public interface ApiVersionInserter { * @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 @@ public interface ApiVersionInserter { * @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 @@ public interface ApiVersionInserter { */ 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 903568b098..a625063943 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 @@ package org.springframework.web.client; 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 @@ final class DefaultApiVersionInserter implements ApiVersionInserter { @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 d67493088c..5b8e601762 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 adddf6e260..7ea43c0ab9 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.AfterEach; 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 @@ public class RestClientVersionTests { 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 @@ public class RestClientVersionTests { 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 065/156] 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 917fcc95bd..2065a962f1 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 f9f0afd77c..4cb7dd9479 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 4be157e33a..9d5a93d26e 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 @@ import org.springframework.web.util.ServletRequestPathUtils; /** * {@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 class PathApiVersionResolver implements ApiVersionResolver { @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 e4fadc5985..2b5c56b78d 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.testfixture.servlet.MockHttpServletRequest; 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 @@ public class PathApiVersionResolverTests { 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 777c5947b7..2da6819498 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 class PathApiVersionResolver implements ApiVersionResolver { @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 049069acfc..0392f4dd87 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 class ApiVersionConfigurer { 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 class ApiVersionConfigurer { 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 3feb8266f3..3e3ec3076f 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 @@ package org.springframework.web.reactive.accept; 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 @@ public class PathApiVersionResolverTests { 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 5042170e6a..e3e3b1c60a 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 class ApiVersionConfigurer { 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 class ApiVersionConfigurer { 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 066/156] 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 c23d60b8c8..70af17d22e 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 @@ public class ApiVersionTests { 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 25637ed8b0..be52b6634d 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 class ApiVersionTests { 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 a85dff6ed3..b9c41fb31b 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.ArrayList; 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 class DefaultApiVersionStrategy implements ApiVersionStrategy { 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 class DefaultApiVersionStrategy implements ApiVersionStrategy { 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 b35282bee9..222798a3bc 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 @@ public class DefaultApiVersionStrategiesTests { 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 @@ public class DefaultApiVersionStrategiesTests { } @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 @@ public class DefaultApiVersionStrategiesTests { @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 @@ public class DefaultApiVersionStrategiesTests { } @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 08816c0032..114f0c95dd 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.ArrayList; 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 class DefaultApiVersionStrategy implements ApiVersionStrategy { 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 class DefaultApiVersionStrategy implements ApiVersionStrategy { * 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 class DefaultApiVersionStrategy implements ApiVersionStrategy { 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 0392f4dd87..2fe21fc539 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.Collections; 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 class ApiVersionConfigurer { 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 class ApiVersionConfigurer { 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 b9e21028c9..367587dd50 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 @@ public class DefaultApiVersionStrategiesTests { } @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 @@ public class DefaultApiVersionStrategiesTests { @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 @@ public class DefaultApiVersionStrategiesTests { } @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 7f73b5e121..ec779c637f 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 @@ class RequestPredicatesTests { 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 06940692aa..b96de727c5 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 @@ public class VersionRequestConditionTests { 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 e3e3b1c60a..e08e170816 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.Collections; 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 class ApiVersionConfigurer { 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 class ApiVersionConfigurer { 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 508ca3b46a..55f9eb0d22 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 @@ class RequestPredicatesTests { 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 5ee4ddff5c..47b41ba456 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 @@ public class VersionRequestConditionTests { 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 067/156] 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 49141b6098..d9693ca61f 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 2ffaa16168..9fe4a75edd 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 @@ public interface WebTestClient { * @since 5.0.2 */ static Builder bindToServer(ClientHttpConnector connector) { - return new DefaultWebTestClientBuilder(connector); + return new DefaultWebTestClientBuilder().clientConnector(connector); } @@ -467,33 +467,6 @@ public interface WebTestClient { */ 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 @@ public interface WebTestClient { */ 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 45177fb058..c46bdc39b5 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 @@ class DefaultRestTestClient implements RestTestClient { 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 @@ class DefaultRestTestClient implements RestTestClient { 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 @@ class DefaultRestTestClient implements RestTestClient { 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 @@ class DefaultRestTestClient implements RestTestClient { @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 14b37ff044..53168bf60d 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.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.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 @@ class DefaultRestTestClientBuilder> implemen 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 @@ class DefaultRestTestClientBuilder> implemen @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 426b8e5d8f..f7f7bd69c6 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 @@ package org.springframework.test.web.servlet.client; 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.HttpHeaders; 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 @@ public interface RestTestClient { */ 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 068/156] 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 87adeaed3e..7ab4a8bd94 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 @@ class QuartzSupportTests { 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 6f56d8a6e5..8dffb08bd6 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 d9d4ea446f..c2e39a84d3 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 @@ public interface LifecycleProcessor extends Lifecycle { 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 ac2039cae6..cf4c43bd26 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 0000000000..759783bc0b --- /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 e8165be8d0..8ac4410891 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 @@ import org.springframework.context.ApplicationContext; * * @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 cc846e3e5f..d741a1f25c 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.PayloadApplicationEvent; 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 abstract class AbstractApplicationContext extends DefaultResourceLoader 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 f5760107f5..02bf8c08ab 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 class DefaultLifecycleProcessor implements LifecycleProcessor, BeanFactor */ @Override public void stop() { - stopBeans(); + stopBeans(false); this.running = false; } @@ -308,7 +308,7 @@ public class DefaultLifecycleProcessor implements LifecycleProcessor, BeanFactor 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 class DefaultLifecycleProcessor implements LifecycleProcessor, BeanFactor 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 class DefaultLifecycleProcessor implements LifecycleProcessor, BeanFactor void stopForRestart() { if (this.running) { this.stoppedBeans = ConcurrentHashMap.newKeySet(); - stopBeans(); + stopBeans(false); this.running = false; } } @@ -361,7 +369,8 @@ public class DefaultLifecycleProcessor implements LifecycleProcessor, BeanFactor 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 @@ public class DefaultLifecycleProcessor implements LifecycleProcessor, BeanFactor (!(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 @@ public class DefaultLifecycleProcessor implements LifecycleProcessor, BeanFactor * @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 @@ public class DefaultLifecycleProcessor implements LifecycleProcessor, BeanFactor 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 @@ public class DefaultLifecycleProcessor implements LifecycleProcessor, BeanFactor 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 class DefaultLifecycleProcessor implements LifecycleProcessor, BeanFactor 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 cb968cf9c1..7904d88138 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 @@ class DefaultLifecycleProcessorTests { 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 @@ class DefaultLifecycleProcessorTests { 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 @@ class DefaultLifecycleProcessorTests { 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 @@ class DefaultLifecycleProcessorTests { 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 8840a4655a..2a319d78b7 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 @@ public interface ContextCache { * {@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 ad52643bd5..5451fb5854 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 class DefaultContextCache implements ContextCache { 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 9e861a1aee..49dac4c34a 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 @@ class UnusedContextsIntegrationTests { // 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 @@ class UnusedContextsIntegrationTests { // 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 @@ class UnusedContextsIntegrationTests { // 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 @@ class UnusedContextsIntegrationTests { // --- 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 @@ class UnusedContextsIntegrationTests { "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 069/156] 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 | 44 ++++++--- .../jms/core/DefaultJmsClientBuilder.java | 88 +++++++++++++++++ .../springframework/jms/core/JmsClient.java | 62 +++++++++--- .../jms/core/MessagePostProcessor.java | 13 +-- .../jms/core/JmsClientTests.java | 61 +++++++++++- .../core/CompositeMessagePostProcessor.java | 48 ++++++++++ 11 files changed, 498 insertions(+), 109 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 5ad13d40b1..27beb62257 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 0000000000..9b8aede1b8 --- /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 0000000000..a9f43e2c5f --- /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 0000000000..3f7468c839 --- /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 0000000000..2e5a4a4a63 --- /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 fb9b2d89a6..ac8d13af80 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.jms.support.JmsAccessor; 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,28 +35,38 @@ import org.springframework.util.Assert; * 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); + } + + 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; } - public DefaultJmsClient(JmsOperations jmsTemplate, @Nullable MessageConverter messageConverter) { - Assert.notNull(jmsTemplate, "JmsTemplate must not be null"); - this.jmsTemplate = jmsTemplate; - this.messageConverter = messageConverter; + void setMessagePostProcessor(MessagePostProcessor messagePostProcessor) { + Assert.notNull(messagePostProcessor, "MessagePostProcessor must not be null"); + this.messagePostProcessor = messagePostProcessor; } @@ -141,17 +152,18 @@ class DefaultJmsClient implements JmsClient { @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 @@ class DefaultJmsClient implements JmsClient { @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 0000000000..da314c2396 --- /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 bbbbc89b43..fb200c5d4e 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 jakarta.jms.Destination; 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 @@ import org.springframework.messaging.converter.MessageConverter; *

      * * @author Juergen Hoeller + * @author Brian Clozel * @since 7.0 * @see JmsTemplate * @see JmsMessagingTemplate @@ -103,16 +105,7 @@ public interface JmsClient { * @param connectionFactory the factory to obtain JMS connections from */ static JmsClient create(ConnectionFactory connectionFactory) { - return new DefaultJmsClient(connectionFactory, null); - } - - /** - * Create a new {@code JmsClient} for the given {@link ConnectionFactory}. - * @param connectionFactory the factory to obtain JMS connections from - * @param messageConverter the message converter for payload objects - */ - static JmsClient create(ConnectionFactory connectionFactory, MessageConverter messageConverter) { - return new DefaultJmsClient(connectionFactory, messageConverter); + return new DefaultJmsClient(connectionFactory); } /** @@ -121,17 +114,56 @@ public interface JmsClient { * (can be a custom {@link JmsOperations} implementation as well) */ static JmsClient create(JmsOperations jmsTemplate) { - return new DefaultJmsClient(jmsTemplate, null); + return new DefaultJmsClient(jmsTemplate); } /** - * Create a new {@code JmsClient} for the given {@link JmsOperations}. + * Obtain a {@code JmsClient} builder that will use the given connection + * factory for JMS connections. + * @param connectionFactory the factory to obtain JMS connections from + * @return a {@code JmsClient} builder that uses the given connection factory. + */ + static Builder builder(ConnectionFactory connectionFactory) { + return new DefaultJmsClientBuilder(connectionFactory); + } + + /** + * 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) - * @param messageConverter the message converter for payload objects + * @return a {@code JmsClient} builder that uses the given JMS template. */ - static JmsClient create(JmsOperations jmsTemplate, MessageConverter messageConverter) { - return new DefaultJmsClient(jmsTemplate, messageConverter); + static Builder builder(JmsOperations jmsTemplate) { + return new DefaultJmsClientBuilder(jmsTemplate); + } + + /** + * A mutable builder for creating a {@link JmsClient}. + */ + 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 aa3d9d9ce7..00b0c94349 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.JMSException; 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 a7731d2798..49b71187a4 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 @@ class JmsClientTests { 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 @@ class JmsClientTests { 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 @@ class JmsClientTests { 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 @@ class JmsClientTests { 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 @@ class JmsClientTests { 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 0000000000..9d2dd046b2 --- /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 070/156] 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 f29b2c479f..17ebebade8 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 @@ import org.springframework.util.Assert; */ public class EntityManagerHolder extends ResourceHolderSupport { - private final @Nullable EntityManager entityManager; + protected @Nullable EntityManager entityManager; private boolean transactionActive; @@ -78,4 +78,8 @@ public class EntityManagerHolder extends ResourceHolderSupport { 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 a452992632..d02950e98b 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 @@ public class JpaTransactionManager extends AbstractPlatformTransactionManager // 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 8ad680da3e..aa1568ab41 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.Interceptor; 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 class HibernateTransactionManager extends AbstractPlatformTransactionMana // 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 class HibernateTransactionManager extends AbstractPlatformTransactionMana } } - /** - * 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 @@ public class HibernateTransactionManager extends AbstractPlatformTransactionMana 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 ed939e6651..722d14ce37 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 java.util.Properties; 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.BeanFactoryAware; 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 @@ import org.springframework.core.type.filter.TypeFilter; * @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 class LocalSessionFactoryBean extends HibernateExceptionTranslator // 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 LocalSessionFactoryBean extends HibernateExceptionTranslator return (this.sessionFactory != null ? this.sessionFactory.getClass() : SessionFactory.class); } + /** + * Return either the singleton SessionFactory or a shared (Stateless)Session proxy. + */ @Override - public boolean isSingleton() { - return true; + 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 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 405ef8e49a..f54ac43614 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 @@ package org.springframework.orm.jpa.hibernate; 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 @@ import org.springframework.orm.jpa.EntityManagerHolder; */ class SessionHolder extends EntityManagerHolder { + private @Nullable StatelessSession statelessSession; + private @Nullable Transaction transaction; private @Nullable FlushMode previousFlushMode; @@ -46,11 +50,37 @@ class SessionHolder extends EntityManagerHolder { 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 @@ class SessionHolder extends EntityManagerHolder { 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 0000000000..c9d7e7e124 --- /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 7d63ae3392..10e238a897 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.apache.commons.logging.LogFactory; 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 class SpringSessionContext implements CurrentSessionContext { @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 @@ public class SpringSessionContext implements CurrentSessionContext { } 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 @@ public class SpringSessionContext implements CurrentSessionContext { } } + + /** + * 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 61985b8825..bebd714c9c 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.core.Ordered; 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 @@ class SpringSessionSynchronization implements TransactionSynchronization, Ordere 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 94d21bd552..6e45ac277d 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 @@ package org.springframework.orm.jpa.hibernate; 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 @@ class HibernateNativeEntityManagerFactoryIntegrationTests extends AbstractContai 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 071/156] 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 0f3cf3d6b5..9d7d61d52c 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 class SimpleAsyncTaskScheduler extends SimpleAsyncTaskExecutor implements @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 adb9eae4aa..33b35c4b37 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 class SimpleAsyncTaskExecutor extends CustomizableThreadCreator 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 class SimpleAsyncTaskExecutor extends CustomizableThreadCreator 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 072/156] 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 d480170357..bcf3684625 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 java.lang.reflect.Method; 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 54283661cc..aa32b9f37a 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 @@ class TransactionalApplicationListenerMethodAdapterTests { 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 @@ class TransactionalApplicationListenerMethodAdapterTests { 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 073/156] 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 8dffb08bd6..fe471a2d25 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 759783bc0b..6fee157991 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 @@ import org.springframework.context.ConfigurableApplicationContext; 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 0bb844431c..d9b56d64b8 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 @@ public interface CacheAwareContextLoaderDelegate { * 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 9e5ce62884..e345a74c7e 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 @@ public interface TestContext extends AttributeAccessor, Serializable { * 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 7d9a06045f..850c752dac 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 class DefaultTestContext implements TestContext { /** * 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 a606d70ddf..ed997cd35c 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 java.util.List; 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 @@ class EventTracker { 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 074/156] 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 b4beba546d..be0eda8517 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 075/156] 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 02bf8c08ab..7d646d8860 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 @@ public class DefaultLifecycleProcessor implements LifecycleProcessor, BeanFactor } }); } + 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 076/156] 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 a7a9e033d1..3247fa213d 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.jspecify.annotations.Nullable; 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 abstract class AopConfigUtils { 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 abstract class AopConfigUtils { 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 3b7b170524..70f0c63122 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 @@ public abstract class AbstractAdvisingBeanPostProcessor extends ProxyProcessorSu 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 @@ public abstract class AbstractAdvisingBeanPostProcessor extends ProxyProcessorSu 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 be021f99f5..8ed496c3da 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 @@ class CglibAopProxy implements AopProxy, Serializable { 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 a2b105839d..b6601801d8 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 @@ final class JdkDynamicAopProxy implements AopProxy, InvocationHandler, Serializa // 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 @@ final class JdkDynamicAopProxy implements AopProxy, InvocationHandler, Serializa 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 3c4ee97be3..ca21266ba0 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 @@ package org.springframework.aop.framework; 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 class ProxyConfig implements Serializable { * 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 class ProxyConfig implements Serializable { * Return whether proxies should perform aggressive optimizations. */ public boolean isOptimize() { - return this.optimize; + return (this.optimize != null && this.optimize); } /** @@ -103,7 +105,7 @@ public class ProxyConfig implements Serializable { * prevented from being cast to {@link Advised}. */ public boolean isOpaque() { - return this.opaque; + return (this.opaque != null && this.opaque); } /** @@ -124,7 +126,7 @@ public class ProxyConfig implements Serializable { * each invocation. */ public boolean isExposeProxy() { - return this.exposeProxy; + return (this.exposeProxy != null && this.exposeProxy); } /** @@ -141,7 +143,7 @@ public class ProxyConfig implements Serializable { * 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 class ProxyConfig implements Serializable { 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 c8c2f56f2b..8e621fd213 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 abstract class AbstractAutoProxyCreator extends ProxyProcessorSupport @Override public void setBeanFactory(BeanFactory beanFactory) { this.beanFactory = beanFactory; + AutoProxyUtils.applyDefaultProxyConfig(this, beanFactory); } /** @@ -471,6 +450,24 @@ public abstract class AbstractAutoProxyCreator extends ProxyProcessorSupport 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 @@ public abstract class AbstractAutoProxyCreator extends ProxyProcessorSupport } } } - 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 c9d07a1fdf..256bdd5c9d 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.BeanFactoryAware; 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 @@ public abstract class AbstractBeanFactoryAwareAdvisingPostProcessor extends Abst } 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 b73b9abd5b..3522bfd8b6 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 @@ package org.springframework.aop.framework.autoproxy; 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 @@ import org.springframework.util.StringUtils; * @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 21577a7edc..ab0a4feea7 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 java.util.concurrent.atomic.AtomicInteger; 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 @@ class RetryInterceptorTests { 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 @@ class RetryInterceptorTests { } + 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 077/156] 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 8e621fd213..ccdf918772 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 @@ public abstract class AbstractAutoProxyCreator extends ProxyProcessorSupport 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 bc83fecad7..7c116824bd 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 java.util.function.Predicate; 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 @@ public abstract class AnnotationConfigUtils { 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 8e8c3b09d8..441d5282cb 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.ConfigurableApplicationContext; 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 @@ import org.springframework.util.Assert; * @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 0000000000..23fe99961b --- /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 144834bd89..c6ef88e0a8 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.JakartaNamedComponent; 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 @@ class ClassPathScanningCandidateComponentProviderTests { 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 @@ class ClassPathScanningCandidateComponentProviderTests { 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 @@ class ClassPathScanningCandidateComponentProviderTests { 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 @@ class ClassPathScanningCandidateComponentProviderTests { 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 @@ class ClassPathScanningCandidateComponentProviderTests { 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 @@ class ClassPathScanningCandidateComponentProviderTests { 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 556d0b91fa..1f758bfff6 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 @@ class EnableAspectJAutoProxyTests { aspectIsApplied(ctx); assertThat(AopUtils.isJdkDynamicProxy(ctx.getBean(FooService.class))).isTrue(); + assertThat(AopUtils.isJdkDynamicProxy(ctx.getBean("otherFooService"))).isTrue(); ctx.close(); } @@ -56,6 +57,7 @@ class EnableAspectJAutoProxyTests { aspectIsApplied(ctx); assertThat(AopUtils.isCglibProxy(ctx.getBean(FooService.class))).isTrue(); + assertThat(AopUtils.isJdkDynamicProxy(ctx.getBean("otherFooService"))).isTrue(); ctx.close(); } @@ -124,7 +126,7 @@ class EnableAspectJAutoProxyTests { } - @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 884b830695..867a484793 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.Aspect; 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.Bean; 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 @@ class ConfigurationClassAspectIntegrationTests { 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 @@ class ConfigurationClassAspectIntegrationTests { } + @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 8c298cd44d..c56e0854cb 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 078/156] 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 0000000000..e733b8b27e --- /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 0000000000..623364ea2d --- /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 079/156] 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 8e4b99c293..325a6e5328 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 @@ public abstract class AopConfigUtils { 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 @@ public abstract class AopConfigUtils { 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 196db14cfa..c2bef3c19d 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 @@ public abstract class AbstractAdvisingBeanPostProcessor extends ProxyProcessorSu } 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 fd5f98aa1b..f456ed3924 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.beans.factory.config.ConfigurableListableBeanFactory; 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 c71da8589e..b83bff6ef9 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 @@ import org.springframework.core.annotation.AliasFor; * 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 b1c71154d8..ec0b7f629b 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 @@ import org.springframework.util.Assert; * * @author Rossen Stoyanchev * @since 6.2 - * * @see RFC 3986 */ abstract class RfcUriParser { @@ -78,10 +77,10 @@ abstract class RfcUriParser { * @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 @@ abstract class RfcUriParser { } }, + HOST_OR_PATH { @Override @@ -158,6 +158,7 @@ abstract class RfcUriParser { } }, + SCHEME_OR_PATH { @Override @@ -188,6 +189,7 @@ abstract class RfcUriParser { } }, + HOST { @Override @@ -229,6 +231,7 @@ abstract class RfcUriParser { } }, + IPV6 { @Override @@ -259,6 +262,7 @@ abstract class RfcUriParser { } }, + PORT { @Override @@ -291,6 +295,7 @@ abstract class RfcUriParser { } }, + PATH { @Override @@ -319,6 +324,7 @@ abstract class RfcUriParser { } }, + QUERY { @Override @@ -334,7 +340,9 @@ abstract class RfcUriParser { } }, + FRAGMENT { + @Override public void handleNext(InternalParser parser, char c, int i) { } @@ -345,6 +353,7 @@ abstract class RfcUriParser { } }, + WILDCARD { @Override @@ -358,6 +367,7 @@ abstract class RfcUriParser { } }; + /** * Method to handle each character from the input string. * @param parser provides access to parsing state, and helper methods @@ -429,6 +439,7 @@ abstract class RfcUriParser { this.uri = uri; } + // Check internal state public boolean hasScheme() { @@ -451,6 +462,7 @@ abstract class RfcUriParser { return (this.index == this.componentIndex); } + // Top-level parse loop, iterate over chars and delegate to states public UriRecord parse() { @@ -475,6 +487,7 @@ abstract class RfcUriParser { return this.uri.charAt(this.index); } + // Transitions and index updates public void advanceTo(State state) { @@ -500,6 +513,7 @@ abstract class RfcUriParser { this.index = index; } + // Component capture public InternalParser resolveIfOpaque() { @@ -593,6 +607,7 @@ abstract class RfcUriParser { return this; } + // Encoding and curly bracket handling /** @@ -643,6 +658,7 @@ abstract class RfcUriParser { 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 080/156] 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 58d150a4c8..429e5d6e7e 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 081/156] 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 e2955266ab..9f5f7740ec 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.ExecutionException; 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 @@ class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest { @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 @@ class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest { 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 @@ class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest { 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 @@ class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest { 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 @@ class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest { this.timeoutFuture.thenRun(() -> { if (future.cancel(true) || future.isCompletedExceptionally() || !future.isDone()) { + isTimeout.set(true); return; } try { @@ -268,6 +281,10 @@ class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest { } }; } + + 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 0000000000..86630e1fd3 --- /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 082/156] 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 9f5f7740ec..0d14667f83 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 @@ class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest { 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 @@ class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest { } } 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 @@ class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest { 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 @@ class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest { .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 @@ class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest { // ignore } }); - } @Nullable @@ -282,8 +282,10 @@ class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest { }; } - 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 86630e1fd3..0000000000 --- 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 0000000000..1cf59ca593 --- /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 083/156] 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 f24351ca09..65968b53a9 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 @@ public class ProblemDetail implements Serializable { * @param type the problem type */ public void setType(URI type) { - Assert.notNull(type, "'type' is required"); this.type = type; } @@ -251,7 +250,7 @@ public class ProblemDetail implements Serializable { @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 084/156] 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 8230b453bb..532e6250fe 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 902a56ac7f..1d582f4f49 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 a2f330b74b..2f0b035eba 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 @@ public @interface RequestMapping { * 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 085/156] 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 532e6250fe..f15948bea1 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 1d582f4f49..64f5abec6c 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 2f0b035eba..3e3dba15a0 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 @@ public @interface RequestMapping { /** * 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 086/156] 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 ed094ebbb8..3c58ba2a59 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 @@ public class ProblemDetail implements Serializable { /** * 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 087/156] 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 3c58ba2a59..ffdfd89ad0 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 @@ public class ProblemDetail implements Serializable { /** * 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 9bf4a0ddd1..93f25b543b 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 @@ final class DefaultErrorResponseBuilder implements ErrorResponse.Builder { } @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 f4b29db0a6..5fc792a398 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 class ErrorResponseException extends NestedRuntimeException implements Er * 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 7ab2faaa55..4096c051d8 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 @@ class ProblemDetailJacksonMixinTests { } @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 @@ class ProblemDetailJacksonMixinTests { } @Test - void readCustomProperty() throws Exception { + void readCustomProperty() { ProblemDetail detail = this.mapper.readValue(""" { "type": "about:blank", @@ -93,7 +91,7 @@ class ProblemDetailJacksonMixinTests { } @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 @@ class ProblemDetailJacksonMixinTests { 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 85747f9a5d..aab6f80d9b 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 @@ public class DispatcherHandlerErrorTests { "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 477a2caf37..1ed443e7e9 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 @@ class RequestMappingExceptionHandlingIntegrationTests extends AbstractRequestMap assertThat(ex.getResponseBodyAsString()).isEqualTo("{" + "\"instance\":\"\\/no-such-handler\"," + "\"status\":404," + - "\"title\":\"Not Found\"," + - "\"type\":\"about:blank\"}"); + "\"title\":\"Not Found\"}"); }); } @@ -142,8 +141,7 @@ class RequestMappingExceptionHandlingIntegrationTests extends AbstractRequestMap "\"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 46c3613ec5..714c5cdbdc 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 @@ class ResponseBodyResultHandlerTests { {\ "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 bc6b8f5cff..9c3932fbb6 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 @@ class ResponseEntityResultHandlerTests { {\ "instance":"\\/path",\ "status":400,\ - "title":"Bad Request",\ - "type":"about:blank"\ + "title":"Bad Request"\ }"""); } @@ -265,8 +264,7 @@ class ResponseEntityResultHandlerTests { {\ "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 2c1554ec02..4f1eecd9a8 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 @@ class RequestResponseBodyMethodProcessorTests { 400 /path Bad Request - about:blank """) .ignoreWhitespace() .areIdentical(); @@ -403,7 +402,6 @@ class RequestResponseBodyMethodProcessorTests { 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 887658fd89..39cc11763f 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 @@ class ResourceHttpRequestHandlerIntegrationTests { "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 088/156] 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 1cf59ca593..b48a4d79f2 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 @@ class JdkClientHttpRequestTests { } 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 089/156] 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 b48a4d79f2..300af1ea22 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.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.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 @@ class JdkClientHttpRequestTests { } @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 f768d641e4..3e2940181c 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 @@ class RequestMappingHandlerAdapterTests { } - 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 090/156] 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 3fc5065b59..7b53e70ff3 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 class PathEditor extends PropertyEditorSupport { 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 091/156] 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 25a6570b13..1ab948e144 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 092/156] 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 b93ab0b6a9..ec40441a7a 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 abstract class ScriptUtils { 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 abstract class ScriptUtils { } } + 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 fd4daf2064..55130eb1ec 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 @@ class JdbcClientIntegrationTests { @Test void updateWithGeneratedKeys() { - int expectedId = 2; + int expectedId = 1; String firstName = "Jane"; String lastName = "Smith"; @@ -92,7 +92,7 @@ class JdbcClientIntegrationTests { @Test void updateWithGeneratedKeysAndKeyColumnNames() { - int expectedId = 2; + int expectedId = 1; String firstName = "Jane"; String lastName = "Smith"; @@ -110,7 +110,7 @@ class JdbcClientIntegrationTests { @Test void updateWithGeneratedKeysUsingNamedParameters() { - int expectedId = 2; + int expectedId = 1; String firstName = "Jane"; String lastName = "Smith"; @@ -129,7 +129,7 @@ class JdbcClientIntegrationTests { @Test void updateWithGeneratedKeysAndKeyColumnNamesUsingNamedParameters() { - int expectedId = 2; + int expectedId = 1; String firstName = "Jane"; String lastName = "Smith"; @@ -217,7 +217,7 @@ class JdbcClientIntegrationTests { 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 02928652e5..720fbb330a 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 @@ class SimpleJdbcInsertIntegrationTests { 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 8eb91b2968..f59db0e6a4 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 @@ import static org.springframework.jdbc.datasource.init.ScriptUtils.executeSqlScr * @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 @@ class ScriptUtilsIntegrationTests extends AbstractDatabaseInitializationTests { 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 6da1c29782..0959c7e695 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 523c4a7c2b..d9cb2918b1 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 093/156] 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 a7f776e8e3..743803f960 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 @@ class JdbcClientIntegrationTests { } 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 094/156] 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 1ce334a9be..d4804f4cc1 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 java.util.List; 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 class InterceptorRegistration { 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 @@ public class InterceptorRegistration { } /** - * 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 5071a49538..31a05bdfc4 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.HttpServletRequest; 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 final class MappedInterceptor implements HandlerInterceptor { */ 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 final class MappedInterceptor implements HandlerInterceptor { } } } - 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 final class MappedInterceptor implements HandlerInterceptor { } } + /** + * 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 f3c2955ff7..25c3cb7e0f 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 jakarta.servlet.http.HttpServletResponse; 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 @@ class MappedInterceptorTests { 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 @@ class MappedInterceptorTests { @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 @@ class MappedInterceptorTests { @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 @@ class MappedInterceptorTests { @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 @@ class MappedInterceptorTests { @SuppressWarnings("removal") @PathPatternsParameterizedTest void customPathMatcher(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 @@ class MappedInterceptorTests { 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 @@ class MappedInterceptorTests { 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 095/156] Update contribution Closes gh-35273 --- .../annotation/InterceptorRegistration.java | 66 ++++---- .../servlet/handler/MappedInterceptor.java | 147 ++++++------------ .../handler/MappedInterceptorTests.java | 124 +++++---------- 3 files changed, 121 insertions(+), 216 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 d4804f4cc1..c86c006387 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 class InterceptorRegistration { * @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 class InterceptorRegistration { * @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 class InterceptorRegistration { /** * 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 class InterceptorRegistration { * 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 @@ public class InterceptorRegistration { @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 31a05bdfc4..7071d32e01 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,28 +78,40 @@ 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; } + /** + * Variation of + * {@link #MappedInterceptor(String[], String[], HttpMethod[], HttpMethod[], HandlerInterceptor, PathPatternParser)} + * without HTTP methods. + * @since 5.3 + * @deprecated in favor of the constructor variant with HTTP methods + */ + @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 @@ -107,16 +119,7 @@ public final class MappedInterceptor implements HandlerInterceptor { * with include patterns only. */ public MappedInterceptor(String @Nullable [] includePatterns, HandlerInterceptor interceptor) { - this(includePatterns, null, null, null, interceptor); - } - - /** - * Variant of - * {@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); + this(includePatterns, null, null, null, interceptor, null); } /** @@ -124,10 +127,10 @@ public final class MappedInterceptor implements HandlerInterceptor { * {@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 final class MappedInterceptor implements HandlerInterceptor { * with a {@link WebRequestInterceptor} as the target. */ public MappedInterceptor(String @Nullable [] includePatterns, WebRequestInterceptor interceptor) { - this(includePatterns, null,null,null, interceptor); + this(includePatterns, 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[], 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 final class MappedInterceptor implements HandlerInterceptor { */ 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,16 @@ public final class MappedInterceptor implements HandlerInterceptor { } } 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; - } - } - } - 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; } @@ -275,13 +253,20 @@ public final class MappedInterceptor implements HandlerInterceptor { if (!match) { return false; } - for (PatternAdapter pathAdapter : this.includePatterns) { - if (pathAdapter.match(path, isPathContainer, pathMatcher)) { - return true; + } + if (!ObjectUtils.isEmpty(this.includeHttpMethods)) { + boolean match = false; + for (HttpMethod included : this.includeHttpMethods) { + if (included == httpMethod) { + match = true; + break; } } + if (!match) { + return false; + } } - return false; + return true; } @@ -365,40 +350,4 @@ public final class MappedInterceptor implements HandlerInterceptor { } } - /** - * 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 25c3cb7e0f..80d7245231 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.Named; 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 @@ class MappedInterceptorTests { 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 @@ class MappedInterceptorTests { @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 @@ class MappedInterceptorTests { @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 @@ class MappedInterceptorTests { @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 @@ class MappedInterceptorTests { @SuppressWarnings("removal") @PathPatternsParameterizedTest void customPathMatcher(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(); + testHttpMethods( + new HttpMethod[] {HttpMethod.GET, HttpMethod.POST}, + new HttpMethod[] {}, + "GET", "POST"); } @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(); + void excludeMultipleMethods() { + testHttpMethods( + new HttpMethod[] {}, + new HttpMethod[] {HttpMethod.GET, HttpMethod.POST, HttpMethod.OPTIONS}, + "HEAD", "PUT", "DELETE", "TRACE", "PATCH"); } - @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(); + 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 @@ class MappedInterceptorTests { 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 @@ class MappedInterceptorTests { 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 096/156] 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 2a5ef2f0ea..a5c38886a5 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 a554d8325a..6156a55bf6 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 722d14ce37..e4a6d556f9 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 @@ import org.springframework.core.type.filter.TypeFilter; * 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 class LocalSessionFactoryBean extends HibernateExceptionTranslator * 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 77d8ca3193..338f235752 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 @@ import org.springframework.util.ClassUtils; * 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 class LocalSessionFactoryBuilder extends Configuration { * @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 e7569de716..a20766242b 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 b5c363d771..4c21adde1b 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 @@ import org.springframework.transaction.support.ResourceTransactionDefinition; /** * {@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 295472300a..bb1eb81b4d 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 @@ import org.jspecify.annotations.Nullable; /** * {@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 097/156] 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 33297dd4f1..6f72bb0f95 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 class DefaultStompSession implements ConnectionHandlingStompSession { 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 class DefaultStompSession implements ConnectionHandlingStompSession { } @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 d940cc7473..bcb816c0f6 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 @@ public interface StompSession { /** * Remove the subscription by sending an UNSUBSCRIBE frame. */ - void unsubscribe(); + Receiptable unsubscribe(); /** * Alternative to {@link #unsubscribe()} with additional custom headers @@ -192,7 +192,7 @@ public interface StompSession { * @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 09b092f86b..c310afb870 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.Arrays; 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 class DefaultStompSessionTests { 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 098/156] 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 6f72bb0f95..4dd0418e6a 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 @@ public class DefaultStompSession implements ConnectionHandlingStompSession { 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 bcb816c0f6..1bce9ff94d 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 @@ public interface StompSession { /** * 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 c310afb870..fea9dc4496 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 class DefaultStompSessionTests { } @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 @@ public class DefaultStompSessionTests { 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 099/156] Only add `httpServiceProxyRegistry` bean when necessary Update `AbstractHttpServiceRegistrar` so that the `httpServiceProxyRegistry` bean is only added when registrations are found. --- .../AbstractHttpServiceRegistrar.java | 26 ++++++++++--------- .../web/service/registry/GroupsMetadata.java | 7 +++++ .../ClientHttpServiceRegistrarTests.java | 25 ++++++++++++++++++ .../registry/HttpServiceRegistrarTests.java | 3 +-- 4 files changed, 47 insertions(+), 14 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 9fc72368d5..1ee41b4f5d 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 abstract class AbstractHttpServiceRegistrar implements registerHttpServices(new DefaultGroupRegistry(), metadata); - RootBeanDefinition proxyRegistryBeanDef = createOrGetRegistry(beanRegistry); + if (this.groupsMetadata.hasRegistrations()) { + RootBeanDefinition proxyRegistryBeanDef = createOrGetRegistry(beanRegistry); - mergeGroups(proxyRegistryBeanDef); + 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); - } - })); + 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 04df0e33a0..4aa5ec1c8b 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 @@ final class GroupsMetadata { 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 7f60b77753..57b507fa70 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 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; @@ -63,6 +66,13 @@ public class ClientHttpServiceRegistrarTests { 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 @@ public class ClientHttpServiceRegistrarTests { } } + @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 80b34af810..35ef88180e 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 @@ public class HttpServiceRegistrarTests { @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 100/156] 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 1ee41b4f5d..ea8af34399 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 abstract class AbstractHttpServiceRegistrar implements 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 4aa5ec1c8b..0c991d4fad 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 @@ final class GroupsMetadata { } /** - * 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 57b507fa70..3373945b12 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 @@ public class ClientHttpServiceRegistrarTests { @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 @@ public class ClientHttpServiceRegistrarTests { } } - @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 101/156] 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 f47c85cb79..7e38dc4954 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 102/156] 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 7e2d5417ff..edcd170bd5 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 7e38dc4954..7ba60ead53 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 103/156] Upgrade SDKMAN to Java 24.0.2 --- .sdkmanrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.sdkmanrc b/.sdkmanrc index b41ba343b0..b280a1b690 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 104/156] 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 eabc2f4b49..e1d0d9f2df 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.Session; 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 class JacksonJsonMessageConverter implements SmartMessageConverter, BeanC 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 class JacksonJsonMessageConverter implements SmartMessageConverter, BeanC 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 @@ public class JacksonJsonMessageConverter implements SmartMessageConverter, BeanC throws JMSException, IOException { String body = message.getText(); - return this.objectMapper.readValue(body, targetJavaType); + return this.jsonMapper.readValue(body, targetJavaType); } /** @@ -386,7 +385,7 @@ public class JacksonJsonMessageConverter implements SmartMessageConverter, BeanC 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 @@ public class JacksonJsonMessageConverter implements SmartMessageConverter, BeanC } 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 @@ public class JacksonJsonMessageConverter implements SmartMessageConverter, BeanC } 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 804f05b411..a2eb535e6d 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.JacksonException; 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 class JacksonJsonMessageConverter extends AbstractMessageConverter { */ 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 @@ public class JacksonJsonMessageConverter extends AbstractMessageConverter { @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 @@ public class JacksonJsonMessageConverter extends AbstractMessageConverter { } 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 @@ public class JacksonJsonMessageConverter extends AbstractMessageConverter { 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 @@ public class JacksonJsonMessageConverter extends AbstractMessageConverter { // 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 92287b4d01..c37ea1914d 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.junit.jupiter.params.provider.ValueSource; 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 c0a3b832eb..1c26fa1865 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.assertj.core.data.Offset; 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 @@ class JsonPathValueAssertTests { 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 17c068dae7..f1c0f00e8d 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.api.Test; 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 @@ class JsonPathExpectationsHelperTests { */ 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 @@ class JsonPathExpectationsHelperTests { return null; } try { - return objectMapper.convertValue(source, targetType); + return jsonMapper.convertValue(source, targetType); } catch (Exception ex) { throw new MappingException(ex); @@ -416,10 +416,10 @@ class JsonPathExpectationsHelperTests { 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 190a72b486..1051834999 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 java.util.Map; 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 @@ import static org.assertj.core.api.Assertions.assertThat; */ 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 2d5ee9beb8..62432a05a0 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 @@ package org.springframework.test.web.reactive.server; 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 @@ import static org.assertj.core.api.Assertions.assertThat; */ 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 a4202c5c1a..fef5ea45a7 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 java.util.Map; 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 class JacksonJsonDecoder extends AbstractJacksonDecoder { } /** - * 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 f883727aa5..6e33178179 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 reactor.core.publisher.Flux; 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 class JacksonJsonEncoder extends AbstractJacksonEncoder { } /** - * 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 f1902a8ad6..c87304c317 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 java.util.List; 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 @@ import org.springframework.http.converter.AbstractJacksonHttpMessageConverter; /** * 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 class JacksonJsonHttpMessageConverter extends AbstractJacksonHttpMessageC } /** - * 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 56a202d0f2..254c157b6b 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 reactor.test.StepVerifier; 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 @@ class JacksonJsonDecoderTests extends AbstractDecoderTests { 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 @@ class JacksonJsonDecoderTests extends AbstractDecoderTests { @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 @@ class JacksonJsonDecoderTests extends AbstractDecoderTests { @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 @@ class JacksonJsonDecoderTests extends AbstractDecoderTests { 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 d0a0ff530f..229fb9fed2 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 org.junit.jupiter.api.Test; 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 @@ class JacksonJsonEncoderTests extends AbstractEncoderTests { @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 @@ class JacksonJsonEncoderTests extends AbstractEncoderTests { @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 @@ class JacksonJsonEncoderTests extends AbstractEncoderTests { @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 b3f7e08f0b..34c5b5e8bf 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 @@ class JacksonJsonHttpMessageConverterTests { 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 @@ class JacksonJsonHttpMessageConverterTests { 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 6c2abcc894..371b668250 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 java.util.Set; 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 @@ import org.springframework.web.servlet.view.AbstractJacksonView; /** * 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 class JacksonJsonView extends AbstractJacksonView { } /** - * 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 ef13f5626c..a23f0fdcc0 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 java.util.List; 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 @@ class SseServerResponseTests { } }); - 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 86a1212d71..2186de361e 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.JacksonException; 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 @@ class JacksonJsonViewTests { @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 d52820dcbc..35941b22e4 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 java.io.InputStream; 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 @@ import org.springframework.util.Assert; */ 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 105/156] 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 35941b22e4..8bbedfb149 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 @@ package org.springframework.web.socket.sockjs.frame; 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 class JacksonJsonSockJsMessageCodec extends AbstractSockJsMessageCodec { @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 106/156] 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 e1d0d9f2df..eabc2f4b49 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.Session; 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 class JacksonJsonMessageConverter implements SmartMessageConverter, BeanC 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 class JacksonJsonMessageConverter implements SmartMessageConverter, BeanC 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 @@ public class JacksonJsonMessageConverter implements SmartMessageConverter, BeanC throws JMSException, IOException { String body = message.getText(); - return this.jsonMapper.readValue(body, targetJavaType); + return this.objectMapper.readValue(body, targetJavaType); } /** @@ -385,7 +386,7 @@ public class JacksonJsonMessageConverter implements SmartMessageConverter, BeanC 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 @@ public class JacksonJsonMessageConverter implements SmartMessageConverter, BeanC } 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 @@ public class JacksonJsonMessageConverter implements SmartMessageConverter, BeanC } 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 a2eb535e6d..804f05b411 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.JacksonException; 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 class JacksonJsonMessageConverter extends AbstractMessageConverter { */ 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 @@ public class JacksonJsonMessageConverter extends AbstractMessageConverter { @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 @@ public class JacksonJsonMessageConverter extends AbstractMessageConverter { } 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 @@ public class JacksonJsonMessageConverter extends AbstractMessageConverter { 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 @@ public class JacksonJsonMessageConverter extends AbstractMessageConverter { // 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 c37ea1914d..92287b4d01 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.junit.jupiter.params.provider.ValueSource; 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 1c26fa1865..c0a3b832eb 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.assertj.core.data.Offset; 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 @@ class JsonPathValueAssertTests { 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 f1c0f00e8d..17c068dae7 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.api.Test; 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 @@ class JsonPathExpectationsHelperTests { */ 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 @@ class JsonPathExpectationsHelperTests { return null; } try { - return jsonMapper.convertValue(source, targetType); + return objectMapper.convertValue(source, targetType); } catch (Exception ex) { throw new MappingException(ex); @@ -416,10 +416,10 @@ class JsonPathExpectationsHelperTests { 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 1051834999..190a72b486 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 java.util.Map; 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 @@ import static org.assertj.core.api.Assertions.assertThat; */ 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 62432a05a0..2d5ee9beb8 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 @@ package org.springframework.test.web.reactive.server; 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 @@ import static org.assertj.core.api.Assertions.assertThat; */ 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 fef5ea45a7..a4202c5c1a 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 java.util.Map; 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 class JacksonJsonDecoder extends AbstractJacksonDecoder { } /** - * 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 6e33178179..f883727aa5 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 reactor.core.publisher.Flux; 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 class JacksonJsonEncoder extends AbstractJacksonEncoder { } /** - * 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 c87304c317..f1902a8ad6 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 java.util.List; 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 @@ import org.springframework.http.converter.AbstractJacksonHttpMessageConverter; /** * 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 class JacksonJsonHttpMessageConverter extends AbstractJacksonHttpMessageC } /** - * 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 254c157b6b..56a202d0f2 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 reactor.test.StepVerifier; 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 @@ class JacksonJsonDecoderTests extends AbstractDecoderTests { 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 @@ class JacksonJsonDecoderTests extends AbstractDecoderTests { @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 @@ class JacksonJsonDecoderTests extends AbstractDecoderTests { @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 @@ class JacksonJsonDecoderTests extends AbstractDecoderTests { 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 229fb9fed2..d0a0ff530f 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 org.junit.jupiter.api.Test; 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 @@ class JacksonJsonEncoderTests extends AbstractEncoderTests { @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 @@ class JacksonJsonEncoderTests extends AbstractEncoderTests { @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 @@ class JacksonJsonEncoderTests extends AbstractEncoderTests { @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 34c5b5e8bf..b3f7e08f0b 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 @@ class JacksonJsonHttpMessageConverterTests { 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 @@ class JacksonJsonHttpMessageConverterTests { 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 371b668250..6c2abcc894 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 java.util.Set; 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 @@ import org.springframework.web.servlet.view.AbstractJacksonView; /** * 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 class JacksonJsonView extends AbstractJacksonView { } /** - * 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 a23f0fdcc0..ef13f5626c 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 java.util.List; 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 @@ class SseServerResponseTests { } }); - 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 2186de361e..86a1212d71 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.JacksonException; 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 @@ class JacksonJsonViewTests { @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 8bbedfb149..70a18aa8cf 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 java.io.InputStream; 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 @@ import org.springframework.util.Assert; */ 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 107/156] 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 e83712dbed..4e9178dfa1 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 108/156] 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 19a06771cd..c5294f4524 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 class CheckstyleConventions { 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 fb9abe45be..2b8e3dab38 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 109/156] 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 d38e31c3ef..bcc47e2b49 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 abstract class AbstractMockHttpServletRequestBuilder new ArrayList<>()); + return self(); + } addToMultiValueMap(this.parameters, name, values); return self(); } @@ -821,11 +825,8 @@ public abstract class AbstractMockHttpServletRequestBuilder { - 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 a180878c4e..f62cce7c55 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 @@ class MockHttpServletRequestBuilderTests { 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 110/156] 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 2b8e3dab38..50fb2b65dc 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 ca30c2e539..83f7c82a64 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.JsonSerializer; 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 @@ class Jackson2ObjectMapperBuilderTests { @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 1255f6e02c..3763042f84 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.JsonSerializer; 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 @@ public class Jackson2ObjectMapperFactoryBeanTests { @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 aab6f80d9b..d94049d428 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 @@ public class DispatcherHandlerErrorTests { 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 1ed443e7e9..1a7fc03eca 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 @@ class RequestMappingExceptionHandlingIntegrationTests extends AbstractRequestMap .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 @@ class RequestMappingExceptionHandlingIntegrationTests extends AbstractRequestMap 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 714c5cdbdc..17fa500d6f 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 @@ class ResponseBodyResultHandlerTests { 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 9c3932fbb6..98a31e66a0 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 @@ class ResponseEntityResultHandlerTests { 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 @@ class ResponseEntityResultHandlerTests { 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 39cc11763f..4fcdab8d06 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 @@ class ResourceHttpRequestHandlerIntegrationTests { 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 111/156] 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 4e9178dfa1..5d96c54f68 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 112/156] 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 5d96c54f68..96b106f330 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 113/156] 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 fc9a83255f..789f858442 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 114/156] 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 8f00a33971..25e5cb4cd7 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 115/156] 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 5fb725a43f..f27dce89cd 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 116/156] 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 09b5e6837e..d75e45175e 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 @@ import java.lang.annotation.Target; *

      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 117/156] 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 c07a74c1df..c91ca7d9c9 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 118/156] 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 6594241154..762c5612f4 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 abstract class AbstractRetryInterceptor implements MethodInterceptor { }); } 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 d9c0f34e27..f0e490a691 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 class RetryException extends Exception { 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 119/156] 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 f0e490a691..dbc3777263 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 @@ 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. * @@ -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 120/156] 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 4ca2b33e49..ee51180701 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 class RetryTemplate implements RetryOperations { "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 96f47a2d1e..953177c4b0 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.api.Test; 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 @@ import static org.junit.jupiter.params.provider.Arguments.argumentSet; */ 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 @@ class RetryTemplateTests { 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 @@ class RetryTemplateTests { 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 @@ class RetryTemplateTests { .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 @@ class RetryTemplateTests { .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 @@ class RetryTemplateTests { )); // 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 @@ class RetryTemplateTests { )); // 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 121/156] 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 953177c4b0..f8fe211f22 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.FileNotFoundException; 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.ArgumentMatchers.eq; 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 @@ class RetryTemplateTests { @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 @@ class RetryTemplateTests { // 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 @@ class RetryTemplateTests { 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 @@ class RetryTemplateTests { inOrder.verify(retryListener).onRetryFailure(retryPolicy, retryable, exception); }); inOrder.verify(retryListener).onRetryPolicyExhaustion(retryPolicy, retryable, exception); - inOrder.verifyNoMoreInteractions(); + verifyNoMoreInteractions(retryListener); } @Test @@ -231,11 +232,11 @@ class RetryTemplateTests { // 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 @@ class RetryTemplateTests { // 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 @@ class RetryTemplateTests { 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 122/156] 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 f8fe211f22..c3259e1087 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.Arguments.ArgumentSet; 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 @@ class RetryTemplateTests { 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 123/156] 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 dbc3777263..eae4859afa 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 class RetryException extends Exception { /** - * 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 124/156] 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 ee51180701..274074413b 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 class RetryTemplate implements RetryOperations { 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 class RetryTemplate implements RetryOperations { .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 125/156] 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 eae4859afa..694cd04465 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 @@ import java.util.Objects; /** * 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 d69b1570d1..d125cb5f62 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 @@ import org.jspecify.annotations.Nullable; 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 274074413b..352278df0c 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 class RetryTemplate implements RetryOperations { /** - * 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 126/156] 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 7011545ed3..3906e72bd5 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 @@ class InternalPathPatternParser { } 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 99c3db0154..e70fe5e832 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 @@ class PathPatternParserTests { @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 127/156] 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 0bcaa56203..fdd0f5e29c 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 kotlin.reflect.jvm.ReflectJvmMapping; 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 class KotlinRequestBodyAdvice extends RequestBodyAdviceAdapter { 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 fb527bc1f8..760c9dcdc9 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 kotlin.reflect.jvm.ReflectJvmMapping; 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 class KotlinResponseBodyAdvice implements ResponseBodyAdvice { KFunction 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 6e87030c25..d81e97844b 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 128/156] 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 50fb2b65dc..906645a397 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 129/156] 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 906645a397..47055ceed6 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 130/156] 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 85048080ea..ed9ed52530 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 @@ public class AnnotatedMethod { } 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 e064aa4e45..e81a08d18b 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 @@ abstract class AnnotationsScanner { } 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 0000000000..1118239d61 --- /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 0face9eab7..66a1b7e4bc 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 @@ class MergedAnnotationsTests { 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 @@ class MergedAnnotationsTests { } } + 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 b114133bc1..291ba4925b 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 @@ import static org.assertj.core.api.Assertions.assertThat; * 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 @@ class HandlerMethodTests { 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 @@ class HandlerMethodTests { } 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 @@ class HandlerMethodTests { } } + + 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 131/156] 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 a16f4985f2..42ad16cd0e 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 f1f9a6652a..da82618630 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 @@ import org.springframework.transaction.TransactionDefinition; * {@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 132/156] 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 01b6cfcd4c..d0dce05f0f 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 98fedfd795..c32075563e 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 abstract class ObjectUtils { * @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 abstract class ObjectUtils { * @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 734b217ab7..096d397cd3 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 @@ public class JdbcTemplate extends JdbcAccessor implements JdbcOperations { * @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 133/156] 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 9327c8cc9f..58309754b5 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 abstract class DataAccessUtils { * @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 abstract class DataAccessUtils { * 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 134/156] =?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 8532d26e40..2a80f47584 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.BeanInfo; import java.beans.IntrospectionException; import java.lang.reflect.Method; -import org.jspecify.annotations.NonNull; - import org.springframework.core.Ordered; /** @@ -44,7 +42,7 @@ import org.springframework.core.Ordered; 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 310348a5cf..2c20d6766b 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.function.Predicate; import java.util.stream.Stream; import java.util.stream.StreamSupport; -import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; /** @@ -483,7 +482,7 @@ final class TypeMappedAnnotations implements MergedAnnotations { } @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 6cb5c2b224..c1f06239b7 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.Objects; 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 @@ class AnnotationsScannerTests { 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 @@ class AnnotationsScannerTests { 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 135/156] 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 cdd8c6c15a..af18ce82e6 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 e7ff8af9a1..8ac7c152c6 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 ff774a4251..eea40b3730 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 5c5dce2a3f..cd75f15499 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 ffbe046f5b..ffc5729b79 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 1b5a9e643a..c9a5f19080 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 a6dfda3823..f8f0062ceb 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 6301994d0b..d869b1fb2a 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 47055ceed6..70808c0247 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 58b566fa37..62a50956d6 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 c1499e0ea1..1b2823dccd 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 class HeadersAdapterBenchmark { 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 d3fc98bc5f..cbeee7cfcd 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 @@ import org.springframework.util.Assert; * 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 8ffbbe33d3..d79d732818 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 @@ import org.springframework.util.StringUtils; * 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 46eee84835..0000000000 --- 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 8028ab37c4..0000000000 --- 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 1af3d3d8ca..0000000000 --- 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 1206a69288..0000000000 --- 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 10c41e5feb..bef99d51c6 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 fb20b1c274..9e9de67ee5 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 class StandardMultipartHttpServletRequest extends AbstractMultipartHttpSe 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 29fdb8b635..3fc79cc8ca 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.client.RestTemplate; 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 @@ class CookieIntegrationTests extends AbstractHttpHandlerIntegrationTests { @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 @@ class CookieIntegrationTests extends AbstractHttpHandlerIntegrationTests { @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 2b61a7f001..32eef589db 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 java.util.stream.Stream; 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 @@ class DefaultServerHttpRequestBuilderTests { 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 05695f5bb8..d90e68d4b0 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.function.Function; 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 @@ class HeadersAdaptersTests { 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 @@ class HeadersAdaptersTests { 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 d6c55c5e02..f290c7c54e 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.Abstra 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 @@ class ZeroCopyIntegrationTests extends AbstractHttpHandlerIntegrationTests { @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 5b92c083d5..ea7c68914b 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 @@ class StandardMultipartHttpServletRequestTests { .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 @@ class StandardMultipartHttpServletRequestTests { 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 b08f66c090..6145f1abab 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 @@ public abstract class AbstractHttpHandlerIntegrationTests { 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 d021b2cdd2..0000000000 --- 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 85bc6bcaf2..dad853c543 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 50bdbac288..834aeef8b5 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 @@ import org.springframework.web.util.pattern.PathPatternParser; * *

      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 abstract class RouterFunctions { * {@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}
    • * *

      Note that {@code HttpWebHandlerAdapter} also implements {@link WebHandler}, * allowing for additional filter and exception handler registration through @@ -294,8 +292,6 @@ public abstract class RouterFunctions { * {@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 25c85eaac4..ee985c60fc 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 @@ import org.springframework.web.reactive.socket.WebSocketSession; /** * 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 5fe77a4147..0000000000 --- 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 cc51ed224d..0000000000 --- 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 6a1aa76b4e..0000000000 --- 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 d92375a0d8..f5eeaf2d1f 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.JettyCoreRequestUp 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 @@ public class HandshakeWebSocketService implements WebSocketService, Lifecycle { 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 93ac308123..0000000000 --- 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 8c1e1c83e7..bc959ea2f5 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.time.Duration; 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.RouterFunction; 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 @@ class MultipartRouterFunctionIntegrationTests extends AbstractRouterFunctionInte @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 @@ class MultipartRouterFunctionIntegrationTests extends AbstractRouterFunctionInte @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 acae680fbb..882353a866 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.JettyC 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 @@ class ContextPathIntegrationTests { 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 0b15010e4b..580f7c58d3 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.reactive.function.client.WebClient; 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 @@ class MultipartWebClientIntegrationTests extends AbstractHttpHandlerIntegrationT @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 e4fb91a771..7b4677bc1f 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.JettyC 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 @@ class SseIntegrationTests extends AbstractHttpHandlerIntegrationTests { 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 45959daa5b..30c384d5c6 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.AfterEach; 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.DispatcherHandler; 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.JettyCoreRequestUp 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.JettyC 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 @@ abstract class AbstractReactiveWebSocketIntegrationTests { 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 @@ abstract class AbstractReactiveWebSocketIntegrationTests { 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 @@ abstract class AbstractReactiveWebSocketIntegrationTests { } } - - @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 5dc8e3c805..b932e5204b 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 2f9bcc1c79..268e2b29db 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 9cb80ea236..0000000000 --- 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 3cfc0eea0d..ae7baaf033 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 042fb7c361..0000000000 --- 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 80dd17dda6..0000000000 --- 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 136/156] 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 83f2bab256..16b6ee2473 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 @@ public interface RetryPolicy { * @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 @@ public interface RetryPolicy { } + 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 interface RetryPolicy { * @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 interface RetryPolicy { * @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 interface RetryPolicy { * @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 interface RetryPolicy { *

      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 interface RetryPolicy { *

      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 interface RetryPolicy { } 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 62b0a42e3f..8f86350fc0 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 @@ class RetryPolicyTests { 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 @@ class RetryPolicyTests { 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 @@ class RetryPolicyTests { 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 @@ class RetryPolicyTests { 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 @@ class RetryPolicyTests { @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 137/156] 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 0000000000..ec2d70c9a5 --- /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 0000000000..94dfe50f24 --- /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 138/156] 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 ec2d70c9a5..7ab2740ba8 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 enum OpenTelemetryServerHttpObservationDocumentation implements Observati }, /** - * 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 94dfe50f24..8a1dcfd95a 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.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. @@ -180,9 +179,7 @@ public class OpenTelemetryServerRequestObservationConvention implements ServerRe 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 0000000000..03ee93e1e6 --- /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 139/156] 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 c9129ef1e5..8b3e163ef4 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 140/156] 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 659cf33a4c..e36d7e7f99 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 141/156] 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 3653d4da95..5bdc8b3c79 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 @@ import org.springframework.context.ApplicationEvent; * 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 142/156] 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 e36d7e7f99..54dc7c1262 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 5bdc8b3c79..98d5f195b4 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 @@ import org.springframework.context.ApplicationEvent; * {@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 143/156] 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 | 64 +++++++++++-------- 8 files changed, 130 insertions(+), 57 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 ee7e8f1793..ff81f8ca10 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 @@ class ReactiveRetryInterceptorTests { 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 ab0a4feea7..f981bfae4a 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 @@ class RetryInterceptorTests { 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 c9f073a96e..796718b34e 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 694cd04465..eef16f68ef 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 @@ import java.util.Objects; * exceptions}. * * @author Mahmoud Ben Hassine + * @author Juergen Hoeller * @since 7.0 * @see RetryOperations */ @@ -51,8 +52,16 @@ public class RetryException extends Exception { * 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 16b6ee2473..3e6bd5cf27 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 @@ public interface RetryPolicy { * 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 @@ public interface RetryPolicy { } - 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 @@ public interface RetryPolicy { () -> "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 @@ public interface RetryPolicy { 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 interface RetryPolicy { *

      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 interface RetryPolicy { 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 d7e559e959..202e384949 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 @@ class MaxAttemptsRetryPolicyTests { 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 @@ class MaxAttemptsRetryPolicyTests { 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 8f86350fc0..fac425dbf3 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 @@ class RetryPolicyTests { @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 @@ class RetryPolicyTests { @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 c3259e1087..4e3ec1c11e 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.Arguments.ArgumentSet; 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 @@ import static org.mockito.Mockito.verifyNoMoreInteractions; * * @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 @@ class RetryTemplateTests { .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,19 +107,8 @@ class RetryTemplateTests { } @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 - } - }; + void retryWithInitialFailureAndZeroRetriesFixedBackOffPolicy() { + RetryPolicy retryPolicy = RetryPolicy.withMaxAttempts(0); RetryTemplate retryTemplate = new RetryTemplate(retryPolicy); retryTemplate.setRetryListener(retryListener); @@ -137,7 +121,31 @@ class RetryTemplateTests { .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); + 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!"); + 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); @@ -282,7 +290,8 @@ class RetryTemplateTests { .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 @@ class RetryTemplateTests { .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 @@ class RetryTemplateTests { } @SafeVarargs - private static final Consumer hasSuppressedExceptionsSatisfyingExactly( + private static Consumer hasSuppressedExceptionsSatisfyingExactly( ThrowingConsumer... requirements) { + return throwable -> assertThat(throwable.getSuppressed()).satisfiesExactly(requirements); } @@ -376,6 +387,7 @@ class RetryTemplateTests { 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 144/156] 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 7b0946122c..2dd1c1b38a 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 @@ import org.springframework.core.retry.support.CompositeRetryListener; * * @author Mahmoud Ben Hassine * @author Sam Brannen + * @author Juergen Hoeller * @since 7.0 * @see CompositeRetryListener */ @@ -64,9 +65,13 @@ public interface RetryListener { * 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 352278df0c..faa6b242cd 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 class RetryTemplate implements RetryOperations { 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 class RetryTemplate implements RetryOperations { 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 class RetryTemplate implements RetryOperations { "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 f71d94d5f8..219ab7b605 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 java.util.List; 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 @@ import org.springframework.util.Assert; *

      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 class CompositeRetryListener implements RetryListener { } @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 4e3ec1c11e..d2e32f6c44 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 @@ class RetryTemplateTests { 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 @@ class RetryTemplateTests { .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 @@ class RetryTemplateTests { .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 @@ class RetryTemplateTests { .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 @@ class RetryTemplateTests { 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 @@ class RetryTemplateTests { 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 @@ class RetryTemplateTests { 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 @@ class RetryTemplateTests { 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 8fac26872f..10bb628f25 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 java.util.List; 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 @@ class CompositeRetryListenerTests { @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 145/156] =?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 d81436315a..21a9552395 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 146/156] 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 762c5612f4..a74385a87c 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.Flux; 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 abstract class AbstractRetryInterceptor implements MethodInterceptor { 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 f981bfae4a..e637c297fa 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 @@ class RetryInterceptorTests { 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 @@ class RetryInterceptorTests { } - static class NonAnnotatedBean { + static class NonAnnotatedBean implements PlainInterface { int counter = 0; @@ -248,6 +274,12 @@ class RetryInterceptorTests { } + 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 147/156] 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 eabc2f4b49..e1d0d9f2df 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.Session; 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 class JacksonJsonMessageConverter implements SmartMessageConverter, BeanC 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 class JacksonJsonMessageConverter implements SmartMessageConverter, BeanC 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 @@ public class JacksonJsonMessageConverter implements SmartMessageConverter, BeanC throws JMSException, IOException { String body = message.getText(); - return this.objectMapper.readValue(body, targetJavaType); + return this.jsonMapper.readValue(body, targetJavaType); } /** @@ -386,7 +385,7 @@ public class JacksonJsonMessageConverter implements SmartMessageConverter, BeanC 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 @@ public class JacksonJsonMessageConverter implements SmartMessageConverter, BeanC } 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 @@ public class JacksonJsonMessageConverter implements SmartMessageConverter, BeanC } 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 804f05b411..a2eb535e6d 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.JacksonException; 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 class JacksonJsonMessageConverter extends AbstractMessageConverter { */ 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 @@ public class JacksonJsonMessageConverter extends AbstractMessageConverter { @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 @@ public class JacksonJsonMessageConverter extends AbstractMessageConverter { } 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 @@ public class JacksonJsonMessageConverter extends AbstractMessageConverter { 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 @@ public class JacksonJsonMessageConverter extends AbstractMessageConverter { // 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 92287b4d01..c37ea1914d 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.junit.jupiter.params.provider.ValueSource; 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 c0a3b832eb..1c26fa1865 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.assertj.core.data.Offset; 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 @@ class JsonPathValueAssertTests { 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 17c068dae7..f1c0f00e8d 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.api.Test; 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 @@ class JsonPathExpectationsHelperTests { */ 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 @@ class JsonPathExpectationsHelperTests { return null; } try { - return objectMapper.convertValue(source, targetType); + return jsonMapper.convertValue(source, targetType); } catch (Exception ex) { throw new MappingException(ex); @@ -416,10 +416,10 @@ class JsonPathExpectationsHelperTests { 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 190a72b486..1051834999 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 java.util.Map; 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 @@ import static org.assertj.core.api.Assertions.assertThat; */ 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 2d5ee9beb8..62432a05a0 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 @@ package org.springframework.test.web.reactive.server; 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 @@ import static org.assertj.core.api.Assertions.assertThat; */ 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 3b5248b4b6..3a5270dfe5 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 @@ import org.springframework.util.MimeType; * * @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 abstract class AbstractJacksonDecoder extends JacksonCodecSupport impleme 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 abstract class AbstractJacksonDecoder extends JacksonCodecSupport impleme 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 abstract class AbstractJacksonDecoder extends JacksonCodecSupport impleme 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 abstract class AbstractJacksonDecoder extends JacksonCodecSupport impleme 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 abstract class AbstractJacksonDecoder extends JacksonCodecSupport impleme } } - 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 c9f67f43f5..75a2e47f7f 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 @@ import org.springframework.util.MimeType; * * @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 abstract class AbstractJacksonEncoder extends JacksonCodecSupport impleme 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 abstract class AbstractJacksonEncoder extends JacksonCodecSupport impleme } 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 abstract class AbstractJacksonEncoder extends JacksonCodecSupport impleme 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 @@ public abstract class AbstractJacksonEncoder extends JacksonCodecSupport impleme } 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 ac52c2ca29..8dc62fe7e1 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.CollectionUtils; 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 @@ 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(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 @@ public abstract class JacksonCodecSupport { } /** - * 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 abstract class JacksonCodecSupport { * @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 abstract class JacksonCodecSupport { * @return a map with registered MediaType-to-ObjectMapper registrations, * or empty if in case of no registrations for the given class. */ - public @Nullable Map 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 abstract class JacksonCodecSupport { return Collections.emptyMap(); } - protected Map, 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 @@ public abstract class JacksonCodecSupport { 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 @@ public abstract class JacksonCodecSupport { } 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 @@ public abstract class JacksonCodecSupport { /** * 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 @@ public abstract class JacksonCodecSupport { } } // 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 79a83187d6..c6669c0485 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 @@ import org.springframework.util.MimeType; * @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 8d04b2be95..980b1299c2 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 @@ import org.springframework.util.MimeType; * @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 a4202c5c1a..096235fab8 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 java.util.Map; 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 @@ import org.springframework.util.MimeTypeUtils; * @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 class JacksonJsonDecoder extends AbstractJacksonDecoder { } /** - * 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 f883727aa5..992f712d87 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 reactor.core.publisher.Flux; 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 @@ import org.springframework.util.MimeType; * @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 class JacksonJsonEncoder extends AbstractJacksonEncoder { } /** - * 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 aac01ee948..26b8736146 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 @@ import org.springframework.util.MimeType; * @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 cbdcbe38af..32eef1291d 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 @@ import org.springframework.util.MimeType; * @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 38b08f4038..b8d3353da5 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 @@ class BaseDefaultCodecs implements CodecConfigurer.DefaultCodecs, CodecConfigure } } 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 da328cbee4..938576e37e 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 @@ import org.springframework.util.TypeUtils; * * @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 @@ public abstract class AbstractJacksonHttpMessageConverter extends AbstractSmartH * 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 @@ public abstract class AbstractJacksonHttpMessageConverter extends AbstractSmartH * 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 @@ public abstract class AbstractJacksonHttpMessageConverter extends AbstractSmartH /** * 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 abstract class AbstractJacksonHttpMessageConverter extends AbstractSmartH } /** - * 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 abstract class AbstractJacksonHttpMessageConverter extends AbstractSmartH * @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 abstract class AbstractJacksonHttpMessageConverter extends AbstractSmartH * @return a map with registered MediaType-to-ObjectMapper registrations, * or empty if in case of no registrations for the given class. */ - public Map 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 abstract class AbstractJacksonHttpMessageConverter extends AbstractSmartH @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 abstract class AbstractJacksonHttpMessageConverter extends AbstractSmartH 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 abstract class AbstractJacksonHttpMessageConverter extends AbstractSmartH 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 abstract class AbstractJacksonHttpMessageConverter extends AbstractSmartH 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 abstract class AbstractJacksonHttpMessageConverter extends AbstractSmartH } } // No registrations - return this.defaultObjectMapper; + return this.defaultMapper; } @Override @@ -334,8 +335,8 @@ public abstract class AbstractJacksonHttpMessageConverter extends AbstractSmartH 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 @@ public abstract class AbstractJacksonHttpMessageConverter extends AbstractSmartH 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 @@ public abstract class AbstractJacksonHttpMessageConverter extends AbstractSmartH 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 @@ public abstract class AbstractJacksonHttpMessageConverter extends AbstractSmartH } 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 @@ public abstract class AbstractJacksonHttpMessageConverter extends AbstractSmartH * @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 348f32df7c..cd5fcefea9 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 @@ import org.springframework.http.converter.AbstractJacksonHttpMessageConverter; * @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 f1902a8ad6..5862aa6e07 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 java.util.List; 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 @@ import org.springframework.http.converter.AbstractJacksonHttpMessageConverter; /** * 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 @@ import org.springframework.http.converter.AbstractJacksonHttpMessageConverter; * @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 class JacksonJsonHttpMessageConverter extends AbstractJacksonHttpMessageC } /** - * 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 d3beaf3131..a6f6c9a968 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 @@ import org.springframework.http.converter.AbstractJacksonHttpMessageConverter; * @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 c375fc7ea9..3213daded7 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 @@ import org.springframework.util.xml.StaxUtils; * @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 9f602ae62d..e4cb422453 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 @@ import org.springframework.http.converter.AbstractJacksonHttpMessageConverter; * @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 56a202d0f2..6ae9bcffad 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 reactor.test.StepVerifier; 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 @@ class JacksonJsonDecoderTests extends AbstractDecoderTests { 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 @@ class JacksonJsonDecoderTests extends AbstractDecoderTests { @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 @@ class JacksonJsonDecoderTests extends AbstractDecoderTests { @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 @@ class JacksonJsonDecoderTests extends AbstractDecoderTests { 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 d0a0ff530f..229fb9fed2 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 org.junit.jupiter.api.Test; 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 @@ class JacksonJsonEncoderTests extends AbstractEncoderTests { @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 @@ class JacksonJsonEncoderTests extends AbstractEncoderTests { @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 @@ class JacksonJsonEncoderTests extends AbstractEncoderTests { @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 b3f7e08f0b..556c506c0a 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 @@ class JacksonJsonHttpMessageConverterTests { 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 @@ class JacksonJsonHttpMessageConverterTests { 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 @@ class JacksonJsonHttpMessageConverterTests { 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 @@ class JacksonJsonHttpMessageConverterTests { 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 98a31e66a0..5be9f01d58 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 org.junit.jupiter.api.Test; 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 @@ class ResponseEntityResultHandlerTests { 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 6c2abcc894..371b668250 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 java.util.Set; 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 @@ import org.springframework.web.servlet.view.AbstractJacksonView; /** * 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 class JacksonJsonView extends AbstractJacksonView { } /** - * 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 75d2426aa8..18db77722b 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.BeforeEach; 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 @@ class WebMvcConfigurationSupportExtensionTests { 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 c6514aa03b..2d4e17a1f3 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 @@ class WebMvcConfigurationSupportTests { .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 ef13f5626c..a23f0fdcc0 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 java.util.List; 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 @@ class SseServerResponseTests { } }); - 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 4f1eecd9a8..81bdb288d2 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 @@ class RequestResponseBodyMethodProcessorTests { 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 86a1212d71..2186de361e 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.JacksonException; 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 @@ class JacksonJsonViewTests { @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 70a18aa8cf..8bbedfb149 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 java.io.InputStream; 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 @@ import org.springframework.util.Assert; */ 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 148/156] 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 7f6f19572a..4bafff8d45 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 11dd6e8c43..f3f4a4a90d 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 @@ import org.jspecify.annotations.Nullable; * @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 149/156] 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 708945b71d..167698514a 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 @@ import static org.mockito.Mockito.mock; /** * Unit tests for {@link HttpServiceProxyFactory}. + * * @author Rossen Stoyanchev */ public class HttpServiceProxyFactoryTests { - @Test void httpExchangeAdapterDecorator() { @@ -44,7 +44,6 @@ public class HttpServiceProxyFactoryTests { } - 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 3373945b12..7f737c9250 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 @@ import static org.mockito.Mockito.mock; /** * 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 d4ed645a0b..97f14e463e 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 @@ record TestGroup( 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 2f059d0674..3a3ec15bc0 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 @@ class TestGroupRegistry implements GroupRegistry { 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 6b81e0e779..86f49a958f 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 cfd064d9e9..202abfb03e 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 a32163ddf3..44227ca040 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 7ab473f584..9c25fc5e66 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 88368f0c93..e5b8584095 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 f0993ffe04..dd56c254ba 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 dfd6e8ee64..c4ee2148d5 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 150/156] 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 cbe60f9a52..b8ee9acb4b 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.BeanFactoryAware; 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 @@ public class DefaultListableBeanFactory extends AbstractAutowireCapableBeanFacto 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 @@ public class DefaultListableBeanFactory extends AbstractAutowireCapableBeanFacto // 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 @@ public class DefaultListableBeanFactory extends AbstractAutowireCapableBeanFacto 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 @@ public class DefaultListableBeanFactory extends AbstractAutowireCapableBeanFacto @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 @@ public class DefaultListableBeanFactory extends AbstractAutowireCapableBeanFacto * 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 151/156] 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 1682c6b48f..dcc9104a93 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 class StandardEvaluationContext implements EvaluationContext { } + /** + * 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 class StandardEvaluationContext implements EvaluationContext { /** * 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 class StandardEvaluationContext implements EvaluationContext { 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 class StandardEvaluationContext implements EvaluationContext { 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 class StandardEvaluationContext implements EvaluationContext { 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 152/156] 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 45119c10bd..26e7553370 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 abstract class AbstractBeanFactory extends FactoryBeanRegistrySupport imp * 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 cde8716ec3..d5fba6ac61 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 @@ class CacheOperationExpressionEvaluatorTests { 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 50ad24d6f5..31811b0cd4 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 @@ class CachePutEvaluationTests { 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 @@ class CachePutEvaluationTests { } + @CacheConfig("test") public static class SimpleService { + private AtomicLong counter = new AtomicLong(); /** @@ -144,4 +147,5 @@ class CachePutEvaluationTests { 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 153/156] 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 dcc9104a93..4d272fc9f6 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 class StandardEvaluationContext implements EvaluationContext { * @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 154/156] 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 96b106f330..47de7e2da7 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 155/156] 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 1e6def4546..7aadf832e7 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 156/156] 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 d3a8df109d..a9e3533cac 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: