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 67874085f08..2ddc29f80a8 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 @@ -256,10 +256,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. +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 cd15384c34e..1590753e000 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 @@ -251,10 +251,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. +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/http/ProblemDetail.java b/spring-web/src/main/java/org/springframework/http/ProblemDetail.java index 617c4fd684d..ed094ebbb86 100644 --- a/spring-web/src/main/java/org/springframework/http/ProblemDetail.java +++ b/spring-web/src/main/java/org/springframework/http/ProblemDetail.java @@ -108,7 +108,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; } @@ -245,7 +244,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) && 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 d8f536e0be8..5eba436ffcb 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.jspecify.annotations.Nullable; @@ -96,12 +97,13 @@ class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest { @Override 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); @@ -119,8 +121,11 @@ class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest { catch (ExecutionException ex) { Throwable cause = ex.getCause(); - if (cause instanceof CancellationException) { - throw new HttpTimeoutException("Request timed out"); + if (cause instanceof CancellationException ce) { + if (timeoutHandler != null) { + timeoutHandler.handleCancellationException(ce); + } + throw new IOException("Request cancelled", cause); } if (cause instanceof UncheckedIOException uioEx) { throw uioEx.getCause(); @@ -136,6 +141,12 @@ class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest { throw (message == null ? new IOException(cause) : new IOException(message, cause)); } } + catch (CancellationException ex) { + if (timeoutHandler != null) { + timeoutHandler.handleCancellationException(ex); + } + throw new IOException("Request cancelled", ex); + } } private HttpRequest buildRequest(HttpHeaders headers, @Nullable Body body) { @@ -234,12 +245,15 @@ class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest { private final CompletableFuture timeoutFuture; + private final AtomicBoolean timeout = new AtomicBoolean(false); + private TimeoutHandler(CompletableFuture> future, Duration timeout) { this.timeoutFuture = new CompletableFuture() .completeOnTimeout(null, timeout.toMillis(), TimeUnit.MILLISECONDS); this.timeoutFuture.thenRun(() -> { + this.timeout.set(true); if (future.cancel(true) || future.isCompletedExceptionally() || !future.isDone()) { return; } @@ -250,7 +264,6 @@ class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest { // ignore } }); - } public @Nullable InputStream wrapInputStream(HttpResponse response) { @@ -267,6 +280,12 @@ class JdkClientHttpRequest extends AbstractStreamingClientHttpRequest { } }; } + + public void handleCancellationException(CancellationException ex) throws HttpTimeoutException { + if (this.timeout.get()) { + throw new HttpTimeoutException(ex.getMessage()); + } + } } } 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 c7c1e3c7069..07f99456178 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,16 +105,20 @@ 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}"). + * 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. *

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 {}; 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 00000000000..1cf59ca5938 --- /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); + } + +}