Create spring-boot-h2console module

This commit is contained in:
Moritz Halbritter
2025-03-25 14:27:08 +01:00
committed by Phillip Webb
parent ee0c4af6d3
commit 65a50949d8
14 changed files with 52 additions and 21 deletions

View File

@@ -0,0 +1,139 @@
/*
* Copyright 2012-2025 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.h2console.autoconfigure;
import java.sql.Connection;
import java.util.List;
import java.util.Objects;
import javax.sql.DataSource;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.h2.server.web.JakartaWebServlet;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.h2console.autoconfigure.H2ConsoleProperties.Settings;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.core.log.LogMessage;
/**
* {@link EnableAutoConfiguration Auto-configuration} for H2's web console.
*
* @author Andy Wilkinson
* @author Marten Deinum
* @author Stephane Nicoll
* @author Phillip Webb
* @since 4.0.0
*/
@AutoConfiguration
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass(JakartaWebServlet.class)
@ConditionalOnBooleanProperty("spring.h2.console.enabled")
@EnableConfigurationProperties(H2ConsoleProperties.class)
public class H2ConsoleAutoConfiguration {
private static final Log logger = LogFactory.getLog(H2ConsoleAutoConfiguration.class);
private final H2ConsoleProperties properties;
H2ConsoleAutoConfiguration(H2ConsoleProperties properties) {
this.properties = properties;
}
@Bean
public ServletRegistrationBean<JakartaWebServlet> h2Console() {
String path = this.properties.getPath();
String urlMapping = path + (path.endsWith("/") ? "*" : "/*");
ServletRegistrationBean<JakartaWebServlet> registration = new ServletRegistrationBean<>(new JakartaWebServlet(),
urlMapping);
configureH2ConsoleSettings(registration, this.properties.getSettings());
return registration;
}
@Bean
H2ConsoleLogger h2ConsoleLogger(ObjectProvider<DataSource> dataSources) {
return new H2ConsoleLogger(dataSources, this.properties.getPath());
}
private void configureH2ConsoleSettings(ServletRegistrationBean<JakartaWebServlet> registration,
Settings settings) {
if (settings.isTrace()) {
registration.addInitParameter("trace", "");
}
if (settings.isWebAllowOthers()) {
registration.addInitParameter("webAllowOthers", "");
}
if (settings.getWebAdminPassword() != null) {
registration.addInitParameter("webAdminPassword", settings.getWebAdminPassword());
}
}
static class H2ConsoleLogger {
H2ConsoleLogger(ObjectProvider<DataSource> dataSources, String path) {
if (logger.isInfoEnabled()) {
ClassLoader classLoader = getClass().getClassLoader();
withThreadContextClassLoader(classLoader, () -> log(getConnectionUrls(dataSources), path));
}
}
private void withThreadContextClassLoader(ClassLoader classLoader, Runnable action) {
ClassLoader previous = Thread.currentThread().getContextClassLoader();
try {
Thread.currentThread().setContextClassLoader(classLoader);
action.run();
}
finally {
Thread.currentThread().setContextClassLoader(previous);
}
}
private List<String> getConnectionUrls(ObjectProvider<DataSource> dataSources) {
return dataSources.orderedStream(ObjectProvider.UNFILTERED)
.map(this::getConnectionUrl)
.filter(Objects::nonNull)
.toList();
}
private String getConnectionUrl(DataSource dataSource) {
try (Connection connection = dataSource.getConnection()) {
return "'" + connection.getMetaData().getURL() + "'";
}
catch (Exception ex) {
return null;
}
}
private void log(List<String> urls, String path) {
if (!urls.isEmpty()) {
logger.info(LogMessage.format("H2 console available at '%s'. %s available at %s", path,
(urls.size() > 1) ? "Databases" : "Database", String.join(", ", urls)));
}
}
}
}

View File

