Introduce `BaseUrl` and `BaseUrlProvider`

Add the concept of a `BaseUrl` to the core `spring-boot-test`
module for use when making test HTTP calls. The web server module
provides `BaseUrlProvider` implementations that provide the actual
base URL (usually `https://localhost:<local-server-port>`).

Test utilities will be able to use `BaseUrlProviders` to find the
`BaseUrl`.

See gh-46356
This commit is contained in:
Phillip Webb 2025-10-01 17:24:20 -07:00
parent 155e3bd5e6
commit 79091f926d
9 changed files with 551 additions and 0 deletions

View File

@ -0,0 +1,103 @@
/*
* Copyright 2012-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.boot.test.http.server;
import java.util.function.Supplier;
import org.jspecify.annotations.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* A base URL that can be used to connect to the running server.
*
* @author Phillip Webb
* @since 4.0.0
*/
public interface BaseUrl {
/**
* Default base URL suitable for mock environments.
*/
BaseUrl DEFAULT = BaseUrl.of("http://localhost");
/**
* Return if the URL will ultimately resolve to an HTTPS address.
* @return if the URL is HTTPS
*/
boolean isHttps();
/**
* Resolve the URL to a string. This method is called as late as possible to ensure
* that an local port information is available.
* @param path the path to append
* @return the resolved base URL
*/
default String resolve(@Nullable String path) {
String resolved = resolve();
if (StringUtils.hasLength(path)) {
if (resolved.endsWith("/") && path.startsWith("/")) {
path = path.substring(1);
}
resolved += (resolved.endsWith("/") || path.startsWith("/")) ? "" : "/";
resolved += path;
}
return resolved;
}
/**
* Resolve the URL to a string. This method is called as late as possible to ensure
* that an local port information is available.
* @return the resolved base URL
*/
String resolve();
/**
* Factory method to create a new {@link BaseUrl}.
* @param url the URL to use
* @return a new {@link BaseUrl} instance
*/
static BaseUrl of(String url) {
Assert.notNull(url, "'url' must not be null");
return of(StringUtils.startsWithIgnoreCase(url, "https"), () -> url);
}
/**
* Factory method to create a new {@link BaseUrl}.
* @param https whether the base URL is https
* @param resolver the resolver used to supply the actual URL
* @return a new {@link BaseUrl} instance
*/
static BaseUrl of(boolean https, Supplier<String> resolver) {
Assert.notNull(resolver, "'resolver' must not be null");
return new BaseUrl() {
@Override
public boolean isHttps() {
return https;
}
@Override
public String resolve() {
return resolver.get();
}
};
}
}

View File

@ -0,0 +1,40 @@
/*
* Copyright 2012-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.boot.test.http.server;
import org.jspecify.annotations.Nullable;
import org.springframework.context.ApplicationContext;
/**
* Strategy used to provide the base URL that can be used to connect to the running
* server. Implementations can be registered in {@code spring.factories} and may accept an
* {@link ApplicationContext} constructor argument.
*
* @author Phillip Webb
* @since 4.0.0
*/
@FunctionalInterface
public interface BaseUrlProvider {
/**
* Return the base URL that can be used to connect to the running server.
* @return the base URL or {@code null}
*/
@Nullable BaseUrl getBaseUrl();
}

View File

@ -0,0 +1,81 @@
/*
* Copyright 2012-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.boot.test.http.server;
import java.util.List;
import java.util.Objects;
import org.jspecify.annotations.Nullable;
import org.springframework.context.ApplicationContext;
import org.springframework.core.io.support.SpringFactoriesLoader;
import org.springframework.core.io.support.SpringFactoriesLoader.ArgumentResolver;
import org.springframework.lang.Contract;
import org.springframework.util.Assert;
/**
* A collection of {@link BaseUrlProvider} instances loaded from {@code spring.factories}.
*
* @author Phillip Webb
* @since 4.0.0
*/
public class BaseUrlProviders {
private List<BaseUrlProvider> providers;
public BaseUrlProviders(ApplicationContext applicationContext) {
Assert.notNull(applicationContext, "'applicationContext' must not be null");
this.providers = SpringFactoriesLoader.forDefaultResourceLocation(applicationContext.getClassLoader())
.load(BaseUrlProvider.class, ArgumentResolver.of(ApplicationContext.class, applicationContext));
}
BaseUrlProviders(List<BaseUrlProvider> providers) {
this.providers = providers;
}
/**
* Return the provided {@link BaseUrl} or {@link BaseUrl#DEFAULT}.
* @return the base URL
*/
public BaseUrl getBaseUrlOrDefault() {
return getBaseUrl(BaseUrl.DEFAULT);
}
/**
* Return the provided {@link BaseUrl} or {@code null}.
* @return the base URL or {@code null}
*/
public @Nullable BaseUrl getBaseUrl() {
return getBaseUrl(null);
}
/**
* Return the provided {@link BaseUrl} or the given fallback.
* @param fallback the fallback
* @return the base URL or the fallback
*/
@Contract("!null -> !null")
public @Nullable BaseUrl getBaseUrl(@Nullable BaseUrl fallback) {
return this.providers.stream()
.map(BaseUrlProvider::getBaseUrl)
.filter(Objects::nonNull)
.findFirst()
.orElse(fallback);
}
}

