diff --git a/core/spring-boot-test/src/main/java/org/springframework/boot/test/http/server/BaseUrl.java b/core/spring-boot-test/src/main/java/org/springframework/boot/test/http/server/BaseUrl.java new file mode 100644 index 00000000000..275a41e046c --- /dev/null +++ b/core/spring-boot-test/src/main/java/org/springframework/boot/test/http/server/BaseUrl.java @@ -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 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(); + } + + }; + } + +} diff --git a/core/spring-boot-test/src/main/java/org/springframework/boot/test/http/server/BaseUrlProvider.java b/core/spring-boot-test/src/main/java/org/springframework/boot/test/http/server/BaseUrlProvider.java new file mode 100644 index 00000000000..218f8fc10aa --- /dev/null +++ b/core/spring-boot-test/src/main/java/org/springframework/boot/test/http/server/BaseUrlProvider.java @@ -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(); + +} diff --git a/core/spring-boot-test/src/main/java/org/springframework/boot/test/http/server/BaseUrlProviders.java b/core/spring-boot-test/src/main/java/org/springframework/boot/test/http/server/BaseUrlProviders.java new file mode 100644 index 00000000000..3fffb581066 --- /dev/null +++ b/core/spring-boot-test/src/main/java/org/springframework/boot/test/http/server/BaseUrlProviders.java @@ -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 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 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); + + } + +} diff --git a/core/spring-boot-test/src/main/java/org/springframework/boot/test/http/server/package-info.java b/core/spring-boot-test/src/main/java/org/springframework/boot/test/http/server/package-info.java new file mode 100644 index 00000000000..bd9db94155a --- /dev/null +++ b/core/spring-boot-test/src/main/java/org/springframework/boot/test/http/server/package-info.java @@ -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; diff --git a/core/spring-boot-test/src/test/java/org/springframework/boot/test/http/server/BaseUrlProvidersTests.java b/core/spring-boot-test/src/test/java/org/springframework/boot/test/http/server/BaseUrlProvidersTests.java new file mode 100644 index 00000000000..8584555ae03 --- /dev/null +++ b/core/spring-boot-test/src/test/java/org/springframework/boot/test/http/server/BaseUrlProvidersTests.java @@ -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(); + } + +} diff --git a/core/spring-boot-test/src/test/java/org/springframework/boot/test/http/server/BaseUrlTests.java b/core/spring-boot-test/src/test/java/org/springframework/boot/test/http/server/BaseUrlTests.java new file mode 100644 index 00000000000..693ce29530e --- /dev/null +++ b/core/spring-boot-test/src/test/java/org/springframework/boot/test/http/server/BaseUrlTests.java @@ -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"); + } + +} diff --git a/module/spring-boot-web-server/src/main/java/org/springframework/boot/web/server/reactive/context/ReactiveWebServerApplicationContextBaseUrlProvider.java b/module/spring-boot-web-server/src/main/java/org/springframework/boot/web/server/reactive/context/ReactiveWebServerApplicationContextBaseUrlProvider.java new file mode 100644 index 00000000000..0e80ed4964e --- /dev/null +++ b/module/spring-boot-web-server/src/main/java/org/springframework/boot/web/server/reactive/context/ReactiveWebServerApplicationContextBaseUrlProvider.java @@ -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; + } + } + +} diff --git a/module/spring-boot-web-server/src/main/java/org/springframework/boot/web/server/servlet/context/ServletWebServerApplicationContextBaseUrlProvider.java b/module/spring-boot-web-server/src/main/java/org/springframework/boot/web/server/servlet/context/ServletWebServerApplicationContextBaseUrlProvider.java new file mode 100644 index 00000000000..96b0a620840 --- /dev/null +++ b/module/spring-boot-web-server/src/main/java/org/springframework/boot/web/server/servlet/context/ServletWebServerApplicationContextBaseUrlProvider.java @@ -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; + } + } + +} diff --git a/module/spring-boot-web-server/src/main/resources/META-INF/spring.factories b/module/spring-boot-web-server/src/main/resources/META-INF/spring.factories index 2629b894119..9531f3d70b1 100644 --- a/module/spring-boot-web-server/src/main/resources/META-INF/spring.factories +++ b/module/spring-boot-web-server/src/main/resources/META-INF/spring.factories @@ -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