@@ -0,0 +1,111 @@
/*
* Copyright 2012-2025 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.h2console.autoconfigure;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.Assert;
/**
* Configuration properties for H2's console.
*
* @author Andy Wilkinson
* @author Marten Deinum
* @author Stephane Nicoll
* @since 4.0.0
*/
@ConfigurationProperties("spring.h2.console")
public class H2ConsoleProperties {
/**
* Path at which the console is available.
*/
private String path = "/h2-console";
/**
* Whether to enable the console.
*/
private boolean enabled = false;
private final Settings settings = new Settings();
public String getPath() {
return this.path;
}
public void setPath(String path) {
Assert.notNull(path, "'path' must not be null");
Assert.isTrue(path.length() > 1, "'path' must have length greater than 1");
Assert.isTrue(path.startsWith("/"), "'path' must start with '/'");
this.path = path;
}
public boolean isEnabled() {
return this.enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public Settings getSettings() {
return this.settings;
}
public static class Settings {
/**
* Whether to enable trace output.
*/
private boolean trace = false;
/**
* Whether to enable remote access.
*/
private boolean webAllowOthers = false;
/**
* Password to access preferences and tools of H2 Console.
*/
private String webAdminPassword;
public boolean isTrace() {
return this.trace;
}
public void setTrace(boolean trace) {
this.trace = trace;
}
public boolean isWebAllowOthers() {
return this.webAllowOthers;
}
public void setWebAllowOthers(boolean webAllowOthers) {
this.webAllowOthers = webAllowOthers;
}
public String getWebAdminPassword() {
return this.webAdminPassword;
}
public void setWebAdminPassword(String webAdminPassword) {
this.webAdminPassword = webAdminPassword;
}
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2012-2025 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.
*/
/**
* Auto-configuration for H2's Console.
*/
package org.springframework.boot.h2console.autoconfigure;

View File

@@ -0,0 +1 @@
org.springframework.boot.h2console.autoconfigure.H2ConsoleAutoConfiguration

View File

@@ -0,0 +1,269 @@
/*
* Copyright 2012-2025 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.h2console.autoconfigure;
import java.net.URL;
import java.net.URLClassLoader;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.SQLException;
import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.BeanCreationException;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.context.properties.ConfigurationPropertiesBindException;
import org.springframework.boot.context.properties.bind.BindException;
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.boot.tomcat.autoconfigure.servlet.TomcatServletWebServerAutoConfiguration;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link H2ConsoleAutoConfiguration}
*
* @author Andy Wilkinson
* @author Marten Deinum
* @author Stephane Nicoll
* @author Shraddha Yeole
* @author Phillip Webb
*/
class H2ConsoleAutoConfigurationTests {
private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(H2ConsoleAutoConfiguration.class));
@Test
void consoleIsDisabledByDefault() {
this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean(ServletRegistrationBean.class));
}
@Test
void propertyCanEnableConsole() {
this.contextRunner.withPropertyValues("spring.h2.console.enabled=true").run((context) -> {
assertThat(context).hasSingleBean(ServletRegistrationBean.class);
ServletRegistrationBean<?> registrationBean = context.getBean(ServletRegistrationBean.class);
assertThat(registrationBean.getUrlMappings()).contains("/h2-console/*");
assertThat(registrationBean.getInitParameters()).doesNotContainKey("trace");
assertThat(registrationBean.getInitParameters()).doesNotContainKey("webAllowOthers");
assertThat(registrationBean.getInitParameters()).doesNotContainKey("webAdminPassword");
});
}
@Test
void customPathMustBeginWithASlash() {
this.contextRunner.withPropertyValues("spring.h2.console.enabled=true", "spring.h2.console.path=custom")
.run((context) -> {
assertThat(context).hasFailed();
assertThat(context.getStartupFailure()).isInstanceOf(BeanCreationException.class)
.cause()
.isInstanceOf(ConfigurationPropertiesBindException.class)
.cause()
.isInstanceOf(BindException.class)
.hasMessageContaining("Failed to bind properties under 'spring.h2.console'");
});
}
@Test
void customPathWithTrailingSlash() {
this.contextRunner.withPropertyValues("spring.h2.console.enabled=true", "spring.h2.console.path=/custom/")
.run((context) -> {
assertThat(context).hasSingleBean(ServletRegistrationBean.class);
ServletRegistrationBean<?> registrationBean = context.getBean(ServletRegistrationBean.class);
assertThat(registrationBean.getUrlMappings()).contains("/custom/*");
});
}
@Test
void customPath() {
this.contextRunner.withPropertyValues("spring.h2.console.enabled=true", "spring.h2.console.path=/custom")
.run((context) -> {
assertThat(context).hasSingleBean(ServletRegistrationBean.class);
ServletRegistrationBean<?> registrationBean = context.getBean(ServletRegistrationBean.class);
assertThat(registrationBean.getUrlMappings()).contains("/custom/*");
});
}
@Test
void customInitParameters() {
this.contextRunner
.withPropertyValues("spring.h2.console.enabled=true", "spring.h2.console.settings.trace=true",
"spring.h2.console.settings.web-allow-others=true",
"spring.h2.console.settings.web-admin-password=abcd")
.run((context) -> {
assertThat(context).hasSingleBean(ServletRegistrationBean.class);
ServletRegistrationBean<?> registrationBean = context.getBean(ServletRegistrationBean.class);
assertThat(registrationBean.getUrlMappings()).contains("/h2-console/*");
assertThat(registrationBean.getInitParameters()).containsEntry("trace", "");
assertThat(registrationBean.getInitParameters()).containsEntry("webAllowOthers", "");
assertThat(registrationBean.getInitParameters()).containsEntry("webAdminPassword", "abcd");
});
}
@Test
@ExtendWith(OutputCaptureExtension.class)
void singleDataSourceUrlIsLoggedWhenOnlyOneAvailable(CapturedOutput output) {
this.contextRunner.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class))
.withPropertyValues("spring.h2.console.enabled=true")
.run((context) -> {
try (Connection connection = context.getBean(DataSource.class).getConnection()) {
assertThat(output).contains("H2 console available at '/h2-console'. Database available at '"
+ connection.getMetaData().getURL() + "'");
}
});
}
@Test
@ExtendWith(OutputCaptureExtension.class)
void noDataSourceIsLoggedWhenNoneAvailable(CapturedOutput output) {
this.contextRunner.withUserConfiguration(FailingDataSourceConfiguration.class)
.withPropertyValues("spring.h2.console.enabled=true")
.run((context) -> assertThat(output).doesNotContain("H2 console available"));
}
@Test
@ExtendWith(OutputCaptureExtension.class)
void allDataSourceUrlsAreLoggedWhenMultipleAvailable(CapturedOutput output) {
ClassLoader webAppClassLoader = new URLClassLoader(new URL[0]);
this.contextRunner.withClassLoader(webAppClassLoader)
.withUserConfiguration(FailingDataSourceConfiguration.class, MultiDataSourceConfiguration.class)
.withPropertyValues("spring.h2.console.enabled=true")
.run((context) -> assertThat(output).contains(
"H2 console available at '/h2-console'. Databases available at 'someJdbcUrl', 'anotherJdbcUrl'"));
}
@Test
@ExtendWith(OutputCaptureExtension.class)
void allDataSourceUrlsAreLoggedWhenNonCandidate(CapturedOutput output) {
ClassLoader webAppClassLoader = new URLClassLoader(new URL[0]);
this.contextRunner.withClassLoader(webAppClassLoader)
.withUserConfiguration(FailingDataSourceConfiguration.class, MultiDataSourceNonCandidateConfiguration.class)
.withPropertyValues("spring.h2.console.enabled=true")
.run((context) -> assertThat(output).contains(
"H2 console available at '/h2-console'. Databases available at 'someJdbcUrl', 'anotherJdbcUrl'"));
}
@Test
void h2ConsoleShouldNotFailIfDatabaseConnectionFails() {
this.contextRunner.withUserConfiguration(FailingDataSourceConfiguration.class)
.withPropertyValues("spring.h2.console.enabled=true")
.run((context) -> assertThat(context.isRunning()).isTrue());
}
@Test
@ExtendWith(OutputCaptureExtension.class)
void dataSourceIsNotInitializedEarly(CapturedOutput output) {
new WebApplicationContextRunner(AnnotationConfigServletWebServerApplicationContext::new)
.withConfiguration(AutoConfigurations.of(H2ConsoleAutoConfiguration.class,
TomcatServletWebServerAutoConfiguration.class))
.withUserConfiguration(EarlyInitializationConfiguration.class)
.withPropertyValues("spring.h2.console.enabled=true", "server.port=0")
.run((context) -> {
try (Connection connection = context.getBean(DataSource.class).getConnection()) {
assertThat(output).contains("H2 console available at '/h2-console'. Database available at '"
+ connection.getMetaData().getURL() + "'");
}
});
}
private static DataSource mockDataSource(String url, ClassLoader classLoader) throws SQLException {
DataSource dataSource = mock(DataSource.class);
given(dataSource.getConnection()).will((invocation) -> {
assertThat(Thread.currentThread().getContextClassLoader()).isEqualTo(classLoader);
Connection connection = mock(Connection.class);
DatabaseMetaData metadata = mock(DatabaseMetaData.class);
given(connection.getMetaData()).willReturn(metadata);
given(metadata.getURL()).willReturn(url);
return connection;
});
return dataSource;
}
@Configuration(proxyBeanMethods = false)
static class FailingDataSourceConfiguration {
@Bean
DataSource dataSource() throws SQLException {
DataSource dataSource = mock(DataSource.class);
given(dataSource.getConnection()).willThrow(IllegalStateException.class);
return dataSource;
}
}
@Configuration(proxyBeanMethods = false)
static class MultiDataSourceConfiguration {
@Bean
@Order(5)
DataSource anotherDataSource() throws SQLException {
return mockDataSource("anotherJdbcUrl", getClass().getClassLoader());
}
@Bean
@Order(0)
DataSource someDataSource() throws SQLException {
return mockDataSource("someJdbcUrl", getClass().getClassLoader());
}
}
@Configuration(proxyBeanMethods = false)
static class MultiDataSourceNonCandidateConfiguration {
@Bean
@Order(5)
DataSource anotherDataSource() throws SQLException {
return mockDataSource("anotherJdbcUrl", getClass().getClassLoader());
}
@Bean(defaultCandidate = false)
@Order(0)
DataSource nonDefaultDataSource() throws SQLException {
return mockDataSource("someJdbcUrl", getClass().getClassLoader());
}
}
@Configuration(proxyBeanMethods = false)
static class EarlyInitializationConfiguration {
@Bean
DataSource dataSource(ConfigurableApplicationContext applicationContext) {
assertThat(applicationContext.getBeanFactory().isConfigurationFrozen()).isTrue();
return new EmbeddedDatabaseBuilder().setType(EmbeddedDatabaseType.H2).build();
}
}
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2012-2025 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.h2console.autoconfigure;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
/**
* Tests for {@link H2ConsoleProperties}.
*
* @author Madhura Bhave
*/
class H2ConsolePropertiesTests {
@Test
void pathMustNotBeEmpty() {
H2ConsoleProperties properties = new H2ConsoleProperties();
assertThatIllegalArgumentException().isThrownBy(() -> properties.setPath(""))
.withMessageContaining("'path' must have length greater than 1");
}
@Test
void pathMustHaveLengthGreaterThanOne() {
H2ConsoleProperties properties = new H2ConsoleProperties();
assertThatIllegalArgumentException().isThrownBy(() -> properties.setPath("/"))
.withMessageContaining("'path' must have length greater than 1");
}
@Test
void customPathMustBeginWithASlash() {
H2ConsoleProperties properties = new H2ConsoleProperties();
assertThatIllegalArgumentException().isThrownBy(() -> properties.setPath("custom"))
.withMessageContaining("'path' must start with '/'");
}
}