View File

@ -0,0 +1,23 @@
/*
* Copyright 2012-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.
*/
/**
* Support for HTTP server testing.
*/
@NullMarked
package org.springframework.boot.test.http.server;
import org.jspecify.annotations.NullMarked;

View File

@ -0,0 +1,62 @@
/*
* Copyright 2012-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.boot.test.http.server;
import java.util.Collections;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link BaseUrlProviders}.
*
* @author Phillip Webb
*/
class BaseUrlProvidersTests {
@Test
void getBaseUrlOrDefaultWhenNoProvidedBaseUrlReturnsDefault() {
assertThat(new BaseUrlProviders(Collections.emptyList()).getBaseUrlOrDefault()).isSameAs(BaseUrl.DEFAULT);
}
@Test
void getBaseUrlWhenNoProvidedBaseUrlReturnsNull() {
assertThat(new BaseUrlProviders(Collections.emptyList()).getBaseUrl()).isNull();
}
@Test
void getBaseUrlWithFallbackWhenNoProvidedBaseUrlReturnsFallback() {
BaseUrl fallback = BaseUrl.of("https://example.com");
assertThat(new BaseUrlProviders(Collections.emptyList()).getBaseUrl(fallback)).isSameAs(fallback);
}
@Test
void getBaseUrlReturnsFirstProvidedBaseUrl() {
BaseUrlProvider p1 = mock();
BaseUrlProvider p2 = mock();
BaseUrl baseUrl = BaseUrl.of("https://example.com");
given(p1.getBaseUrl()).willReturn(baseUrl);
assertThat(new BaseUrlProviders(List.of(p1, p2)).getBaseUrl()).isSameAs(baseUrl);
then(p2).shouldHaveNoInteractions();
}
}

View File

@ -0,0 +1,85 @@
/*
* Copyright 2012-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.boot.test.http.server;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
/**
* Tests for {@link BaseUrl}.
*
* @author Phillip Webb
*/
class BaseUrlTests {
@Test
void resolveWithString() {
assertThat(BaseUrl.of("http://localhost").resolve(null)).isEqualTo("http://localhost");
assertThat(BaseUrl.of("http://localhost").resolve("")).isEqualTo("http://localhost");
assertThat(BaseUrl.of("http://localhost").resolve("path")).isEqualTo("http://localhost/path");
assertThat(BaseUrl.of("http://localhost").resolve("/path")).isEqualTo("http://localhost/path");
assertThat(BaseUrl.of("http://localhost/").resolve("path")).isEqualTo("http://localhost/path");
assertThat(BaseUrl.of("http://localhost/").resolve("/path")).isEqualTo("http://localhost/path");
}
@Test
void ofWhenHttp() {
BaseUrl baseUrl = BaseUrl.of("http://localhost:8080/context");
assertThat(baseUrl.isHttps()).isFalse();
assertThat(baseUrl.resolve()).isEqualTo("http://localhost:8080/context");
}
@Test
void ofWhenHttps() {
BaseUrl baseUrl = BaseUrl.of("https://localhost:8080/context");
assertThat(baseUrl.isHttps()).isTrue();
assertThat(baseUrl.resolve()).isEqualTo("https://localhost:8080/context");
}
@Test
void ofWhenUppercaseHttps() {
BaseUrl baseUrl = BaseUrl.of("HTTPS://localhost:8080/context");
assertThat(baseUrl.isHttps()).isTrue();
assertThat(baseUrl.resolve()).isEqualTo("HTTPS://localhost:8080/context");
}
@Test
void ofWhenUrlIssNull() {
assertThatIllegalArgumentException().isThrownBy(() -> BaseUrl.of(null)).withMessage("'url' must not be null");
}
@Test
void of() {
AtomicInteger atomicInteger = new AtomicInteger();
BaseUrl baseUrl = BaseUrl.of(true, () -> String.valueOf(atomicInteger.incrementAndGet()));
assertThat(atomicInteger.get()).isZero();
assertThat(baseUrl.isHttps()).isTrue();
assertThat(baseUrl.resolve()).isEqualTo("1");
assertThat(baseUrl.resolve()).isEqualTo("2");
}
@Test
void ofWhenResolverIsNull() {
assertThatIllegalArgumentException().isThrownBy(() -> BaseUrl.of(true, null))
.withMessage("'resolver' must not be null");
}
}

View File

@ -0,0 +1,76 @@
/*
* Copyright 2012-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.boot.web.server.reactive.context;
import org.jspecify.annotations.Nullable;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.boot.test.http.server.BaseUrl;
import org.springframework.boot.test.http.server.BaseUrlProvider;
import org.springframework.boot.web.server.AbstractConfigurableWebServerFactory;
import org.springframework.boot.web.server.reactive.AbstractReactiveWebServerFactory;
import org.springframework.context.ApplicationContext;
/**
* {@link BaseUrlProvider} for a {@link ReactiveWebServerApplicationContext}.
*
* @author Phillip Webb
*/
class ReactiveWebServerApplicationContextBaseUrlProvider implements BaseUrlProvider {
private final @Nullable ReactiveWebServerApplicationContext context;
ReactiveWebServerApplicationContextBaseUrlProvider(ApplicationContext context) {
this.context = getWebServerApplicationContextIfPossible(context);
}
static @Nullable ReactiveWebServerApplicationContext getWebServerApplicationContextIfPossible(
ApplicationContext context) {
try {
return (ReactiveWebServerApplicationContext) context;
}
catch (NoClassDefFoundError | ClassCastException ex) {
return null;
}
}
@Override
public @Nullable BaseUrl getBaseUrl() {
if (this.context == null) {
return null;
}
boolean sslEnabled = isSslEnabled(this.context);
return BaseUrl.of(sslEnabled, () -> {
String scheme = (sslEnabled) ? "https" : "http";
String port = this.context.getEnvironment().getProperty("local.server.port", "8080");
String path = this.context.getEnvironment().getProperty("spring.webflux.base-path", "");
return scheme + "://localhost:" + port + path;
});
}
private boolean isSslEnabled(ReactiveWebServerApplicationContext context) {
try {
AbstractConfigurableWebServerFactory webServerFactory = context
.getBean(AbstractReactiveWebServerFactory.class);
return webServerFactory.getSsl() != null && webServerFactory.getSsl().isEnabled();
}
catch (NoSuchBeanDefinitionException ex) {
return false;
}
}
}

View File

@ -0,0 +1,76 @@
/*
* Copyright 2012-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.boot.web.server.servlet.context;
import org.jspecify.annotations.Nullable;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.boot.test.http.server.BaseUrl;
import org.springframework.boot.test.http.server.BaseUrlProvider;
import org.springframework.boot.web.server.AbstractConfigurableWebServerFactory;
import org.springframework.boot.web.server.reactive.AbstractReactiveWebServerFactory;
import org.springframework.context.ApplicationContext;
/**
* {@link BaseUrlProvider} for a {@link ServletWebServerApplicationContext}.
*
* @author Phillip Webb
*/
class ServletWebServerApplicationContextBaseUrlProvider implements BaseUrlProvider {
private final @Nullable ServletWebServerApplicationContext context;
ServletWebServerApplicationContextBaseUrlProvider(ApplicationContext context) {
this.context = getWebServerApplicationContextIfPossible(context);
}
static @Nullable ServletWebServerApplicationContext getWebServerApplicationContextIfPossible(
ApplicationContext context) {
try {
return (ServletWebServerApplicationContext) context;
}
catch (NoClassDefFoundError | ClassCastException ex) {
return null;
}
}
@Override
public @Nullable BaseUrl getBaseUrl() {
if (this.context == null) {
return null;
}
boolean sslEnabled = isSslEnabled(this.context);
return BaseUrl.of(sslEnabled, () -> {
String scheme = (sslEnabled) ? "https" : "http";
String port = this.context.getEnvironment().getProperty("local.server.port", "8080");
String path = this.context.getEnvironment().getProperty("server.servlet.context-path", "");
return scheme + "://localhost:" + port + path;
});
}
private boolean isSslEnabled(ServletWebServerApplicationContext context) {
try {
AbstractConfigurableWebServerFactory webServerFactory = context
.getBean(AbstractReactiveWebServerFactory.class);
return webServerFactory.getSsl() != null && webServerFactory.getSsl().isEnabled();
}
catch (NoSuchBeanDefinitionException ex) {
return false;
}
}
}

View File

@ -11,3 +11,8 @@ org.springframework.boot.web.server.context.ServerPortInfoApplicationContextInit
org.springframework.boot.diagnostics.FailureAnalyzer=\
org.springframework.boot.web.server.PortInUseFailureAnalyzer,\
org.springframework.boot.web.server.context.MissingWebServerFactoryBeanFailureAnalyzer
# HTTP Server Test Base URL Providers
org.springframework.boot.test.http.server.BaseUrlProvider=\
org.springframework.boot.web.server.reactive.context.ReactiveWebServerApplicationContextBaseUrlProvider,\
org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContextBaseUrlProvider