diff --git a/build-plugin/spring-boot-gradle-plugin/build.gradle b/build-plugin/spring-boot-gradle-plugin/build.gradle index 99058c2c1d2..ac6a02c1d99 100644 --- a/build-plugin/spring-boot-gradle-plugin/build.gradle +++ b/build-plugin/spring-boot-gradle-plugin/build.gradle @@ -49,6 +49,7 @@ dependencies { implementation("org.apache.commons:commons-compress") implementation("org.springframework:spring-core") + optional("com.google.protobuf:protobuf-gradle-plugin") optional("org.graalvm.buildtools:native-gradle-plugin") optional("org.cyclonedx:cyclonedx-gradle-plugin") { exclude(group: "javax.annotation", module: "javax.annotation-api") diff --git a/build-plugin/spring-boot-gradle-plugin/src/docs/antora/modules/gradle-plugin/pages/reacting.adoc b/build-plugin/spring-boot-gradle-plugin/src/docs/antora/modules/gradle-plugin/pages/reacting.adoc index d1ec0b5817b..5ccd7f80e3f 100644 --- a/build-plugin/spring-boot-gradle-plugin/src/docs/antora/modules/gradle-plugin/pages/reacting.adoc +++ b/build-plugin/spring-boot-gradle-plugin/src/docs/antora/modules/gradle-plugin/pages/reacting.adoc @@ -104,3 +104,14 @@ When the {url-cyclonedx-docs-gradle-plugin}[CycloneDX plugin] is applied to a pr .. Disable full license texts. . Adds the SBOM under `META-INF/sbom` in the generated jar or war file. . Adds the `Sbom-Format` and `Sbom-Location` to the manifest of the jar or war file. + + + +[[reacting-to-other-plugins.protobuf]] +== Reacting to the Protobuf Plugin + +When the {url-protobuf-docs-gradle-plugin}[Protobuf plugin] is applied to a project, the Spring Boot plugin: + +. Configures `protoc` to use the artifact `com.google.protobuf:protoc`, aligning its version with that of Protobuf dependencies on the runtime classpath. +. Configures the `grpc` plugin to use the artifact `io.grpc:protoc-gen-grpc-java`, aligning its version with that of gRPC dependencies on the runtime classpath. +. Configures the `grpc` plugin of all generate proto tasks with the option `@generated=omit`. diff --git a/build-plugin/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/ProtobufPluginAction.java b/build-plugin/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/ProtobufPluginAction.java new file mode 100644 index 00000000000..1ce24affa7f --- /dev/null +++ b/build-plugin/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/ProtobufPluginAction.java @@ -0,0 +1,157 @@ +/* + * 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.gradle.plugin; + +import java.util.List; +import java.util.Optional; + +import com.google.protobuf.gradle.ExecutableLocator; +import com.google.protobuf.gradle.GenerateProtoTask; +import com.google.protobuf.gradle.GenerateProtoTask.PluginOptions; +import com.google.protobuf.gradle.ProtobufExtension; +import com.google.protobuf.gradle.ProtobufExtension.GenerateProtoTaskCollection; +import com.google.protobuf.gradle.ProtobufPlugin; +import org.gradle.api.Action; +import org.gradle.api.NamedDomainObjectContainer; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.artifacts.DependencyResolveDetails; +import org.gradle.api.artifacts.ModuleVersionSelector; +import org.gradle.api.artifacts.component.ModuleComponentIdentifier; +import org.gradle.api.artifacts.result.DependencyResult; +import org.gradle.api.artifacts.result.ResolvedComponentResult; +import org.jspecify.annotations.Nullable; + +/** + * {@link Action} that is executed in response to the {@link ProtobufPlugin} being + * applied. + * + * @author Andy Wilkinson + */ +final class ProtobufPluginAction implements PluginApplicationAction { + + private static final Dependency protocDependency = new Dependency("com.google.protobuf", "protoc"); + + private static final Dependency grpcDependency = new Dependency("io.grpc", "protoc-gen-grpc-java"); + + private static final List versionAlignment = List.of( + protocDependency.alignVersionWith("com.google.protobuf", "protobuf-java-util"), + grpcDependency.alignVersionWith("io.grpc", "grpc-util")); + + @Override + public Class> getPluginClass() { + return ProtobufPlugin.class; + } + + @Override + public void execute(Project project) { + ProtobufExtension protobuf = project.getExtensions().getByType(ProtobufExtension.class); + protobuf.protoc(this::configureProtoc); + protobuf.plugins(this::configurePlugins); + protobuf.generateProtoTasks(this::configureGenerateProtoTasks); + project.getConfigurations() + .named(this::isProtobufToolsLocator) + .configureEach((configuration) -> configureProtobufToolsLocator(project, configuration)); + } + + private void configureProtoc(ExecutableLocator protoc) { + protoc.setArtifact(protocDependency.asDependencySpec()); + } + + private ExecutableLocator configurePlugins(NamedDomainObjectContainer plugins) { + return plugins.create("grpc", (grpc) -> grpc.setArtifact(grpcDependency.asDependencySpec())); + } + + private void configureGenerateProtoTasks(GenerateProtoTaskCollection tasks) { + tasks.all().configureEach(this::configureGenerateProtoTask); + } + + private void configureGenerateProtoTask(GenerateProtoTask task) { + task.plugins((plugins) -> plugins.create("grpc", this::configureGrpcOptions)); + } + + private void configureGrpcOptions(PluginOptions grpc) { + grpc.option("@generated=omit"); + } + + private boolean isProtobufToolsLocator(String name) { + return name.startsWith("protobufToolsLocator_"); + } + + private void configureProtobufToolsLocator(Project project, Configuration configuration) { + configuration.getResolutionStrategy().eachDependency((details) -> { + VersionAlignment versionAlignment = versionAlignmentFor(details); + if (versionAlignment != null) { + versionAlignment.applyIfPossible(project, details); + } + }); + } + + private @Nullable VersionAlignment versionAlignmentFor(DependencyResolveDetails details) { + if ("null".equals(details.getRequested().getVersion())) { + for (VersionAlignment alignment : versionAlignment) { + if (alignment.accepts(details)) { + return alignment; + } + } + } + return null; + } + + private record Dependency(String group, String module) { + + private VersionAlignment alignVersionWith(String group, String module) { + return new VersionAlignment(this, new Dependency(group, module)); + } + + private String asDependencySpec() { + return this.group + ":" + this.module; + } + + } + + private record VersionAlignment(Dependency target, Dependency source) { + + void applyIfPossible(Project project, DependencyResolveDetails details) { + versionFromRuntimeClasspath(project, source()).ifPresent(details::useVersion); + } + + boolean accepts(DependencyResolveDetails details) { + ModuleVersionSelector requested = details.getRequested(); + return target().group().equals(requested.getGroup()) && target().module().equals(requested.getName()); + } + + private Optional versionFromRuntimeClasspath(Project project, Dependency source) { + return project.getConfigurations() + .getByName("runtimeClasspath") + .getIncoming() + .getResolutionResult() + .getAllDependencies() + .stream() + .map(DependencyResult::getFrom) + .map(ResolvedComponentResult::getId) + .filter(ModuleComponentIdentifier.class::isInstance) + .map(ModuleComponentIdentifier.class::cast) + .filter((id) -> id.getGroup().equals(source.group()) && id.getModule().equals(source.module())) + .map(ModuleComponentIdentifier::getVersion) + .findFirst(); + } + + } + +} diff --git a/build-plugin/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/SpringBootPlugin.java b/build-plugin/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/SpringBootPlugin.java index 9c9d9e026c3..48b07bc2fff 100644 --- a/build-plugin/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/SpringBootPlugin.java +++ b/build-plugin/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/SpringBootPlugin.java @@ -148,7 +148,7 @@ public class SpringBootPlugin implements Plugin { List actions = Arrays.asList(new JavaPluginAction(singlePublishedArtifact), new WarPluginAction(singlePublishedArtifact), new DependencyManagementPluginAction(), new ApplicationPluginAction(), new KotlinPluginAction(), new NativeImagePluginAction(), - new CyclonedxPluginAction()); + new CyclonedxPluginAction(), new ProtobufPluginAction()); for (PluginApplicationAction action : actions) { withPluginClassOfAction(action, (pluginClass) -> project.getPlugins().withType(pluginClass, (plugin) -> action.execute(project))); diff --git a/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/ProtobufPluginActionIntegrationTests.java b/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/ProtobufPluginActionIntegrationTests.java new file mode 100644 index 00000000000..1bec7cf1c88 --- /dev/null +++ b/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/ProtobufPluginActionIntegrationTests.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.gradle.plugin; + +import org.junit.jupiter.api.TestTemplate; + +import org.springframework.boot.gradle.junit.GradleCompatibility; +import org.springframework.boot.testsupport.gradle.testkit.GradleBuild; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link ProtobufPluginAction}. + * + * @author Andy Wilkinson + */ +@GradleCompatibility +class ProtobufPluginActionIntegrationTests { + + @SuppressWarnings("NullAway.Init") + GradleBuild gradleBuild; + + @TestTemplate + void configuresProtocArtifact() { + assertThat(this.gradleBuild.build("protocArtifact").getOutput()) + .contains("protoc artifact: 'com.google.protobuf:protoc'"); + } + + @TestTemplate + void configuresGrpcPlugin() { + assertThat(this.gradleBuild.build("grpcPlugin").getOutput()) + .contains("grpc plugin artifact: 'io.grpc:protoc-gen-grpc-java'"); + } + + @TestTemplate + void configuresGenerateProtoTasksToOmitGenerated() { + assertThat(this.gradleBuild.build("generateProtoTasksGrpcPluginOptions").getOutput()) + .contains("generateProto: [[@generated=omit]]") + .contains("generateTestProto: [[@generated=omit]]"); + } + + @TestTemplate + void alignsVersionOfProtocDependency() { + assertThat(this.gradleBuild.build("dependencies", "--configuration", "protobufToolsLocator_protoc").getOutput()) + .contains("com.google.protobuf:protoc:null -> 4.34.0"); + } + + @TestTemplate + void alignsVersionOfGrpcDependency() { + assertThat(this.gradleBuild.build("dependencies", "--configuration", "protobufToolsLocator_grpc").getOutput()) + .contains("io.grpc:protoc-gen-grpc-java:null -> 1.79.0"); + } + + @TestTemplate + void usesVersionOfProtocDependencyWhenSpecified() { + assertThat(this.gradleBuild.build("dependencies", "--configuration", "protobufToolsLocator_protoc").getOutput()) + .contains("com.google.protobuf:protoc:4.33.5"); + } + + @TestTemplate + void usesVersionOfGrpcPluginDependencyWhenSpecified() { + assertThat(this.gradleBuild.build("dependencies", "--configuration", "protobufToolsLocator_grpc").getOutput()) + .contains("io.grpc:protoc-gen-grpc-java:1.78.0"); + + } + +} diff --git a/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/testkit/PluginClasspathGradleBuild.java b/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/testkit/PluginClasspathGradleBuild.java index 1e7785c3565..d11755d68d6 100644 --- a/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/testkit/PluginClasspathGradleBuild.java +++ b/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/testkit/PluginClasspathGradleBuild.java @@ -22,6 +22,7 @@ import java.util.ArrayList; import java.util.List; import com.fasterxml.jackson.annotation.JsonView; +import com.google.protobuf.gradle.ProtobufPlugin; import com.sun.jna.Platform; import io.spring.gradle.dependencymanagement.DependencyManagementPlugin; import org.antlr.v4.runtime.Lexer; @@ -124,6 +125,10 @@ public class PluginClasspathGradleBuild extends GradleBuild { classpath.add(new File(pathOfJarContaining("org.codehaus.plexus.util.xml.pull.XmlPullParserException"))); classpath.add(new File(pathOfJarContaining("org.codehaus.stax2.ri.Stax2WriterAdapter"))); classpath.add(new File(pathOfJarContaining("org.cyclonedx.model.ExternalReference"))); + // Protobuf dependencies + classpath.add(new File(pathOfJarContaining(ProtobufPlugin.class))); + classpath.add(new File(pathOfJarContaining("com.google.gradle.osdetector.OsDetectorPlugin"))); + classpath.add(new File(pathOfJarContaining("kr.motd.maven.os.FileOperationProvider"))); return classpath; } diff --git a/build-plugin/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/ProtobufPluginActionIntegrationTests-usesVersionOfGrpcPluginDependencyWhenSpecified.gradle b/build-plugin/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/ProtobufPluginActionIntegrationTests-usesVersionOfGrpcPluginDependencyWhenSpecified.gradle new file mode 100644 index 00000000000..d6b113dc4fe --- /dev/null +++ b/build-plugin/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/ProtobufPluginActionIntegrationTests-usesVersionOfGrpcPluginDependencyWhenSpecified.gradle @@ -0,0 +1,37 @@ +/* + * 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. + */ + +plugins { + id 'org.springframework.boot' version '{version}' + id 'java' +} + +apply plugin: 'com.google.protobuf' + +group = 'com.example' +version = '0.0.1' + +repositories { + mavenCentral() +} + +protobuf { + plugins { + grpc { + artifact = "io.grpc:protoc-gen-grpc-java:1.78.0" + } + } +} diff --git a/build-plugin/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/ProtobufPluginActionIntegrationTests-usesVersionOfProtocDependencyWhenSpecified.gradle b/build-plugin/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/ProtobufPluginActionIntegrationTests-usesVersionOfProtocDependencyWhenSpecified.gradle new file mode 100644 index 00000000000..f5fea2ed595 --- /dev/null +++ b/build-plugin/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/ProtobufPluginActionIntegrationTests-usesVersionOfProtocDependencyWhenSpecified.gradle @@ -0,0 +1,35 @@ +/* + * 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. + */ + +plugins { + id 'org.springframework.boot' version '{version}' + id 'java' +} + +apply plugin: 'com.google.protobuf' + +group = 'com.example' +version = '0.0.1' + +repositories { + mavenCentral() +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:4.33.5" + } +} diff --git a/build-plugin/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/ProtobufPluginActionIntegrationTests.gradle b/build-plugin/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/ProtobufPluginActionIntegrationTests.gradle new file mode 100644 index 00000000000..103a6ed72d4 --- /dev/null +++ b/build-plugin/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/ProtobufPluginActionIntegrationTests.gradle @@ -0,0 +1,63 @@ +/* + * 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. + */ + +plugins { + id 'org.springframework.boot' version '{version}' + id 'java' +} + +apply plugin: 'com.google.protobuf' + +group = 'com.example' +version = '0.0.1' + +repositories { + mavenCentral() +} + +dependencies { + implementation("com.google.protobuf:protobuf-java-util:4.34.0") + implementation("io.grpc:grpc-util:1.79.0") +} + +tasks.register("protocArtifact") { + doFirst { + protobuf { + protoc { + println "protoc artifact: '$artifact'" + } + } + } +} + +tasks.register("grpcPlugin") { + doFirst { + protobuf { + plugins { + grpc { + println "grpc plugin artifact: '$artifact'" + } + } + } + } +} + +tasks.register("generateProtoTasksGrpcPluginOptions") { + doFirst { + tasks.withType(com.google.protobuf.gradle.GenerateProtoTask).each { println "${it.name}: ${it.pluginsForCaching.collect { it.options }}" } + } +} + diff --git a/buildSrc/src/main/java/org/springframework/boot/build/bom/BomExtension.java b/buildSrc/src/main/java/org/springframework/boot/build/bom/BomExtension.java index 3d4dc4b6d6c..7ab8a4c9986 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/bom/BomExtension.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/bom/BomExtension.java @@ -93,6 +93,13 @@ public class BomExtension { return this.libraries; } + public Library getLibrary(String name) { + return getLibraries().stream() + .filter((library) -> library.getName().equals(name)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No library found named '%s'".formatted(name))); + } + public void upgrade(Action action) { action.execute(this.upgradeHandler); } diff --git a/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java b/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java index dc56a5b977c..5eb82902989 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java @@ -63,6 +63,7 @@ public abstract class DocumentConfigurationProperties extends DefaultTask { Snippets snippets = new Snippets(this.configurationPropertyMetadata, getDeprecated().getOrElse(false)); snippets.add("application-properties.core", "Core Properties", this::corePrefixes); snippets.add("application-properties.cache", "Cache Properties", this::cachePrefixes); + snippets.add("application-properties.grpc", "gRPC Properties", this::grpcPrefixes); snippets.add("application-properties.mail", "Mail Properties", this::mailPrefixes); snippets.add("application-properties.json", "JSON Properties", this::jsonPrefixes); snippets.add("application-properties.data", "Data Properties", this::dataPrefixes); @@ -115,6 +116,10 @@ public abstract class DocumentConfigurationProperties extends DefaultTask { config.accept("spring.cache"); } + private void grpcPrefixes(Config prefix) { + prefix.accept("spring.grpc"); + } + private void mailPrefixes(Config config) { config.accept("spring.mail"); config.accept("spring.sendgrid"); diff --git a/buildSrc/src/main/resources/org/springframework/boot/build/antora/antora-asciidoc-attributes.properties b/buildSrc/src/main/resources/org/springframework/boot/build/antora/antora-asciidoc-attributes.properties index e1fef5f204f..f64a46c277c 100644 --- a/buildSrc/src/main/resources/org/springframework/boot/build/antora/antora-asciidoc-attributes.properties +++ b/buildSrc/src/main/resources/org/springframework/boot/build/antora/antora-asciidoc-attributes.properties @@ -35,6 +35,7 @@ url-native-build-tools-docs-gradle-plugin={url-native-build-tools-docs}/gradle-p url-native-build-tools-docs-maven-plugin={url-native-build-tools-docs}/maven-plugin.html url-paketo-docs=https://paketo.io/docs url-paketo-docs-java-buildpack={url-paketo-docs}/buildpacks/language-family-buildpacks/java +url-protobuf-docs-gradle-plugin=https://github.com/google/protobuf-gradle-plugin url-pulsar-client-api-javadoc=https://javadoc.io/doc/org.apache.pulsar/pulsar-client-api/{version-pulsar-client-api} url-spring-boot-for-apache-geode-docs=https://docs.spring.io/spring-boot-data-geode-build/2.0.x/reference/html5 url-spring-boot-for-apache-geode-site=https://github.com/spring-projects/spring-boot-data-geode diff --git a/documentation/spring-boot-actuator-docs/src/test/java/org/springframework/boot/actuate/docs/health/HealthEndpointDocumentationTests.java b/documentation/spring-boot-actuator-docs/src/test/java/org/springframework/boot/actuate/docs/health/HealthEndpointDocumentationTests.java index ddf01dbc92b..5db10993d81 100644 --- a/documentation/spring-boot-actuator-docs/src/test/java/org/springframework/boot/actuate/docs/health/HealthEndpointDocumentationTests.java +++ b/documentation/spring-boot-actuator-docs/src/test/java/org/springframework/boot/actuate/docs/health/HealthEndpointDocumentationTests.java @@ -34,8 +34,6 @@ import org.springframework.boot.health.actuate.endpoint.HealthEndpoint; import org.springframework.boot.health.actuate.endpoint.HealthEndpointGroup; import org.springframework.boot.health.actuate.endpoint.HealthEndpointGroups; import org.springframework.boot.health.actuate.endpoint.HttpCodeStatusMapper; -import org.springframework.boot.health.actuate.endpoint.SimpleHttpCodeStatusMapper; -import org.springframework.boot.health.actuate.endpoint.SimpleStatusAggregator; import org.springframework.boot.health.actuate.endpoint.StatusAggregator; import org.springframework.boot.health.application.DiskSpaceHealthIndicator; import org.springframework.boot.health.autoconfigure.registry.HealthContributorNameGenerator; @@ -136,9 +134,9 @@ class HealthEndpointDocumentationTests extends MockMvcEndpointDocumentationTests private static final class TestHealthEndpointGroup implements HealthEndpointGroup { - private final StatusAggregator statusAggregator = new SimpleStatusAggregator(); + private final StatusAggregator statusAggregator = StatusAggregator.getDefault(); - private final HttpCodeStatusMapper httpCodeStatusMapper = new SimpleHttpCodeStatusMapper(); + private final HttpCodeStatusMapper httpCodeStatusMapper = HttpCodeStatusMapper.getDefault(); @Override public boolean isMember(String name) { diff --git a/documentation/spring-boot-docs/build.gradle b/documentation/spring-boot-docs/build.gradle index e89fb1017b0..fe01de87d6f 100644 --- a/documentation/spring-boot-docs/build.gradle +++ b/documentation/spring-boot-docs/build.gradle @@ -110,6 +110,9 @@ dependencies { implementation(project(path: ":module:spring-boot-data-redis-test")) implementation(project(path: ":module:spring-boot-devtools")) implementation(project(path: ":module:spring-boot-graphql-test")) + implementation(project(path: ":module:spring-boot-grpc-client")) + implementation(project(path: ":module:spring-boot-grpc-server")) + implementation(project(path: ":module:spring-boot-grpc-test")) implementation(project(path: ":module:spring-boot-health")) implementation(project(path: ":module:spring-boot-hibernate")) implementation(project(path: ":module:spring-boot-http-converter")) diff --git a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/testing/test-modules.adoc b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/testing/test-modules.adoc index 8a16fbe5f7c..9d00d5e08b4 100644 --- a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/testing/test-modules.adoc +++ b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/testing/test-modules.adoc @@ -43,6 +43,9 @@ Spring Boot offers several focused, feature-specific `-test` modules: |`spring-boot-graphql-test` |Testing applications that use Spring GraphQL. Provides the `@GraphQlTest` test slice. +|`spring-boot-grpc-test` +|Testing applications that use Spring gRPC. + |`spring-boot-jdbc-test` |Testing applications that using Spring JDBC. Provides the `@JdbcTest` test slice. diff --git a/gradle.properties b/gradle.properties index 11e584a9b01..32a7df567a2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -24,5 +24,6 @@ snakeYamlVersion=2.6 springFrameworkVersion=7.0.6 springFramework60xVersion=6.0.23 tomcatVersion=11.0.18 +protobufGradlePluginVersion=0.9.6 kotlin.stdlib.default.dependency=false diff --git a/integration-test/spring-boot-actuator-integration-tests/src/test/java/org/springframework/boot/actuate/health/TestHealthEndpointGroup.java b/integration-test/spring-boot-actuator-integration-tests/src/test/java/org/springframework/boot/actuate/health/TestHealthEndpointGroup.java index d020daffcd5..f88911196b4 100644 --- a/integration-test/spring-boot-actuator-integration-tests/src/test/java/org/springframework/boot/actuate/health/TestHealthEndpointGroup.java +++ b/integration-test/spring-boot-actuator-integration-tests/src/test/java/org/springframework/boot/actuate/health/TestHealthEndpointGroup.java @@ -23,8 +23,6 @@ import org.springframework.boot.health.actuate.endpoint.AdditionalHealthEndpoint import org.springframework.boot.health.actuate.endpoint.HealthEndpointGroup; import org.springframework.boot.health.actuate.endpoint.HealthEndpointGroups; import org.springframework.boot.health.actuate.endpoint.HttpCodeStatusMapper; -import org.springframework.boot.health.actuate.endpoint.SimpleHttpCodeStatusMapper; -import org.springframework.boot.health.actuate.endpoint.SimpleStatusAggregator; import org.springframework.boot.health.actuate.endpoint.StatusAggregator; /** @@ -34,9 +32,9 @@ import org.springframework.boot.health.actuate.endpoint.StatusAggregator; */ class TestHealthEndpointGroup implements HealthEndpointGroup { - private final StatusAggregator statusAggregator = new SimpleStatusAggregator(); + private final StatusAggregator statusAggregator = StatusAggregator.getDefault(); - private final HttpCodeStatusMapper httpCodeStatusMapper = new SimpleHttpCodeStatusMapper(); + private final HttpCodeStatusMapper httpCodeStatusMapper = HttpCodeStatusMapper.getDefault(); private final Predicate memberPredicate; diff --git a/module/spring-boot-autoconfigure-classic-modules/build.gradle b/module/spring-boot-autoconfigure-classic-modules/build.gradle index 3075fd4970f..2b45c0d9ec7 100644 --- a/module/spring-boot-autoconfigure-classic-modules/build.gradle +++ b/module/spring-boot-autoconfigure-classic-modules/build.gradle @@ -97,6 +97,12 @@ dependencies { api(project(":module:spring-boot-graphql")) { transitive = false } + api(project(":module:spring-boot-grpc-client")) { + transitive = false + } + api(project(":module:spring-boot-grpc-server")) { + transitive = false + } api(project(":module:spring-boot-groovy-templates")) { transitive = false } diff --git a/module/spring-boot-grpc-client/build.gradle b/module/spring-boot-grpc-client/build.gradle new file mode 100644 index 00000000000..6a2d64dd9aa --- /dev/null +++ b/module/spring-boot-grpc-client/build.gradle @@ -0,0 +1,55 @@ +/* + * 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. + */ + +plugins { + id "java-library" + id "org.springframework.boot.auto-configuration" + id "org.springframework.boot.configuration-properties" + id "org.springframework.boot.deployed" + id "org.springframework.boot.optional-dependencies" +} + +description = "Spring Boot gRPC Client" + +dependencies { + api(project(":core:spring-boot")) + api("org.springframework.grpc:spring-grpc-core") + + optional(project(":core:spring-boot-autoconfigure")) + optional(project(":module:spring-boot-micrometer-observation")) + optional("io.grpc:grpc-grpclb") + optional("io.grpc:grpc-stub") + optional("io.grpc:grpc-netty") + optional("io.grpc:grpc-netty-shaded") + optional("io.grpc:grpc-inprocess") + optional("io.grpc:grpc-kotlin-stub") { + exclude group: "javax.annotation", module: "javax.annotation-api" + } + optional("io.grpc:grpc-xds") { + exclude group: "javax.annotation", module: "javax.annotation-api" + } + optional("io.micrometer:micrometer-core") + + testImplementation(project(":core:spring-boot-test")) + testImplementation(project(":test-support:spring-boot-test-support")) + testImplementation("org.yaml:snakeyaml") + + testRuntimeOnly("ch.qos.logback:logback-classic") +} + +tasks.named("compileTestJava") { + options.nullability.checking = "tests" +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/CompositeChannelFactoryAutoConfiguration.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/CompositeChannelFactoryAutoConfiguration.java new file mode 100644 index 00000000000..35ec955fb80 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/CompositeChannelFactoryAutoConfiguration.java @@ -0,0 +1,73 @@ +/* + * 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.grpc.client.autoconfigure; + +import io.grpc.stub.AbstractStub; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate; +import org.springframework.boot.autoconfigure.condition.NoneNestedConditions; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Primary; +import org.springframework.grpc.client.CompositeGrpcChannelFactory; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; +import org.springframework.grpc.client.GrpcChannelFactory; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for a + * {@link CompositeGrpcChannelFactory}. + * + * @author Chris Bono + * @since 4.1.0 + */ +@AutoConfiguration +@ConditionalOnClass({ AbstractStub.class, GrpcChannelBuilderCustomizer.class }) +@ConditionalOnProperty(name = "spring.grpc.client.enabled", matchIfMissing = true) +@Conditional(CompositeChannelFactoryAutoConfiguration.MultipleNonPrimaryChannelFactoriesCondition.class) +public final class CompositeChannelFactoryAutoConfiguration { + + @Bean + @Primary + CompositeGrpcChannelFactory compositeChannelFactory(ObjectProvider channelFactoriesProvider) { + return new CompositeGrpcChannelFactory(channelFactoriesProvider.orderedStream().toList()); + } + + static class MultipleNonPrimaryChannelFactoriesCondition extends NoneNestedConditions { + + MultipleNonPrimaryChannelFactoriesCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnMissingBean(GrpcChannelFactory.class) + static class NoChannelFactoryCondition { + + } + + @ConditionalOnSingleCandidate(GrpcChannelFactory.class) + static class SingleInjectableChannelFactoryCondition { + + } + + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ConditionalOnGrpcClientChannelFactoryEnabled.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ConditionalOnGrpcClientChannelFactoryEnabled.java new file mode 100644 index 00000000000..2d4dc03cc2a --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ConditionalOnGrpcClientChannelFactoryEnabled.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.grpc.client.autoconfigure; + +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.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.context.annotation.Conditional; + +/** + * {@link Conditional @Conditional} that matches when + * {@code spring.grpc.client.channelfactory.enabled} is {@code true} or missing. + * + * @author Phillip Webb + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Documented +@ConditionalOnBooleanProperty(name = "spring.grpc.client.channelfactory.enabled", matchIfMissing = true) +@interface ConditionalOnGrpcClientChannelFactoryEnabled { + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelBuilderCustomizers.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelBuilderCustomizers.java new file mode 100644 index 00000000000..f09f34f8abb --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelBuilderCustomizers.java @@ -0,0 +1,113 @@ +/* + * 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.grpc.client.autoconfigure; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; + +import io.grpc.CompressorRegistry; +import io.grpc.DecompressorRegistry; +import io.grpc.ManagedChannelBuilder; +import org.jspecify.annotations.Nullable; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.util.LambdaSafe; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; + +/** + * Invokes the customizations to a {@link ManagedChannelBuilder} based on the provided + * beans. + * + * @author Chris Bono + * @author Phillip Webb + */ +@Order(Ordered.LOWEST_PRECEDENCE) +class GrpcChannelBuilderCustomizers { + + private final List> customizers; + + GrpcChannelBuilderCustomizers(GrpcClientProperties grpcClientProperties, + ObjectProvider compressorRegistry, + ObjectProvider decompressorRegistry, + ObjectProvider> customizers, + ObjectProvider defaultServiceConfigCustomizers) { + this(grpcClientProperties, compressorRegistry.getIfAvailable(), decompressorRegistry.getIfAvailable(), + customizers.orderedStream().toList(), defaultServiceConfigCustomizers.orderedStream().toList()); + } + + GrpcChannelBuilderCustomizers(List> customizers) { + this(null, null, null, customizers, Collections.emptyList()); + } + + GrpcChannelBuilderCustomizers(@Nullable GrpcClientProperties grpcClientProperties, + @Nullable CompressorRegistry compressorRegistry, @Nullable DecompressorRegistry decompressorRegistry, + List> customizers, + List defaultServiceConfigCustomizers) { + List> all = new ArrayList<>(); + addCustomizer(all, compressorRegistry, ManagedChannelBuilder::compressorRegistry); + addCustomizer(all, decompressorRegistry, ManagedChannelBuilder::decompressorRegistry); + if (grpcClientProperties != null) { + all.add(new PropertiesGrpcChannelBuilderCustomizer<>(grpcClientProperties)); + } + all.addAll(customizers); + all.add(customizeDefaultServiceConfig(grpcClientProperties, defaultServiceConfigCustomizers)); + this.customizers = List.copyOf(all); + } + + private static , T> void addCustomizer( + List> customizers, @Nullable T bean, BiConsumer action) { + if (bean != null) { + GrpcChannelBuilderCustomizer customizer = (target, builder) -> action.accept(builder, bean); + customizers.add(customizer); + } + } + + private > GrpcChannelBuilderCustomizer customizeDefaultServiceConfig( + @Nullable GrpcClientProperties properties, + List customizers) { + PropertiesGrpcClientDefaultServiceConfigCustomizer propertiesCustomizer = (properties != null) + ? new PropertiesGrpcClientDefaultServiceConfigCustomizer(properties) : null; + return (target, builder) -> { + Map defaultServiceConfig = new LinkedHashMap<>(); + if (propertiesCustomizer != null) { + propertiesCustomizer.customize(target, defaultServiceConfig); + } + customizers.forEach((customizer) -> customizer.customize(target, defaultServiceConfig)); + if (!defaultServiceConfig.isEmpty()) { + builder.defaultServiceConfig(defaultServiceConfig); + } + }; + } + + > List> forFactory() { + return List.of(this::apply); + } + + @SuppressWarnings("unchecked") + > void apply(String target, T builder) { + LambdaSafe.callbacks(GrpcChannelBuilderCustomizer.class, this.customizers, builder) + .withLogger(GrpcChannelBuilderCustomizers.class) + .invoke((customizer) -> customizer.customize(target, builder)); + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelFactoryCustomizer.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelFactoryCustomizer.java new file mode 100644 index 00000000000..321097d3312 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelFactoryCustomizer.java @@ -0,0 +1,38 @@ +/* + * 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.grpc.client.autoconfigure; + +import org.springframework.grpc.client.GrpcChannelFactory; + +/** + * Callback interface that can be implemented by beans wishing to customize the + * {@link GrpcChannelFactory} before it is fully initialized, in particular to tune its + * configuration. + * + * @author Chris Bono + * @since 4.1.0 + */ +@FunctionalInterface +public interface GrpcChannelFactoryCustomizer { + + /** + * Customize the given {@link GrpcChannelFactory}. + * @param factory the factory to customize + */ + void customize(GrpcChannelFactory factory); + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfiguration.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfiguration.java new file mode 100644 index 00000000000..e16476d8f23 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfiguration.java @@ -0,0 +1,98 @@ +/* + * 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.grpc.client.autoconfigure; + +import io.grpc.CompressorRegistry; +import io.grpc.DecompressorRegistry; +import io.grpc.ManagedChannelBuilder; +import io.grpc.stub.AbstractStub; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.grpc.client.ChannelCredentialsProvider; +import org.springframework.grpc.client.ClientInterceptorsConfigurer; +import org.springframework.grpc.client.CoroutineStubFactory; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for gRPC clients. + * + * @author Dave Syer + * @author Chris Bono + * @author Phillip Webb + * @since 4.1.0 + */ +@AutoConfiguration(before = CompositeChannelFactoryAutoConfiguration.class) +@ConditionalOnClass({ AbstractStub.class, GrpcChannelBuilderCustomizer.class }) +@ConditionalOnProperty(name = "spring.grpc.client.enabled", matchIfMissing = true) +@EnableConfigurationProperties(GrpcClientProperties.class) +@Import({ GrpcClientCodecConfiguration.class, ShadedNettyGrpcClientConfiguration.class, + NettyGrpcClientConfiguration.class, InProcessGrpcClientConfiguration.class }) +public final class GrpcClientAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + ClientInterceptorsConfigurer grpcClientInterceptorsConfigurer(ApplicationContext applicationContext) { + return new ClientInterceptorsConfigurer(applicationContext); + } + + @Bean + @ConditionalOnMissingBean(ChannelCredentialsProvider.class) + PropertiesChannelCredentialsProvider grpcChannelCredentialsProvider(SslBundles bundles, + GrpcClientProperties properties) { + return new PropertiesChannelCredentialsProvider(properties, bundles); + } + + @Bean + > PropertiesGrpcChannelBuilderCustomizer grpcClientPropertiesChannelCustomizer( + GrpcClientProperties properties) { + return new PropertiesGrpcChannelBuilderCustomizer<>(properties); + } + + @Bean + GrpcChannelBuilderCustomizers grpcDefaultServicesChannelBuilderCustomizer(GrpcClientProperties grpcClientProperties, + ObjectProvider compressorRegistry, + ObjectProvider decompressorRegistry, + ObjectProvider> customizers, + ObjectProvider defaultServiceConfigCustomizers) { + return new GrpcChannelBuilderCustomizers(grpcClientProperties, compressorRegistry, decompressorRegistry, + customizers, defaultServiceConfigCustomizers); + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(name = "io.grpc.kotlin.AbstractCoroutineStub") + static class GrpcClientCoroutineStubConfiguration { + + @Bean + @ConditionalOnMissingBean + CoroutineStubFactory coroutineStubFactory() { + return new CoroutineStubFactory(); + } + + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientCodecConfiguration.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientCodecConfiguration.java new file mode 100644 index 00000000000..0ed44cb87fb --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientCodecConfiguration.java @@ -0,0 +1,83 @@ +/* + * 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.grpc.client.autoconfigure; + +import java.util.List; + +import io.grpc.Codec; +import io.grpc.Compressor; +import io.grpc.CompressorRegistry; +import io.grpc.Decompressor; +import io.grpc.DecompressorRegistry; +import io.grpc.ServerBuilder; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * The configuration that contains all codec related beans for clients. + * + * @author Andrei Lisa + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(Codec.class) +class GrpcClientCodecConfiguration { + + /** + * The compressor registry that is set on the + * {@link ServerBuilder#compressorRegistry(CompressorRegistry) server builder} . + * @param compressors the compressors to use on the registry + * @return a new {@link CompressorRegistry#newEmptyInstance() registry} with the + * specified compressors or the {@link CompressorRegistry#getDefaultInstance() default + * registry} if no custom compressors are available in the application context. + */ + @Bean + @ConditionalOnMissingBean + CompressorRegistry grpcCompressorRegistry(List compressors) { + if (compressors.isEmpty()) { + return CompressorRegistry.getDefaultInstance(); + } + CompressorRegistry registry = CompressorRegistry.newEmptyInstance(); + compressors.forEach(registry::register); + return registry; + } + + /** + * The decompressor registry that is set on the + * {@link ServerBuilder#decompressorRegistry(DecompressorRegistry) server builder}. + * @param decompressors the decompressors to use on the registry + * @return a new {@link DecompressorRegistry#emptyInstance() registry} with the + * specified decompressors or the {@link DecompressorRegistry#getDefaultInstance() + * default registry} if no custom decompressors are available in the application + * context. + */ + @Bean + @ConditionalOnMissingBean + DecompressorRegistry grpcDecompressorRegistry(List decompressors) { + if (decompressors.isEmpty()) { + return DecompressorRegistry.getDefaultInstance(); + } + DecompressorRegistry registry = DecompressorRegistry.emptyInstance(); + for (Decompressor decompressor : decompressors) { + registry = registry.with(decompressor, false); + } + return registry; + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientDefaultServiceConfigCustomizer.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientDefaultServiceConfigCustomizer.java new file mode 100644 index 00000000000..2b39c51f24b --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientDefaultServiceConfigCustomizer.java @@ -0,0 +1,49 @@ +/* + * 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.grpc.client.autoconfigure; + +import java.util.Map; + +import io.grpc.ManagedChannelBuilder; + +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; +import org.springframework.grpc.client.VirtualTargets; + +/** + * Callback interface that can be used to customize the default service config of the + * {@link GrpcChannelBuilderCustomizer}. + *

+ * This customizer should be used instead of calling + * {@link ManagedChannelBuilder#defaultServiceConfig(Map)} from a + * {@link GrpcChannelBuilderCustomizer} since it allows multiple customizers to update the + * same default service config, rather than having a "last wins" outcome. + * + * @author Phillip Webb + * @since 4.1.0 + * @see GrpcChannelBuilderCustomizer + */ +@FunctionalInterface +public interface GrpcClientDefaultServiceConfigCustomizer { + + /** + * Customize the given default service config. + * @param target the target (which may be a {@link VirtualTargets virtual target}). + * @param defaultServiceConfig the default service config to customize + */ + void customize(String target, Map defaultServiceConfig); + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientObservationAutoConfiguration.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientObservationAutoConfiguration.java new file mode 100644 index 00000000000..394b7aabe31 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientObservationAutoConfiguration.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.grpc.client.autoconfigure; + +import io.grpc.stub.AbstractStub; +import io.micrometer.core.instrument.binder.grpc.GrpcClientObservationConvention; +import io.micrometer.core.instrument.binder.grpc.ObservationGrpcClientInterceptor; +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.grpc.client.GlobalClientInterceptor; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for gRPC observation support. + * + * @author Chris Bono + * @author Phillip Webb + * @since 4.1.0 + */ + +@AutoConfiguration( + afterName = "org.springframework.boot.micrometer.observation.autoconfigure.ObservationAutoConfiguration") +@ConditionalOnClass({ AbstractStub.class, GrpcChannelBuilderCustomizer.class, ObservationRegistry.class, + ObservationGrpcClientInterceptor.class }) +@ConditionalOnProperty(name = "spring.grpc.client.enabled", matchIfMissing = true) +@ConditionalOnProperty(name = "spring.grpc.client.observation.enabled", havingValue = "true", matchIfMissing = true) +@ConditionalOnBean(ObservationRegistry.class) +public final class GrpcClientObservationAutoConfiguration { + + @Bean + @GlobalClientInterceptor + @ConditionalOnMissingBean + ObservationGrpcClientInterceptor grpcClientObservationInterceptor(ObservationRegistry observationRegistry, + ObjectProvider GrpcClientObservationConvention) { + ObservationGrpcClientInterceptor interceptor = new ObservationGrpcClientInterceptor(observationRegistry); + GrpcClientObservationConvention.ifAvailable(interceptor::setCustomConvention); + return interceptor; + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientProperties.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientProperties.java new file mode 100644 index 00000000000..e57ecece11b --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientProperties.java @@ -0,0 +1,382 @@ +/* + * 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.grpc.client.autoconfigure; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.Name; +import org.springframework.boot.convert.DurationUnit; +import org.springframework.util.unit.DataSize; + +/** + * Configuration properties for gRPC clients. + * + * @author Chris Bono + * @author Phillip Webb + * @since 4.1.0 + */ +@ConfigurationProperties("spring.grpc.client") +public class GrpcClientProperties { + + /** + * Map of channel configured by name. + */ + private final Map channel = new LinkedHashMap<>(); + + public Map getChannel() { + return this.channel; + } + + /** + * Channel Properties. + */ + public static class Channel { + + static final String DEFAULT_TARGET = "static://localhost:9090"; + + /** + * The channel target address. + */ + private String target = DEFAULT_TARGET; + + /** + * The custom User-Agent for the channel. + */ + private @Nullable String userAgent; + + /** + * Bypass certificate validation for easier testing (so the remote certificate + * could be anonymous). Should not be set in production. + */ + private boolean bypassCertificateValidation; + + /** + * Service config for the channel. + */ + private @Nullable ServiceConfig serviceConfig; + + private final Inbound inbound = new Inbound(); + + @Name("default") + private final Default defaultProperties = new Default(); + + private final Idle idle = new Idle(); + + private final Keepalive keepalive = new Keepalive(); + + private final Ssl ssl = new Ssl(); + + private final Health health = new Health(); + + public String getTarget() { + return this.target; + } + + public void setTarget(String target) { + this.target = target; + } + + public @Nullable String getUserAgent() { + return this.userAgent; + } + + public void setUserAgent(@Nullable String userAgent) { + this.userAgent = userAgent; + } + + public boolean isBypassCertificateValidation() { + return this.bypassCertificateValidation; + } + + public void setBypassCertificateValidation(boolean bypassCertificateValidation) { + this.bypassCertificateValidation = bypassCertificateValidation; + } + + public @Nullable ServiceConfig getServiceConfig() { + return this.serviceConfig; + } + + public void setServiceConfig(@Nullable ServiceConfig serviceConfig) { + this.serviceConfig = serviceConfig; + } + + public Inbound getInbound() { + return this.inbound; + } + + public Default getDefault() { + return this.defaultProperties; + } + + public Idle getIdle() { + return this.idle; + } + + public Keepalive getKeepalive() { + return this.keepalive; + } + + public Ssl getSsl() { + return this.ssl; + } + + public Health getHealth() { + return this.health; + } + + /** + * In-bound properties. + */ + public static class Inbound { + + private final Message message = new Message(); + + private final Metadata metadata = new Metadata(); + + public Message getMessage() { + return this.message; + } + + public Metadata getMetadata() { + return this.metadata; + } + + /** + * In-bound message properties. + */ + public static class Message { + + /** + * Maximum message size allowed to be received by the channel. Set to '-1' + * to use the highest possible limit (not recommended). + */ + private DataSize maxSize = DataSize.ofBytes(4194304); + + public DataSize getMaxSize() { + return this.maxSize; + } + + public void setMaxSize(DataSize maxSize) { + this.maxSize = maxSize; + } + + } + + /** + * In-bound metadata properties. + */ + public static class Metadata { + + /** + * Maximum metadata size allowed to be received by the channel. Set to + * '-1' to use the highest possible limit (not recommended). + */ + private DataSize maxSize = DataSize.ofBytes(8192); + + public DataSize getMaxSize() { + return this.maxSize; + } + + public void setMaxSize(DataSize maxSize) { + this.maxSize = maxSize; + } + + } + + } + + /** + * Properties for client defaults. + */ + public static class Default { + + /** + * The default deadline for RPCs performed on this channel. + */ + private @Nullable Duration deadline; + + /** + * The load balancing policy the channel should use. + */ + private String loadBalancingPolicy = "round_robin"; + + public @Nullable Duration getDeadline() { + return this.deadline; + } + + public void setDeadline(@Nullable Duration deadline) { + this.deadline = deadline; + } + + public String getLoadBalancingPolicy() { + return this.loadBalancingPolicy; + } + + public void setLoadBalancingPolicy(String loadBalancingPolicy) { + this.loadBalancingPolicy = loadBalancingPolicy; + } + + } + + /** + * Idle properties. + */ + public static class Idle { + + /** + * The duration without ongoing RPCs before going to idle mode. + */ + @DurationUnit(ChronoUnit.SECONDS) + private Duration timeout = Duration.ofSeconds(20); + + public Duration getTimeout() { + return this.timeout; + } + + public void setTimeout(Duration timeout) { + this.timeout = timeout; + } + + } + + /** + * Keep-alive properties. + */ + public static class Keepalive { + + /** + * The delay before sending a keepAlive. Note that shorter intervals increase + * the network burden for the server and this value can not be lower than + * 'permitKeepAliveTime' on the server. + */ + @DurationUnit(ChronoUnit.SECONDS) + private Duration time = Duration.ofMinutes(5); + + /** + * The default timeout for a keepAlives ping request. + */ + @DurationUnit(ChronoUnit.SECONDS) + private Duration timeout = Duration.ofSeconds(20); + + /** + * Whether a keepAlive will be performed when there are no outstanding RPC on + * a connection. + */ + private boolean withoutCalls; + + public Duration getTime() { + return this.time; + } + + public void setTime(Duration time) { + this.time = time; + } + + public Duration getTimeout() { + return this.timeout; + } + + public void setTimeout(Duration timeout) { + this.timeout = timeout; + } + + public boolean isWithoutCalls() { + return this.withoutCalls; + } + + public void setWithoutCalls(boolean withoutCalls) { + this.withoutCalls = withoutCalls; + } + + } + + /** + * Health properties. + */ + public static class Health { + + /** + * Whether to enable client-side health check for the channel. + */ + private boolean enabled; + + /** + * Name of the service to check health on. + */ + private @Nullable String serviceName; + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public @Nullable String getServiceName() { + return this.serviceName; + } + + public void setServiceName(String serviceName) { + this.serviceName = serviceName; + } + + } + + /** + * SSL properties. + */ + public static class Ssl { + + /** + * Whether to enable SSL support. Enabled automatically if "bundle" is + * provided unless specified otherwise. + */ + private @Nullable Boolean enabled; + + /** + * SSL bundle name. + */ + private @Nullable String bundle; + + public @Nullable Boolean getEnabled() { + return this.enabled; + } + + public void setEnabled(@Nullable Boolean enabled) { + this.enabled = enabled; + } + + public @Nullable String getBundle() { + return this.bundle; + } + + public void setBundle(@Nullable String bundle) { + this.bundle = bundle; + } + + } + + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/InProcessGrpcClientConfiguration.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/InProcessGrpcClientConfiguration.java new file mode 100644 index 00000000000..6227c48e267 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/InProcessGrpcClientConfiguration.java @@ -0,0 +1,56 @@ +/* + * 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.grpc.client.autoconfigure; + +import io.grpc.inprocess.InProcessChannelBuilder; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.client.ClientInterceptorFilter; +import org.springframework.grpc.client.ClientInterceptorsConfigurer; +import org.springframework.grpc.client.InProcessGrpcChannelFactory; + +/** + * {@link Configuration @Configuration} for an in-process gRPC client. + * + * @author Chris Bono + * @author Phillip Webb + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(InProcessChannelBuilder.class) +@ConditionalOnMissingBean(InProcessGrpcChannelFactory.class) +@ConditionalOnGrpcClientChannelFactoryEnabled +@ConditionalOnProperty(name = "spring.grpc.client.inprocess.enabled", havingValue = "true", matchIfMissing = true) +class InProcessGrpcClientConfiguration { + + @Bean + InProcessGrpcChannelFactory inProcessGrpcChannelFactory(GrpcChannelBuilderCustomizers grpcChannelBuilderCustomizers, + ClientInterceptorsConfigurer interceptorsConfigurer, + ObjectProvider interceptorFilter, + ObjectProvider channelFactoryCustomizers) { + InProcessGrpcChannelFactory factory = new InProcessGrpcChannelFactory( + grpcChannelBuilderCustomizers.forFactory(), interceptorsConfigurer); + interceptorFilter.ifAvailable(factory::setInterceptorFilter); + channelFactoryCustomizers.orderedStream().forEach((customizer) -> customizer.customize(factory)); + return factory; + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/NettyGrpcClientConfiguration.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/NettyGrpcClientConfiguration.java new file mode 100644 index 00000000000..f97641a398f --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/NettyGrpcClientConfiguration.java @@ -0,0 +1,60 @@ +/* + * 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.grpc.client.autoconfigure; + +import io.grpc.Channel; +import io.grpc.netty.NettyChannelBuilder; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.grpc.client.ChannelCredentialsProvider; +import org.springframework.grpc.client.ClientInterceptorsConfigurer; +import org.springframework.grpc.client.GrpcChannelFactory; +import org.springframework.grpc.client.InProcessGrpcChannelFactory; +import org.springframework.grpc.client.NettyGrpcChannelFactory; + +/** + * {@link Configuration @Configuration} for a Netty gRPC client. + * + * @author Chris Bono + * @author Phillip Webb + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass({ Channel.class, NettyChannelBuilder.class }) +@ConditionalOnMissingBean(value = GrpcChannelFactory.class, ignored = InProcessGrpcChannelFactory.class) +@ConditionalOnGrpcClientChannelFactoryEnabled +class NettyGrpcClientConfiguration { + + @Bean + NettyGrpcChannelFactory nettyGrpcChannelFactory(Environment environment, GrpcClientProperties properties, + GrpcChannelBuilderCustomizers grpcChannelBuilderCustomizers, + ClientInterceptorsConfigurer interceptorsConfigurer, + ObjectProvider channelFactoryCustomizers, + ChannelCredentialsProvider credentials) { + NettyGrpcChannelFactory factory = new NettyGrpcChannelFactory(grpcChannelBuilderCustomizers.forFactory(), + interceptorsConfigurer); + factory.setCredentialsProvider(credentials); + factory.setVirtualTargets(new PropertiesVirtualTargets(environment, properties)); + channelFactoryCustomizers.orderedStream().forEach((customizer) -> customizer.customize(factory)); + return factory; + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesChannelCredentialsProvider.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesChannelCredentialsProvider.java new file mode 100644 index 00000000000..1ad894b40bf --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesChannelCredentialsProvider.java @@ -0,0 +1,65 @@ +/* + * 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.grpc.client.autoconfigure; + +import io.grpc.ChannelCredentials; +import io.grpc.InsecureChannelCredentials; +import io.grpc.TlsChannelCredentials; +import io.grpc.TlsChannelCredentials.Builder; + +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel; +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel.Ssl; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.grpc.client.ChannelCredentialsProvider; +import org.springframework.grpc.internal.InsecureTrustManagerFactory; + +/** + * {@link ChannelCredentialsProvider} backed by {@link GrpcClientProperties}. + * + * @param properties the client properties + * @param bundles the SSL bundles + * @author David Syer + * @author Phillip Webb + */ +record PropertiesChannelCredentialsProvider(GrpcClientProperties properties, + SslBundles bundles) implements ChannelCredentialsProvider { + + @Override + public ChannelCredentials getChannelCredentials(String target) { + Channel channel = this.properties.getChannel().get(target); + channel = (channel != null) ? channel : this.properties.getChannel().get("default"); + if (channel == null || isInsecure(channel.getSsl())) { + return InsecureChannelCredentials.create(); + } + Builder builder = TlsChannelCredentials.newBuilder(); + if (channel.getSsl().getBundle() != null) { + SslBundle bundle = this.bundles.getBundle(channel.getSsl().getBundle()); + builder.trustManager(bundle.getManagers().getTrustManagerFactory().getTrustManagers()); + builder.keyManager(bundle.getManagers().getKeyManagerFactory().getKeyManagers()); + } + if (channel.isBypassCertificateValidation()) { + builder.trustManager(InsecureTrustManagerFactory.INSTANCE.getTrustManagers()); + } + return builder.build(); + } + + private boolean isInsecure(Ssl ssl) { + return Boolean.FALSE.equals(ssl.getEnabled()) || (ssl.getBundle() == null && ssl.getEnabled() == null); + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcChannelBuilderCustomizer.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcChannelBuilderCustomizer.java new file mode 100644 index 00000000000..3f3ff314eca --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcChannelBuilderCustomizer.java @@ -0,0 +1,94 @@ +/* + * 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.grpc.client.autoconfigure; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import io.grpc.ManagedChannelBuilder; + +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; +import org.springframework.grpc.client.interceptor.DefaultDeadlineSetupClientInterceptor; +import org.springframework.util.Assert; +import org.springframework.util.unit.DataSize; + +/** + * {@link GrpcChannelBuilderCustomizer} that maps {@link GrpcClientProperties} to a + * {@link ManagedChannelBuilder}. + * + * @param the type of the builder + * @param properties the properties to map + * @author David Syer + * @author Chris Bono + * @author Phillip Webb + */ +record PropertiesGrpcChannelBuilderCustomizer>( + GrpcClientProperties properties) implements GrpcChannelBuilderCustomizer { + + private static final Channel STOCK_DEFAULT_CHANNEL = new Channel(); + + @Override + public void customize(String target, T builder) { + Channel channel = getChannel(target); + PropertyMapper map = PropertyMapper.get(); + map.from(channel::getUserAgent).to(builder::userAgent); + map.from(channel.getInbound().getMessage()::getMaxSize).asInt(this::maxSize).to(builder::maxInboundMessageSize); + map.from(channel.getInbound().getMetadata()::getMaxSize) + .asInt(this::maxSize) + .to(builder::maxInboundMetadataSize); + map.from(channel.getDefault()::getDeadline) + .when((deadline) -> deadline.toMillis() > 0L) + .as(DefaultDeadlineSetupClientInterceptor::new) + .to(builder::intercept); + map.from(channel.getDefault()::getLoadBalancingPolicy) + .when((policy) -> supportsLoadBalancing(target, channel)) + .to(builder::defaultLoadBalancingPolicy); + map.from(channel.getIdle()::getTimeout).to(durationProperty(builder::idleTimeout)); + map.from(channel.getKeepalive()::getTime).to(durationProperty(builder::keepAliveTime)); + map.from(channel.getKeepalive()::getTimeout).to(durationProperty(builder::keepAliveTimeout)); + map.from(channel.getKeepalive()::isWithoutCalls).to(builder::keepAliveWithoutCalls); + } + + private Channel getChannel(String target) { + Channel channel = this.properties.getChannel().get(target); + channel = (channel != null) ? channel : this.properties.getChannel().get("default"); + return (channel != null) ? channel : STOCK_DEFAULT_CHANNEL; + } + + private boolean supportsLoadBalancing(String target, Channel channel) { + return !(isUnixOrInProcessTarget(target) || isUnixOrInProcessTarget(channel.getTarget())); + } + + private boolean isUnixOrInProcessTarget(String target) { + return target.startsWith("unix:") || target.startsWith("in-process:"); + } + + Consumer durationProperty(BiConsumer setter) { + return (duration) -> setter.accept(duration.toNanos(), TimeUnit.NANOSECONDS); + } + + private int maxSize(DataSize maxSize) { + long bytes = maxSize.toBytes(); + Assert.state(bytes >= 0 || bytes == -1, () -> "Unsupported max size value " + maxSize); + return (bytes >= 0 && bytes <= Integer.MAX_VALUE) ? (int) bytes : Integer.MAX_VALUE; + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcClientDefaultServiceConfigCustomizer.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcClientDefaultServiceConfigCustomizer.java new file mode 100644 index 00000000000..c33f9f5782e --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcClientDefaultServiceConfigCustomizer.java @@ -0,0 +1,77 @@ +/* + * 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.grpc.client.autoconfigure; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel; +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel.Health; +import org.springframework.util.Assert; + +/** + * {@link GrpcClientDefaultServiceConfigCustomizer} to apply {@link GrpcClientProperties}. + * + * @author Chris Bono + * @author Phillip Webb + * @param properties the client properties + */ +record PropertiesGrpcClientDefaultServiceConfigCustomizer( + GrpcClientProperties properties) implements GrpcClientDefaultServiceConfigCustomizer { + + @Override + public void customize(String target, Map defaultServiceConfig) { + Channel channel = this.properties.getChannel().get(target); + channel = (channel != null) ? channel : this.properties.getChannel().get("default"); + if (channel == null) { + return; + } + applyServiceConfig(channel.getServiceConfig(), defaultServiceConfig); + applyHealth(channel.getHealth(), defaultServiceConfig); + } + + private void applyServiceConfig(@Nullable ServiceConfig serviceConfig, Map defaultServiceConfig) { + if (serviceConfig != null) { + serviceConfig.applyTo(defaultServiceConfig); + } + } + + private void applyHealth(Health health, Map defaultServiceConfig) { + if (!health.isEnabled()) { + return; + } + String serviceName = (health.getServiceName() != null) ? health.getServiceName() : ""; + Map healthCheckConfig = cloneOrCreateHealthCheckConfig(defaultServiceConfig); + String existingServiceName = (String) healthCheckConfig.get(ServiceConfig.HEALTH_CHECK_SERVICE_NAME_KEY); + Assert.state(existingServiceName == null || serviceName.equals(existingServiceName), + () -> "Unable to change health check config service name from '%s' to '%s'" + .formatted(existingServiceName, serviceName)); + healthCheckConfig.put(ServiceConfig.HEALTH_CHECK_SERVICE_NAME_KEY, serviceName); + defaultServiceConfig.put(ServiceConfig.HEALTH_CHECK_CONFIG_KEY, healthCheckConfig); + } + + @SuppressWarnings("unchecked") + private Map cloneOrCreateHealthCheckConfig(Map defaultServiceConfig) { + Map healthCheckConfig = (Map) defaultServiceConfig + .get(ServiceConfig.HEALTH_CHECK_CONFIG_KEY); + return new LinkedHashMap<>((healthCheckConfig != null) ? healthCheckConfig : Collections.emptyMap()); + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesVirtualTargets.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesVirtualTargets.java new file mode 100644 index 00000000000..458925dab47 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesVirtualTargets.java @@ -0,0 +1,59 @@ +/* + * 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.grpc.client.autoconfigure; + +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel; +import org.springframework.core.env.PropertyResolver; +import org.springframework.grpc.client.VirtualTargets; + +/** + * {@link VirtualTargets} supporting named channels from {@link GrpcClientProperties} and + * directly specified targets (which may include property placeholders). + * + * @param propertyResolver the property resolver + * @param properties the client properties + * @author Chris Bono + * @author Phillip Webb + */ +record PropertiesVirtualTargets(PropertyResolver propertyResolver, + GrpcClientProperties properties) implements VirtualTargets { + + @Override + public String getTarget(String target) { + Channel channel = this.properties.getChannel().get(target); + if (channel != null) { + return clean(this.propertyResolver.resolvePlaceholders(channel.getTarget())); + } + if ("default".equals(target)) { + return clean(Channel.DEFAULT_TARGET); + } + target = this.propertyResolver.resolvePlaceholders(target); + if (target.contains(":/") || target.startsWith("unix:")) { + return clean(target); + } + return target; + } + + private String clean(String target) { + if (target.startsWith("static:") || target.startsWith("tcp:")) { + String withoutScheme = target.substring(target.indexOf(":") + 1); + return withoutScheme.replaceFirst("/*", ""); + } + return target; + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ServiceConfig.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ServiceConfig.java new file mode 100644 index 00000000000..1ddd11a3a62 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ServiceConfig.java @@ -0,0 +1,430 @@ +/* + * 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.grpc.client.autoconfigure; + +import java.time.Duration; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Function; + +import io.grpc.Status; +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.boot.context.properties.PropertyMapper.Source.Adapter; +import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException; +import org.springframework.boot.context.properties.source.MutuallyExclusiveConfigurationPropertiesException; +import org.springframework.util.CollectionUtils; +import org.springframework.util.unit.DataSize; + +/** + * Bindable service configuration for gRPC channel. Allows type safe binding of common + * service configuration options which can ultimately be applied to the {@link Map} + * provided by a {@link GrpcClientDefaultServiceConfigCustomizer}. + *

+ * The configuration provided here is a subset of the canonical service_config.proto + * protocol definition. For advanced or experimental service configurations, use the + * {@link GrpcClientDefaultServiceConfigCustomizer} to directly add any entries supported + * by {@code grpc-java}. + * + * @author Phillip Webb + * @param loadbalancing load balancing configurations in the order that they should be + * applied + * @param method method configuration + * @param retrythrottling retry throttling policy + * @param healthcheck health check configuration + * @since 4.1.0 + * @see GrpcClientDefaultServiceConfigCustomizer + * @see io.grpc.internal.ServiceConfigUtil + */ +public record ServiceConfig(@Nullable List loadbalancing, @Nullable List method, + @Nullable RetryThrottlingPolicy retrythrottling, @Nullable HealthCheckConfig healthcheck) { + + static final String HEALTH_CHECK_CONFIG_KEY = "healthCheckConfig"; + + static final String HEALTH_CHECK_SERVICE_NAME_KEY = "serviceName"; + + /** + * Apply this service config to the given gRPC Java config Map. + * @param grpcJavaConfig the gRPC Java config map + */ + public void applyTo(Map grpcJavaConfig) { + applyTo(new GrpcJavaConfig(grpcJavaConfig)); + } + + private void applyTo(GrpcJavaConfig config) { + PropertyMapper map = PropertyMapper.get(); + map.from(this::loadbalancing) + .as(listOf(LoadBalancingConfig::grpcJavaConfig)) + .to(config.in("loadBalancingConfig")); + map.from(this::method).as(listOf(MethodConfig::grpcJavaConfig)).to(config.in("methodConfig")); + map.from(this::retrythrottling).as(RetryThrottlingPolicy::grpcJavaConfig).to(config.in("retryThrottling")); + map.from(this::healthcheck).as(HealthCheckConfig::grpcJavaConfig).to(config.in(HEALTH_CHECK_CONFIG_KEY)); + } + + static Adapter, @Nullable List>> listOf(Function> adapter) { + return (list) -> (!CollectionUtils.isEmpty(list)) ? list.stream().map(adapter).toList() : null; + } + + static String durationString(Duration duration) { + return duration.getSeconds() + "." + duration.getNano() + "s"; + } + + static String bytesString(DataSize dataSize) { + return Long.toString(dataSize.toBytes()); + } + + /** + * Load balancing config. + * + * @param pickfirst 'pick first' load balancing + * @param roundrobin 'round robin' load balancing + * @param weightedroundrobin 'weighted round robin' load balancing + * @param grpc 'grpc' load balancing + */ + public record LoadBalancingConfig(@Nullable PickFirstLoadBalancingConfig pickfirst, + @Nullable RoundRobinLoadBalancingConfig roundrobin, + @Nullable WeightedRoundRobinLoadBalancingConfig weightedroundrobin, + @Nullable GrpcLoadBalancingConfig grpc) { + + public LoadBalancingConfig { + if (pickfirst == null && roundrobin == null && weightedroundrobin == null && grpc == null) { + throw new InvalidConfigurationPropertyValueException("loadbalancing", null, + "Missing load balancing strategy"); + } + MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> { + entries.put("loadbalancing.pickfirst", pickfirst); + entries.put("loadbalancing.roundrobin", roundrobin); + entries.put("loadbalancing.weightedroundrobin", weightedroundrobin); + entries.put("loadbalancing.grpc", grpc); + }); + } + + Map grpcJavaConfig() { + LinkedHashMap grpcJavaConfig = new LinkedHashMap<>(); + PropertyMapper map = PropertyMapper.get(); + map.from(this::pickfirst) + .as(PickFirstLoadBalancingConfig::grpcJavaConfig) + .to((loadBalancingConfig) -> grpcJavaConfig.put("pick_first", loadBalancingConfig)); + map.from(this::roundrobin) + .as(RoundRobinLoadBalancingConfig::grpcJavaConfig) + .to((loadBalancingConfig) -> grpcJavaConfig.put("round_robin", loadBalancingConfig)); + map.from(this::weightedroundrobin) + .as(WeightedRoundRobinLoadBalancingConfig::grpcJavaConfig) + .to((loadBalancingConfig) -> grpcJavaConfig.put("weighted_round_robin", loadBalancingConfig)); + map.from(this::grpc) + .as(GrpcLoadBalancingConfig::grpcJavaConfig) + .to((loadBalancingConfig) -> grpcJavaConfig.put("grpclb", loadBalancingConfig)); + return grpcJavaConfig; + } + + /** + * 'pick first' load balancing. + * + * @param shuffleAddressList randomly shuffle the list of addresses received from + * the name resolver before attempting to connect to them. + */ + public record PickFirstLoadBalancingConfig(Boolean shuffleAddressList) { + + Map grpcJavaConfig() { + // Aligned with PickFirstLoadBalancerProvider + GrpcJavaConfig grpcJavaConfig = new GrpcJavaConfig(); + PropertyMapper map = PropertyMapper.get(); + map.from(this::shuffleAddressList).to(grpcJavaConfig.in("shuffleAddressList")); + return grpcJavaConfig.asMap(); + } + + } + + /** + * 'round robin' load balancing. + */ + public record RoundRobinLoadBalancingConfig() { + + /** + * Return the gRPC java config as supported by the + * {@code SecretRoundRobinLoadBalancerProvider}. + * @return the config + */ + Map grpcJavaConfig() { + return Collections.emptyMap(); + } + + } + + /** + * 'weighted round robin' load balancing. + * + * @param blackoutPeriod must report load metrics continuously for at least this + * long before the endpoint weight will be used + * @param weightExpirationPeriod if has not reported load metrics in this long, + * then we stop using the reported weight + * @param outOfBandReportingPeriod load reporting interval to request from the + * server + * @param enableOutOfBandLoadReport whether to enable out-of-band utilization + * reporting collection from the endpoints + * @param weightUpdatePeriod how often endpoint weights are recalculated + * @param errorUtilizationPenalty multiplier used to adjust endpoint weights with + * the error rate calculated as eps/qps + */ + public record WeightedRoundRobinLoadBalancingConfig(Duration blackoutPeriod, Duration weightExpirationPeriod, + Duration outOfBandReportingPeriod, Boolean enableOutOfBandLoadReport, Duration weightUpdatePeriod, + Float errorUtilizationPenalty) { + + Map grpcJavaConfig() { + // Aligned with WeightedRoundRobinLoadBalancerProvider + GrpcJavaConfig grpcJavaConfig = new GrpcJavaConfig(); + PropertyMapper map = PropertyMapper.get(); + map.from(this::blackoutPeriod) + .as(ServiceConfig::durationString) + .to(grpcJavaConfig.in("blackoutPeriod")); + map.from(this::weightExpirationPeriod) + .as(ServiceConfig::durationString) + .to(grpcJavaConfig.in("weightExpirationPeriod")); + map.from(this::outOfBandReportingPeriod) + .as(ServiceConfig::durationString) + .to(grpcJavaConfig.in("oobReportingPeriod")); + map.from(this::enableOutOfBandLoadReport).to(grpcJavaConfig.in("enableOobLoadReport")); + map.from(this::weightUpdatePeriod) + .as(ServiceConfig::durationString) + .to(grpcJavaConfig.in("weightUpdatePeriod")); + map.from(this::errorUtilizationPenalty).to(grpcJavaConfig.in("errorUtilizationPenalty")); + return grpcJavaConfig.asMap(); + } + + } + + /** + * 'grpc' load balancing. + * + * @param child what load balancer policies to use for routing between the backend + * addresses + * @param serviceName override of the service name to be sent to the balancer + * @param initialFallbackTimeout timeout in seconds for receiving the server list + */ + public record GrpcLoadBalancingConfig(List child, String serviceName, + Duration initialFallbackTimeout) { + + public GrpcLoadBalancingConfig { + child.forEach(this::assertChild); + } + + private void assertChild(LoadBalancingConfig child) { + if (child.pickfirst() == null && child.roundrobin() == null) { + throw new InvalidConfigurationPropertyValueException("loadbalancing.grpc.child", null, + "Only 'pickfirst' or 'roundrobin' child load balancer strategies can be used"); + } + } + + Map grpcJavaConfig() { + // Aligned with GrpclbLoadBalancerProvider + GrpcJavaConfig grpcJavaConfig = new GrpcJavaConfig(); + PropertyMapper map = PropertyMapper.get(); + map.from(this::child) + .as(listOf(LoadBalancingConfig::grpcJavaConfig)) + .to(grpcJavaConfig.in("childPolicy")); + map.from(this::serviceName).to(grpcJavaConfig.in("serviceName")); + map.from(this::initialFallbackTimeout) + .as(ServiceConfig::durationString) + .to(grpcJavaConfig.in("initialFallbackTimeout")); + return grpcJavaConfig.asMap(); + } + + } + + } + + /** + * Method configuration. + * + * @param name Names of the methods to which this configuration applies + * @param waitForReady Whether RPCs sent to this method should wait until the + * connection is ready by default + * @param maxRequestMessage maximum allowed payload size for an individual request or + * object in a stream + * @param maxResponseMessage maximum allowed payload size for an individual response + * or object in a stream + * @param timeout default timeout for RPCs sent to this method + * @param retry retry policy for outgoing RPCs + * @param hedging hedging policy for outgoing RPCs + */ + public record MethodConfig(List name, Boolean waitForReady, DataSize maxRequestMessage, + DataSize maxResponseMessage, Duration timeout, RetryPolicy retry, HedgingPolicy hedging) { + + public MethodConfig { + MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> { + entries.put("method.retry", retry); + entries.put("method.hedging", hedging); + }); + } + + static @Nullable List> grpcJavaConfigs(List methodConfigs) { + return (!CollectionUtils.isEmpty(methodConfigs)) + ? methodConfigs.stream().map(MethodConfig::grpcJavaConfig).toList() : null; + } + + Map grpcJavaConfig() { + GrpcJavaConfig grpcJavaConfig = new GrpcJavaConfig(); + PropertyMapper map = PropertyMapper.get(); + map.from(this::name).as(listOf(Name::grpcJavaConfig)).to(grpcJavaConfig.in("name")); + map.from(this::waitForReady).to(grpcJavaConfig.in("waitForReady")); + map.from(this::maxRequestMessage) + .as(ServiceConfig::bytesString) + .to(grpcJavaConfig.in("maxRequestMessageBytes")); + map.from(this::maxResponseMessage) + .as(ServiceConfig::bytesString) + .to(grpcJavaConfig.in("maxResponseMessageBytes")); + map.from(this::timeout).as(ServiceConfig::durationString).to(grpcJavaConfig.in("timeout")); + map.from(this::retry).as(RetryPolicy::grpcJavaConfig).to(grpcJavaConfig.in("retryPolicy")); + map.from(this::hedging).as(HedgingPolicy::grpcJavaConfig).to(grpcJavaConfig.in("hedgingPolicy")); + return grpcJavaConfig.asMap(); + } + + /** + * The name of a gRPC method. + * + * @param service service name + * @param method method name + */ + public record Name(String service, String method) { + + Map grpcJavaConfig() { + GrpcJavaConfig grpcJavaConfig = new GrpcJavaConfig(); + PropertyMapper map = PropertyMapper.get(); + map.from(this::service).to(grpcJavaConfig.in("service")); + map.from(this::method).to(grpcJavaConfig.in("method")); + return grpcJavaConfig.asMap(); + } + + } + + /** + * Retry policy for outgoing RPCs. + * + * @param maxAttempts maximum number of RPC attempts, including the original + * attempt + * @param initialBackoff initial exponential backoff + * @param maxBackoff maximum exponential backoff + * @param backoffMultiplier exponential backoff multiplier + * @param perAttemptReceiveTimeout per-attempt receive timeout + * @param retryableStatusCodes status codes which may be retried + */ + public record RetryPolicy(Integer maxAttempts, Duration initialBackoff, Duration maxBackoff, + Double backoffMultiplier, Duration perAttemptReceiveTimeout, Set retryableStatusCodes) { + + Map grpcJavaConfig() { + GrpcJavaConfig grpcJavaConfig = new GrpcJavaConfig(); + PropertyMapper map = PropertyMapper.get(); + map.from(this::maxAttempts).as(Objects::toString).to(grpcJavaConfig.in("maxAttempts")); + map.from(this::initialBackoff) + .as(ServiceConfig::durationString) + .to(grpcJavaConfig.in("initialBackoff")); + map.from(this::maxBackoff).as(ServiceConfig::durationString).to(grpcJavaConfig.in("maxBackoff")); + map.from(this::backoffMultiplier).to(grpcJavaConfig.in("backoffMultiplier")); + map.from(this::perAttemptReceiveTimeout) + .as(ServiceConfig::durationString) + .to(grpcJavaConfig.in("perAttemptRecvTimeout")); + map.from(this::retryableStatusCodes) + .as((codes) -> codes.stream().map(Objects::toString).toList()) + .to(grpcJavaConfig.in("retryableStatusCodes")); + return grpcJavaConfig.asMap(); + } + + } + + /** + * Hedging policy for outgoing RPCs. + * + * @param maxAttempts maximum number of send attempts + * @param delay delay for subsequent RPCs + * @param nonFatalStatusCodes status codes which indicate other hedged RPCs may + * still succeed + */ + public record HedgingPolicy(Integer maxAttempts, Duration delay, Set nonFatalStatusCodes) { + + Map grpcJavaConfig() { + GrpcJavaConfig grpcJavaConfig = new GrpcJavaConfig(); + PropertyMapper map = PropertyMapper.get(); + map.from(this::maxAttempts).as(Objects::toString).to(grpcJavaConfig.in("maxAttempts")); + map.from(this::delay).as(ServiceConfig::durationString).to(grpcJavaConfig.in("hedgingDelay")); + map.from(this::nonFatalStatusCodes) + .as((codes) -> codes.stream().map(Objects::toString).toList()) + .to(grpcJavaConfig.in("nonFatalStatusCodes")); + return grpcJavaConfig.asMap(); + } + + } + } + + /** + * Retry throttling policy. + * + * @param maxTokens maximum number of tokens + * @param tokenRatio the token ratio + */ + public record RetryThrottlingPolicy(Float maxTokens, Float tokenRatio) { + + Map grpcJavaConfig() { + GrpcJavaConfig grpcJavaConfig = new GrpcJavaConfig(); + PropertyMapper map = PropertyMapper.get(); + map.from(this::maxTokens).as(Objects::toString).to(grpcJavaConfig.in("maxTokens")); + map.from(this::tokenRatio).as(Objects::toString).to(grpcJavaConfig.in("tokenRatio")); + return grpcJavaConfig.asMap(); + } + + } + + /** + * Health check configuration. + * + * @param serviceName service name to use in the health-checking request. + */ + public record HealthCheckConfig(String serviceName) { + + Map grpcJavaConfig() { + GrpcJavaConfig grpcJavaConfig = new GrpcJavaConfig(); + PropertyMapper map = PropertyMapper.get(); + map.from(this::serviceName).to(grpcJavaConfig.in(HEALTH_CHECK_SERVICE_NAME_KEY)); + return grpcJavaConfig.asMap(); + } + + } + + /** + * Internal helper to collection gRPC java config. + * + * @param asMap the underling data as a map + */ + record GrpcJavaConfig(Map asMap) { + + GrpcJavaConfig() { + this(new LinkedHashMap<>()); + } + + Consumer in(String key) { + return (value) -> this.asMap.put(key, value); + } + + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ShadedNettyGrpcClientConfiguration.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ShadedNettyGrpcClientConfiguration.java new file mode 100644 index 00000000000..e63b9659f04 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ShadedNettyGrpcClientConfiguration.java @@ -0,0 +1,58 @@ +/* + * 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.grpc.client.autoconfigure; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.grpc.client.ChannelCredentialsProvider; +import org.springframework.grpc.client.ClientInterceptorsConfigurer; +import org.springframework.grpc.client.GrpcChannelFactory; +import org.springframework.grpc.client.InProcessGrpcChannelFactory; +import org.springframework.grpc.client.ShadedNettyGrpcChannelFactory; + +/** + * {@link Configuration @Configuration} for a Shaded Netty gRPC client. + * + * @author Chris Bono + * @author Phillip Webb + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass({ io.grpc.netty.shaded.io.netty.channel.Channel.class, + io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class }) +@ConditionalOnMissingBean(value = GrpcChannelFactory.class, ignored = InProcessGrpcChannelFactory.class) +@ConditionalOnGrpcClientChannelFactoryEnabled +class ShadedNettyGrpcClientConfiguration { + + @Bean + ShadedNettyGrpcChannelFactory shadedNettyGrpcChannelFactory(Environment environment, + GrpcClientProperties properties, GrpcChannelBuilderCustomizers grpcChannelBuilderCustomizers, + ClientInterceptorsConfigurer interceptorsConfigurer, + ObjectProvider channelFactoryCustomizers, + ChannelCredentialsProvider credentials) { + ShadedNettyGrpcChannelFactory factory = new ShadedNettyGrpcChannelFactory( + grpcChannelBuilderCustomizers.forFactory(), interceptorsConfigurer); + factory.setCredentialsProvider(credentials); + factory.setVirtualTargets(new PropertiesVirtualTargets(environment, properties)); + channelFactoryCustomizers.orderedStream().forEach((customizer) -> customizer.customize(factory)); + return factory; + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/package-info.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/package-info.java new file mode 100644 index 00000000000..66f01b0431b --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/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. + */ + +/** + * Auto-configuration for gRPC clients. + */ +@NullMarked +package org.springframework.boot.grpc.client.autoconfigure; + +import org.jspecify.annotations.NullMarked; diff --git a/module/spring-boot-grpc-client/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/module/spring-boot-grpc-client/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 00000000000..262cb1f229f --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,29 @@ +{ + "groups": [], + "properties": [ + { + "name": "spring.grpc.client.channelfactory.enabled", + "type": "java.lang.Boolean", + "description": "Whether to enable gRPC channel factory bean auto-configuration.", + "defaultValue": true + }, + { + "name": "spring.grpc.client.enabled", + "type": "java.lang.Boolean", + "description": "Whether to enable gRPC client auto-configuration.", + "defaultValue": true + }, + { + "name": "spring.grpc.client.inprocess.enabled", + "type": "java.lang.Boolean", + "description": "Whether to configure the in-process channel factory.", + "defaultValue": true + }, + { + "name": "spring.grpc.client.observation.enabled", + "type": "java.lang.Boolean", + "description": "Whether to enable Observations on the client.", + "defaultValue": true + } + ] +} diff --git a/module/spring-boot-grpc-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/module/spring-boot-grpc-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..7d3249ab92f --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,3 @@ +org.springframework.boot.grpc.client.autoconfigure.CompositeChannelFactoryAutoConfiguration +org.springframework.boot.grpc.client.autoconfigure.GrpcClientAutoConfiguration +org.springframework.boot.grpc.client.autoconfigure.GrpcClientObservationAutoConfiguration diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/CompositeChannelFactoryAutoConfigurationTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/CompositeChannelFactoryAutoConfigurationTests.java new file mode 100644 index 00000000000..e3be4daf5cd --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/CompositeChannelFactoryAutoConfigurationTests.java @@ -0,0 +1,140 @@ +/* + * 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.grpc.client.autoconfigure; + +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.netty.NettyChannelBuilder; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.grpc.client.CompositeGrpcChannelFactory; +import org.springframework.grpc.client.GrpcChannelFactory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link CompositeChannelFactoryAutoConfiguration}. + * + * @author Chris Bono + */ +class CompositeChannelFactoryAutoConfigurationTests { + + private ApplicationContextRunner contextRunnerWithoutChannelFactories() { + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcClientAutoConfiguration.class, SslAutoConfiguration.class, + CompositeChannelFactoryAutoConfiguration.class)) + .withClassLoader(new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class, + NettyChannelBuilder.class, InProcessChannelBuilder.class)); + } + + @Test + void whenNoChannelFactoriesDoesNotAutoconfigureComposite() { + this.contextRunnerWithoutChannelFactories() + .run((context) -> assertThat(context).doesNotHaveBean(GrpcChannelFactory.class)); + } + + @Test + void whenSingleChannelFactoryDoesNotAutoconfigureComposite() { + GrpcChannelFactory channelFactory1 = mock(); + this.contextRunnerWithoutChannelFactories() + .withBean("channelFactory1", GrpcChannelFactory.class, () -> channelFactory1) + .run((context) -> assertThat(context).hasSingleBean(GrpcChannelFactory.class) + .getBean(GrpcChannelFactory.class) + .isNotInstanceOf(CompositeGrpcChannelFactory.class) + .isSameAs(channelFactory1)); + } + + @Test + void whenMultipleChannelFactoriesWithPrimaryDoesNotAutoconfigureComposite() { + GrpcChannelFactory channelFactory1 = mock(); + GrpcChannelFactory channelFactory2 = mock(); + this.contextRunnerWithoutChannelFactories() + .withBean("channelFactory1", GrpcChannelFactory.class, () -> channelFactory1) + .withBean("channelFactory2", GrpcChannelFactory.class, () -> channelFactory2, (bd) -> bd.setPrimary(true)) + .run((context) -> { + assertThat(context).getBeans(GrpcChannelFactory.class) + .containsOnlyKeys("channelFactory1", "channelFactory2"); + assertThat(context).getBean(GrpcChannelFactory.class) + .isNotInstanceOf(CompositeGrpcChannelFactory.class) + .isSameAs(channelFactory2); + }); + } + + @Test + void whenMultipleChannelFactoriesDoesAutoconfigureComposite() { + GrpcChannelFactory channelFactory1 = mock(); + GrpcChannelFactory channelFactory2 = mock(); + this.contextRunnerWithoutChannelFactories() + .withBean("channelFactory1", GrpcChannelFactory.class, () -> channelFactory1) + .withBean("channelFactory2", GrpcChannelFactory.class, () -> channelFactory2) + .run((context) -> { + assertThat(context).getBeans(GrpcChannelFactory.class) + .containsOnlyKeys("channelFactory1", "channelFactory2", "compositeChannelFactory"); + assertThat(context).getBean(GrpcChannelFactory.class).isInstanceOf(CompositeGrpcChannelFactory.class); + }); + } + + @Test + void compositeAutoconfiguredAsExpected() { + this.contextRunnerWithoutChannelFactories() + .withUserConfiguration(MultipleFactoriesTestConfig.class) + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class) + .isInstanceOf(CompositeGrpcChannelFactory.class) + .extracting("channelFactories") + .asInstanceOf(InstanceOfAssertFactories.list(GrpcChannelFactory.class)) + .containsExactly(MultipleFactoriesTestConfig.CHANNEL_FACTORY_BAR, + MultipleFactoriesTestConfig.CHANNEL_FACTORY_BAZ, + MultipleFactoriesTestConfig.CHANNEL_FACTORY_FOO)); + + } + + @Configuration(proxyBeanMethods = false) + static class MultipleFactoriesTestConfig { + + static GrpcChannelFactory CHANNEL_FACTORY_FOO = mock(); + static GrpcChannelFactory CHANNEL_FACTORY_BAR = mock(); + static GrpcChannelFactory CHANNEL_FACTORY_BAZ = mock(); + + @Bean + @Order(3) + GrpcChannelFactory channelFactoryFoo() { + return CHANNEL_FACTORY_FOO; + } + + @Bean + @Order(1) + GrpcChannelFactory channelFactoryBar() { + return CHANNEL_FACTORY_BAR; + } + + @Bean + @Order(2) + GrpcChannelFactory channelFactoryBaz() { + return CHANNEL_FACTORY_BAZ; + } + + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelBuilderCustomizersTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelBuilderCustomizersTests.java new file mode 100644 index 00000000000..ac59391bca2 --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelBuilderCustomizersTests.java @@ -0,0 +1,276 @@ +/* + * 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.grpc.client.autoconfigure; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import io.grpc.CompressorRegistry; +import io.grpc.DecompressorRegistry; +import io.grpc.ManagedChannelBuilder; +import io.grpc.netty.NettyChannelBuilder; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel; +import org.springframework.boot.grpc.client.autoconfigure.ServiceConfig.HealthCheckConfig; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +/** + * Tests for {@link GrpcChannelBuilderCustomizers}. + * + * @author Chris Bono + * @author Phillip Webb + */ +class GrpcChannelBuilderCustomizersTests { + + @Test + void applyWhenHasProperties() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channel = new Channel(); + channel.setUserAgent("spring-boot"); + properties.getChannel().put("target", channel); + GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(properties, null, null, + Collections.emptyList(), Collections.emptyList()); + NettyChannelBuilder builder = mock(NettyChannelBuilder.class); + customizers.apply("target", builder); + then(builder).should().userAgent("spring-boot"); + } + + @Test + void applyWhenHasCompressorRegistry() { + CompressorRegistry compressorRegistry = mock(); + GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(null, compressorRegistry, null, + Collections.emptyList(), Collections.emptyList()); + NettyChannelBuilder builder = mock(NettyChannelBuilder.class); + customizers.apply("target", builder); + then(builder).should().compressorRegistry(compressorRegistry); + } + + @Test + void applyWhenHasDecompressorRegistry() { + DecompressorRegistry decompressorRegistry = mock(); + GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(null, null, decompressorRegistry, + Collections.emptyList(), Collections.emptyList()); + NettyChannelBuilder builder = mock(NettyChannelBuilder.class); + customizers.apply("target", builder); + then(builder).should().decompressorRegistry(decompressorRegistry); + } + + @Test + void applyWhenEmptyCustomizersDoesNothing() { + NettyChannelBuilder builder = mock(NettyChannelBuilder.class); + new GrpcChannelBuilderCustomizers(Collections.emptyList()).apply("target", builder); + then(builder).shouldHaveNoInteractions(); + } + + @Test + void applyWhenSimpleChannelBuilder() { + GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers( + List.of(new SimpleChannelBuilderCustomizer())); + NettyChannelBuilder builder = mock(NettyChannelBuilder.class); + customizers.apply("target", builder); + then(builder).should().flowControlWindow(100); + } + + @Test + @SuppressWarnings("unchecked") + void applyWhenGenericCustomizersRespectsGeneric() { + List> list = new ArrayList<>(); + list.add(new TestCustomizer<>()); + list.add(new TestNettyChannelBuilderCustomizer()); + list.add(new TestShadedNettyChannelBuilderCustomizer()); + GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(list); + customizers.apply("target", mock(ManagedChannelBuilder.class)); + assertThat(list.get(0).getCount()).isOne(); + assertThat(list.get(1).getCount()).isZero(); + assertThat(list.get(2).getCount()).isZero(); + customizers.apply("target", mock(NettyChannelBuilder.class)); + assertThat(list.get(0).getCount()).isEqualTo(2); + assertThat(list.get(1).getCount()).isOne(); + assertThat(list.get(2).getCount()).isZero(); + customizers.apply("target", mock(io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)); + assertThat(list.get(0).getCount()).isEqualTo(3); + assertThat(list.get(1).getCount()).isOne(); + assertThat(list.get(2).getCount()).isOne(); + } + + @Test + void applyWhenHasGrpcClientDefaultServiceConfigCustomizers() { + GrpcClientDefaultServiceConfigCustomizer defaultConfigCustomizer1 = (target, defaultServiceConfig) -> { + defaultServiceConfig.put("c", "v1"); + defaultServiceConfig.put("c1", "v1"); + }; + GrpcClientDefaultServiceConfigCustomizer defaultConfigCustomizer2 = (target, defaultServiceConfig) -> { + defaultServiceConfig.put("c", "v2"); + defaultServiceConfig.put("c2", "v2"); + }; + GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(null, null, null, + Collections.emptyList(), List.of(defaultConfigCustomizer1, defaultConfigCustomizer2)); + NettyChannelBuilder builder = mock(NettyChannelBuilder.class); + customizers.apply("target", builder); + Map expected = new LinkedHashMap<>(); + expected.put("c", "v2"); + expected.put("c1", "v1"); + expected.put("c2", "v2"); + then(builder).should().defaultServiceConfig(expected); + } + + @Test + void applyWhenHasChannelHealthAddsHealthServiceConfig() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channel = new Channel(); + channel.getHealth().setEnabled(true); + channel.getHealth().setServiceName("testservice"); + properties.getChannel().put("target", channel); + GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(properties, null, null, + Collections.emptyList(), Collections.emptyList()); + NettyChannelBuilder builder = mock(NettyChannelBuilder.class); + customizers.apply("target", builder); + Map expected = new LinkedHashMap<>(); + expected.put("healthCheckConfig", Map.of("serviceName", "testservice")); + then(builder).should().defaultServiceConfig(expected); + } + + @Test + void applyWhenHasDefaultHealthAddsHealthServiceConfig() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channel = new Channel(); + channel.getHealth().setEnabled(true); + channel.getHealth().setServiceName("testdefaultservice"); + properties.getChannel().put("default", channel); + GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(properties, null, null, + Collections.emptyList(), Collections.emptyList()); + NettyChannelBuilder builder = mock(NettyChannelBuilder.class); + customizers.apply("target", builder); + Map expected = new LinkedHashMap<>(); + expected.put("healthCheckConfig", Map.of("serviceName", "testdefaultservice")); + then(builder).should().defaultServiceConfig(expected); + } + + @Test + void applyWhenHasServiceConfig() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channel = new Channel(); + ServiceConfig serviceConfig = new ServiceConfig(null, null, null, new HealthCheckConfig("test")); + channel.setServiceConfig(serviceConfig); + properties.getChannel().put("default", channel); + GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(properties, null, null, + Collections.emptyList(), Collections.emptyList()); + NettyChannelBuilder builder = mock(NettyChannelBuilder.class); + customizers.apply("target", builder); + Map expected = new LinkedHashMap<>(); + expected.put("healthCheckConfig", Map.of("serviceName", "test")); + then(builder).should().defaultServiceConfig(expected); + } + + @Test + void applyWhenHasClashingServiceConfigAndHealth() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channel = new Channel(); + channel.getHealth().setEnabled(true); + channel.getHealth().setServiceName("fromhealth"); + ServiceConfig serviceConfig = new ServiceConfig(null, null, null, new HealthCheckConfig("fromservice")); + channel.setServiceConfig(serviceConfig); + properties.getChannel().put("default", channel); + GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(properties, null, null, + Collections.emptyList(), Collections.emptyList()); + NettyChannelBuilder builder = mock(NettyChannelBuilder.class); + assertThatIllegalStateException().isThrownBy(() -> customizers.apply("target", builder)) + .withMessage("Unable to change health check config service name from 'fromservice' to 'fromhealth'"); + } + + @Test + void applyWhenHealthEnabledAndNoServiceNameAddsHealthConfig() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channel = new Channel(); + channel.getHealth().setEnabled(true); + properties.getChannel().put("target", channel); + GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(properties, null, null, + Collections.emptyList(), Collections.emptyList()); + NettyChannelBuilder builder = mock(NettyChannelBuilder.class); + customizers.apply("target", builder); + Map expected = new LinkedHashMap<>(); + expected.put("healthCheckConfig", Map.of("serviceName", "")); + then(builder).should().defaultServiceConfig(expected); + } + + @Test + void applyWhenNoCustomizersOrHealthDoesSetDefaultServiceConfig() { + GrpcClientProperties properties = new GrpcClientProperties(); + GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(properties, null, null, + Collections.emptyList(), Collections.emptyList()); + NettyChannelBuilder builder = mock(NettyChannelBuilder.class); + customizers.apply("target", builder); + then(builder).should(never()).defaultServiceConfig(any()); + } + + static class SimpleChannelBuilderCustomizer implements GrpcChannelBuilderCustomizer { + + @Override + public void customize(String target, NettyChannelBuilder channelBuilder) { + channelBuilder.flowControlWindow(100); + } + + } + + /** + * Test customizer that will match any {@link GrpcChannelBuilderCustomizer}. + * + * @param the builder type + */ + static class TestCustomizer> implements GrpcChannelBuilderCustomizer { + + private int count; + + @Override + public void customize(String targetOrChannelName, T channelBuilder) { + this.count++; + } + + int getCount() { + return this.count; + } + + } + + /** + * Test customizer that will match only {@link NettyChannelBuilder}. + */ + static class TestNettyChannelBuilderCustomizer extends TestCustomizer { + + } + + /** + * Test customizer that will match only + * {@link io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder}. + */ + static class TestShadedNettyChannelBuilderCustomizer + extends TestCustomizer { + + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfigurationTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfigurationTests.java new file mode 100644 index 00000000000..1aecb618ec7 --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfigurationTests.java @@ -0,0 +1,396 @@ +/* + * 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.grpc.client.autoconfigure; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import io.grpc.Codec; +import io.grpc.CompressorRegistry; +import io.grpc.DecompressorRegistry; +import io.grpc.ManagedChannelBuilder; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.kotlin.AbstractCoroutineStub; +import io.grpc.netty.NettyChannelBuilder; +import io.grpc.stub.AbstractStub; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.mockito.Mockito; + +import org.springframework.boot.autoconfigure.AutoConfigurationPackage; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientAutoConfiguration.GrpcClientCoroutineStubConfiguration; +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel; +import org.springframework.boot.grpc.client.autoconfigure.test.scan.DummyBlockingGrpc; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.grpc.client.ChannelCredentialsProvider; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; +import org.springframework.grpc.client.GrpcChannelFactory; +import org.springframework.grpc.client.InProcessGrpcChannelFactory; +import org.springframework.grpc.client.NettyGrpcChannelFactory; +import org.springframework.grpc.client.ShadedNettyGrpcChannelFactory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +/** + * Tests for {@link GrpcClientAutoConfiguration}. + * + * @author Chris Bono + * @author Phillip Webb + */ +@SuppressWarnings({ "unchecked", "rawtypes" }) +class GrpcClientAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcClientAutoConfiguration.class, SslAutoConfiguration.class)); + + private final ApplicationContextRunner contextRunnerWithoutInProcessChannelFactory = this.contextRunner + .withPropertyValues("spring.grpc.client.inprocess.enabled=false"); + + @Test + void whenGrpcStubNotOnClasspathThenAutoConfigurationIsSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(AbstractStub.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcClientAutoConfiguration.class)); + } + + @Test + void whenGrpcKotlinIsNotOnClasspathThenAutoConfigurationIsSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(AbstractCoroutineStub.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcClientCoroutineStubConfiguration.class)); + } + + @Test + void whenClientEnabledPropertySetFalseThenAutoConfigurationIsSkipped() { + this.contextRunner.withPropertyValues("spring.grpc.client.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcClientAutoConfiguration.class)); + } + + @Test + void whenClientEnabledPropertyNotSetThenAutoConfigurationIsNotSkipped() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(GrpcClientAutoConfiguration.class)); + } + + @Test + void whenClientEnabledPropertySetTrueThenAutoConfigurationIsNotSkipped() { + this.contextRunner.withPropertyValues("spring.grpc.client.enabled=true") + .run((context) -> assertThat(context).hasSingleBean(GrpcClientAutoConfiguration.class)); + } + + @Test + void whenHasUserDefinedCredentialsProviderDoesNotAutoConfigureBean() { + ChannelCredentialsProvider customCredentialsProvider = mock(ChannelCredentialsProvider.class); + this.contextRunner + .withBean("customCredentialsProvider", ChannelCredentialsProvider.class, () -> customCredentialsProvider) + .run((context) -> assertThat(context).getBean(ChannelCredentialsProvider.class) + .isSameAs(customCredentialsProvider)); + } + + @Test + void credentialsProviderAutoConfiguredAsExpected() { + this.contextRunner.run((context) -> assertThat(context).getBean(PropertiesChannelCredentialsProvider.class) + .hasFieldOrPropertyWithValue("properties", context.getBean(GrpcClientProperties.class)) + .extracting("bundles") + .isInstanceOf(SslBundles.class)); + } + + @Test + void clientPropertiesAutoConfiguredResolvesPlaceholders() { + this.contextRunner + .withPropertyValues("spring.grpc.client.channel.c1.target=my-server-${channelName}:8888", "channelName=foo") + .run((context) -> assertThat(context).getBean(GrpcClientProperties.class).satisfies((properties) -> { + Channel channel = properties.getChannel().get("c1"); + assertThat(channel).isNotNull(); + assertThat(channel.getTarget()).isEqualTo("my-server-foo:8888"); + })); + } + + @Test + void clientPropertiesChannelCustomizerAutoConfiguredWithHealthAsExpected() { + this.contextRunner + .withPropertyValues("spring.grpc.client.channel.test.health.enabled=true", + "spring.grpc.client.channel.test.health.service-name=my-service") + .run((context) -> { + GrpcChannelBuilderCustomizers customizers = context.getBean(GrpcChannelBuilderCustomizers.class); + ManagedChannelBuilder builder = Mockito.mock(); + customizers.apply("test", builder); + Map healthCheckConfig = Map.of("healthCheckConfig", Map.of("serviceName", "my-service")); + then(builder).should().defaultServiceConfig(healthCheckConfig); + }); + } + + @Test + void clientPropertiesChannelCustomizerAutoConfiguredWithoutHealthAsExpected() { + this.contextRunner.run((context) -> { + GrpcChannelBuilderCustomizers customizers = context.getBean(GrpcChannelBuilderCustomizers.class); + ManagedChannelBuilder builder = Mockito.mock(); + customizers.apply("test", builder); + then(builder).should(never()).defaultServiceConfig(anyMap()); + }); + } + + @Test + void compressionCustomizerAutoConfiguredAsExpected() { + this.contextRunner.run((context) -> { + GrpcChannelBuilderCustomizers customizers = context.getBean(GrpcChannelBuilderCustomizers.class); + CompressorRegistry compressorRegistry = context.getBean(CompressorRegistry.class); + ManagedChannelBuilder builder = Mockito.mock(); + customizers.apply("testChannel", builder); + then(builder).should().compressorRegistry(compressorRegistry); + }); + } + + @Test + void compressionCustomizerWhenNoRegistrry() { + // Codec class guards the imported GrpcCodecConfiguration to hide registry + this.contextRunner.withClassLoader(new FilteredClassLoader(Codec.class)).run((context) -> { + GrpcChannelBuilderCustomizers customizers = context.getBean(GrpcChannelBuilderCustomizers.class); + ManagedChannelBuilder builder = Mockito.mock(); + customizers.apply("testChannel", builder); + then(builder).should(never()).compressorRegistry(any()); + }); + } + + @Test + void decompressionCustomizerAutoConfiguredAsExpected() { + this.contextRunner.run((context) -> { + GrpcChannelBuilderCustomizers customizers = context.getBean(GrpcChannelBuilderCustomizers.class); + DecompressorRegistry decompressorRegistry = context.getBean(DecompressorRegistry.class); + ManagedChannelBuilder builder = Mockito.mock(); + customizers.apply("testChannel", builder); + then(builder).should().decompressorRegistry(decompressorRegistry); + }); + } + + @Test + void whenNoDecompressorRegistryThenDecompressionCustomizerIsNotConfigured() { + // Codec class guards the imported GrpcCodecConfiguration to hide registry + this.contextRunner.withClassLoader(new FilteredClassLoader(Codec.class)).run((context) -> { + GrpcChannelBuilderCustomizers customizers = context.getBean(GrpcChannelBuilderCustomizers.class); + ManagedChannelBuilder builder = Mockito.mock(); + customizers.apply("testChannel", builder); + then(builder).should(never()).compressorRegistry(any()); + }); + } + + @Test + void whenInProcessEnabledPropNotSetDoesAutoconfigureInProcess() { + this.contextRunner.run((context) -> assertThat(context).getBeans(GrpcChannelFactory.class) + .containsKey("inProcessGrpcChannelFactory")); + } + + @Test + void whenInProcessEnabledPropSetToTrueDoesAutoconfigureInProcess() { + this.contextRunner.withPropertyValues("spring.grpc.client.inprocess.enabled=true") + .run((context) -> assertThat(context).getBeans(GrpcChannelFactory.class) + .containsKey("inProcessGrpcChannelFactory")); + } + + @Test + void whenInProcessEnabledPropSetToFalseDoesNotAutoconfigureInProcess() { + this.contextRunner.withPropertyValues("spring.grpc.client.inprocess.enabled=false") + .run((context) -> assertThat(context).getBeans(GrpcChannelFactory.class) + .doesNotContainKey("inProcessGrpcChannelFactory")); + } + + @Test + void whenInProcessIsNotOnClasspathDoesNotAutoconfigureInProcess() { + this.contextRunner.withClassLoader(new FilteredClassLoader(InProcessChannelBuilder.class)) + .run((context) -> assertThat(context).getBeans(GrpcChannelFactory.class) + .doesNotContainKey("inProcessGrpcChannelFactory")); + } + + @Test + void whenHasUserDefinedInProcessChannelFactoryDoesNotAutoConfigureBean() { + InProcessGrpcChannelFactory customChannelFactory = mock(); + this.contextRunner + .withClassLoader(new FilteredClassLoader(NettyChannelBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)) + .withBean("customChannelFactory", InProcessGrpcChannelFactory.class, () -> customChannelFactory) + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class).isSameAs(customChannelFactory)); + } + + @Test + void whenHasUserDefinedChannelFactoryDoesNotAutoConfigureNettyOrShadedNetty() { + GrpcChannelFactory customChannelFactory = mock(); + this.contextRunnerWithoutInProcessChannelFactory + .withBean("customChannelFactory", GrpcChannelFactory.class, () -> customChannelFactory) + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class).isSameAs(customChannelFactory)); + } + + @Test + void userDefinedChannelFactoryWithInProcessChannelFactory() { + GrpcChannelFactory customChannelFactory = mock(); + this.contextRunner.withBean("customChannelFactory", GrpcChannelFactory.class, () -> customChannelFactory) + .run((context) -> assertThat(context).getBeans(GrpcChannelFactory.class) + .containsOnlyKeys("customChannelFactory", "inProcessGrpcChannelFactory")); + } + + @Test + void whenShadedAndNonShadedNettyOnClasspathShadedNettyFactoryIsAutoConfigured() { + this.contextRunnerWithoutInProcessChannelFactory + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class) + .isInstanceOf(ShadedNettyGrpcChannelFactory.class)); + } + + @Test + void shadedNettyWithInProcessChannelFactory() { + this.contextRunner.run((context) -> assertThat(context).getBeans(GrpcChannelFactory.class) + .containsOnlyKeys("shadedNettyGrpcChannelFactory", "inProcessGrpcChannelFactory")); + } + + @Test + void whenOnlyNonShadedNettyOnClasspathNonShadedNettyFactoryIsAutoConfigured() { + this.contextRunnerWithoutInProcessChannelFactory + .withClassLoader(new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)) + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class) + .isInstanceOf(NettyGrpcChannelFactory.class)); + } + + @Test + void nonShadedNettyWithInProcessChannelFactory() { + this.contextRunner + .withClassLoader(new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)) + .run((context) -> assertThat(context).getBeans(GrpcChannelFactory.class) + .containsOnlyKeys("nettyGrpcChannelFactory", "inProcessGrpcChannelFactory")); + } + + @Test + void whenShadedNettyAndNettyNotOnClasspathNoChannelFactoryIsAutoConfigured() { + this.contextRunnerWithoutInProcessChannelFactory + .withClassLoader(new FilteredClassLoader(NettyChannelBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcChannelFactory.class)); + } + + @Test + void noChannelFactoryWithInProcessChannelFactory() { + this.contextRunner + .withClassLoader(new FilteredClassLoader(NettyChannelBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)) + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class) + .isInstanceOf(InProcessGrpcChannelFactory.class)); + } + + @Test + void shadedNettyChannelFactoryAutoConfiguredAsExpected() { + this.contextRunnerWithoutInProcessChannelFactory + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class) + .isInstanceOf(ShadedNettyGrpcChannelFactory.class) + .hasFieldOrPropertyWithValue("credentials", context.getBean(PropertiesChannelCredentialsProvider.class)) + .extracting("targets") + .isInstanceOf(PropertiesVirtualTargets.class)); + } + + @Test + void nettyChannelFactoryAutoConfiguredAsExpected() { + this.contextRunnerWithoutInProcessChannelFactory + .withClassLoader(new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)) + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class) + .isInstanceOf(NettyGrpcChannelFactory.class) + .hasFieldOrPropertyWithValue("credentials", context.getBean(PropertiesChannelCredentialsProvider.class)) + .extracting("targets") + .isInstanceOf(PropertiesVirtualTargets.class)); + } + + @Test + void inProcessChannelFactoryAutoConfiguredAsExpected() { + this.contextRunner + .withClassLoader(new FilteredClassLoader(NettyChannelBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)) + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class) + .isInstanceOf(InProcessGrpcChannelFactory.class) + .extracting("credentials") + .isSameAs(ChannelCredentialsProvider.INSECURE)); + } + + @Test + void shadedNettyChannelFactoryAutoConfiguredWithCustomizers() { + io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder builder = mock(); + channelFactoryAutoConfiguredWithCustomizers(this.contextRunnerWithoutInProcessChannelFactory, builder, + ShadedNettyGrpcChannelFactory.class); + } + + @Test + void nettyChannelFactoryAutoConfiguredWithCustomizers() { + NettyChannelBuilder builder = mock(); + channelFactoryAutoConfiguredWithCustomizers( + this.contextRunnerWithoutInProcessChannelFactory.withClassLoader( + new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)), + builder, NettyGrpcChannelFactory.class); + } + + @Test + void inProcessChannelFactoryAutoConfiguredWithCustomizers() { + InProcessChannelBuilder builder = mock(); + channelFactoryAutoConfiguredWithCustomizers( + this.contextRunner.withClassLoader(new FilteredClassLoader(NettyChannelBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)), + builder, InProcessGrpcChannelFactory.class); + } + + private > void channelFactoryAutoConfiguredWithCustomizers( + ApplicationContextRunner contextRunner, ManagedChannelBuilder mockChannelBuilder, + Class expectedChannelFactoryType) { + contextRunner.withUserConfiguration(ChannelBuilderCustomizersConfig.class) + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class) + .isInstanceOf(expectedChannelFactoryType) + .extracting("globalCustomizers", InstanceOfAssertFactories.list(GrpcChannelBuilderCustomizer.class)) + .satisfies((allCustomizers) -> { + allCustomizers.forEach((c) -> c.customize("channel1", mockChannelBuilder)); + InOrder ordered = inOrder(mockChannelBuilder); + ordered.verify(mockChannelBuilder).keepAliveTime(40L, TimeUnit.SECONDS); + ordered.verify(mockChannelBuilder).keepAliveTime(50L, TimeUnit.SECONDS); + })); + } + + @Configuration(proxyBeanMethods = false) + @AutoConfigurationPackage(basePackageClasses = DummyBlockingGrpc.class) + static class AutoConfigurePackagesConfiguration { + + } + + @Configuration(proxyBeanMethods = false) + static class ChannelBuilderCustomizersConfig { + + @Bean + @Order(100) + > GrpcChannelBuilderCustomizer customizerOne() { + return (target, builder) -> builder.keepAliveTime(40L, TimeUnit.SECONDS); + } + + @Bean + @Order(200) + > GrpcChannelBuilderCustomizer customizerTwo() { + return (target, builder) -> builder.keepAliveTime(50L, TimeUnit.SECONDS); + } + + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientObservationAutoConfigurationTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientObservationAutoConfigurationTests.java new file mode 100644 index 00000000000..358814ab5bf --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientObservationAutoConfigurationTests.java @@ -0,0 +1,112 @@ +/* + * 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.grpc.client.autoconfigure; + +import java.util.Map; + +import io.grpc.stub.AbstractStub; +import io.micrometer.core.instrument.binder.grpc.ObservationGrpcClientInterceptor; +import io.micrometer.observation.ObservationRegistry; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.grpc.client.GlobalClientInterceptor; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for the {@link GrpcClientObservationAutoConfiguration}. + * + * @author Chris Bono + */ +class GrpcClientObservationAutoConfigurationTests { + + private static final AutoConfigurations autoConfigurations = AutoConfigurations + .of(GrpcClientObservationAutoConfiguration.class); + + private ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(autoConfigurations) + .withBean("observationRegistry", ObservationRegistry.class, Mockito::mock); + + @Test + void whenObservationRegistryNotOnClasspathAutoConfigSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(ObservationRegistry.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcClientObservationAutoConfiguration.class)); + } + + @Test + void whenObservationGrpcClientInterceptorNotOnClasspathAutoConfigSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(ObservationGrpcClientInterceptor.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcClientObservationAutoConfiguration.class)); + } + + @Test + void whenObservationRegistryNotProvidedThenAutoConfigSkipped() { + new ApplicationContextRunner().withConfiguration(autoConfigurations) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcClientObservationAutoConfiguration.class)); + } + + @Test + void whenObservationPropertyEnabledThenAutoConfigNotSkipped() { + this.contextRunner.withPropertyValues("spring.grpc.client.observation.enabled=true") + .run((context) -> assertThat(context).hasSingleBean(GrpcClientObservationAutoConfiguration.class)); + } + + @Test + void whenObservationPropertyDisabledThenAutoConfigIsSkipped() { + this.contextRunner.withPropertyValues("spring.grpc.client.observation.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcClientObservationAutoConfiguration.class)); + } + + @Test + void whenClientEnabledPropertyNotSetThenAutoConfigNotSkipped() { + this.contextRunner + .run((context) -> assertThat(context).hasSingleBean(GrpcClientObservationAutoConfiguration.class)); + } + + @Test + void whenClientEnabledPropertySetTrueThenAutoConfigIsNotSkipped() { + this.contextRunner.withPropertyValues("spring.grpc.client.enabled=true") + .run((context) -> assertThat(context).hasSingleBean(GrpcClientObservationAutoConfiguration.class)); + } + + @Test + void whenClientEnabledPropertySetFalseThenAutoConfigIsSkipped() { + this.contextRunner.withPropertyValues("spring.grpc.client.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcClientObservationAutoConfiguration.class)); + } + + @Test + void whenGrpcStubNotOnClasspathThenAutoConfigIsSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(AbstractStub.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcClientObservationAutoConfiguration.class)); + } + + @Test + void whenAllConditionsAreMetThenInterceptorConfiguredAsExpected() { + this.contextRunner.run((context) -> { + assertThat(context).hasSingleBean(ObservationGrpcClientInterceptor.class); + Map annotated = context.getBeansWithAnnotation(GlobalClientInterceptor.class); + assertThat(annotated).hasEntrySatisfying("grpcClientObservationInterceptor", + ObservationGrpcClientInterceptor.class::isInstance); + }); + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientPropertiesTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientPropertiesTests.java new file mode 100644 index 00000000000..5989f9d34f3 --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientPropertiesTests.java @@ -0,0 +1,158 @@ +/* + * 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.grpc.client.autoconfigure; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel; +import org.springframework.boot.testsupport.classpath.resources.WithResource; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.io.ClassPathResource; +import org.springframework.util.unit.DataSize; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link GrpcClientProperties}. + * + * @author Chris Bono + * @author Phillip Webb + */ +class GrpcClientPropertiesTests { + + @Test + @WithResource(name = "client.yaml", content = """ + channel: + a: + target: static://my-server:8888 + b: + user-agent: me""") + void defaultValues() throws Exception { + GrpcClientProperties properties = bind(); + Channel channelA = properties.getChannel().get("a"); + assertThat(channelA).isNotNull(); + assertThat(channelA.getUserAgent()).isNull(); + assertThat(channelA.isBypassCertificateValidation()).isFalse(); + assertThat(channelA.getInbound().getMessage().getMaxSize()).isEqualTo(DataSize.ofBytes(4194304)); + assertThat(channelA.getInbound().getMetadata().getMaxSize()).isEqualTo(DataSize.ofBytes(8192)); + assertThat(channelA.getDefault().getDeadline()).isNull(); + assertThat(channelA.getIdle().getTimeout()).isEqualTo(Duration.ofSeconds(20)); + assertThat(channelA.getKeepalive().getTime()).isEqualTo(Duration.ofMinutes(5)); + assertThat(channelA.getKeepalive().getTimeout()).isEqualTo(Duration.ofSeconds(20)); + assertThat(channelA.getKeepalive().isWithoutCalls()).isFalse(); + assertThat(channelA.getSsl().getEnabled()).isNull(); + assertThat(channelA.getSsl().getBundle()).isNull(); + Channel channelB = properties.getChannel().get("b"); + assertThat(channelB).isNotNull(); + assertThat(channelB.getTarget()).isEqualTo("static://localhost:9090"); + } + + @Test + @WithResource(name = "client.yaml", content = """ + channel: + test: + target: static://my-server:8888 + user-agent: me + bypass-certificate-validation: true + inbound: + message: + max-size: 200MB + metadata: + max-size: 1GB + default: + deadline: 1s + load-balancing-policy: pick_first + idle: + timeout: 1m + keepalive: + time: 200s + timeout: 60000ms + without-calls: true + ssl: + enabled: true + bundle: my-bundle + health: + enabled: true + service-name: my-service""") + void specificProperties() throws Exception { + GrpcClientProperties properties = bind(); + Channel channel = properties.getChannel().get("test"); + assertThat(channel).isNotNull(); + assertThat(channel.getTarget()).isEqualTo("static://my-server:8888"); + assertThat(channel.getUserAgent()).isEqualTo("me"); + assertThat(channel.isBypassCertificateValidation()).isTrue(); + assertThat(channel.getInbound().getMessage().getMaxSize()).isEqualTo(DataSize.ofMegabytes(200)); + assertThat(channel.getInbound().getMetadata().getMaxSize()).isEqualTo(DataSize.ofGigabytes(1)); + assertThat(channel.getDefault().getDeadline()).isEqualTo(Duration.ofSeconds(1)); + assertThat(channel.getIdle().getTimeout()).isEqualTo(Duration.ofMinutes(1)); + assertThat(channel.getKeepalive().getTime()).isEqualTo(Duration.ofSeconds(200)); + assertThat(channel.getKeepalive().getTimeout()).isEqualTo(Duration.ofMillis(60000)); + assertThat(channel.getKeepalive().isWithoutCalls()).isTrue(); + assertThat(channel.getSsl().getEnabled()).isTrue(); + assertThat(channel.getSsl().getBundle()).isEqualTo("my-bundle"); + } + + @Test + @WithResource(name = "client.yaml", content = """ + channel: + test: + idle: + timeout: 1 + keepalive: + time: 60 + timeout: 5""") + void withoutKeepAliveUnitsSpecified() throws Exception { + GrpcClientProperties properties = bind(); + Channel channel = properties.getChannel().get("test"); + assertThat(channel).isNotNull(); + assertThat(channel.getIdle().getTimeout()).isEqualTo(Duration.ofSeconds(1)); + assertThat(channel.getKeepalive().getTime()).isEqualTo(Duration.ofSeconds(60)); + assertThat(channel.getKeepalive().getTimeout()).isEqualTo(Duration.ofSeconds(5)); + } + + @Test + @WithResource(name = "client.yaml", content = """ + channel: + test: + inbound: + message: + max-size: 1000 + metadata: + max-size: 256""") + void withoutInboundSizeUnitsSpecified() throws Exception { + GrpcClientProperties properties = bind(); + Channel channel = properties.getChannel().get("test"); + assertThat(channel).isNotNull(); + assertThat(channel.getInbound().getMessage().getMaxSize()).isEqualTo(DataSize.ofBytes(1000)); + assertThat(channel.getInbound().getMetadata().getMaxSize()).isEqualTo(DataSize.ofBytes(256)); + } + + private GrpcClientProperties bind() throws Exception { + StandardEnvironment environment = new StandardEnvironment(); + new YamlPropertySourceLoader().load("client.yaml", new ClassPathResource("client.yaml")) + .forEach(environment.getPropertySources()::addLast); + return Binder.get(environment) + .bind("", Bindable.of(GrpcClientProperties.class)) + .orElseGet(GrpcClientProperties::new); + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcCodecConfigurationTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcCodecConfigurationTests.java new file mode 100644 index 00000000000..247168ee92f --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcCodecConfigurationTests.java @@ -0,0 +1,100 @@ +/* + * 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.grpc.client.autoconfigure; + +import io.grpc.Codec; +import io.grpc.Compressor; +import io.grpc.CompressorRegistry; +import io.grpc.Decompressor; +import io.grpc.DecompressorRegistry; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GrpcClientCodecConfiguration}. + * + * @author Andrei Lisa + */ +class GrpcCodecConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcClientCodecConfiguration.class)); + + @Test + void whenCodecNotOnClasspathThenAutoconfigurationSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(Codec.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcClientCodecConfiguration.class)); + } + + @Test + void whenHasCustomCompressorRegistryDoesNotAutoConfigureBean() { + CompressorRegistry customRegistry = mock(); + this.contextRunner.withBean("customCompressorRegistry", CompressorRegistry.class, () -> customRegistry) + .run((context) -> assertThat(context).getBean(CompressorRegistry.class).isSameAs(customRegistry)); + } + + @Test + void compressorRegistryAutoConfiguredAsExpected() { + this.contextRunner.run((context) -> assertThat(context).getBean(CompressorRegistry.class) + .isSameAs(CompressorRegistry.getDefaultInstance())); + } + + @Test + void whenCustomCompressorsThenCompressorRegistryIsNewInstance() { + Compressor compressor = mock(); + given(compressor.getMessageEncoding()).willReturn("foo"); + this.contextRunner.withBean(Compressor.class, () -> compressor).run((context) -> { + assertThat(context).hasSingleBean(CompressorRegistry.class); + CompressorRegistry registry = context.getBean(CompressorRegistry.class); + assertThat(registry).isNotSameAs(CompressorRegistry.getDefaultInstance()); + assertThat(registry.lookupCompressor("foo")).isSameAs(compressor); + }); + } + + @Test + void whenHasCustomDecompressorRegistryDoesNotAutoConfigureBean() { + DecompressorRegistry customRegistry = mock(); + this.contextRunner.withBean("customDecompressorRegistry", DecompressorRegistry.class, () -> customRegistry) + .run((context) -> assertThat(context).getBean(DecompressorRegistry.class).isSameAs(customRegistry)); + } + + @Test + void decompressorRegistryAutoConfiguredAsExpected() { + this.contextRunner.run((context) -> assertThat(context).getBean(DecompressorRegistry.class) + .isSameAs(DecompressorRegistry.getDefaultInstance())); + } + + @Test + void whenCustomDecompressorsThenDecompressorRegistryIsNewInstance() { + Decompressor decompressor = mock(); + given(decompressor.getMessageEncoding()).willReturn("foo"); + this.contextRunner.withBean(Decompressor.class, () -> decompressor).run((context) -> { + assertThat(context).hasSingleBean(DecompressorRegistry.class); + DecompressorRegistry registry = context.getBean(DecompressorRegistry.class); + assertThat(registry).isNotSameAs(DecompressorRegistry.getDefaultInstance()); + assertThat(registry.lookupDecompressor("foo")).isSameAs(decompressor); + }); + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesChannelCredentialsProviderTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesChannelCredentialsProviderTests.java new file mode 100644 index 00000000000..45e08db1dcd --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesChannelCredentialsProviderTests.java @@ -0,0 +1,179 @@ +/* + * 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.grpc.client.autoconfigure; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509ExtendedTrustManager; + +import io.grpc.ChannelCredentials; +import io.grpc.InsecureChannelCredentials; +import io.grpc.TlsChannelCredentials; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel; +import org.springframework.boot.ssl.DefaultSslBundleRegistry; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.boot.ssl.SslManagerBundle; +import org.springframework.grpc.client.ChannelCredentialsProvider; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link PropertiesChannelCredentialsProvider}. + * + * @author Phillip Webb + */ +class PropertiesChannelCredentialsProviderTests { + + private final TrustManager[] trustManagers = { mock() }; + + private final KeyManager[] keyManagers = { mock() }; + + @Test + void getChannelCredentialsWhenTargetMatchesChannel() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channelProperties = new Channel(); + channelProperties.getSsl().setEnabled(true); + properties.getChannel().put("test", channelProperties); + SslBundles sslBundles = new DefaultSslBundleRegistry(); + ChannelCredentialsProvider provider = new PropertiesChannelCredentialsProvider(properties, sslBundles); + TlsChannelCredentials credentials = (TlsChannelCredentials) provider.getChannelCredentials("test"); + assertThat(credentials.getTrustManagers()).isNull(); + assertThat(credentials.getKeyManagers()).isNull(); + } + + @Test + void getChannelCredentialsWhenTargetDoesNotMatchChannelAndHasDefault() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channelProperties = new Channel(); + channelProperties.getSsl().setEnabled(true); + properties.getChannel().put("default", channelProperties); + SslBundles sslBundles = new DefaultSslBundleRegistry(); + ChannelCredentialsProvider provider = new PropertiesChannelCredentialsProvider(properties, sslBundles); + TlsChannelCredentials credentials = (TlsChannelCredentials) provider.getChannelCredentials("test"); + assertThat(credentials.getTrustManagers()).isNull(); + assertThat(credentials.getKeyManagers()).isNull(); + } + + @Test + void getChannelCredentialsWhenTargetDoesNotMatchChannelAndHasNoDefaultUsesInsecure() { + GrpcClientProperties properties = new GrpcClientProperties(); + SslBundles sslBundles = new DefaultSslBundleRegistry(); + ChannelCredentialsProvider provider = new PropertiesChannelCredentialsProvider(properties, sslBundles); + ChannelCredentials credentials = provider.getChannelCredentials("test"); + assertThat(credentials).isInstanceOf(InsecureChannelCredentials.class); + } + + @Test + void getChannelCredentialsWhenSslExplictlyDisabled() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channelProperties = new Channel(); + channelProperties.getSsl().setEnabled(false); + properties.getChannel().put("test", channelProperties); + SslBundles sslBundles = new DefaultSslBundleRegistry(); + ChannelCredentialsProvider provider = new PropertiesChannelCredentialsProvider(properties, sslBundles); + ChannelCredentials credentials = provider.getChannelCredentials("test"); + assertThat(credentials).isInstanceOf(InsecureChannelCredentials.class); + } + + @Test + void getChannelCredentialsWhenSslExplictlyEnabledAndNoBundle() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channelProperties = new Channel(); + channelProperties.getSsl().setEnabled(true); + properties.getChannel().put("test", channelProperties); + SslBundles sslBundles = new DefaultSslBundleRegistry(); + ChannelCredentialsProvider provider = new PropertiesChannelCredentialsProvider(properties, sslBundles); + TlsChannelCredentials credentials = (TlsChannelCredentials) provider.getChannelCredentials("test"); + assertThat(credentials.getTrustManagers()).isNull(); + assertThat(credentials.getKeyManagers()).isNull(); + } + + @Test + void getChannelCredentialsWhenNoSslEnabledSetButHasBundle() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channelProperties = new Channel(); + channelProperties.getSsl().setBundle("test"); + properties.getChannel().put("test", channelProperties); + SslBundles sslBundles = new DefaultSslBundleRegistry("test", mockBundle()); + ChannelCredentialsProvider provider = new PropertiesChannelCredentialsProvider(properties, sslBundles); + TlsChannelCredentials credentials = (TlsChannelCredentials) provider.getChannelCredentials("test"); + assertThat(credentials.getTrustManagers()).containsExactly(this.trustManagers); + assertThat(credentials.getKeyManagers()).containsExactly(this.keyManagers); + } + + @Test + void getChannelCredentialsWhenNoSslEnabledSetAndNoBundle() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channelProperties = new Channel(); + properties.getChannel().put("test", channelProperties); + SslBundles sslBundles = new DefaultSslBundleRegistry(); + ChannelCredentialsProvider provider = new PropertiesChannelCredentialsProvider(properties, sslBundles); + ChannelCredentials credentials = provider.getChannelCredentials("test"); + assertThat(credentials).isInstanceOf(InsecureChannelCredentials.class); + } + + @Test + void getChannelCredentialsWhenSslEnabledAndHasBundle() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channelProperties = new Channel(); + channelProperties.getSsl().setEnabled(true); + channelProperties.getSsl().setBundle("test"); + properties.getChannel().put("test", channelProperties); + SslBundles sslBundles = new DefaultSslBundleRegistry("test", mockBundle()); + ChannelCredentialsProvider provider = new PropertiesChannelCredentialsProvider(properties, sslBundles); + TlsChannelCredentials credentials = (TlsChannelCredentials) provider.getChannelCredentials("test"); + assertThat(credentials.getTrustManagers()).containsExactly(this.trustManagers); + assertThat(credentials.getKeyManagers()).containsExactly(this.keyManagers); + } + + @Test + void getChannelCredentialsWhenBypassCertificateValidation() { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channelProperties = new Channel(); + channelProperties.setBypassCertificateValidation(true); + channelProperties.getSsl().setBundle("test"); + properties.getChannel().put("test", channelProperties); + SslBundles sslBundles = new DefaultSslBundleRegistry("test", mockBundle()); + ChannelCredentialsProvider provider = new PropertiesChannelCredentialsProvider(properties, sslBundles); + TlsChannelCredentials credentials = (TlsChannelCredentials) provider.getChannelCredentials("test"); + TrustManager trustManager = credentials.getTrustManagers().get(0); + assertThat(trustManager.getClass().getName()).contains("InsecureTrustManager"); + assertThat(((X509ExtendedTrustManager) trustManager).getAcceptedIssuers()).isEmpty(); + assertThat(credentials.getKeyManagers()).containsExactly(this.keyManagers); + } + + private SslBundle mockBundle() { + SslBundle bundle = mock(); + SslManagerBundle managerBundle = mock(); + TrustManagerFactory trustManagerFactory = mock(); + KeyManagerFactory keyManagerFactory = mock(); + given(bundle.getManagers()).willReturn(managerBundle); + given(managerBundle.getTrustManagerFactory()).willReturn(trustManagerFactory); + given(managerBundle.getKeyManagerFactory()).willReturn(keyManagerFactory); + given(trustManagerFactory.getTrustManagers()).willReturn(this.trustManagers); + given(keyManagerFactory.getKeyManagers()).willReturn(this.keyManagers); + return bundle; + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcChannelBuilderCustomizerTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcChannelBuilderCustomizerTests.java new file mode 100644 index 00000000000..1c29640301a --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcChannelBuilderCustomizerTests.java @@ -0,0 +1,209 @@ +/* + * 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.grpc.client.autoconfigure; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import io.grpc.ClientInterceptor; +import io.grpc.ManagedChannelBuilder; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; +import org.springframework.grpc.client.interceptor.DefaultDeadlineSetupClientInterceptor; +import org.springframework.util.unit.DataSize; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +/** + * Tests for {@link PropertiesGrpcChannelBuilderCustomizer}. + * + * @author Phillip Webb + */ +class PropertiesGrpcChannelBuilderCustomizerTests { + + @Test + > void customizeWhenHasMatchingChannel() { + GrpcClientProperties properties = new GrpcClientProperties(); + properties.getChannel().put("test", createTestChannelProperties()); + GrpcChannelBuilderCustomizer customizer = new PropertiesGrpcChannelBuilderCustomizer<>(properties); + T builder = mock(); + customizer.customize("test", builder); + assertMapped(builder); + } + + @Test + > void customizeWhenHasDefaultChannel() { + GrpcClientProperties properties = new GrpcClientProperties(); + properties.getChannel().put("default", createTestChannelProperties()); + GrpcChannelBuilderCustomizer customizer = new PropertiesGrpcChannelBuilderCustomizer<>(properties); + T builder = mock(); + customizer.customize("test", builder); + assertMapped(builder); + } + + @Test + > void customizeWhenHasNoMatchAndNoDefault() { + GrpcClientProperties properties = new GrpcClientProperties(); + properties.getChannel().put("other", createTestChannelProperties()); + GrpcChannelBuilderCustomizer customizer = new PropertiesGrpcChannelBuilderCustomizer<>(properties); + T builder = mock(); + customizer.customize("test", builder); + assertMappedStockDefaults(builder); + } + + @Test + > void customizeWhenChannelDoesNotSupportLoadBalancingDoesNotMapDefaultLoadBalancer() { + assertNoLoadBalancerMappedBasedOnChannel("unix:test"); + assertNoLoadBalancerMappedBasedOnChannel("in-process:test"); + } + + private > void assertNoLoadBalancerMappedBasedOnChannel(String target) { + T builder = getBuilder((channelProperties) -> { + channelProperties.setTarget(target); + channelProperties.getDefault().setLoadBalancingPolicy("testlbp"); + }); + then(builder).should(never()).defaultLoadBalancingPolicy(any()); + } + + @Test + > void customizeWhenTargetDoesNotSupportLoadBalancingDoesNotMapDefaultLoadBalancer() { + GrpcChannelBuilderCustomizer customizer = getCustomizer( + (channelProperties) -> channelProperties.setTarget("static://localhost:1234")); + assertNoLoadBalancerMappedBasedOnTarget(customizer, "unix:test"); + assertNoLoadBalancerMappedBasedOnTarget(customizer, "in-process:test"); + } + + private > void assertNoLoadBalancerMappedBasedOnTarget( + GrpcChannelBuilderCustomizer customizer, String target) { + T builder = mock(); + customizer.customize(target, builder); + then(builder).should(never()).defaultLoadBalancingPolicy(any()); + } + + @Test + > void customizeWhenMaxMessageSizeIsMinusOneUsesMaxValue() { + T builder = getBuilder( + (channelProperties) -> channelProperties.getInbound().getMessage().setMaxSize(DataSize.ofBytes(-1))); + then(builder).should().maxInboundMessageSize(Integer.MAX_VALUE); + } + + @Test + > void customizeWhenMaxMessageSizeIsTooBigUsesMaxValue() { + T builder = getBuilder((channelProperties) -> channelProperties.getInbound() + .getMessage() + .setMaxSize(DataSize.ofBytes((long) Integer.MAX_VALUE + 100))); + then(builder).should().maxInboundMessageSize(Integer.MAX_VALUE); + } + + @Test + > void customizeWhenMaxMessageSizeIsNegativeAndNotMinusOneThrowsException() { + assertThatIllegalStateException().isThrownBy(() -> getBuilder( + (channelProperties) -> channelProperties.getInbound().getMessage().setMaxSize(DataSize.ofBytes(-2)))) + .withMessage("Unsupported max size value -2B"); + } + + @Test + > void customizeWhenMaxMetadataSizeIsMinusOneUsesMaxValue() { + T builder = getBuilder( + (channelProperties) -> channelProperties.getInbound().getMetadata().setMaxSize(DataSize.ofBytes(-1))); + then(builder).should().maxInboundMetadataSize(Integer.MAX_VALUE); + } + + @Test + > void customizeWhenMaxMetadataSizeIsTooBigUsesMaxValue() { + T builder = getBuilder((channelProperties) -> channelProperties.getInbound() + .getMetadata() + .setMaxSize(DataSize.ofBytes((long) Integer.MAX_VALUE + 100))); + then(builder).should().maxInboundMetadataSize(Integer.MAX_VALUE); + } + + @Test + > void customizeWhenMaxMetadataSizeIsNegativeAndNotMinusOneThrowsException() { + assertThatIllegalStateException().isThrownBy(() -> getBuilder( + (channelProperties) -> channelProperties.getInbound().getMetadata().setMaxSize(DataSize.ofBytes(-2)))) + .withMessage("Unsupported max size value -2B"); + } + + private > T getBuilder(Consumer setup) { + GrpcChannelBuilderCustomizer customizer = getCustomizer(setup); + T builder = mock(); + customizer.customize("test", builder); + return builder; + } + + private > GrpcChannelBuilderCustomizer getCustomizer( + Consumer setup) { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channelProperties = new Channel(); + setup.accept(channelProperties); + properties.getChannel().put("test", channelProperties); + GrpcChannelBuilderCustomizer customizer = new PropertiesGrpcChannelBuilderCustomizer<>(properties); + return customizer; + } + + private Channel createTestChannelProperties() { + Channel properties = new Channel(); + properties.setUserAgent("testua"); + properties.getInbound().getMessage().setMaxSize(DataSize.ofBytes(10)); + properties.getInbound().getMetadata().setMaxSize(DataSize.ofBytes(20)); + properties.getDefault().setDeadline(Duration.ofMinutes(5)); + properties.getDefault().setLoadBalancingPolicy("testlbp"); + properties.getIdle().setTimeout(Duration.ofMinutes(6)); + properties.getKeepalive().setTime(Duration.ofMinutes(7)); + properties.getKeepalive().setTimeout(Duration.ofMinutes(8)); + properties.getKeepalive().setWithoutCalls(true); + return properties; + } + + private > void assertMapped(T builder) { + then(builder).should().userAgent("testua"); + then(builder).should().maxInboundMessageSize(10); + then(builder).should().maxInboundMetadataSize(20); + ArgumentCaptor interceptors = ArgumentCaptor.captor(); + then(builder).should().intercept(interceptors.capture()); + ClientInterceptor interceptor = interceptors.getValue()[0]; + assertThat(interceptor).isInstanceOf(DefaultDeadlineSetupClientInterceptor.class) + .extracting("defaultDeadline") + .isEqualTo(Duration.ofMinutes(5)); + then(builder).should().defaultLoadBalancingPolicy("testlbp"); + then(builder).should().idleTimeout(360000000000L, TimeUnit.NANOSECONDS); + then(builder).should().keepAliveTime(420000000000L, TimeUnit.NANOSECONDS); + then(builder).should().keepAliveTimeout(480000000000L, TimeUnit.NANOSECONDS); + then(builder).should().keepAliveWithoutCalls(true); + } + + private > void assertMappedStockDefaults(T builder) { + then(builder).should().maxInboundMessageSize(4194304); + then(builder).should().maxInboundMetadataSize(8192); + then(builder).should().defaultLoadBalancingPolicy("round_robin"); + then(builder).should().idleTimeout(20000000000L, TimeUnit.NANOSECONDS); + then(builder).should().keepAliveTime(300000000000L, TimeUnit.NANOSECONDS); + then(builder).should().keepAliveTimeout(20000000000L, TimeUnit.NANOSECONDS); + then(builder).should().keepAliveWithoutCalls(false); + then(builder).shouldHaveNoMoreInteractions(); + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesVirtualTargetsTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesVirtualTargetsTests.java new file mode 100644 index 00000000000..ebd497660b2 --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesVirtualTargetsTests.java @@ -0,0 +1,129 @@ +/* + * 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.grpc.client.autoconfigure; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel; +import org.springframework.grpc.client.VirtualTargets; +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link PropertiesVirtualTargets}. + * + * @author Chris Bono + * @author Phillip Webb + */ +class PropertiesVirtualTargetsTests { + + @Test + void getTargetWhenHasMatchingChannel() { + GrpcClientProperties properties = createProperties("test", "my-server:8888"); + VirtualTargets targets = new PropertiesVirtualTargets(new MockEnvironment(), properties); + assertThat(targets.getTarget("test")).isEqualTo("my-server:8888"); + } + + @Test + void getTargetWhenDefaultAndDefaultChannelDefined() { + GrpcClientProperties properties = createProperties("default", "my-server:8888"); + VirtualTargets targets = new PropertiesVirtualTargets(new MockEnvironment(), properties); + assertThat(targets.getTarget("default")).isEqualTo("my-server:8888"); + } + + @Test + void getTargetWhenDefaultAndNoDefaultChannelDefined() { + GrpcClientProperties properties = new GrpcClientProperties(); + VirtualTargets targets = new PropertiesVirtualTargets(new MockEnvironment(), properties); + assertThat(targets.getTarget("default")).isEqualTo("localhost:9090"); + } + + @Test + void getTargetWhenChannelHasStaticTargetReturnsStrippedTarget() { + GrpcClientProperties properties = createProperties("test", "static://my-server:8888"); + VirtualTargets targets = new PropertiesVirtualTargets(new MockEnvironment(), properties); + assertThat(targets.getTarget("test")).isEqualTo("my-server:8888"); + } + + @Test + void getTargetWhenChannelHasTcpTargetReturnsStrippedTarget() { + GrpcClientProperties properties = createProperties("test", "tcp://my-server:8888"); + VirtualTargets targets = new PropertiesVirtualTargets(new MockEnvironment(), properties); + assertThat(targets.getTarget("test")).isEqualTo("my-server:8888"); + } + + @Test + void getTargetWhenChannelHasOtherUrlTarget() { + GrpcClientProperties properties = createProperties("test", "foo://my-server:8888"); + VirtualTargets targets = new PropertiesVirtualTargets(new MockEnvironment(), properties); + assertThat(targets.getTarget("test")).isEqualTo("foo://my-server:8888"); + } + + @Test + void getTargetWhenStaticReturnsStripped() { + GrpcClientProperties properties = new GrpcClientProperties(); + VirtualTargets targets = new PropertiesVirtualTargets(new MockEnvironment(), properties); + assertThat(targets.getTarget("static://my-server:8888")).isEqualTo("my-server:8888"); + } + + @Test + void getTargetWhenTcpReturnsStripped() { + GrpcClientProperties properties = new GrpcClientProperties(); + VirtualTargets targets = new PropertiesVirtualTargets(new MockEnvironment(), properties); + assertThat(targets.getTarget("tcp://my-server:8888")).isEqualTo("my-server:8888"); + } + + @Test + void getTargetWhenUnixUrlDoesNotPrependStatic() { + GrpcClientProperties properties = new GrpcClientProperties(); + VirtualTargets targets = new PropertiesVirtualTargets(new MockEnvironment(), properties); + assertThat(targets.getTarget("foo://bar")).isEqualTo("foo://bar"); + } + + @Test + void getTargetWhenUrlReturnsAsIs() { + GrpcClientProperties properties = new GrpcClientProperties(); + VirtualTargets targets = new PropertiesVirtualTargets(new MockEnvironment(), properties); + assertThat(targets.getTarget("unix:foo")).isEqualTo("unix:foo"); + } + + @Test + void getTargetUrlWhenHasColonWithoutSlashReturnsAsIs() { + GrpcClientProperties properties = new GrpcClientProperties(); + VirtualTargets targets = new PropertiesVirtualTargets(new MockEnvironment(), properties); + assertThat(targets.getTarget("localhost:123/bar")).isEqualTo("localhost:123/bar"); + } + + @Test + void getTargetWhenNotChannelNameResolvesPlaceholders() { + GrpcClientProperties properties = new GrpcClientProperties(); + MockEnvironment environment = new MockEnvironment(); + environment.setProperty("channelName", "foo"); + VirtualTargets targets = new PropertiesVirtualTargets(environment, properties); + assertThat(targets.getTarget("my-server-${channelName}:8888")).isEqualTo("my-server-foo:8888"); + } + + private GrpcClientProperties createProperties(String name, String target) { + GrpcClientProperties properties = new GrpcClientProperties(); + Channel channel = new Channel(); + channel.setTarget(target); + properties.getChannel().put(name, channel); + return properties; + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/ServiceConfigTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/ServiceConfigTests.java new file mode 100644 index 00000000000..f7b4e56174e --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/ServiceConfigTests.java @@ -0,0 +1,393 @@ +/* + * 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.grpc.client.autoconfigure; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import io.grpc.LoadBalancerRegistry; +import io.grpc.NameResolver.ConfigOrError; +import io.grpc.Status.Code; +import io.grpc.internal.AutoConfiguredLoadBalancerFactory; +import io.grpc.internal.ScParser; +import io.grpc.internal.ServiceConfigUtil; +import io.grpc.internal.ServiceConfigUtil.LbConfig; +import io.grpc.internal.ServiceConfigUtil.PolicySelection; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.context.properties.bind.BindException; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException; +import org.springframework.boot.context.properties.source.MutuallyExclusiveConfigurationPropertiesException; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.boot.testsupport.classpath.resources.WithResource; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link ServiceConfig}. + * + * @author Phillip Webb + */ +class ServiceConfigTests { + + @Test + @WithResource(name = "config.yaml", content = """ + config: + load-balancing: + - pickfirst: {} + """) + void pickFirstLoadBalancing() throws Exception { + Map map = bindAndGetAsMap(); + assertThat(map).containsKey("loadBalancingConfig"); + List> loadBalancingConfigs = ServiceConfigUtil.getLoadBalancingConfigsFromServiceConfig(map); + assertThat(loadBalancingConfigs).hasSize(1); + assertThat(loadBalancingConfigs.get(0)).containsKey("pick_first"); + PolicySelection loadBalancingPolicySelection = getLoadBalancingPolicySelection(loadBalancingConfigs); + assertThat(loadBalancingPolicySelection.toString()).contains("PickFirstLoadBalancer"); + assertThat(loadBalancingPolicySelection.getConfig()).extracting("shuffleAddressList").isNull(); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + load-balancing: + - pickfirst: + shuffle-address-list: true + """) + void pickFirstLoadBalancingWithProperties() throws Exception { + Map map = bindAndGetAsMap(); + assertThat(map).containsKey("loadBalancingConfig"); + List> loadBalancingConfigs = ServiceConfigUtil.getLoadBalancingConfigsFromServiceConfig(map); + assertThat(loadBalancingConfigs).hasSize(1); + assertThat(loadBalancingConfigs.get(0)).containsKey("pick_first"); + PolicySelection loadBalancingPolicySelection = getLoadBalancingPolicySelection(loadBalancingConfigs); + assertThat(loadBalancingPolicySelection.toString()).contains("PickFirstLoadBalancer"); + assertThat(loadBalancingPolicySelection.getConfig()).extracting("shuffleAddressList").isEqualTo(Boolean.TRUE); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + load-balancing: + - roundrobin: {} + """) + void roundRobinLoadBalancing() throws Exception { + Map map = bindAndGetAsMap(); + assertThat(map).containsKey("loadBalancingConfig"); + List> loadBalancingConfigs = ServiceConfigUtil.getLoadBalancingConfigsFromServiceConfig(map); + assertThat(loadBalancingConfigs).hasSize(1); + assertThat(loadBalancingConfigs.get(0)).containsKey("round_robin"); + PolicySelection loadBalancingPolicySelection = getLoadBalancingPolicySelection(loadBalancingConfigs); + assertThat(loadBalancingPolicySelection.toString()).contains("policy=round_robin") + .contains("no service config"); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + load-balancing: + - weightedroundrobin: {} + """) + void weightedRoundRobinLoadBalancing() throws Exception { + Map map = bindAndGetAsMap(); + assertThat(map).containsKey("loadBalancingConfig"); + List> loadBalancingConfigs = ServiceConfigUtil.getLoadBalancingConfigsFromServiceConfig(map); + assertThat(loadBalancingConfigs).hasSize(1); + assertThat(loadBalancingConfigs.get(0)).containsKey("weighted_round_robin"); + PolicySelection loadBalancingPolicySelection = getLoadBalancingPolicySelection(loadBalancingConfigs); + assertThat(loadBalancingPolicySelection.toString()).contains("WeightedRoundRobinLoadBalancerProvider"); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + load-balancing: + - weightedroundrobin: + blackout-period: 1m + weight-expiration-period: 500ms + out-of-band-reporting-period: 1s + enable-out-of-band-load-report: true + weight-update-period: 2s + error-utilization-penalty: 0.5 + """) + void weightedRoundRobinLoadBalancingWithProperties() throws Exception { + Map map = bindAndGetAsMap(); + assertThat(map).containsKey("loadBalancingConfig"); + List> loadBalancingConfigs = ServiceConfigUtil.getLoadBalancingConfigsFromServiceConfig(map); + assertThat(loadBalancingConfigs).hasSize(1); + assertThat(loadBalancingConfigs.get(0)).containsKey("weighted_round_robin"); + PolicySelection loadBalancingPolicySelection = getLoadBalancingPolicySelection(loadBalancingConfigs); + assertThat(loadBalancingPolicySelection.toString()).contains("WeightedRoundRobinLoadBalancerProvider"); + assertThat(loadBalancingPolicySelection.getConfig()).extracting("blackoutPeriodNanos") + .isEqualTo(Duration.ofMinutes(1).toNanos()); + assertThat(loadBalancingPolicySelection.getConfig()).extracting("weightExpirationPeriodNanos") + .isEqualTo(Duration.ofMillis(500).toNanos()); + assertThat(loadBalancingPolicySelection.getConfig()).extracting("enableOobLoadReport").isEqualTo(true); + assertThat(loadBalancingPolicySelection.getConfig()).extracting("oobReportingPeriodNanos") + .isEqualTo(Duration.ofSeconds(1).toNanos()); + assertThat(loadBalancingPolicySelection.getConfig()).extracting("weightUpdatePeriodNanos") + .isEqualTo(Duration.ofSeconds(2).toNanos()); + assertThat(loadBalancingPolicySelection.getConfig()).extracting("errorUtilizationPenalty").isEqualTo(0.5f); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + load-balancing: + - grpc: + child: + - roundrobin: {} + - pickfirst: {} + service-name: test + initial-fallback-timeout: 10s + """) + void grpcLoadBalancingWithProperties() throws Exception { + Map map = bindAndGetAsMap(); + assertThat(map).containsKey("loadBalancingConfig"); + List> loadBalancingConfigs = ServiceConfigUtil.getLoadBalancingConfigsFromServiceConfig(map); + assertThat(loadBalancingConfigs).hasSize(1); + assertThat(loadBalancingConfigs.get(0)).containsKey("grpclb"); + PolicySelection loadBalancingPolicySelection = getLoadBalancingPolicySelection(loadBalancingConfigs); + assertThat(loadBalancingPolicySelection.toString()).contains("GrpclbLoadBalancerProvider"); + assertThat(loadBalancingPolicySelection.getConfig()).extracting("mode").hasToString("ROUND_ROBIN"); + assertThat(loadBalancingPolicySelection.getConfig()).extracting("serviceName").isEqualTo("test"); + assertThat(loadBalancingPolicySelection.getConfig()).extracting("fallbackTimeoutMs") + .isEqualTo(Duration.ofSeconds(10).toMillis()); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + load-balancing: + - pickfirst: {} + - weightedroundrobin: {} + """) + void multipleLoadBalancerPolicies() throws Exception { + Map map = bindAndGetAsMap(); + assertThat(map).containsKey("loadBalancingConfig"); + List> loadBalancingConfigs = ServiceConfigUtil.getLoadBalancingConfigsFromServiceConfig(map); + assertThat(loadBalancingConfigs).hasSize(2); + assertThat(loadBalancingConfigs.get(0)).containsKey("pick_first"); + assertThat(loadBalancingConfigs.get(1)).containsKey("weighted_round_robin"); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + load-balancing: + - pickfirst: {} + weightedroundrobin: {} + """) + void whenMultileLoadBalancingPoliciesInListItemThrowsException() { + assertThatExceptionOfType(BindException.class).isThrownBy(() -> bindAndGetAsMap()) + .havingRootCause() + .isInstanceOf(MutuallyExclusiveConfigurationPropertiesException.class); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + load-balancing: + - {} + """) + void whenNoLoadBalancingPoliciesInListItemThrowsException() { + assertThatExceptionOfType(BindException.class).isThrownBy(() -> bindAndGetAsMap()) + .havingRootCause() + .isInstanceOf(InvalidConfigurationPropertyValueException.class); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + method: + - name: + - service: s-one + method: m-one + - service: s-two + method: m-two + wait-for-ready: true + max-request-message: 10KB + max-response-message: 20KB + timeout: 30s + """) + @SuppressWarnings("unchecked") + void methodConfig() throws Exception { + Map map = bindAndGetAsMap(); + assertThat(map).containsKey("methodConfig"); + Map serviceMethodMap = getServiceMethodMap(map, false); + assertThat(serviceMethodMap).containsOnlyKeys("s-one/m-one", "s-two/m-two"); + Object methodInfo = serviceMethodMap.get("s-one/m-one"); + assertThat(methodInfo).extracting("timeoutNanos").isEqualTo(Duration.ofSeconds(30).toNanos()); + assertThat(methodInfo).extracting("waitForReady").isEqualTo(Boolean.TRUE); + assertThat(methodInfo).extracting("maxOutboundMessageSize").isEqualTo(10240); + assertThat(methodInfo).extracting("maxInboundMessageSize").isEqualTo(20480); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + method: + - name: + - service: s-one + method: m-one + retry: + max-attempts: 2 + initial-backoff: 1m + max-backoff: 1h + backoff-multiplier: 2.5 + per-attempt-receive-timeout: 2s + retryable-status-codes: + - cancelled + - already-exists + """) + void methodConfigRetryPolicy() throws Exception { + Map map = bindAndGetAsMap(); + Map serviceMethodMap = getServiceMethodMap(map, true); + Object methodInfo = serviceMethodMap.get("s-one/m-one"); + assertThat(methodInfo).extracting("retryPolicy.maxAttempts").isEqualTo(2); + assertThat(methodInfo).extracting("retryPolicy.initialBackoffNanos").isEqualTo(Duration.ofMinutes(1).toNanos()); + assertThat(methodInfo).extracting("retryPolicy.maxBackoffNanos").isEqualTo(Duration.ofHours(1).toNanos()); + assertThat(methodInfo).extracting("retryPolicy.backoffMultiplier").isEqualTo(2.5); + assertThat(methodInfo).extracting("retryPolicy.perAttemptRecvTimeoutNanos") + .isEqualTo(Duration.ofSeconds(2).toNanos()); + assertThat(methodInfo).extracting("retryPolicy.retryableStatusCodes") + .asInstanceOf(InstanceOfAssertFactories.SET) + .containsExactlyInAnyOrder(Code.CANCELLED, Code.ALREADY_EXISTS); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + method: + - name: + - service: s-one + method: m-one + hedging: + max-attempts: 4 + delay: 6s + non-fatal-status-codes: + - invalid-argument + - deadline-exceeded + """) + void methodConfigHedgingPolicy() throws Exception { + Map map = bindAndGetAsMap(); + Map serviceMethodMap = getServiceMethodMap(map, true); + Object methodInfo = serviceMethodMap.get("s-one/m-one"); + assertThat(methodInfo).extracting("hedgingPolicy.maxAttempts").isEqualTo(4); + assertThat(methodInfo).extracting("hedgingPolicy.hedgingDelayNanos").isEqualTo(Duration.ofSeconds(6).toNanos()); + assertThat(methodInfo).extracting("hedgingPolicy.nonFatalStatusCodes") + .asInstanceOf(InstanceOfAssertFactories.SET) + .containsExactlyInAnyOrder(Code.INVALID_ARGUMENT, Code.DEADLINE_EXCEEDED); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + method: + - name: + - service: s-one + method: m-one + retry: {} + hedging: {} + """) + void whenMultiplePoliciesInMethodConfigThrowsException() { + assertThatExceptionOfType(BindException.class).isThrownBy(() -> bindAndGetAsMap()) + .havingRootCause() + .isInstanceOf(MutuallyExclusiveConfigurationPropertiesException.class); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + retrythrottling: + max-tokens: 2.5 + token-ratio: 1.5 + """) + void retryThrottling() throws Exception { + Map map = bindAndGetAsMap(); + assertThat(map).containsKey("retryThrottling"); + Object throttle = ReflectionTestUtils.invokeMethod(ServiceConfigUtil.class, "getThrottlePolicy", map); + assertThat(throttle).extracting("maxTokens").isEqualTo(2500); + assertThat(throttle).extracting("tokenRatio").isEqualTo(1500); + } + + @Test + @WithResource(name = "config.yaml", content = """ + config: + healthcheck: + service-name: test + """) + @SuppressWarnings("unchecked") + void healthCheck() throws Exception { + Map map = bindAndGetAsMap(); + assertThat(map).containsKey("healthCheckConfig"); + Map healthCheckedService = (Map) ServiceConfigUtil.getHealthCheckedService(map); + assertThat(healthCheckedService).hasSize(1).containsEntry("serviceName", "test"); + } + + private PolicySelection getLoadBalancingPolicySelection(List> rawConfigs) { + List unwrappedConfigs = ServiceConfigUtil.unwrapLoadBalancingConfigList(rawConfigs); + LoadBalancerRegistry registry = LoadBalancerRegistry.getDefaultRegistry(); + ConfigOrError selected = ServiceConfigUtil.selectLbPolicyFromList(unwrappedConfigs, registry); + assertThat(selected).isNotNull(); + PolicySelection policySelection = (PolicySelection) selected.getConfig(); + if (policySelection == null) { + System.err.println(selected); + System.err.println(selected.getError()); + if (selected.getError() != null && selected.getError().asException() != null) { + selected.getError().asException().printStackTrace(); + } + } + assertThat(policySelection).isNotNull(); + return policySelection; + } + + @SuppressWarnings("unchecked") + private Map getServiceMethodMap(Map map, boolean retryEnabled) { + ScParser scParser = new ScParser(retryEnabled, 100, 100, new AutoConfiguredLoadBalancerFactory("pick_first")); + Object config = scParser.parseServiceConfig(map).getConfig(); + assertThat(config).isNotNull(); + Object serviceMethodMap = ReflectionTestUtils.getField(config, "serviceMethodMap"); + assertThat(serviceMethodMap).isNotNull(); + return (Map) serviceMethodMap; + } + + private Map bindAndGetAsMap() throws Exception { + Map map = new LinkedHashMap<>(); + bind().applyTo(map); + return map; + } + + private ServiceConfig bind() throws Exception { + YamlPropertySourceLoader loader = new YamlPropertySourceLoader(); + PropertySource propertySource = loader.load("config.yaml", new ClassPathResource("config.yaml")).get(0); + MockEnvironment environment = new MockEnvironment(); + environment.getPropertySources().addLast(propertySource); + Binder binder = Binder.get(environment); + return binder.bind("config", ServiceConfig.class).get(); + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/test/scan/DummyBlockingGrpc.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/test/scan/DummyBlockingGrpc.java new file mode 100644 index 00000000000..a54e133f342 --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/test/scan/DummyBlockingGrpc.java @@ -0,0 +1,46 @@ +/* + * 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.grpc.client.autoconfigure.test.scan; + +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.stub.AbstractBlockingStub; +import io.grpc.stub.AbstractStub.StubFactory; + +public final class DummyBlockingGrpc { + + private DummyBlockingGrpc() { + } + + public static DummyBlockingStub newBlockingStub(io.grpc.Channel channel) { + return AbstractBlockingStub.newStub((StubFactory) DummyBlockingStub::new, channel); + } + + public static class DummyBlockingStub extends AbstractBlockingStub { + + protected DummyBlockingStub(Channel channel, CallOptions callOptions) { + super(channel, callOptions); + } + + @Override + protected DummyBlockingStub build(Channel channel, CallOptions callOptions) { + return new DummyBlockingStub(channel, callOptions); + } + + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/test/scan/DummyBlockingV2Grpc.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/test/scan/DummyBlockingV2Grpc.java new file mode 100644 index 00000000000..a405adc2adf --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/test/scan/DummyBlockingV2Grpc.java @@ -0,0 +1,46 @@ +/* + * 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.grpc.client.autoconfigure.test.scan; + +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.stub.AbstractBlockingStub; +import io.grpc.stub.AbstractStub.StubFactory; + +public final class DummyBlockingV2Grpc { + + private DummyBlockingV2Grpc() { + } + + public static DummyBlockingV2Stub newBlockingV2Stub(io.grpc.Channel channel) { + return AbstractBlockingStub.newStub((StubFactory) DummyBlockingV2Stub::new, channel); + } + + public static class DummyBlockingV2Stub extends AbstractBlockingStub { + + protected DummyBlockingV2Stub(Channel channel, CallOptions callOptions) { + super(channel, callOptions); + } + + @Override + protected DummyBlockingV2Stub build(Channel channel, CallOptions callOptions) { + return new DummyBlockingV2Stub(channel, callOptions); + } + + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/test/scan/package-info.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/test/scan/package-info.java new file mode 100644 index 00000000000..caad90364c6 --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/test/scan/package-info.java @@ -0,0 +1,20 @@ +/* + * 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. + */ + +@NullMarked +package org.springframework.boot.grpc.client.autoconfigure.test.scan; + +import org.jspecify.annotations.NullMarked; diff --git a/module/spring-boot-grpc-server/build.gradle b/module/spring-boot-grpc-server/build.gradle new file mode 100644 index 00000000000..28f6200c30e --- /dev/null +++ b/module/spring-boot-grpc-server/build.gradle @@ -0,0 +1,60 @@ +/* + * 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. + */ + +plugins { + id "java-library" + id "org.springframework.boot.auto-configuration" + id "org.springframework.boot.configuration-properties" + id "org.springframework.boot.deployed" + id "org.springframework.boot.optional-dependencies" +} + +description = "Spring Boot gRPC Server" + +dependencies { + api(project(":core:spring-boot")) + api("org.springframework.grpc:spring-grpc-core") + + optional(project(":core:spring-boot-autoconfigure")) + optional(project(":module:spring-boot-health")) + optional(project(":module:spring-boot-micrometer-observation")) + optional(project(":module:spring-boot-security")) + optional(project(":module:spring-boot-security-oauth2-resource-server")) + optional("com.fasterxml.jackson.core:jackson-annotations") + optional("io.projectreactor:reactor-core") + optional("io.grpc:grpc-servlet-jakarta") + optional("io.grpc:grpc-services") + optional("io.grpc:grpc-netty") + optional("io.grpc:grpc-netty-shaded") + optional("io.grpc:grpc-inprocess") + optional("io.grpc:grpc-kotlin-stub") { + exclude group: "javax.annotation", module: "javax.annotation-api" + } + optional("io.micrometer:micrometer-core") + optional("io.micrometer:context-propagation") + optional("jakarta.servlet:jakarta.servlet-api") + + testImplementation(project(":core:spring-boot-test")) + testImplementation(project(":test-support:spring-boot-test-support")) + testImplementation(testFixtures(project(":core:spring-boot-autoconfigure"))) + testImplementation("org.springframework:spring-web") + + testRuntimeOnly("ch.qos.logback:logback-classic") +} + +tasks.named("compileTestJava") { + options.nullability.checking = "tests" +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/GrpcServletRegistration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/GrpcServletRegistration.java new file mode 100644 index 00000000000..1ddbbaf9d9b --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/GrpcServletRegistration.java @@ -0,0 +1,116 @@ +/* + * 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.grpc.server; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import io.grpc.ServerServiceDefinition; +import io.grpc.ServiceDescriptor; +import io.grpc.servlet.jakarta.GrpcServlet; +import io.grpc.servlet.jakarta.ServletServerBuilder; +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletRegistration.Dynamic; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.web.servlet.DynamicRegistrationBean; +import org.springframework.core.log.LogMessage; +import org.springframework.grpc.server.service.GrpcServiceConfigurer; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; +import org.springframework.grpc.server.service.GrpcServiceSpec; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; + +/** + * {@link DynamicRegistrationBean} that can be used to register a {@link GrpcServlet}. + * + * @author David Syer + * @author Chris Bono + * @author Toshiaki Maki + * @author Phillip Webb + * @since 4.0.0 + */ +public class GrpcServletRegistration extends DynamicRegistrationBean { + + private static Log logger = LogFactory.getLog(GrpcServletRegistration.class); + + private final GrpcServlet servlet; + + private final String[] urlMappings; + + /** + * Create a new {@link GrpcServletRegistration} instance. + * @param serviceDiscoverer the gRPC service discoverer + * @param serviceConfigurer the gRPC service configurer + */ + public GrpcServletRegistration(GrpcServiceDiscoverer serviceDiscoverer, GrpcServiceConfigurer serviceConfigurer) { + this(serviceDiscoverer, serviceConfigurer, null); + } + + /** + * Create a new {@link GrpcServletRegistration} instance. + * @param serviceDiscoverer the gRPC service discoverer + * @param serviceConfigurer the gRPC service configurer + * @param serverBuilderCustomizer an optional customizer to configure the + * {@link ServletServerBuilder} + */ + public GrpcServletRegistration(GrpcServiceDiscoverer serviceDiscoverer, GrpcServiceConfigurer serviceConfigurer, + @Nullable Consumer serverBuilderCustomizer) { + Assert.notNull(serviceDiscoverer, "'serviceDiscoverer' must not be null"); + Assert.notNull(serviceConfigurer, "'serviceConfigurer' must not be null"); + ServletServerBuilder builder = new ServletServerBuilder(); + List urlMappings = new ArrayList<>(); + for (GrpcServiceSpec spec : serviceDiscoverer.findServices()) { + ServiceDescriptor descriptor = spec.service().bindService().getServiceDescriptor(); + logger.info(LogMessage.format("Registering servlet gRPC service: %s", descriptor.getName())); + urlMappings.add("/" + descriptor.getName() + "/*"); + ServerServiceDefinition definition = serviceConfigurer.configure(spec, null); + builder.addService(definition); + } + if (serverBuilderCustomizer != null) { + serverBuilderCustomizer.accept(builder); + } + this.servlet = builder.buildServlet(); + this.urlMappings = urlMappings.toArray(String[]::new); + } + + @Override + protected Dynamic addRegistration(String description, ServletContext servletContext) { + return servletContext.addServlet(getName(), this.servlet); + } + + @Override + protected void configure(Dynamic registration) { + super.configure(registration); + if (!ObjectUtils.isEmpty(this.urlMappings)) { + registration.addMapping(this.urlMappings); + } + } + + @Override + protected String getDescription() { + return getName(); + } + + private String getName() { + return getOrDeduceName(this.servlet); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ConditionalOnGrpcServerFactoryEnabled.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ConditionalOnGrpcServerFactoryEnabled.java new file mode 100644 index 00000000000..6a166bd13e3 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ConditionalOnGrpcServerFactoryEnabled.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.grpc.server.autoconfigure; + +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.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.context.annotation.Conditional; + +/** + * {@link Conditional @Conditional} that matches when + * {@code spring.grpc.server.factory.enabled} is {@code true} or missing. + * + * @author Phillip Webb + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Documented +@ConditionalOnBooleanProperty(name = "spring.grpc.server.factory.enabled", matchIfMissing = true) +@interface ConditionalOnGrpcServerFactoryEnabled { + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ConditionalOnMissingNetworkGrpcServer.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ConditionalOnMissingNetworkGrpcServer.java new file mode 100644 index 00000000000..fec5c84dfaf --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ConditionalOnMissingNetworkGrpcServer.java @@ -0,0 +1,47 @@ +/* + * 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.grpc.server.autoconfigure; + +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.boot.grpc.server.GrpcServletRegistration; +import org.springframework.context.annotation.Conditional; +import org.springframework.grpc.server.GrpcServerFactory; +import org.springframework.grpc.server.InProcessGrpcServerFactory; + +/** + * {@link Conditional @Conditional} that matches when no network gRPC server is found. + * Concretely: + *

    + *
  • There are no {@link GrpcServletRegistration} beans.
  • + *
  • There are no {@link GrpcServerFactory} beans (ignoring + * {@link InProcessGrpcServerFactory} beans)
  • + *
+ * + * @author Phillip Webb + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Documented +@Conditional(MissingNetworkGrpcServerCondition.class) +@interface ConditionalOnMissingNetworkGrpcServer { + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerAutoConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerAutoConfiguration.java new file mode 100644 index 00000000000..c4b872c2a10 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerAutoConfiguration.java @@ -0,0 +1,107 @@ +/* + * 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.grpc.server.autoconfigure; + +import java.util.List; + +import io.grpc.BindableService; +import io.grpc.CompressorRegistry; +import io.grpc.DecompressorRegistry; +import io.grpc.Grpc; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.grpc.server.GlobalServerInterceptor; +import org.springframework.grpc.server.GrpcServerFactory; +import org.springframework.grpc.server.ServerBuilderCustomizer; +import org.springframework.grpc.server.exception.CompositeGrpcExceptionHandler; +import org.springframework.grpc.server.exception.GrpcExceptionHandler; +import org.springframework.grpc.server.exception.GrpcExceptionHandlerInterceptor; +import org.springframework.grpc.server.exception.ReactiveStubBeanDefinitionRegistrar; +import org.springframework.grpc.server.service.DefaultGrpcServiceConfigurer; +import org.springframework.grpc.server.service.DefaultGrpcServiceDiscoverer; +import org.springframework.grpc.server.service.GrpcServiceConfigurer; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for Spring gRPC server-side + * components. + * + * @author David Syer + * @author Chris Bono + * @author Phillip Webb + * @since 4.1.0 + */ +@AutoConfiguration +@ConditionalOnClass({ GrpcServerFactory.class, Grpc.class }) +@ConditionalOnBean(BindableService.class) +@ConditionalOnBooleanProperty(name = "spring.grpc.server.enabled", matchIfMissing = true) +@EnableConfigurationProperties(GrpcServerProperties.class) +@Import({ GrpcServerCodecConfiguration.class, ServletGrpcServerConfiguration.class, + ShadedNettyGrpcServerConfiguration.class, NettyGrpcServerConfiguration.class, + InProcessGrpcServerConfiguration.class }) +public final class GrpcServerAutoConfiguration { + + @Bean + GrpcServerBuilderCustomizers grpcServerBuilderCustomizers(GrpcServerProperties grpcServerProperties, + ObjectProvider compressorRegistry, + ObjectProvider decompressorRegistry, + ObjectProvider executorProvider, + ObjectProvider> customizers) { + return new GrpcServerBuilderCustomizers(grpcServerProperties, compressorRegistry, decompressorRegistry, + executorProvider, customizers); + } + + @Bean + @ConditionalOnMissingBean(GrpcServiceConfigurer.class) + DefaultGrpcServiceConfigurer grpcServiceConfigurer(ApplicationContext applicationContext) { + return new DefaultGrpcServiceConfigurer(applicationContext); + } + + @Bean + @ConditionalOnMissingBean(GrpcServiceDiscoverer.class) + DefaultGrpcServiceDiscoverer grpcServiceDiscoverer(ApplicationContext applicationContext) { + return new DefaultGrpcServiceDiscoverer(applicationContext); + } + + @Bean + @GlobalServerInterceptor + @ConditionalOnMissingBean + GrpcExceptionHandlerInterceptor globalExceptionHandlerInterceptor(List exceptionHandlers) { + CompositeGrpcExceptionHandler compositeHandler = new CompositeGrpcExceptionHandler( + exceptionHandlers.toArray(GrpcExceptionHandler[]::new)); + return new GrpcExceptionHandlerInterceptor(compositeHandler); + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(name = "com.salesforce.reactivegrpc.common.Function") + @Import(ReactiveStubBeanDefinitionRegistrar.class) + static class ReactiveStubConfiguration { + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerBuilderCustomizers.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerBuilderCustomizers.java new file mode 100644 index 00000000000..5f45bff14de --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerBuilderCustomizers.java @@ -0,0 +1,89 @@ +/* + * 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.grpc.server.autoconfigure; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiConsumer; + +import io.grpc.CompressorRegistry; +import io.grpc.DecompressorRegistry; +import io.grpc.ServerBuilder; +import org.jspecify.annotations.Nullable; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.util.LambdaSafe; +import org.springframework.grpc.server.ServerBuilderCustomizer; + +/** + * Invokes the customizations to a {@link ServerBuilder} based on the provided beans. + * + * @author Chris Bono + * @author Phillip Webb + */ +class GrpcServerBuilderCustomizers { + + private final List> customizers; + + GrpcServerBuilderCustomizers(GrpcServerProperties grpcServerProperties, + ObjectProvider compressorRegistry, + ObjectProvider decompressorRegistry, + ObjectProvider executorProvider, + ObjectProvider> customizers) { + this(grpcServerProperties, compressorRegistry.getIfAvailable(), decompressorRegistry.getIfAvailable(), + executorProvider.getIfAvailable(), customizers.orderedStream().toList()); + } + + GrpcServerBuilderCustomizers(List> customizers) { + this(null, null, null, null, customizers); + } + + GrpcServerBuilderCustomizers(@Nullable GrpcServerProperties grpcServerProperties, + @Nullable CompressorRegistry compressorRegistry, @Nullable DecompressorRegistry decompressorRegistry, + @Nullable GrpcServerExecutorProvider executorProvider, + List> customizers) { + List> all = new ArrayList<>(); + addCustomizer(all, compressorRegistry, ServerBuilder::compressorRegistry); + addCustomizer(all, decompressorRegistry, ServerBuilder::decompressorRegistry); + addCustomizer(all, executorProvider, (builder, bean) -> builder.executor(bean.getExecutor())); + if (grpcServerProperties != null) { + all.add(new PropertiesServerBuilderCustomizer<>(grpcServerProperties)); + } + all.addAll(customizers); + this.customizers = List.copyOf(all); + } + + private static , T> void addCustomizer(List> customizers, + @Nullable T bean, BiConsumer action) { + if (bean != null) { + ServerBuilderCustomizer customizer = (builder) -> action.accept(builder, bean); + customizers.add(customizer); + } + } + + > List> forFactory() { + return List.of(this::apply); + } + + @SuppressWarnings("unchecked") + > void apply(T builder) { + LambdaSafe.callbacks(ServerBuilderCustomizer.class, this.customizers, builder) + .withLogger(GrpcServerBuilderCustomizers.class) + .invoke((customizer) -> customizer.customize(builder)); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerCodecConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerCodecConfiguration.java new file mode 100644 index 00000000000..abeed22da13 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerCodecConfiguration.java @@ -0,0 +1,80 @@ +/* + * 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.grpc.server.autoconfigure; + +import java.util.List; + +import io.grpc.Compressor; +import io.grpc.CompressorRegistry; +import io.grpc.Decompressor; +import io.grpc.DecompressorRegistry; +import io.grpc.ServerBuilder; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * The configuration that contains all gRPC codec related beans. + * + * @author Andrei Lisa + */ +@Configuration(proxyBeanMethods = false) +class GrpcServerCodecConfiguration { + + /** + * The compressor registry that is set on the + * {@link ServerBuilder#compressorRegistry(CompressorRegistry) server builder} . + * @param compressors the compressors to use on the registry + * @return a new {@link CompressorRegistry#newEmptyInstance() registry} with the + * specified compressors or the {@link CompressorRegistry#getDefaultInstance() default + * registry} if no custom compressors are available in the application context. + */ + @Bean + @ConditionalOnMissingBean + CompressorRegistry grpcCompressorRegistry(List compressors) { + if (compressors.isEmpty()) { + return CompressorRegistry.getDefaultInstance(); + } + CompressorRegistry registry = CompressorRegistry.newEmptyInstance(); + compressors.forEach(registry::register); + return registry; + } + + /** + * The decompressor registry that is set on the + * {@link ServerBuilder#decompressorRegistry(DecompressorRegistry) server builder}. + * @param decompressors the decompressors to use on the registry + * @return a new {@link DecompressorRegistry#emptyInstance() registry} with the + * specified decompressors or the {@link DecompressorRegistry#getDefaultInstance() + * default registry} if no custom decompressors are available in the application + * context. + */ + @Bean + @ConditionalOnMissingBean + DecompressorRegistry grpcDecompressorRegistry(List decompressors) { + if (decompressors.isEmpty()) { + return DecompressorRegistry.getDefaultInstance(); + } + DecompressorRegistry registry = DecompressorRegistry.emptyInstance(); + for (Decompressor decompressor : decompressors) { + registry = registry.with(decompressor, false); + } + return registry; + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerExecutorProvider.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerExecutorProvider.java new file mode 100644 index 00000000000..0c914bc7377 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerExecutorProvider.java @@ -0,0 +1,36 @@ +/* + * 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.grpc.server.autoconfigure; + +import java.util.concurrent.Executor; + +/** + * Strategy interface to determine the {@link Executor} to use for the gRPC server. + * + * @author Chris Bono + * @since 4.1.0 + */ +@FunctionalInterface +public interface GrpcServerExecutorProvider { + + /** + * Returns a {@link Executor} for the gRPC server, if it needs to be customized. + * @return the executor to use for the gRPC server + */ + Executor getExecutor(); + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerFactoryCustomizer.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerFactoryCustomizer.java new file mode 100644 index 00000000000..63e65678d28 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerFactoryCustomizer.java @@ -0,0 +1,37 @@ +/* + * 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.grpc.server.autoconfigure; + +import org.springframework.grpc.server.GrpcServerFactory; + +/** + * Callback interface that can be implemented by beans wishing to customize the + * {@link GrpcServerFactory server factory} before it is fully initialized. + * + * @author Chris Bono + * @since 4.1.0 + */ +@FunctionalInterface +public interface GrpcServerFactoryCustomizer { + + /** + * Customize the given {@link GrpcServerFactory}. + * @param serverFactory the server factory to customize + */ + void customize(GrpcServerFactory serverFactory); + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerObservationAutoConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerObservationAutoConfiguration.java new file mode 100644 index 00000000000..19d2da8c2ac --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerObservationAutoConfiguration.java @@ -0,0 +1,73 @@ +/* + * 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.grpc.server.autoconfigure; + +import io.grpc.BindableService; +import io.micrometer.core.instrument.binder.grpc.ObservationGrpcServerInterceptor; +import io.micrometer.core.instrument.kotlin.ObservationCoroutineContextServerInterceptor; +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.grpc.server.GlobalServerInterceptor; +import org.springframework.grpc.server.GrpcServerFactory; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for gRPC server-side observations. + * + * @author Sunny Tang + * @author Chris Bono + * @author Dave Syer + * @since 1.0.0 + */ +@AutoConfiguration( + afterName = "org.springframework.boot.micrometer.observation.autoconfigure.ObservationAutoConfiguration") +@ConditionalOnClass({ BindableService.class, GrpcServerFactory.class, ObservationRegistry.class, + ObservationGrpcServerInterceptor.class }) +@ConditionalOnBean(ObservationRegistry.class) +@ConditionalOnBooleanProperty(name = "spring.grpc.server.enabled", matchIfMissing = true) +@ConditionalOnBooleanProperty(name = "spring.grpc.server.observation.enabled", matchIfMissing = true) +public final class GrpcServerObservationAutoConfiguration { + + @Bean + @Order(0) + @GlobalServerInterceptor + ObservationGrpcServerInterceptor grpcServerObservationInterceptor(ObservationRegistry observationRegistry) { + return new ObservationGrpcServerInterceptor(observationRegistry); + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(name = { "io.grpc.kotlin.AbstractCoroutineStub", "io.micrometer.context.ContextRegistry" }) + static class GrpcServerCoroutineStubConfiguration { + + @Bean + @Order(10) + @GlobalServerInterceptor + ObservationCoroutineContextServerInterceptor grpcServerObservationCoroutineContextServerInterceptor( + ObservationRegistry observationRegistry) { + return new ObservationCoroutineContextServerInterceptor(observationRegistry); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerProperties.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerProperties.java new file mode 100644 index 00000000000..0cd2266f106 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerProperties.java @@ -0,0 +1,495 @@ +/* + * 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.grpc.server.autoconfigure; + +import java.net.InetAddress; +import java.time.Duration; +import java.time.temporal.ChronoUnit; + +import io.grpc.TlsServerCredentials.ClientAuth; +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.convert.DataSizeUnit; +import org.springframework.boot.convert.DurationUnit; +import org.springframework.util.unit.DataSize; +import org.springframework.util.unit.DataUnit; + +/** + * {@link ConfigurationProperties Properties} for Spring gRPC servers. + * + * @author Chris Bono + * @author Phillip Webb + * @since 4.1.0 + */ +@ConfigurationProperties("spring.grpc.server") +public class GrpcServerProperties { + + /** + * Port on which the gRPC server should listen. Use '0' to bind to a dynamic port. + */ + private @Nullable Integer port; + + /** + * Network address to which the gRPC server should bind. + */ + private @Nullable InetAddress address; + + private final Shutdown shutdown = new Shutdown(); + + private final Inbound inbound = new Inbound(); + + private final Inprocess inprocess = new Inprocess(); + + private final Keepalive keepalive = new Keepalive(); + + private final Ssl ssl = new Ssl(); + + private final Netty netty = new Netty(); + + private final Servlet servlet = new Servlet(); + + public @Nullable Integer getPort() { + return this.port; + } + + public void setPort(@Nullable Integer port) { + this.port = port; + } + + public @Nullable InetAddress getAddress() { + return this.address; + } + + public void setAddress(@Nullable InetAddress address) { + this.address = address; + } + + public Shutdown getShutdown() { + return this.shutdown; + } + + public Inbound getInbound() { + return this.inbound; + } + + public Inprocess getInprocess() { + return this.inprocess; + } + + public Keepalive getKeepalive() { + return this.keepalive; + } + + public Ssl getSsl() { + return this.ssl; + } + + public Netty getNetty() { + return this.netty; + } + + public Servlet getServlet() { + return this.servlet; + } + + /** + * Server shutdown properties. + */ + public static class Shutdown { + + /** + * Maximum time to wait for the server to gracefully shutdown. When the value is + * negative, the server waits forever. When the value is 0, the server will force + * shutdown immediately. The default is 30 seconds. + */ + @DurationUnit(ChronoUnit.SECONDS) + private Duration gracePeriod = Duration.ofSeconds(30); + + public Duration getGracePeriod() { + return this.gracePeriod; + } + + public void setGracePeriod(Duration gracePeriod) { + this.gracePeriod = gracePeriod; + } + + } + + /** + * In-bound properties. + */ + public static class Inbound { + + private final Message message = new Message(); + + private final Metadata metadata = new Metadata(); + + public Message getMessage() { + return this.message; + } + + public Metadata getMetadata() { + return this.metadata; + } + + /** + * In-bound message properties. + */ + public static class Message { + + /** + * Maximum message size allowed to be received by the server (default 4MiB). + */ + @DataSizeUnit(DataUnit.BYTES) + private DataSize maxSize = DataSize.ofBytes(4194304); + + public DataSize getMaxSize() { + return this.maxSize; + } + + public void setMaxSize(DataSize maxSize) { + this.maxSize = maxSize; + } + + } + + /** + * In-bound metadata properties. + */ + public static class Metadata { + + /** + * Maximum metadata size allowed to be received by the server (default 8KiB). + */ + @DataSizeUnit(DataUnit.BYTES) + private DataSize maxSize = DataSize.ofBytes(8192); + + public DataSize getMaxSize() { + return this.maxSize; + } + + public void setMaxSize(DataSize maxSize) { + this.maxSize = maxSize; + } + + } + + } + + /** + * In-process gRPC properties. + */ + public static class Inprocess { + + /** + * The name of the in-process server or null to not start the in-process server. + */ + private @Nullable String name; + + public @Nullable String getName() { + return this.name; + } + + public void setName(@Nullable String name) { + this.name = name; + } + + } + + /** + * Keep-alive properties. + */ + public static class Keepalive { + + /** + * Duration without read activity before sending a keep alive ping (default 2h). + */ + @DurationUnit(ChronoUnit.SECONDS) + private @Nullable Duration time = Duration.ofHours(2); + + /** + * Maximum time to wait for read activity after sending a keep alive ping. If + * sender does not receive an acknowledgment within this time, it will close the + * connection (default 20s). + */ + @DurationUnit(ChronoUnit.SECONDS) + private @Nullable Duration timeout = Duration.ofSeconds(20); + + private final Permit permit = new Permit(); + + private final Connection connection = new Connection(); + + public @Nullable Duration getTime() { + return this.time; + } + + public void setTime(@Nullable Duration time) { + this.time = time; + } + + public @Nullable Duration getTimeout() { + return this.timeout; + } + + public void setTimeout(@Nullable Duration timeout) { + this.timeout = timeout; + } + + public Permit getPermit() { + return this.permit; + } + + public Connection getConnection() { + return this.connection; + } + + /** + * Keep-alive permit properties. + */ + public static class Permit { + + /** + * Maximum keep-alive time clients are permitted to configure (default 5m). + */ + @DurationUnit(ChronoUnit.SECONDS) + private @Nullable Duration time = Duration.ofMinutes(5); + + /** + * Whether clients are permitted to send keep alive pings when there are no + * outstanding RPCs on the connection (default false). + */ + private boolean withoutCalls; + + public @Nullable Duration getTime() { + return this.time; + } + + public void setTime(@Nullable Duration time) { + this.time = time; + } + + public boolean isWithoutCalls() { + return this.withoutCalls; + } + + public void setWithoutCalls(boolean withoutCalls) { + this.withoutCalls = withoutCalls; + } + + } + + /** + * Keep-alive connection properties. + */ + public static class Connection { + + /** + * Maximum time a connection can remain idle before being gracefully + * terminated (default infinite). + */ + @DurationUnit(ChronoUnit.SECONDS) + private @Nullable Duration maxIdleTime; + + /** + * Maximum time a connection may exist before being gracefully terminated + * (default infinite). + */ + @DurationUnit(ChronoUnit.SECONDS) + private @Nullable Duration maxAge; + + /** + * Maximum time for graceful connection termination (default infinite). + */ + @DurationUnit(ChronoUnit.SECONDS) + private @Nullable Duration gracePeriod = Duration.ofSeconds(30); + + public @Nullable Duration getMaxIdleTime() { + return this.maxIdleTime; + } + + public void setMaxIdleTime(@Nullable Duration maxIdleTime) { + this.maxIdleTime = maxIdleTime; + } + + public @Nullable Duration getMaxAge() { + return this.maxAge; + } + + public void setMaxAge(@Nullable Duration maxAge) { + this.maxAge = maxAge; + } + + public @Nullable Duration getGracePeriod() { + return this.gracePeriod; + } + + public void setGracePeriod(@Nullable Duration gracePeriod) { + this.gracePeriod = gracePeriod; + } + + } + + } + + /** + * SSL properties. + */ + public static class Ssl { + + /** + * Whether to enable SSL support. + */ + private @Nullable Boolean enabled; + + /** + * Client authentication mode. + */ + private ClientAuth clientAuth = ClientAuth.NONE; + + /** + * SSL bundle name. Should match a bundle configured in spring.ssl.bundle. + */ + private @Nullable String bundle; + + /** + * Flag to indicate that client authentication is secure (i.e. certificates are + * checked). Do not set this to false in production. + */ + private boolean secure = true; + + public @Nullable Boolean getEnabled() { + return this.enabled; + } + + public void setEnabled(@Nullable Boolean enabled) { + this.enabled = enabled; + } + + public @Nullable String getBundle() { + return this.bundle; + } + + public void setBundle(@Nullable String bundle) { + this.bundle = bundle; + } + + public void setClientAuth(ClientAuth clientAuth) { + this.clientAuth = clientAuth; + } + + public ClientAuth getClientAuth() { + return this.clientAuth; + } + + public void setSecure(boolean secure) { + this.secure = secure; + } + + public boolean isSecure() { + return this.secure; + } + + } + + /** + * Netty server properties. + */ + public static class Netty { + + /** + * Transport mechanism used for Netty and Netty Shaded servers. If not specified + * will the appropriate transport will be picked based on the + * 'deomain-socket-path' or 'address/port'. + */ + private @Nullable Transport transport; + + /** + * Path of the domain socket that should be used. + */ + private @Nullable String domainSocketPath; + + public @Nullable Transport getTransport() { + return this.transport; + } + + public void setTransport(@Nullable Transport transport) { + this.transport = transport; + } + + public @Nullable String getDomainSocketPath() { + return this.domainSocketPath; + } + + public void setDomainSocketPath(@Nullable String domainSocketPath) { + this.domainSocketPath = domainSocketPath; + } + + public enum Transport { + + /** + * TCP transport. + */ + TCP, + + /** + * Domain socket transport. + */ + DOMAIN_SOCKET + + } + + } + + /** + * Servlet properties. + */ + public static class Servlet { + + /** + * Whether to use a servlet server in a servlet-based web application. When the + * value is false, a native gRPC server will be created as long as one is + * available, and it will listen on its own port. Should only be needed if the + * GrpcServlet is on the classpath. + */ + private boolean enabled; + + /** + * Whether to validate that HTTP/2 is enabled. Validation may need to be skipped + * if your servlet container is not configured using properties. + */ + private boolean validateHttp2 = true; + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public boolean isValidateHttp2() { + return this.validateHttp2; + } + + public void setValidateHttp2(boolean validateHttp2) { + this.validateHttp2 = validateHttp2; + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerServicesAutoConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerServicesAutoConfiguration.java new file mode 100644 index 00000000000..46fc58604eb --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerServicesAutoConfiguration.java @@ -0,0 +1,58 @@ +/* + * 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.grpc.server.autoconfigure; + +import io.grpc.BindableService; +import io.grpc.protobuf.services.ProtoReflectionServiceV1; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.server.GrpcServerFactory; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for gRPC server services. + * + * @author Haris Zujo + * @author Dave Syer + * @author Chris Bono + * @author Andrey Litvitski + * @since 4.1.0 + */ +@AutoConfiguration(before = GrpcServerAutoConfiguration.class) +@ConditionalOnClass({ GrpcServerFactory.class, io.grpc.Grpc.class }) +@ConditionalOnBooleanProperty(name = "spring.grpc.server.enabled", matchIfMissing = true) +public final class GrpcServerServicesAutoConfiguration { + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(ProtoReflectionServiceV1.class) + @ConditionalOnBean(BindableService.class) + @ConditionalOnBooleanProperty(name = "spring.grpc.server.reflection.enabled", matchIfMissing = true) + static class GrpcServerReflectionServiceConfiguration { + + @Bean + BindableService grpcServerReflectionService() { + return ProtoReflectionServiceV1.newInstance(); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/InProcessGrpcServerConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/InProcessGrpcServerConfiguration.java new file mode 100644 index 00000000000..649853b821b --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/InProcessGrpcServerConfiguration.java @@ -0,0 +1,71 @@ +/* + * 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.grpc.server.autoconfigure; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.server.InProcessGrpcServerFactory; +import org.springframework.grpc.server.lifecycle.GrpcServerLifecycle; +import org.springframework.grpc.server.service.GrpcServiceConfigurer; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; +import org.springframework.util.Assert; + +/** + * {@link Configuration @Configuration} for an in-process gRPC server. + * + * @author David Syer + * @author Chris Bono + * @author Toshiaki Maki + * @author Phillip Webb + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnMissingBean(InProcessGrpcServerFactory.class) +@ConditionalOnProperty("spring.grpc.server.inprocess.name") +@ConditionalOnGrpcServerFactoryEnabled +class InProcessGrpcServerConfiguration { + + @Bean + InProcessGrpcServerFactory inProcessGrpcServerFactory(GrpcServerProperties properties, + GrpcServiceDiscoverer serviceDiscoverer, GrpcServiceConfigurer serviceConfigurer, + GrpcServerBuilderCustomizers grpcServerBuilderCustomizers, + ObjectProvider customizers) { + String inProcessName = properties.getInprocess().getName(); + Assert.state(inProcessName != null, "No inprocess name provided"); + InProcessGrpcServerFactory factory = new InProcessGrpcServerFactory(inProcessName, + grpcServerBuilderCustomizers.forFactory()); + customizers.orderedStream().forEach((customizer) -> customizer.customize(factory)); + serviceDiscoverer.findServices() + .stream() + .map((spec) -> serviceConfigurer.configure(spec, factory)) + .forEach(factory::addService); + return factory; + } + + @Bean + @ConditionalOnBean(InProcessGrpcServerFactory.class) + @ConditionalOnMissingBean(name = "inProcessGrpcServerLifecycle") + GrpcServerLifecycle inProcessGrpcServerLifecycle(InProcessGrpcServerFactory factory, + GrpcServerProperties properties, ApplicationEventPublisher eventPublisher) { + return new GrpcServerLifecycle(factory, properties.getShutdown().getGracePeriod(), eventPublisher); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/MissingNetworkGrpcServerCondition.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/MissingNetworkGrpcServerCondition.java new file mode 100644 index 00000000000..8dbd3943513 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/MissingNetworkGrpcServerCondition.java @@ -0,0 +1,47 @@ +/* + * 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.grpc.server.autoconfigure; + +import org.springframework.boot.autoconfigure.condition.AllNestedConditions; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.grpc.server.GrpcServletRegistration; +import org.springframework.context.annotation.Condition; +import org.springframework.grpc.server.GrpcServerFactory; +import org.springframework.grpc.server.InProcessGrpcServerFactory; + +/** + * {@link Condition} that matches when no network gRPC server is found. + * + * @author Phillip Webb + */ +class MissingNetworkGrpcServerCondition extends AllNestedConditions { + + MissingNetworkGrpcServerCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnMissingBean(GrpcServletRegistration.class) + static class MissingGrpcServletRegistrationBean { + + } + + @ConditionalOnMissingBean(value = GrpcServerFactory.class, ignored = InProcessGrpcServerFactory.class) + static class MissingGrpcFactoryBean { + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/NettyAddress.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/NettyAddress.java new file mode 100644 index 00000000000..4e66805002a --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/NettyAddress.java @@ -0,0 +1,94 @@ +/* + * 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.grpc.server.autoconfigure; + +import java.net.InetAddress; + +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException; +import org.springframework.boot.context.properties.source.MutuallyExclusiveConfigurationPropertiesException; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerProperties.Netty.Transport; +import org.springframework.grpc.internal.GrpcUtils; +import org.springframework.grpc.server.GrpcServerFactory; +import org.springframework.util.StringUtils; + +/** + * Address {@link GrpcServerFactory} address. + * + * @author Phillip Webb + * @param transport the transport to use + * @param address the bind address + * @param port the listen port + * @param domainSocketPath the domain socket path + */ +record NettyAddress(@Nullable Transport transport, @Nullable InetAddress address, @Nullable Integer port, + @Nullable String domainSocketPath) { + + @Override + public final String toString() { + Transport transport = (this.transport != null) ? this.transport : deduceTransport(); + return switch (transport) { + case TCP -> tcpAddress(); + case DOMAIN_SOCKET -> domainSocketAddress(); + }; + } + + private Transport deduceTransport() { + MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> { + entries.put("spring.grpc.server.address", this.address); + entries.put("spring.grpc.server.netty.domain-socket-path", this.domainSocketPath); + }); + MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> { + entries.put("spring.grpc.server.port", this.port); + entries.put("spring.grpc.server.netty.domain-socket-path", this.domainSocketPath); + }); + if (this.address != null || this.port != null) { + return Transport.TCP; + } + if (this.domainSocketPath != null) { + return Transport.DOMAIN_SOCKET; + } + return Transport.TCP; + } + + private String tcpAddress() { + String address = (this.address != null) ? toString(this.address) : GrpcUtils.ANY_IP_ADDRESS; + int port = (this.port != null) ? this.port : GrpcUtils.DEFAULT_PORT; + return address + ":" + port; + } + + private String domainSocketAddress() { + if (!StringUtils.hasText(this.domainSocketPath)) { + throw new InvalidConfigurationPropertyValueException("spring.grpc.server.netty.domain-socket-path", + this.domainSocketPath, + "A path is required when spring.grpc.server.netty.transport is set to 'domain-socket'"); + } + return "unix:" + this.domainSocketPath; + } + + private static String toString(InetAddress address) { + String hostName = address.getHostName(); + return (hostName != null) ? hostName : address.getHostAddress(); + } + + static NettyAddress fromProperties(GrpcServerProperties properties) { + return new NettyAddress(properties.getNetty().getTransport(), properties.getAddress(), properties.getPort(), + properties.getNetty().getDomainSocketPath()); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/NettyGrpcServerConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/NettyGrpcServerConfiguration.java new file mode 100644 index 00000000000..a2901f3ee04 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/NettyGrpcServerConfiguration.java @@ -0,0 +1,74 @@ +/* + * 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.grpc.server.autoconfigure; + +import io.grpc.netty.NettyServerBuilder; +import io.netty.handler.ssl.util.InsecureTrustManagerFactory; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.server.NettyGrpcServerFactory; +import org.springframework.grpc.server.lifecycle.GrpcServerLifecycle; +import org.springframework.grpc.server.service.GrpcServiceConfigurer; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; + +/** + * {@link Configuration @Configuration} for a Netty gRPC server. + * + * @author David Syer + * @author Chris Bono + * @author Toshiaki Maki + * @author Phillip Webb + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(NettyServerBuilder.class) +@ConditionalOnMissingNetworkGrpcServer +@ConditionalOnGrpcServerFactoryEnabled +class NettyGrpcServerConfiguration { + + @Bean + NettyGrpcServerFactory nettyGrpcServerFactory(GrpcServerProperties properties, + GrpcServiceDiscoverer serviceDiscoverer, GrpcServiceConfigurer serviceConfigurer, + GrpcServerBuilderCustomizers grpcServerBuilderCustomizers, SslBundles bundles, + ObjectProvider customizers) { + NettyAddress address = NettyAddress.fromProperties(properties); + ServerCredentials credentials = ServerCredentials.get(properties.getSsl(), bundles, + InsecureTrustManagerFactory.INSTANCE); + NettyGrpcServerFactory factory = new NettyGrpcServerFactory(address.toString(), + grpcServerBuilderCustomizers.forFactory(), credentials.keyManagerFactory(), + credentials.trustManagerFactory(), credentials.clientAuth()); + customizers.orderedStream().forEach((customizer) -> customizer.customize(factory)); + serviceDiscoverer.findServices() + .stream() + .map((spec) -> serviceConfigurer.configure(spec, factory)) + .forEach(factory::addService); + return factory; + } + + @Bean + @ConditionalOnMissingBean(name = "nettyGrpcServerLifecycle") + GrpcServerLifecycle nettyGrpcServerLifecycle(NettyGrpcServerFactory factory, GrpcServerProperties properties, + ApplicationEventPublisher eventPublisher) { + return new GrpcServerLifecycle(factory, properties.getShutdown().getGracePeriod(), eventPublisher); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/PropertiesServerBuilderCustomizer.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/PropertiesServerBuilderCustomizer.java new file mode 100644 index 00000000000..7e32d05a0f7 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/PropertiesServerBuilderCustomizer.java @@ -0,0 +1,89 @@ +/* + * 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.grpc.server.autoconfigure; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import io.grpc.ManagedChannelBuilder; +import io.grpc.ServerBuilder; + +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerProperties.Inbound; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerProperties.Keepalive; +import org.springframework.grpc.server.ServerBuilderCustomizer; +import org.springframework.util.ClassUtils; +import org.springframework.util.unit.DataSize; + +/** + * {@link ServerBuilderCustomizer} that maps {@link GrpcServerProperties} to a + * {@link ManagedChannelBuilder}. + * + * @param the type of server builder + * @param properties the properties to map + * @author Chris Bono + * @author Phillip Webb + */ +record PropertiesServerBuilderCustomizer>( + GrpcServerProperties properties) implements ServerBuilderCustomizer { + + @Override + public void customize(T builder) { + mapInboundProperties(this.properties.getInbound(), builder); + if (supportsKeepAliveProperties(builder)) { + mapKeepaliveProperties(this.properties.getKeepalive(), builder); + } + } + + private void mapInboundProperties(Inbound properties, T builder) { + PropertyMapper map = PropertyMapper.get(); + map.from(properties.getMessage()::getMaxSize).asInt(DataSize::toBytes).to(builder::maxInboundMessageSize); + map.from(properties.getMetadata()::getMaxSize).asInt(DataSize::toBytes).to(builder::maxInboundMetadataSize); + } + + private void mapKeepaliveProperties(Keepalive properties, T builder) { + PropertyMapper map = PropertyMapper.get(); + map.from(properties::getTime).to(durationProperty(builder::keepAliveTime)); + map.from(properties::getTimeout).to(durationProperty(builder::keepAliveTimeout)); + map.from(properties.getConnection()::getMaxIdleTime).to(durationProperty(builder::maxConnectionIdle)); + map.from(properties.getConnection()::getMaxAge).to(durationProperty(builder::maxConnectionAge)); + map.from(properties.getConnection()::getGracePeriod).to(durationProperty(builder::maxConnectionAgeGrace)); + map.from(properties.getPermit()::getTime).to(durationProperty(builder::permitKeepAliveTime)); + map.from(properties.getPermit()::isWithoutCalls).to(builder::permitKeepAliveWithoutCalls); + } + + private Consumer durationProperty(BiConsumer setter) { + return (duration) -> setter.accept(duration.toNanos(), TimeUnit.NANOSECONDS); + } + + private boolean supportsKeepAliveProperties(T builder) { + return !isInstance("io.grpc.inprocess.InProcessServerBuilder", builder) + && !isInstance("io.grpc.servlet.jakarta.ServletServerBuilder", builder); + } + + private boolean isInstance(String className, T builder) { + try { + return ClassUtils.forName(className, builder.getClass().getClassLoader()).isInstance(builder); + } + catch (ClassNotFoundException | LinkageError ex) { + return false; + } + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ServerCredentials.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ServerCredentials.java new file mode 100644 index 00000000000..96354b22618 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ServerCredentials.java @@ -0,0 +1,66 @@ +/* + * 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.grpc.server.autoconfigure; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.TrustManagerFactory; + +import io.grpc.TlsServerCredentials.ClientAuth; +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.ssl.SslBundles; +import org.springframework.boot.ssl.SslManagerBundle; +import org.springframework.util.Assert; + +/** + * Server credential details to use with gRPC servers. + * + * @author Chris Bono + * @author Phillip Webb + * @param keyManagerFactory the key manager factory to use or {@code null} + * @param trustManagerFactory the trust manager factory to use or {@code null} + * @param clientAuth the client auth to use + */ +record ServerCredentials(@Nullable KeyManagerFactory keyManagerFactory, + @Nullable TrustManagerFactory trustManagerFactory, ClientAuth clientAuth) { + + /** + * Return the credentials to use based on the given properties. + * @param properties the SSL properties + * @param bundles the SSL bundles + * @param insecureTrustManagerFactory the trust manager factory to use for insecure + * connections + * @return the server credentials to use + */ + static ServerCredentials get(GrpcServerProperties.Ssl properties, SslBundles bundles, + TrustManagerFactory insecureTrustManagerFactory) { + Boolean enabled = properties.getEnabled(); + String bundle = properties.getBundle(); + ClientAuth clientAuth = properties.getClientAuth(); + if (Boolean.FALSE.equals(enabled) || (enabled == null && bundle == null)) { + return new ServerCredentials(null, null, clientAuth); + } + Assert.state(bundle != null, + () -> "SSL bundle-name is requested when 'spring.grpc.server.ssl.enabled' is true"); + SslManagerBundle managers = bundles.getBundle(bundle).getManagers(); + KeyManagerFactory keyManagerFactory = managers.getKeyManagerFactory(); + TrustManagerFactory trustManagerFactory = (!properties.isSecure()) ? insecureTrustManagerFactory + : managers.getTrustManagerFactory(); + return new ServerCredentials(keyManagerFactory, trustManagerFactory, clientAuth); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ServletGrpcServerConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ServletGrpcServerConfiguration.java new file mode 100644 index 00000000000..5ac366e658a --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ServletGrpcServerConfiguration.java @@ -0,0 +1,61 @@ +/* + * 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.grpc.server.autoconfigure; + +import io.grpc.servlet.jakarta.GrpcServlet; + +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.diagnostics.FailureAnalyzedException; +import org.springframework.boot.grpc.server.GrpcServletRegistration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.grpc.server.service.GrpcServiceConfigurer; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; + +/** + * {@link Configuration @Configuration} for a Servlet gRPC server. + * + * @author David Syer + * @author Chris Bono + * @author Toshiaki Maki + * @author Phillip Webb + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@ConditionalOnClass(GrpcServlet.class) +@ConditionalOnMissingNetworkGrpcServer +@ConditionalOnBooleanProperty(name = "spring.grpc.server.servlet.enabled", matchIfMissing = true) +class ServletGrpcServerConfiguration { + + @Bean + GrpcServletRegistration grpcServletRegistration(Environment environment, GrpcServerProperties properties, + GrpcServiceDiscoverer serviceDiscoverer, GrpcServiceConfigurer serviceConfigurer, + GrpcServerBuilderCustomizers grpcServerBuilderCustomizers) { + if (properties.getServlet().isValidateHttp2() + && !Boolean.TRUE.equals(environment.getProperty("server.http2.enabled", Boolean.class))) { + throw new FailureAnalyzedException( + "Configuration property 'server.http2.enabled' should be set to true for gRPC support", + "Update your application to correct the invalid configuration.\n" + + "You can also set 'spring.grpc.server.servlet.validate-http2' to false to disable the validation."); + } + return new GrpcServletRegistration(serviceDiscoverer, serviceConfigurer, grpcServerBuilderCustomizers::apply); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ShadedNettyGrpcServerConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ShadedNettyGrpcServerConfiguration.java new file mode 100644 index 00000000000..6c8f836887a --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ShadedNettyGrpcServerConfiguration.java @@ -0,0 +1,74 @@ +/* + * 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.grpc.server.autoconfigure; + +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; +import io.grpc.netty.shaded.io.netty.handler.ssl.util.InsecureTrustManagerFactory; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.server.ShadedNettyGrpcServerFactory; +import org.springframework.grpc.server.lifecycle.GrpcServerLifecycle; +import org.springframework.grpc.server.service.GrpcServiceConfigurer; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; + +/** + * {@link Configuration @Configuration} for a Shaded Netty gRPC server. + * + * @author David Syer + * @author Chris Bono + * @author Toshiaki Maki + * @author Phillip Webb + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(NettyServerBuilder.class) +@ConditionalOnMissingNetworkGrpcServer +@ConditionalOnGrpcServerFactoryEnabled +class ShadedNettyGrpcServerConfiguration { + + @Bean + ShadedNettyGrpcServerFactory shadedNettyGrpcServerFactory(GrpcServerProperties properties, + GrpcServiceDiscoverer serviceDiscoverer, GrpcServiceConfigurer serviceConfigurer, + GrpcServerBuilderCustomizers grpcServerBuilderCustomizers, SslBundles bundles, + ObjectProvider customizers) { + NettyAddress address = NettyAddress.fromProperties(properties); + ServerCredentials serverCredentials = ServerCredentials.get(properties.getSsl(), bundles, + InsecureTrustManagerFactory.INSTANCE); + ShadedNettyGrpcServerFactory factory = new ShadedNettyGrpcServerFactory(address.toString(), + grpcServerBuilderCustomizers.forFactory(), serverCredentials.keyManagerFactory(), + serverCredentials.trustManagerFactory(), serverCredentials.clientAuth()); + customizers.orderedStream().forEach((customizer) -> customizer.customize(factory)); + serviceDiscoverer.findServices() + .stream() + .map((spec) -> serviceConfigurer.configure(spec, factory)) + .forEach(factory::addService); + return factory; + } + + @Bean + @ConditionalOnMissingBean(name = "shadedNettyGrpcServerLifecycle") + GrpcServerLifecycle shadedNettyGrpcServerLifecycle(ShadedNettyGrpcServerFactory factory, + GrpcServerProperties properties, ApplicationEventPublisher eventPublisher) { + return new GrpcServerLifecycle(factory, properties.getShutdown().getGracePeriod(), eventPublisher); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponent.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponent.java new file mode 100644 index 00000000000..b523e6113d3 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponent.java @@ -0,0 +1,59 @@ +/* + * 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.grpc.server.autoconfigure.health; + +import org.springframework.boot.grpc.server.health.HealthCheckedGrpcComponent; +import org.springframework.boot.grpc.server.health.StatusAggregator; +import org.springframework.boot.grpc.server.health.StatusMapper; +import org.springframework.boot.health.autoconfigure.contributor.HealthContributorMembership; + +/** + * Auto-configured {@link HealthCheckedGrpcComponent}. + * + * @author Phillip Webb + */ +class AutoConfiguredHealthCheckedGrpcComponent implements HealthCheckedGrpcComponent { + + private final HealthContributorMembership membership; + + private final StatusAggregator statusAggregator; + + private final StatusMapper statusMapper; + + AutoConfiguredHealthCheckedGrpcComponent(HealthContributorMembership membership, StatusAggregator statusAggregator, + StatusMapper statusMapper) { + this.membership = membership; + this.statusAggregator = statusAggregator; + this.statusMapper = statusMapper; + } + + @Override + public boolean isMember(String healthContributorName) { + return this.membership.isMember(healthContributorName); + } + + @Override + public StatusAggregator getStatusAggregator() { + return this.statusAggregator; + } + + @Override + public StatusMapper getStatusMapper() { + return this.statusMapper; + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponents.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponents.java new file mode 100644 index 00000000000..98248c2b885 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponents.java @@ -0,0 +1,144 @@ +/* + * 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.grpc.server.autoconfigure.health; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.function.Supplier; + +import io.grpc.health.v1.HealthCheckResponse.ServingStatus; +import org.jspecify.annotations.Nullable; + +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryUtils; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.annotation.BeanFactoryAnnotationUtils; +import org.springframework.boot.grpc.server.autoconfigure.health.GrpcServerHealthProperties.Service; +import org.springframework.boot.grpc.server.autoconfigure.health.GrpcServerHealthProperties.Status; +import org.springframework.boot.grpc.server.health.HealthCheckedGrpcComponent; +import org.springframework.boot.grpc.server.health.HealthCheckedGrpcComponents; +import org.springframework.boot.grpc.server.health.StatusAggregator; +import org.springframework.boot.grpc.server.health.StatusMapper; +import org.springframework.boot.health.autoconfigure.contributor.HealthContributorMembership; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.util.CollectionUtils; +import org.springframework.util.ObjectUtils; + +/** + * Auto-configured {@link HealthCheckedGrpcComponents}. + * + * @author Phillip Webb + */ +class AutoConfiguredHealthCheckedGrpcComponents implements HealthCheckedGrpcComponents { + + private final HealthCheckedGrpcComponent server; + + private final Map services; + + /** + * Create a new {@link AutoConfiguredHealthCheckedGrpcComponents} instance. + * @param applicationContext the application context used to check for override beans + * @param properties the grpc server health properties + */ + AutoConfiguredHealthCheckedGrpcComponents(ApplicationContext applicationContext, + GrpcServerHealthProperties properties) { + ListableBeanFactory beanFactory = (applicationContext instanceof ConfigurableApplicationContext configurableContext) + ? configurableContext.getBeanFactory() : applicationContext; + StatusAggregator statusAggregator = getNonQualifiedBean(beanFactory, StatusAggregator.class, + () -> StatusAggregator.of(properties.getStatus().getOrder())); + StatusMapper statusMapper = getNonQualifiedBean(beanFactory, StatusMapper.class, + () -> StatusMapper.of(properties.getStatus().getMapping())); + this.server = new AutoConfiguredHealthCheckedGrpcComponent(HealthContributorMembership.always(), + statusAggregator, statusMapper); + this.services = createServices(properties.getService(), beanFactory, statusAggregator, statusMapper); + } + + private Map createServices(Map serviceProperties, + BeanFactory beanFactory, StatusAggregator defaultStatusAggregator, StatusMapper defaultStatusMapper) { + Map services = new TreeMap<>(); + serviceProperties.forEach((serviceName, service) -> { + Status status = service.getStatus(); + StatusAggregator statusAggregator = getQualifiedBean(beanFactory, StatusAggregator.class, serviceName, + () -> createStatusAggregator(status.getOrder(), defaultStatusAggregator)); + StatusMapper statusMapper = getQualifiedBean(beanFactory, StatusMapper.class, serviceName, + () -> createStatusMapper(status.getMapping(), defaultStatusMapper)); + HealthContributorMembership membership = HealthContributorMembership.byIncludeExclude(service.getInclude(), + service.getExclude()); + services.put(serviceName, + new AutoConfiguredHealthCheckedGrpcComponent(membership, statusAggregator, statusMapper)); + }); + return Collections.unmodifiableMap(services); + } + + private StatusAggregator createStatusAggregator(List order, StatusAggregator defaultStatusAggregator) { + return (!CollectionUtils.isEmpty(order)) ? StatusAggregator.of(order) : defaultStatusAggregator; + } + + private StatusMapper createStatusMapper(Map mapping, StatusMapper defaultStatusMapper) { + return (!CollectionUtils.isEmpty(mapping)) ? StatusMapper.of(mapping) : defaultStatusMapper; + } + + private T getNonQualifiedBean(ListableBeanFactory beanFactory, Class type, Supplier fallback) { + List candidates = new ArrayList<>(); + for (String beanName : BeanFactoryUtils.beanNamesForTypeIncludingAncestors(beanFactory, type)) { + String[] aliases = beanFactory.getAliases(beanName); + if (!BeanFactoryAnnotationUtils.isQualifierMatch( + (qualifier) -> !qualifier.equals(beanName) && !ObjectUtils.containsElement(aliases, qualifier), + beanName, beanFactory)) { + candidates.add(beanName); + } + } + if (candidates.isEmpty()) { + return fallback.get(); + } + if (candidates.size() == 1) { + return beanFactory.getBean(candidates.get(0), type); + } + return beanFactory.getBean(type); + } + + private T getQualifiedBean(BeanFactory beanFactory, Class type, String qualifier, Supplier fallback) { + try { + return BeanFactoryAnnotationUtils.qualifiedBeanOfType(beanFactory, type, qualifier); + } + catch (NoSuchBeanDefinitionException ex) { + return fallback.get(); + } + } + + @Override + public @Nullable HealthCheckedGrpcComponent getServer() { + return this.server; + } + + @Override + public Set getServiceNames() { + return this.services.keySet(); + } + + @Override + public @Nullable HealthCheckedGrpcComponent getService(String serviceName) { + return this.services.get(serviceName); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfiguration.java new file mode 100644 index 00000000000..f525a6e77b6 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfiguration.java @@ -0,0 +1,142 @@ +/* + * 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.grpc.server.autoconfigure.health; + +import io.grpc.BindableService; +import io.grpc.Grpc; +import io.grpc.protobuf.services.HealthStatusManager; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.grpc.server.autoconfigure.health.GrpcServerHealthAutoConfiguration.NotDisabledAndHasBindableServiceOrExplicitlyEnabledCondition; +import org.springframework.boot.grpc.server.health.GrpcServerHealth; +import org.springframework.boot.grpc.server.health.HealthCheckedGrpcComponents; +import org.springframework.boot.grpc.server.health.StatusAggregator; +import org.springframework.boot.grpc.server.health.StatusMapper; +import org.springframework.boot.health.autoconfigure.contributor.HealthContributorMembershipValidator; +import org.springframework.boot.health.registry.HealthContributorRegistry; +import org.springframework.boot.health.registry.ReactiveHealthContributorRegistry; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.server.GrpcServerFactory; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for gRPC server-side health service. + * + * @author Daniel Theuke + * @author Chris Bono + * @author Phillip Webb + * @since 4.1.0 + */ +@AutoConfiguration( + afterName = "org.springframework.boot.health.autoconfigure.registry.HealthContributorRegistryAutoConfiguration") +@ConditionalOnClass({ GrpcServerFactory.class, Grpc.class, HealthStatusManager.class }) +@ConditionalOnBooleanProperty(name = "spring.grpc.server.enabled", matchIfMissing = true) +@Conditional(NotDisabledAndHasBindableServiceOrExplicitlyEnabledCondition.class) +@EnableConfigurationProperties(GrpcServerHealthProperties.class) +public final class GrpcServerHealthAutoConfiguration { + + @Bean(destroyMethod = "enterTerminalState") + @ConditionalOnMissingBean + HealthStatusManager grpcServerHealthStatusManager() { + return new HealthStatusManager(); + } + + @Bean + BindableService grpcServerHealthService(HealthStatusManager healthStatusManager) { + return healthStatusManager.getHealthService(); + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnBean(type = "org.springframework.boot.health.registry.HealthContributorRegistry") + static class GrpcServerHealthContributorConfiguration { + + static final String VALIDATE_MEMBERSHIP_PROPERTY = "spring.grpc.server.health.services.validate-membership"; + + @Bean + @ConditionalOnMissingBean + StatusAggregator grpcServerHealthStatusAggregator(GrpcServerHealthProperties properties) { + return StatusAggregator.of(properties.getStatus().getOrder()); + } + + @Bean + @ConditionalOnMissingBean + StatusMapper grpcServerHealthHttpCodeStatusMapper(GrpcServerHealthProperties properties) { + return StatusMapper.of(properties.getStatus().getMapping()); + } + + @Bean + @ConditionalOnMissingBean(HealthCheckedGrpcComponents.class) + AutoConfiguredHealthCheckedGrpcComponents grpcServerHealthCheckedGrpcComponents( + ApplicationContext applicationContext, GrpcServerHealthProperties properties) { + return new AutoConfiguredHealthCheckedGrpcComponents(applicationContext, properties); + } + + @Bean + @ConditionalOnMissingBean + GrpcServerHealth grpcServerHealth(HealthContributorRegistry healthContributorRegistry, + ObjectProvider reactiveHealthContributorRegistry, + HealthCheckedGrpcComponents healthCheckedGrpcComponents) { + return new GrpcServerHealth(healthContributorRegistry, reactiveHealthContributorRegistry.getIfAvailable(), + healthCheckedGrpcComponents); + } + + @Bean + @ConditionalOnBooleanProperty(name = VALIDATE_MEMBERSHIP_PROPERTY, matchIfMissing = true) + HealthContributorMembershipValidator grpcServerHealthServiceMembershipValidator( + GrpcServerHealthProperties properties, HealthContributorRegistry healthContributorRegistry, + ObjectProvider reactiveHealthContributorRegistry) { + return new HealthContributorMembershipValidator(healthContributorRegistry, + reactiveHealthContributorRegistry.getIfAvailable(), VALIDATE_MEMBERSHIP_PROPERTY, + (members) -> properties.getService().forEach((serviceName, service) -> { + String property = "spring.grpc.server.health.service." + serviceName; + members.member(property + ".include".formatted(serviceName), service.getInclude()); + members.member(property + ".exclude".formatted(serviceName), service.getExclude()); + })); + } + + } + + static class NotDisabledAndHasBindableServiceOrExplicitlyEnabledCondition extends AnyNestedCondition { + + NotDisabledAndHasBindableServiceOrExplicitlyEnabledCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnBean(BindableService.class) + @ConditionalOnBooleanProperty(name = "spring.grpc.server.health.enabled", matchIfMissing = true) + static class NotDisabledAndHasBindableService { + + } + + @ConditionalOnBooleanProperty(name = "spring.grpc.server.health.enabled") + static class ExplicitlyEnabled { + + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthProperties.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthProperties.java new file mode 100644 index 00000000000..fc80154becc --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthProperties.java @@ -0,0 +1,248 @@ +/* + * 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.grpc.server.autoconfigure.health; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import io.grpc.health.v1.HealthCheckResponse.ServingStatus; +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; +import org.springframework.util.CollectionUtils; + +/** + * {@link ConfigurationProperties Properties} for Spring gRPC server health. + * + * @author Phillip Webb + * @author Chris Bono + * @since 4.1.0 + */ +@ConfigurationProperties("spring.grpc.server.health") +public class GrpcServerHealthProperties { + + /** + * Whether to auto-configure Health feature on the gRPC server. + */ + private @Nullable Boolean enabled; + + /** + * Whether to include the overall server health. + */ + private boolean includeOverallHealth = true; + + /** + * Properties that apply to all services. + */ + private final Services services = new Services(); + + /** + * Service specific health reporting. + */ + private final Map service = new LinkedHashMap<>(); + + /** + * Status configuration. + */ + private final Status status = new Status(); + + /** + * Schedule configuration. + */ + private final Schedule schedule = new Schedule(); + + public @Nullable Boolean getEnabled() { + return this.enabled; + } + + public void setEnabled(@Nullable Boolean enabled) { + this.enabled = enabled; + } + + public boolean isIncludeOverallHealth() { + return this.includeOverallHealth; + } + + public void setIncludeOverallHealth(boolean includeOverallHealth) { + this.includeOverallHealth = includeOverallHealth; + } + + public Services getServices() { + return this.services; + } + + public Map getService() { + return this.service; + } + + public Status getStatus() { + return this.status; + } + + public Schedule getSchedule() { + return this.schedule; + } + + /** + * Properties applying to all services. + */ + public static class Services { + + /** + * Whether to validate health membership for services on startup. Validation fails + * if a service includes or excludes a health contributor that does not exist. + */ + private boolean validateMembership = true; + + public boolean isValidateMembership() { + return this.validateMembership; + } + + public void setValidateMembership(boolean validateMembership) { + this.validateMembership = validateMembership; + } + + } + + /** + * A health for a specific service. + */ + public static class Service { + + /** + * Health indicator IDs that should be included or '*' for all. + */ + private @Nullable Set include; + + /** + * Health indicator IDs that should be excluded or '*' for all. + */ + private @Nullable Set exclude; + + /** + * Status configuration. + */ + @NestedConfigurationProperty + private final Status status = new Status(); + + public Status getStatus() { + return this.status; + } + + public @Nullable Set getInclude() { + return this.include; + } + + public void setInclude(@Nullable Set include) { + this.include = include; + } + + public @Nullable Set getExclude() { + return this.exclude; + } + + public void setExclude(@Nullable Set exclude) { + this.exclude = exclude; + } + + } + + /** + * Status properties for the group. + */ + public static class Status { + + /** + * List of health statuses in order of severity. + */ + private List order = new ArrayList<>(); + + /** + * Mapping of health statuses to gRPC service status. By default, registered + * health statuses map to sensible defaults (for example, UP maps to SERVING). + */ + private final Map mapping = new HashMap<>(); + + public List getOrder() { + return this.order; + } + + public void setOrder(List statusOrder) { + if (!CollectionUtils.isEmpty(statusOrder)) { + this.order = statusOrder; + } + } + + public Map getMapping() { + return this.mapping; + } + + } + + /** + * Health task scheduling. + */ + public static class Schedule { + + /** + * Whether to schedule updates to gRPC server health based on application health. + */ + private boolean enabled = true; + + /** + * How often to update the health status. + */ + private Duration period = Duration.ofSeconds(5); + + /** + * The initial delay before updating the health status the very first time. + */ + private Duration delay = Duration.ofSeconds(5); + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public Duration getPeriod() { + return this.period; + } + + public void setPeriod(Duration period) { + this.period = period; + } + + public Duration getDelay() { + return this.delay; + } + + public void setDelay(Duration delay) { + this.delay = delay; + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthScheduler.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthScheduler.java new file mode 100644 index 00000000000..3bf7cb8a0af --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthScheduler.java @@ -0,0 +1,69 @@ +/* + * 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.grpc.server.autoconfigure.health; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.ScheduledFuture; + +import io.grpc.protobuf.services.HealthStatusManager; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.boot.grpc.server.health.GrpcServerHealth; +import org.springframework.context.ApplicationListener; +import org.springframework.core.log.LogMessage; +import org.springframework.grpc.server.lifecycle.GrpcServerStartedEvent; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.util.function.SingletonSupplier; + +/** + * Schedules gRPC health updates one the gRPC server has been started. + * + * @author Phillip Webb + * @author Chris Bono + */ +class GrpcServerHealthScheduler implements ApplicationListener { + + private static final Log logger = LogFactory.getLog(GrpcServerHealthScheduler.class); + + private final SingletonSupplier> scheduleHealth; + + GrpcServerHealthScheduler(GrpcServerHealth grpcServerHealth, HealthStatusManager grpcServerHealthStatusManager, + TaskScheduler taskScheduler, Duration period, Duration delay) { + this(Clock.systemDefaultZone(), grpcServerHealth, grpcServerHealthStatusManager, taskScheduler, period, delay); + } + + GrpcServerHealthScheduler(Clock clock, GrpcServerHealth grpcServerHealth, + HealthStatusManager grpcServerHealthStatusManager, TaskScheduler taskScheduler, Duration period, + Duration delay) { + this.scheduleHealth = SingletonSupplier.of(() -> { + logger.debug(LogMessage + .of(() -> "Scheduling gRPC server health updates every %s seconds (after a delay of %s seconds)" + .formatted((period.toMillis() / 1000.0), delay.toMillis() / 1000.0))); + Runnable task = () -> grpcServerHealth.update(grpcServerHealthStatusManager); + return taskScheduler.scheduleAtFixedRate(task, Instant.now(clock).plus(delay), period); + }); + } + + @Override + public void onApplicationEvent(GrpcServerStartedEvent event) { + this.scheduleHealth.get(); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthSchedulerAutoConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthSchedulerAutoConfiguration.java new file mode 100644 index 00000000000..972d4a79d3f --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthSchedulerAutoConfiguration.java @@ -0,0 +1,58 @@ +/* + * 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.grpc.server.autoconfigure.health; + +import io.grpc.Grpc; +import io.grpc.protobuf.services.HealthStatusManager; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.task.DefaultTaskSchedulerConfiguration; +import org.springframework.boot.grpc.server.autoconfigure.health.GrpcServerHealthProperties.Schedule; +import org.springframework.boot.grpc.server.health.GrpcServerHealth; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.grpc.server.GrpcServerFactory; +import org.springframework.scheduling.TaskScheduler; + +/** + * {@link EnableAutoConfiguration Auto-configuration} to invoke {@link GrpcServerHealth} + * updates using a {@link TaskScheduler}. + * + * @author Phillip Webb + * @since 4.1.0 + */ +@AutoConfiguration(after = GrpcServerHealthAutoConfiguration.class) +@ConditionalOnBooleanProperty(name = "spring.grpc.server.health.schedule.enabled", matchIfMissing = true) +@Import(DefaultTaskSchedulerConfiguration.class) +@ConditionalOnClass({ GrpcServerFactory.class, Grpc.class, HealthStatusManager.class }) +public final class GrpcServerHealthSchedulerAutoConfiguration { + + @Bean + @ConditionalOnBean({ TaskScheduler.class, GrpcServerHealth.class }) + GrpcServerHealthScheduler grpcServerHealthScheduler(GrpcServerHealth grpcServerHealth, + HealthStatusManager grpcServerHealthStatusManager, TaskScheduler taskScheduler, + GrpcServerHealthProperties properties) { + Schedule schedule = properties.getSchedule(); + return new GrpcServerHealthScheduler(grpcServerHealth, grpcServerHealthStatusManager, taskScheduler, + schedule.getPeriod(), schedule.getDelay()); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/package-info.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/package-info.java new file mode 100644 index 00000000000..764a25d911e --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/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. + */ + +/** + * Auto-configuration for gRPC server health checks. + */ +@NullMarked +package org.springframework.boot.grpc.server.autoconfigure.health; + +import org.jspecify.annotations.NullMarked; diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/package-info.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/package-info.java new file mode 100644 index 00000000000..70facb5a710 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/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. + */ + +/** + * Auto-configuration for gRPC server. + */ +@NullMarked +package org.springframework.boot.grpc.server.autoconfigure; + +import org.jspecify.annotations.NullMarked; diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcDisableCsrfHttpConfigurer.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcDisableCsrfHttpConfigurer.java new file mode 100644 index 00000000000..fa407365734 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcDisableCsrfHttpConfigurer.java @@ -0,0 +1,86 @@ +/* + * 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.grpc.server.autoconfigure.security; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.beans.factory.BeanFactoryUtils; +import org.springframework.boot.grpc.server.GrpcServletRegistration; +import org.springframework.boot.grpc.server.autoconfigure.security.web.servlet.GrpcRequest; +import org.springframework.context.ApplicationContext; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; +import org.springframework.security.web.csrf.CsrfFilter; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.ObjectUtils; + +/** + * A custom {@link AbstractHttpConfigurer} that disables CSRF protection for gRPC + * requests. + *

+ * This configurer checks the application context to determine if CSRF protection should + * be disabled for gRPC requests based on the property + * {@code spring.grpc.server.security.csrf.enabled}. By default, CSRF protection is + * disabled unless explicitly enabled in the application properties. + *

+ * + * @author Dave Syer + * @see AbstractHttpConfigurer + * @see HttpSecurity + */ +class GrpcDisableCsrfHttpConfigurer extends AbstractHttpConfigurer { + + @Override + public void init(HttpSecurity http) { + ApplicationContext context = http.getSharedObject(ApplicationContext.class); + if (context != null && isCsrfConfigurerPresent(http) && hasBean(context, GrpcServiceDiscoverer.class) + && hasBean(context, GrpcServletRegistration.class) && isCsrfEnabled(context)) { + http.csrf(this::disable); + } + } + + @SuppressWarnings("unchecked") + private boolean isCsrfConfigurerPresent(HttpSecurity http) { + return http.getConfigurer(CsrfConfigurer.class) != null; + } + + private boolean hasBean(ApplicationContext context, Class type) { + return !ObjectUtils.isEmpty(BeanFactoryUtils.beanNamesForTypeIncludingAncestors(context, type)); + } + + private boolean isCsrfEnabled(ApplicationContext context) { + return context.getEnvironment().getProperty("spring.grpc.server.security.csrf.enabled", Boolean.class, true); + } + + private void disable(CsrfConfigurer csrf) { + csrf.requireCsrfProtectionMatcher(GrpcCsrfRequestMatcher.INSTANCE); + } + + static class GrpcCsrfRequestMatcher implements RequestMatcher { + + static GrpcCsrfRequestMatcher INSTANCE = new GrpcCsrfRequestMatcher(); + + @Override + public boolean matches(HttpServletRequest request) { + return CsrfFilter.DEFAULT_CSRF_MATCHER.matches(request) && !GrpcRequest.toAnyService().matches(request); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerOAuth2ResourceServerAutoConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerOAuth2ResourceServerAutoConfiguration.java new file mode 100644 index 00000000000..26350670885 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerOAuth2ResourceServerAutoConfiguration.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.grpc.server.autoconfigure.security; + +import io.grpc.BindableService; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.grpc.server.GlobalServerInterceptor; +import org.springframework.grpc.server.GrpcServerFactory; +import org.springframework.grpc.server.security.AuthenticationProcessInterceptor; +import org.springframework.grpc.server.security.GrpcSecurity; +import org.springframework.security.config.ObjectPostProcessor; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; + +import static org.springframework.security.config.Customizer.withDefaults; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for gRPC OAuth2 resource server. + * + * @author Dave Syer + * @author Andrey Litvitski + * @author Phillip Webb + * @since 4.1.0 + */ +@AutoConfiguration(beforeName = "org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration", + afterName = { "org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration", + "org.springframework.boot.security.oauth2.server.resource.autoconfigure.OAuth2ResourceServerAutoConfiguration" }, + after = { GrpcServerSecurityAutoConfiguration.class, GrpcServerAutoConfiguration.class }) +@ConditionalOnBooleanProperty(name = "spring.grpc.server.enabled", matchIfMissing = true) +@ConditionalOnClass({ BindableService.class, GrpcServerFactory.class, ObjectPostProcessor.class }) +@ConditionalOnMissingBean(AuthenticationProcessInterceptor.class) +@ConditionalOnBean(GrpcSecurity.class) +public final class GrpcServerOAuth2ResourceServerAutoConfiguration { + + @Bean + @ConditionalOnBean(OpaqueTokenIntrospector.class) + @GlobalServerInterceptor + AuthenticationProcessInterceptor opaqueTokenAuthenticationProcessInterceptor(GrpcSecurity grpcSecurity) + throws Exception { + grpcSecurity.authorizeRequests((requests) -> requests.allRequests().authenticated()); + grpcSecurity.oauth2ResourceServer((resourceServer) -> resourceServer.opaqueToken(withDefaults())); + return grpcSecurity.build(); + } + + @Bean + @ConditionalOnBean(JwtDecoder.class) + @GlobalServerInterceptor + AuthenticationProcessInterceptor jwtAuthenticationProcessInterceptor(GrpcSecurity grpcSecurity) throws Exception { + grpcSecurity.authorizeRequests((requests) -> requests.allRequests().authenticated()); + grpcSecurity.oauth2ResourceServer((resourceServer) -> resourceServer.jwt(withDefaults())); + return grpcSecurity.build(); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerSecurityAutoConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerSecurityAutoConfiguration.java new file mode 100644 index 00000000000..07ef0e7afc0 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerSecurityAutoConfiguration.java @@ -0,0 +1,127 @@ +/* + * 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.grpc.server.autoconfigure.security; + +import io.grpc.BindableService; +import io.grpc.internal.GrpcUtil; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.grpc.server.GrpcServletRegistration; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerAutoConfiguration; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerExecutorProvider; +import org.springframework.boot.grpc.server.autoconfigure.security.GrpcServerSecurityAutoConfiguration.ExceptionHandlerConfiguration; +import org.springframework.boot.grpc.server.autoconfigure.security.GrpcServerSecurityAutoConfiguration.GrpcNativeSecurityConfigurerConfiguration; +import org.springframework.boot.grpc.server.autoconfigure.security.GrpcServerSecurityAutoConfiguration.GrpcServletSecurityConfigurerConfiguration; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.grpc.server.GlobalServerInterceptor; +import org.springframework.grpc.server.GrpcServerFactory; +import org.springframework.grpc.server.security.CoroutineSecurityContextInterceptor; +import org.springframework.grpc.server.security.GrpcSecurity; +import org.springframework.grpc.server.security.SecurityContextServerInterceptor; +import org.springframework.grpc.server.security.SecurityGrpcExceptionHandler; +import org.springframework.security.concurrent.DelegatingSecurityContextExecutor; +import org.springframework.security.config.ObjectPostProcessor; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.authentication.configuration.EnableGlobalAuthentication; +import org.springframework.security.web.SecurityFilterChain; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for gRPC server-side security. + * + * @author Dave Syer + * @author Chris Bono + * @author Andrey Litvitski + * @author Phillip Webb + * @since 4.1.0 + */ +@AutoConfiguration(after = GrpcServerAutoConfiguration.class, + afterName = "org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration") +@ConditionalOnBooleanProperty(name = "spring.grpc.server.enabled", matchIfMissing = true) +@ConditionalOnClass({ BindableService.class, GrpcServerFactory.class, ObjectPostProcessor.class }) +@Import({ ExceptionHandlerConfiguration.class, GrpcNativeSecurityConfigurerConfiguration.class, + GrpcServletSecurityConfigurerConfiguration.class }) +public final class GrpcServerSecurityAutoConfiguration { + + @Configuration(proxyBeanMethods = false) + @Import(AuthenticationConfiguration.class) + static class ExceptionHandlerConfiguration { + + @Bean + SecurityGrpcExceptionHandler accessExceptionHandler() { + return new SecurityGrpcExceptionHandler(); + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnBean(GrpcServerFactory.class) + @EnableGlobalAuthentication + static class GrpcNativeSecurityConfigurerConfiguration { + + @Bean + GrpcSecurity grpcSecurity(ApplicationContext context, ObjectPostProcessor objectPostProcessor, + AuthenticationConfiguration authenticationConfiguration) { + AuthenticationManagerBuilder authenticationManagerBuilder = authenticationConfiguration + .authenticationManagerBuilder(objectPostProcessor, context); + authenticationManagerBuilder + .parentAuthenticationManager(authenticationConfiguration.getAuthenticationManager()); + return new GrpcSecurity(objectPostProcessor, authenticationManagerBuilder, context); + } + + } + + @ConditionalOnBean({ GrpcServletRegistration.class, SecurityFilterChain.class }) + @Configuration(proxyBeanMethods = false) + static class GrpcServletSecurityConfigurerConfiguration { + + @Bean + @GlobalServerInterceptor + SecurityContextServerInterceptor securityContextInterceptor() { + return new SecurityContextServerInterceptor(); + } + + @Bean + @ConditionalOnMissingBean + GrpcServerExecutorProvider grpcServerExecutorProvider() { + return () -> new DelegatingSecurityContextExecutor(GrpcUtil.SHARED_CHANNEL_EXECUTOR.create()); + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(name = "io.grpc.kotlin.CoroutineContextServerInterceptor") + static class GrpcClientCoroutineStubConfiguration { + + @Bean + @GlobalServerInterceptor + @ConditionalOnMissingBean + CoroutineSecurityContextInterceptor coroutineSecurityContextInterceptor() { + return new CoroutineSecurityContextInterceptor(); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/package-info.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/package-info.java new file mode 100644 index 00000000000..264c92d6e9a --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/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. + */ + +/** + * Auto-configuration for gRPC server security. + */ +@NullMarked +package org.springframework.boot.grpc.server.autoconfigure.security; + +import org.jspecify.annotations.NullMarked; diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/reactive/GrpcRequest.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/reactive/GrpcRequest.java new file mode 100644 index 00000000000..c298429d767 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/reactive/GrpcRequest.java @@ -0,0 +1,148 @@ +/* + * 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.grpc.server.autoconfigure.security.web.reactive; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Mono; + +import org.springframework.boot.grpc.server.autoconfigure.security.web.servlet.GrpcRequest.GrpcServletRequestMatcher; +import org.springframework.boot.security.web.reactive.ApplicationContextServerWebExchangeMatcher; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; +import org.springframework.security.web.server.util.matcher.OrServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.web.server.ServerWebExchange; + +/** + * Factory that can be used to create a {@link ServerWebExchangeMatcher} to match against + * gRPC service locations. + * + * @author Dave Syer + * @author Phillip Webb + * @since 4.1.0 + */ +public final class GrpcRequest { + + private GrpcRequest() { + } + + /** + * Returns a matcher that includes all gRPC services. The + * {@link GrpcReactiveRequestMatcher#excluding(String...) excluding} method can be + * used to remove specific services by name if required. For example: + * + *
+	 * GrpcReactiveRequest.toAnyService().excluding("my-service")
+	 * 
+ * @return the configured {@link ServerWebExchangeMatcher} + */ + public static GrpcReactiveRequestMatcher toAnyService() { + return new GrpcReactiveRequestMatcher(Collections.emptySet()); + } + + /** + * The matcher used to match against service locations. + */ + public static final class GrpcReactiveRequestMatcher + extends ApplicationContextServerWebExchangeMatcher { + + private static final ServerWebExchangeMatcher EMPTY_MATCHER = (exchange) -> MatchResult.notMatch(); + + private final Set excludes; + + private volatile @Nullable ServerWebExchangeMatcher delegate; + + private GrpcReactiveRequestMatcher(Set excludes) { + super(GrpcServiceDiscoverer.class); + this.excludes = excludes; + } + + /** + * Return a new {@link GrpcServletRequestMatcher} based on this one but excluding + * the specified services. + * @param services additional services to exclude + * @return a new {@link GrpcServletRequestMatcher} + */ + public GrpcReactiveRequestMatcher excluding(String... services) { + return excluding(Set.of(services)); + } + + /** + * Return a new {@link GrpcServletRequestMatcher} based on this one but excluding + * the specified services. + * @param services additional service names to exclude + * @return a new {@link GrpcServletRequestMatcher} + */ + public GrpcReactiveRequestMatcher excluding(Collection services) { + Assert.notNull(services, "'services' must not be null"); + Set excludes = new LinkedHashSet<>(this.excludes); + excludes.addAll(services); + return new GrpcReactiveRequestMatcher(excludes); + } + + @Override + protected void initialized(Supplier context) { + this.delegate = createDelegate(context.get()); + + } + + private ServerWebExchangeMatcher createDelegate(GrpcServiceDiscoverer serviceDiscoverer) { + List delegateMatchers = getDelegateMatchers(serviceDiscoverer); + return (!CollectionUtils.isEmpty(delegateMatchers)) ? new OrServerWebExchangeMatcher(delegateMatchers) + : EMPTY_MATCHER; + } + + private List getDelegateMatchers(GrpcServiceDiscoverer serviceDiscoverer) { + return getPatterns(serviceDiscoverer).map(this::getDelegateMatcher).toList(); + } + + private Stream getPatterns(GrpcServiceDiscoverer serviceDiscoverer) { + return serviceDiscoverer.listServiceNames().stream().filter(this::isExcluded).map(this::getPath); + } + + private boolean isExcluded(String service) { + return !this.excludes.stream().anyMatch((candidate) -> candidate.equals(service)); + } + + private String getPath(String service) { + return "/" + service + "/**"; + } + + private ServerWebExchangeMatcher getDelegateMatcher(String path) { + Assert.hasText(path, "'path' must not be empty"); + return new PathPatternParserServerWebExchangeMatcher(path); + } + + @Override + protected Mono matches(ServerWebExchange exchange, Supplier context) { + Assert.state(this.delegate != null, "'delegate' must not be null"); + return this.delegate.matches(exchange); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/reactive/package-info.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/reactive/package-info.java new file mode 100644 index 00000000000..51f413b9a25 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/reactive/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. + */ + +/** + * Auto-configuration for gRPC web security when using a servlet stack. + */ +@NullMarked +package org.springframework.boot.grpc.server.autoconfigure.security.web.reactive; + +import org.jspecify.annotations.NullMarked; diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/servlet/GrpcRequest.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/servlet/GrpcRequest.java new file mode 100644 index 00000000000..f2eb644214f --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/servlet/GrpcRequest.java @@ -0,0 +1,148 @@ +/* + * 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.grpc.server.autoconfigure.security.web.servlet; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import jakarta.servlet.http.HttpServletRequest; +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.security.web.servlet.ApplicationContextRequestMatcher; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.security.web.util.matcher.OrRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * Factory that can be used to create a {@link RequestMatcher} to match against gRPC + * service locations. + * + * @author Dave Syer + * @author Phillip Webb + * @since 4.1.0 + */ +public final class GrpcRequest { + + private static final RequestMatcher EMPTY_MATCHER = (request) -> false; + + private static final GrpcServletRequestMatcher TO_ANY_SERVICE = new GrpcServletRequestMatcher( + Collections.emptySet()); + + private GrpcRequest() { + } + + /** + * Returns a matcher that includes all gRPC services. The + * {@link GrpcServletRequestMatcher#excluding(String...) excluding} method can be used + * to remove specific services by name if required. For example: + * + *
+	 * GrpcServletRequest.toAnyService().excluding("my-service")
+	 * 
+ * @return the configured {@link RequestMatcher} + */ + public static GrpcServletRequestMatcher toAnyService() { + return TO_ANY_SERVICE; + } + + /** + * The matcher used to match against service locations. + */ + public static final class GrpcServletRequestMatcher + extends ApplicationContextRequestMatcher { + + private final Set excludes; + + private volatile @Nullable RequestMatcher delegate; + + private GrpcServletRequestMatcher(Set exclusions) { + super(GrpcServiceDiscoverer.class); + this.excludes = exclusions; + } + + /** + * Return a new {@link GrpcServletRequestMatcher} based on this one but excluding + * the specified services. + * @param services additional services to exclude + * @return a new {@link GrpcServletRequestMatcher} + */ + public GrpcServletRequestMatcher excluding(String... services) { + return excluding(Set.of(services)); + } + + /** + * Return a new {@link GrpcServletRequestMatcher} based on this one but excluding + * the specified services. + * @param services additional service names to exclude + * @return a new {@link GrpcServletRequestMatcher} + */ + public GrpcServletRequestMatcher excluding(Collection services) { + Assert.notNull(services, "'services' must not be null"); + Set excludes = new LinkedHashSet<>(this.excludes); + excludes.addAll(services); + return new GrpcServletRequestMatcher(excludes); + } + + @Override + protected void initialized(Supplier context) { + this.delegate = createDelegate(context.get()); + } + + private @Nullable RequestMatcher createDelegate(GrpcServiceDiscoverer grpcServiceDiscoverer) { + List delegateMatchers = getDelegateMatchers(grpcServiceDiscoverer); + return (!CollectionUtils.isEmpty(delegateMatchers)) ? new OrRequestMatcher(delegateMatchers) + : EMPTY_MATCHER; + } + + private List getDelegateMatchers(GrpcServiceDiscoverer serviceDiscoverer) { + return getPatterns(serviceDiscoverer).map(this::getDelegateMatcher).toList(); + } + + private Stream getPatterns(GrpcServiceDiscoverer serviceDiscoverer) { + return serviceDiscoverer.listServiceNames().stream().filter(this::isExcluded).map(this::getPath); + } + + private boolean isExcluded(String service) { + return !this.excludes.stream().anyMatch((candidate) -> candidate.equals(service)); + } + + private String getPath(String service) { + return "/" + service + "/**"; + } + + private RequestMatcher getDelegateMatcher(String path) { + Assert.hasText(path, "'path' must not be empty"); + return PathPatternRequestMatcher.withDefaults().matcher(path); + } + + @Override + protected boolean matches(HttpServletRequest request, Supplier context) { + Assert.state(this.delegate != null, "'delegate' must not be null"); + return this.delegate.matches(request); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/servlet/package-info.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/servlet/package-info.java new file mode 100644 index 00000000000..441fff7c423 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/servlet/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. + */ + +/** + * Auto-configuration for gRPC web security when using a reactive stack. + */ +@NullMarked +package org.springframework.boot.grpc.server.autoconfigure.security.web.servlet; + +import org.jspecify.annotations.NullMarked; diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/GrpcServerHealth.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/GrpcServerHealth.java new file mode 100644 index 00000000000..0ff80ca4cc6 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/GrpcServerHealth.java @@ -0,0 +1,122 @@ +/* + * 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.grpc.server.health; + +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.BiConsumer; + +import io.grpc.health.v1.HealthCheckResponse.ServingStatus; +import io.grpc.protobuf.services.HealthStatusManager; +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.health.contributor.CompositeHealthContributor; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthContributors; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.boot.health.contributor.Status; +import org.springframework.boot.health.registry.HealthContributorRegistry; +import org.springframework.boot.health.registry.ReactiveHealthContributorRegistry; +import org.springframework.util.Assert; + +/** + * Provides health information for a gRPC server that can be used to update a + * {@link HealthStatusManager}. + * + * @author Phillip Webb + * @since 4.1.0 + */ +public class GrpcServerHealth { + + private HealthContributorRegistry registry; + + private @Nullable ReactiveHealthContributorRegistry fallbackRegistry; + + private HealthCheckedGrpcComponents components; + + /** + * Create a new {@link GrpcServerHealth} instance. + * @param registry the health contributor registry + * @param fallbackRegistry the fallback registry or {@code null} + * @param components the components used to provide the server health + */ + public GrpcServerHealth(HealthContributorRegistry registry, + @Nullable ReactiveHealthContributorRegistry fallbackRegistry, HealthCheckedGrpcComponents components) { + Assert.notNull(registry, "'registry' must not be null"); + Assert.notNull(components, "'components' must not be null"); + this.registry = registry; + this.fallbackRegistry = fallbackRegistry; + this.components = components; + } + + public void update(HealthStatusManager manager) { + update(manager::setStatus); + } + + public void update(BiConsumer updator) { + Cache cache = new Cache(); + HealthCheckedGrpcComponent serverComponent = this.components.getServer(); + if (serverComponent != null) { + updator.accept("", getServingStatus(cache, serverComponent)); + } + for (String serviceName : this.components.getServiceNames()) { + HealthCheckedGrpcComponent serviceComponent = this.components.getService(serviceName); + if (!serviceName.isEmpty() && serviceComponent != null) { + updator.accept(serviceName, getServingStatus(cache, serviceComponent)); + } + } + } + + private ServingStatus getServingStatus(Cache cache, HealthCheckedGrpcComponent component) { + Set statuses = new LinkedHashSet<>(); + collectStatuses(cache, component, statuses, this.registry, ""); + if (this.fallbackRegistry != null) { + collectStatuses(cache, component, statuses, this.fallbackRegistry.asHealthContributors(), ""); + } + Status status = component.getStatusAggregator().getAggregateStatus(statuses); + return component.getStatusMapper().getServingStatus(status); + } + + private void collectStatuses(Cache cache, HealthCheckedGrpcComponent component, Set statuses, + HealthContributors contributors, String prefix) { + for (HealthContributors.Entry entry : contributors) { + String name = (prefix.isEmpty()) ? entry.name() : prefix + "/" + entry.name(); + if (entry.contributor() instanceof CompositeHealthContributor composite) { + collectStatuses(cache, component, statuses, composite, name); + } + else if (component.isMember(name)) { + Health health = cache.getHealth(name, (HealthIndicator) entry.contributor()); + if (health != null) { + statuses.add(health.getStatus()); + } + } + } + } + + class Cache { + + private final Map health = new HashMap<>(); + + Health getHealth(String name, HealthIndicator indicator) { + return this.health.computeIfAbsent(name, (key) -> indicator.health(false)); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/HealthCheckedGrpcComponent.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/HealthCheckedGrpcComponent.java new file mode 100644 index 00000000000..fd71d19007e --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/HealthCheckedGrpcComponent.java @@ -0,0 +1,46 @@ +/* + * 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.grpc.server.health; + +/** + * A single gRPC component that can be checked for health. + * + * @author Phillip Webb + * @since 4.1.0 + */ +public interface HealthCheckedGrpcComponent { + + /** + * Returns {@code true} if the given health contributor is a member of this component. + * @param healthContributorName the contributor name + * @return {@code true} if the contributor is a member + */ + boolean isMember(String healthContributorName); + + /** + * Returns the status aggregator that should be used for this component. + * @return the status aggregator + */ + StatusAggregator getStatusAggregator(); + + /** + * Returns the {@link StatusMapper} that should be used for this component. + * @return the status mapper + */ + StatusMapper getStatusMapper(); + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/HealthCheckedGrpcComponents.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/HealthCheckedGrpcComponents.java new file mode 100644 index 00000000000..5673c6bbf66 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/HealthCheckedGrpcComponents.java @@ -0,0 +1,54 @@ +/* + * 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.grpc.server.health; + +import java.util.Set; + +import org.jspecify.annotations.Nullable; + +/** + * A collection of {@link HealthCheckedGrpcComponent components} used to check the health + * of a gRPC server. + * + * @author Phillip Webb + * @since 4.1.0 + * @see GrpcServerHealth + */ +public interface HealthCheckedGrpcComponents { + + /** + * Return the component that represents the overall server health or {@code null} if + * no overall health should be reported. + * @return the server component or {@code null} + */ + @Nullable HealthCheckedGrpcComponent getServer(); + + /** + * Return the names of the services that contribute health checks. + * @return the service names + */ + Set getServiceNames(); + + /** + * Return the component for the service with the specified name or {@code null} if the + * name is not known. + * @param serviceName the name of the service + * @return the service component or {@code null} + */ + @Nullable HealthCheckedGrpcComponent getService(String serviceName); + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/SimpleServingStatusMapper.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/SimpleServingStatusMapper.java new file mode 100644 index 00000000000..769c4b0cce2 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/SimpleServingStatusMapper.java @@ -0,0 +1,79 @@ +/* + * 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.grpc.server.health; + +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +import io.grpc.health.v1.HealthCheckResponse.ServingStatus; +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.health.contributor.Status; +import org.springframework.lang.Contract; + +/** + * Simple {@link StatusMapper} backed by map of {@link Status#getCode() status code} to + * {@link ServingStatus}. + * + * @author Phillip Webb + */ +class SimpleServingStatusMapper implements StatusMapper { + + static final SimpleServingStatusMapper DEFAULT_MAPPINGS; + static { + Map mappings = new HashMap<>(); + mappings.put(Status.DOWN.getCode(), ServingStatus.NOT_SERVING); + mappings.put(Status.OUT_OF_SERVICE.getCode(), ServingStatus.NOT_SERVING); + mappings.put(Status.UNKNOWN.getCode(), ServingStatus.UNKNOWN); + DEFAULT_MAPPINGS = new SimpleServingStatusMapper(mappings); + } + + private final Map mappings; + + SimpleServingStatusMapper(Map mappings) { + this.mappings = getUniformMappings(mappings); + } + + @Override + public ServingStatus getServingStatus(Status status) { + String code = getUniformCode(status.getCode()); + return this.mappings.getOrDefault(code, ServingStatus.SERVING); + } + + private static Map getUniformMappings(Map mappings) { + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : mappings.entrySet()) { + String code = getUniformCode(entry.getKey()); + if (code != null) { + result.putIfAbsent(code, entry.getValue()); + } + } + return Collections.unmodifiableMap(result); + } + + @Contract("!null -> !null") + private static @Nullable String getUniformCode(@Nullable String code) { + return (code != null) ? code.codePoints() + .filter(Character::isLetterOrDigit) + .map(Character::toLowerCase) + .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) + .toString() : null; + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/SimpleStatusAggregator.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/SimpleStatusAggregator.java new file mode 100644 index 00000000000..1c4f57a038e --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/SimpleStatusAggregator.java @@ -0,0 +1,74 @@ +/* + * 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.grpc.server.health; + +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.health.contributor.Status; +import org.springframework.lang.Contract; + +/** + * {@link StatusAggregator} backed by an ordered status list. + * + * @author Phillip Webb + */ +class SimpleStatusAggregator implements StatusAggregator { + + static final SimpleStatusAggregator DEFAULT_ORDER = new SimpleStatusAggregator( + Status.DEFAULT_ORDER.stream().map(Status::getCode)); + + private final List order; + + private final Comparator comparator = Comparator.comparingInt(this::orderIndex) + .thenComparing(Status::getCode); + + SimpleStatusAggregator(Stream order) { + this.order = order.map(SimpleStatusAggregator::getUniformCode).toList(); + } + + @Override + public Status getAggregateStatus(Set statuses) { + return statuses.stream().filter(this::contains).min(this.comparator).orElse(Status.UNKNOWN); + } + + private boolean contains(Status status) { + return this.order.contains(getUniformCode(status)); + } + + private int orderIndex(Status status) { + return this.order.indexOf(getUniformCode(status)); + } + + private static @Nullable String getUniformCode(Status status) { + return getUniformCode(status.getCode()); + } + + @Contract("!null -> !null") + private static @Nullable String getUniformCode(@Nullable String code) { + return (code != null) ? code.codePoints() + .filter(Character::isLetterOrDigit) + .map(Character::toLowerCase) + .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) + .toString() : null; + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/StatusAggregator.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/StatusAggregator.java new file mode 100644 index 00000000000..f8fe4e47b61 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/StatusAggregator.java @@ -0,0 +1,95 @@ +/* + * 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.grpc.server.health; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.Status; +import org.springframework.util.ObjectUtils; + +/** + * Strategy used to aggregate {@link Status} instances. + *

+ * This is required in order to combine subsystem states expressed through + * {@link Health#getStatus()} into one state for the gRPC health. + * + * @author Phillip Webb + * @since 4.1.0 + */ +@FunctionalInterface +public interface StatusAggregator { + + /** + * Return the aggregate status for the given set of statuses. + * @param statuses the statuses to aggregate + * @return the aggregate status + */ + default Status getAggregateStatus(Status... statuses) { + return getAggregateStatus(new LinkedHashSet<>(Arrays.asList(statuses))); + } + + /** + * Return the aggregate status for the given set of statuses. + * @param statuses the statuses to aggregate + * @return the aggregate status + */ + Status getAggregateStatus(Set statuses); + + /** + * Return a {@link StatusAggregator} backed by the given ordered status list. + * @param order the status order + * @return a {@link StatusAggregator} instance or {@link #getDefault()} if no order is + * provided + */ + static StatusAggregator of(Status... order) { + return (!ObjectUtils.isEmpty(order)) ? new SimpleStatusAggregator(Arrays.stream(order).map(Status::getCode)) + : getDefault(); + } + + /** + * Return a {@link StatusAggregator} backed by the given ordered status list. + * @param order the status order + * @return a {@link StatusAggregator} instance or {@link #getDefault()} if no order is + * provided + */ + static StatusAggregator of(String... order) { + return (!ObjectUtils.isEmpty(order)) ? new SimpleStatusAggregator(Arrays.stream(order)) : getDefault(); + } + + /** + * Return a {@link StatusAggregator} backed by the given ordered status list. + * @param order the status order + * @return a {@link StatusAggregator} instance or {@link #getDefault()} if no order is + * provided + */ + static StatusAggregator of(List order) { + return (!ObjectUtils.isEmpty(order)) ? new SimpleStatusAggregator(order.stream()) : getDefault(); + } + + /** + * Return {@link StatusAggregator} instance using default ordering rules. + * @return a {@code StatusAggregator} with default ordering rules. + */ + static StatusAggregator getDefault() { + return SimpleStatusAggregator.DEFAULT_ORDER; + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/StatusMapper.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/StatusMapper.java new file mode 100644 index 00000000000..837a1be208d --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/StatusMapper.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.grpc.server.health; + +import java.util.Map; + +import io.grpc.health.v1.HealthCheckResponse.ServingStatus; +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.health.contributor.Status; +import org.springframework.util.CollectionUtils; + +/** + * Strategy used to map a {@link Status health status} to a gRPC {@link ServingStatus}. + * + * @author Phillip Webb + * @since 4.1.0 + */ +@FunctionalInterface +public interface StatusMapper { + + /** + * Return the HTTP status code that corresponds to the given {@link Status health + * status}. + * @param status the health status to map + * @return the corresponding HTTP status code + */ + ServingStatus getServingStatus(Status status); + + /** + * Create a new {@link StatusMapper} with the specified mappings. + * @param mappings the mappings to use or {@code null} to use the default mappings + * @return a {@link StatusMapper} or {@link #getDefault()} + */ + static StatusMapper of(@Nullable Map mappings) { + return CollectionUtils.isEmpty(mappings) ? SimpleServingStatusMapper.DEFAULT_MAPPINGS + : new SimpleServingStatusMapper(mappings); + } + + /** + * Return an {@link StatusMapper} instance using default mappings. + * @return a mapper using default mappings + */ + static StatusMapper getDefault() { + return SimpleServingStatusMapper.DEFAULT_MAPPINGS; + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/package-info.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/package-info.java new file mode 100644 index 00000000000..cd575a4c15a --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/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 gRPC server health checks. + */ +@NullMarked +package org.springframework.boot.grpc.server.health; + +import org.jspecify.annotations.NullMarked; diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/package-info.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/package-info.java new file mode 100644 index 00000000000..384fc973d81 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/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. + */ + +/** + * Spring gRPC server support classes. + */ +@NullMarked +package org.springframework.boot.grpc.server; + +import org.jspecify.annotations.NullMarked; diff --git a/module/spring-boot-grpc-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/module/spring-boot-grpc-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 00000000000..a49d405d6c2 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,29 @@ +{ + "groups": [], + "properties": [ + { + "name": "spring.grpc.server.enabled", + "type": "java.lang.Boolean", + "description": "Whether to enable gRPC server auto-configuration.", + "defaultValue": true + }, + { + "name": "spring.grpc.server.factory.enabled", + "type": "java.lang.Boolean", + "description": "Whether to enable gRPC server factory bean auto-configuration.", + "defaultValue": true + }, + { + "name": "spring.grpc.server.observation.enabled", + "type": "java.lang.Boolean", + "description": "Whether to enable Observations on the server.", + "defaultValue": true + }, + { + "name": "spring.grpc.server.reflection.enabled", + "type": "java.lang.Boolean", + "description": "Whether to enable Reflection on the gRPC server.", + "defaultValue": true + } + ] +} diff --git a/module/spring-boot-grpc-server/src/main/resources/META-INF/spring.factories b/module/spring-boot-grpc-server/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000000..3ae2a7f4b1d --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer=\ +org.springframework.boot.grpc.server.autoconfigure.security.GrpcDisableCsrfHttpConfigurer diff --git a/module/spring-boot-grpc-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/module/spring-boot-grpc-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..1b4dd838523 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,7 @@ +org.springframework.boot.grpc.server.autoconfigure.GrpcServerAutoConfiguration +org.springframework.boot.grpc.server.autoconfigure.GrpcServerObservationAutoConfiguration +org.springframework.boot.grpc.server.autoconfigure.GrpcServerServicesAutoConfiguration +org.springframework.boot.grpc.server.autoconfigure.health.GrpcServerHealthAutoConfiguration +org.springframework.boot.grpc.server.autoconfigure.health.GrpcServerHealthSchedulerAutoConfiguration +org.springframework.boot.grpc.server.autoconfigure.security.GrpcServerOAuth2ResourceServerAutoConfiguration +org.springframework.boot.grpc.server.autoconfigure.security.GrpcServerSecurityAutoConfiguration diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/GrpcServletRegistrationTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/GrpcServletRegistrationTests.java new file mode 100644 index 00000000000..765830cc7b7 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/GrpcServletRegistrationTests.java @@ -0,0 +1,152 @@ +/* + * 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.grpc.server; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +import io.grpc.BindableService; +import io.grpc.ServerInterceptor; +import io.grpc.ServerServiceDefinition; +import io.grpc.servlet.jakarta.GrpcServlet; +import io.grpc.servlet.jakarta.ServletServerBuilder; +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRegistration.Dynamic; +import org.junit.jupiter.api.Test; + +import org.springframework.grpc.server.service.GrpcServiceConfigurer; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; +import org.springframework.grpc.server.service.GrpcServiceInfo; +import org.springframework.grpc.server.service.GrpcServiceSpec; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +/** + * Tests for {@link GrpcServletRegistration}. + * + * @author Phillip Webb + */ +class GrpcServletRegistrationTests { + + private final GrpcServiceConfigurer serviceConfigurer = mock(); + + private final GrpcServiceDiscoverer serviceDiscoverer = mock(); + + @Test + @SuppressWarnings("NullAway") // Test null check + void createWhenServiceDiscovererIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> new GrpcServletRegistration(null, this.serviceConfigurer)) + .withMessage("'serviceDiscoverer' must not be null"); + } + + @Test + @SuppressWarnings("NullAway") // Test null check + void createWhenServiceConfigurerIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> new GrpcServletRegistration(this.serviceDiscoverer, null)) + .withMessage("'serviceConfigurer' must not be null"); + } + + @Test + void createWhenServerBuilderCustomizerIsNullDoesNotApplyCustomization() { + assertThatNoException() + .isThrownBy(() -> new GrpcServletRegistration(this.serviceDiscoverer, this.serviceConfigurer, null)); + } + + @Test + void createWhenServerBuilderCustomizerIsNotNullAppliesCustomization() { + Consumer serverBuilderCustomizer = mock(); + new GrpcServletRegistration(this.serviceDiscoverer, this.serviceConfigurer, serverBuilderCustomizer); + then(serverBuilderCustomizer).should().accept(any(ServletServerBuilder.class)); + } + + @Test + void addRegistrationAddsBuiltServlet() { + GrpcServletRegistration registration = new GrpcServletRegistration(this.serviceDiscoverer, + this.serviceConfigurer); + ServletContext servletContext = mock(); + Dynamic result = mock(); + given(servletContext.addServlet(eq("grpcServlet"), any(GrpcServlet.class))).willReturn(result); + assertThat(registration.addRegistration("test", servletContext)).isEqualTo(result); + } + + @Test + void onStartupWhenHasServicesRegistersAndAddsUrlMappingsBasedOnDescriptorName() throws ServletException { + BindableService service1 = mock(BindableService.class); + ServerServiceDefinition serviceDefinition1 = ServerServiceDefinition.builder("s1").build(); + given(service1.bindService()).willReturn(serviceDefinition1); + GrpcServiceInfo info1 = new GrpcServiceInfo(emptyServiceInterceptors(), new String[0], false); + BindableService service2 = mock(BindableService.class); + ServerServiceDefinition serviceDefinition2 = ServerServiceDefinition.builder("s2").build(); + given(service2.bindService()).willReturn(serviceDefinition2); + GrpcServiceInfo info2 = new GrpcServiceInfo(emptyServiceInterceptors(), new String[0], false); + List specs = new ArrayList<>(); + specs.add(new GrpcServiceSpec(service1, info1)); + specs.add(new GrpcServiceSpec(service2, info2)); + given(this.serviceDiscoverer.findServices()).willReturn(specs); + given(this.serviceConfigurer.configure(any(GrpcServiceSpec.class), eq(null))).willAnswer((invocation) -> { + GrpcServiceSpec spec = invocation.getArgument(0, GrpcServiceSpec.class); + return spec.service().bindService(); + }); + GrpcServletRegistration registration = new GrpcServletRegistration(this.serviceDiscoverer, + this.serviceConfigurer); + ServletContext servletContext = mock(ServletContext.class); + Dynamic result = mock(Dynamic.class); + given(servletContext.addServlet(eq("grpcServlet"), any(GrpcServlet.class))).willReturn(result); + registration.onStartup(servletContext); + then(result).should().addMapping("/s1/*", "/s2/*"); + } + + @Test + void onStartupWhenHasNoServicesDoesNotAddUrlMappings() throws ServletException { + given(this.serviceDiscoverer.findServices()).willReturn(Collections.emptyList()); + given(this.serviceConfigurer.configure(any(GrpcServiceSpec.class), eq(null))).willAnswer((invocation) -> { + GrpcServiceSpec spec = invocation.getArgument(0, GrpcServiceSpec.class); + return spec.service().bindService(); + }); + GrpcServletRegistration registration = new GrpcServletRegistration(this.serviceDiscoverer, + this.serviceConfigurer); + ServletContext servletContext = mock(ServletContext.class); + Dynamic result = mock(Dynamic.class); + given(servletContext.addServlet(eq("grpcServlet"), any(GrpcServlet.class))).willReturn(result); + registration.onStartup(servletContext); + then(result).should(never()).addMapping(); + } + + @Test + void getDescriptionReturnsDeducedServletName() { + GrpcServletRegistration registration = new GrpcServletRegistration(this.serviceDiscoverer, + this.serviceConfigurer); + assertThat(registration.getDescription()).isEqualTo("grpcServlet"); + } + + @SuppressWarnings("unchecked") + private Class[] emptyServiceInterceptors() { + return (Class[]) new Class[] {}; + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerAutoConfigurationTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerAutoConfigurationTests.java new file mode 100644 index 00000000000..497715b3f44 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerAutoConfigurationTests.java @@ -0,0 +1,493 @@ +/* + * 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.grpc.server.autoconfigure; + +import java.util.concurrent.atomic.AtomicReference; + +import io.grpc.BindableService; +import io.grpc.ServerServiceDefinition; +import io.grpc.ServiceDescriptor; +import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.internal.GrpcUtil; +import io.grpc.netty.NettyServerBuilder; +import io.grpc.servlet.jakarta.GrpcServlet; +import io.grpc.servlet.jakarta.ServletServerBuilder; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; +import org.springframework.boot.grpc.server.GrpcServletRegistration; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.assertj.ApplicationContextAssertProvider; +import org.springframework.boot.test.context.runner.AbstractApplicationContextRunner; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.context.runner.ContextConsumer; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.server.GrpcServerFactory; +import org.springframework.grpc.server.InProcessGrpcServerFactory; +import org.springframework.grpc.server.NettyGrpcServerFactory; +import org.springframework.grpc.server.ServerBuilderCustomizer; +import org.springframework.grpc.server.ShadedNettyGrpcServerFactory; +import org.springframework.grpc.server.lifecycle.GrpcServerLifecycle; +import org.springframework.grpc.server.service.DefaultGrpcServiceDiscoverer; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; +import org.springframework.util.unit.DataSize; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests fir {@link GrpcServerAutoConfiguration}. + * + * @author Chris Bono + * @author Andrey Litvitski + * @author Phillip Webb + */ +class GrpcServerAutoConfigurationTests { + + private static final AutoConfigurations autoConfigurations = AutoConfigurations + .of(GrpcServerAutoConfiguration.class, SslAutoConfiguration.class); + + private final BindableService service = mock(); + + private final ServerServiceDefinition serviceDefinition = ServerServiceDefinition.builder("my-service").build(); + + private ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(autoConfigurations) + .with(this::noOpLifecycleBeans) + .with(this::serviceBean); + + @BeforeEach + void setup() { + given(this.service.bindService()).willReturn(this.serviceDefinition); + } + + @Test + void whenGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(BindableService.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerAutoConfiguration.class)); + } + + @Test + void whenSpringGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(GrpcServerFactory.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerAutoConfiguration.class)); + } + + @Test + void whenNoBindableServicesRegisteredAutoConfigurationIsSkipped() { + new ApplicationContextRunner().withConfiguration(autoConfigurations) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertySetFalseThenAutoConfigurationIsSkipped() { + this.contextRunner.withPropertyValues("spring.grpc.server.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertyNotSetThenAutoConfigurationIsNotSkipped() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(GrpcServerAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertySetTrueThenAutoConfigurationIsNotSkipped() { + this.contextRunner.withPropertyValues("spring.grpc.server.enabled=true") + .run((context) -> assertThat(context).hasSingleBean(GrpcServerAutoConfiguration.class)); + } + + @Test + void whenHasUserDefinedGrpcServiceDiscovererDoesNotAutoConfigureBean() { + GrpcServiceDiscoverer customGrpcServiceDiscoverer = mock(GrpcServiceDiscoverer.class); + new ApplicationContextRunner().withConfiguration(autoConfigurations) + .with(this::noOpLifecycleBeans) + .withBean("customGrpcServiceDiscoverer", GrpcServiceDiscoverer.class, () -> customGrpcServiceDiscoverer) + .withPropertyValues("spring.grpc.server.port=0") + .run((context) -> assertThat(context).getBean(GrpcServiceDiscoverer.class) + .isSameAs(customGrpcServiceDiscoverer)); + } + + @Test + void grpcServiceDiscovererAutoConfiguredAsExpected() { + new ApplicationContextRunner().withConfiguration(autoConfigurations) + .with(this::serviceBean) + .run((context) -> assertThat(context).getBean(GrpcServiceDiscoverer.class) + .isInstanceOf(DefaultGrpcServiceDiscoverer.class)); + } + + @Test + void serverBuilderCustomizersAutoConfiguredAsExpected() { + this.contextRunner.withUserConfiguration(ServerBuilderCustomizersConfig.class) + .run((context) -> assertThat(context).getBean(GrpcServerBuilderCustomizers.class) + .extracting("customizers", InstanceOfAssertFactories.list(ServerBuilderCustomizer.class)) + .contains(ServerBuilderCustomizersConfig.bar, ServerBuilderCustomizersConfig.foo)); + } + + @Test + void customizersAreAppliedToNettyServer() { + AtomicReference applied = new AtomicReference<>(); + ServerBuilderCustomizer customizer = applied::set; + this.contextRunner.withBean(ServerBuilderCustomizer.class, () -> customizer) + .withClassLoader(new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)) + .run((context) -> { + context.getBean(GrpcServerFactory.class).createServer(); + assertThat(applied.get()).isInstanceOf(NettyServerBuilder.class); + }); + } + + @Test + void customizersAreAppliedToShadedNettyServer() { + AtomicReference applied = new AtomicReference<>(); + ServerBuilderCustomizer customizer = applied::set; + this.contextRunner.withBean(ServerBuilderCustomizer.class, () -> customizer) + .withClassLoader(new FilteredClassLoader(NettyServerBuilder.class)) + .run((context) -> { + context.getBean(GrpcServerFactory.class).createServer(); + assertThat(applied.get()).isInstanceOf(io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class); + }); + } + + @Test + void customizersAreAppliedToInProcessServer() { + AtomicReference applied = new AtomicReference<>(); + ServerBuilderCustomizer customizer = applied::set; + this.contextRunner.withBean(ServerBuilderCustomizer.class, () -> customizer) + .withClassLoader(new FilteredClassLoader(NettyServerBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)) + .withPropertyValues("spring.grpc.server.inprocess.name=test") + .run((context) -> { + context.getBean(GrpcServerFactory.class).createServer(); + assertThat(applied.get()).isInstanceOf(InProcessServerBuilder.class); + }); + } + + @Test + void whenHasUserDefinedServerFactoryDoesNotAutoConfigureBean() { + GrpcServerFactory customServerFactory = mock(GrpcServerFactory.class); + this.contextRunner.withBean("customServerFactory", GrpcServerFactory.class, () -> customServerFactory) + .run((context) -> assertThat(context).getBean(GrpcServerFactory.class).isSameAs(customServerFactory)); + } + + @Test + void userDefinedServerFactoryWithInProcessServerFactory() { + GrpcServerFactory customServerFactory = mock(GrpcServerFactory.class); + this.contextRunner.withPropertyValues("spring.grpc.server.inprocess.name=foo") + .withBean("customServerFactory", GrpcServerFactory.class, () -> customServerFactory) + .run((context) -> assertThat(context).getBeans(GrpcServerFactory.class) + .containsOnlyKeys("customServerFactory", "inProcessGrpcServerFactory")); + } + + @Test + void whenShadedAndNonShadedNettyOnClasspathShadedNettyFactoryIsAutoConfigured() { + this.contextRunner.run((context) -> assertThat(context).getBean(GrpcServerFactory.class) + .isInstanceOf(ShadedNettyGrpcServerFactory.class)); + } + + @Test + void shadedNettyFactoryWithInProcessServerFactory() { + this.contextRunner.withPropertyValues("spring.grpc.server.inprocess.name=foo") + .run((context) -> assertThat(context).getBeans(GrpcServerFactory.class) + .containsOnlyKeys("shadedNettyGrpcServerFactory", "inProcessGrpcServerFactory")); + } + + @Test + void whenOnlyNonShadedNettyOnClasspathNonShadedNettyFactoryIsAutoConfigured() { + this.contextRunner + .withClassLoader(new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)) + .run((context) -> assertThat(context).getBean(GrpcServerFactory.class) + .isInstanceOf(NettyGrpcServerFactory.class)); + } + + @Test + void nonShadedNettyFactoryWithInProcessServerFactory() { + this.contextRunner + .withClassLoader(new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)) + .withPropertyValues("spring.grpc.server.inprocess.name=foo") + .run((context) -> assertThat(context).getBeans(GrpcServerFactory.class) + .containsOnlyKeys("nettyGrpcServerFactory", "inProcessGrpcServerFactory")); + } + + @Test + void whenShadedNettyAndNettyNotOnClasspathNoServerFactoryIsAutoConfigured() { + this.contextRunner + .withClassLoader(new FilteredClassLoader(NettyServerBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerFactory.class)); + } + + @Test + void noServerFactoryWithInProcessServerFactory() { + this.contextRunner + .withClassLoader(new FilteredClassLoader(NettyServerBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)) + .withPropertyValues("spring.grpc.server.inprocess.name=foo") + .run((context) -> assertThat(context).getBean(GrpcServerFactory.class) + .isInstanceOf(InProcessGrpcServerFactory.class)); + } + + @Test + void shadedNettyServerFactoryAutoConfiguredWithCustomLifecycle() { + GrpcServerLifecycle customServerLifecycle = mock(GrpcServerLifecycle.class); + new ApplicationContextRunner().withConfiguration(autoConfigurations) + .with(this::serviceBean) + .withBean("shadedNettyGrpcServerLifecycle", GrpcServerLifecycle.class, () -> customServerLifecycle) + .run((context) -> { + assertThat(context).getBean(GrpcServerFactory.class).isInstanceOf(ShadedNettyGrpcServerFactory.class); + assertThat(context).getBean("shadedNettyGrpcServerLifecycle", GrpcServerLifecycle.class) + .isSameAs(customServerLifecycle); + }); + } + + @Test + void nettyServerFactoryAutoConfiguredWithCustomLifecycle() { + GrpcServerLifecycle customServerLifecycle = mock(GrpcServerLifecycle.class); + new ApplicationContextRunner().withConfiguration(autoConfigurations) + .with(this::serviceBean) + .withClassLoader(new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)) + .withBean("nettyGrpcServerLifecycle", GrpcServerLifecycle.class, () -> customServerLifecycle) + .run((context) -> { + assertThat(context).getBean(GrpcServerFactory.class).isInstanceOf(NettyGrpcServerFactory.class); + assertThat(context).getBean("nettyGrpcServerLifecycle", GrpcServerLifecycle.class) + .isSameAs(customServerLifecycle); + }); + } + + @Test + void inProcessServerFactoryAutoConfiguredWithCustomLifecycle() { + GrpcServerLifecycle customServerLifecycle = mock(GrpcServerLifecycle.class); + new ApplicationContextRunner().withConfiguration(autoConfigurations) + .with(this::serviceBean) + .withPropertyValues("spring.grpc.server.inprocess.name=foo") + .withClassLoader(new FilteredClassLoader(NettyServerBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)) + .withBean("inProcessGrpcServerLifecycle", GrpcServerLifecycle.class, () -> customServerLifecycle) + .run((context) -> { + assertThat(context).getBean(GrpcServerFactory.class).isInstanceOf(InProcessGrpcServerFactory.class); + assertThat(context).getBean("inProcessGrpcServerLifecycle", GrpcServerLifecycle.class) + .isSameAs(customServerLifecycle); + }); + } + + @Test + void shadedNettyServerFactoryAutoConfiguredAsExpected() { + this.contextRunner.withPropertyValues("spring.grpc.server.address=192.168.0.1", "spring.grpc.server.port=6160") + .run(assertThatServerIsConfigured(ShadedNettyGrpcServerFactory.class, "192.168.0.1:6160", + "shadedNettyGrpcServerLifecycle")); + } + + @Test + void nettyServerFactoryAutoConfiguredAsExpected() { + this.contextRunner.withPropertyValues("spring.grpc.server.address=192.168.0.1", "spring.grpc.server.port=6160") + .withClassLoader(new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)) + .run(assertThatServerIsConfigured(NettyGrpcServerFactory.class, "192.168.0.1:6160", + "nettyGrpcServerLifecycle")); + } + + @Test + void serverFactoryAutoConfiguredInWebAppWhenServletDisabled() { + new WebApplicationContextRunner().withConfiguration(autoConfigurations) + .with(this::noOpLifecycleBeans) + .with(this::serviceBean) + .withPropertyValues("spring.grpc.server.address=192.168.0.1") + .withPropertyValues("spring.grpc.server.port=6160") + .withPropertyValues("spring.grpc.server.servlet.enabled=false") + .run(assertThatServerIsConfigured(ShadedNettyGrpcServerFactory.class, "192.168.0.1:6160", + "shadedNettyGrpcServerLifecycle")); + } + + @Test + void inProcessServerFactoryAutoConfiguredAsExpected() { + this.contextRunner.withPropertyValues("spring.grpc.server.inprocess.name=foo") + .withClassLoader(new FilteredClassLoader(NettyServerBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)) + .run(assertThatServerIsConfigured(InProcessGrpcServerFactory.class, "foo", "inProcessGrpcServerLifecycle")); + } + + @Test + void nettyServerFactoryAutoConfiguredWithSsl() { + this.contextRunner.withPropertyValues("spring.grpc.server.address=192.168.0.1", "spring.grpc.server.port=6160", + "spring.grpc.server.ssl.bundle=ssltest", + "spring.ssl.bundle.jks.ssltest.keystore.location=classpath:org/springframework/boot/grpc/server/autoconfigure/test.jks", + "spring.ssl.bundle.jks.ssltest.keystore.password=secret", + "spring.ssl.bundle.jks.ssltest.key.password=password") + .withClassLoader(new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)) + .run(assertThatServerIsConfigured(NettyGrpcServerFactory.class, "192.168.0.1:6160", + "nettyGrpcServerLifecycle")); + } + + private ContextConsumer> assertThatServerIsConfigured( + Class expectedServerFactoryType, String expectedAddress, String expectedLifecycleBeanName) { + return (context) -> { + assertThat(context).getBean(GrpcServerFactory.class) + .isInstanceOf(expectedServerFactoryType) + .hasFieldOrPropertyWithValue("address", expectedAddress) + .extracting("serviceList", InstanceOfAssertFactories.list(ServerServiceDefinition.class)) + .singleElement() + .extracting(ServerServiceDefinition::getServiceDescriptor) + .extracting(ServiceDescriptor::getName) + .isEqualTo("my-service"); + assertThat(context).getBean(expectedLifecycleBeanName, GrpcServerLifecycle.class).isNotNull(); + }; + } + + private , C extends ConfigurableApplicationContext, A extends ApplicationContextAssertProvider> R serviceBean( + R contextRunner) { + return contextRunner.withBean(BindableService.class, () -> this.service); + } + + private , C extends ConfigurableApplicationContext, A extends ApplicationContextAssertProvider> R noOpLifecycleBeans( + R contextRunner) { + return contextRunner.withBean("shadedNettyGrpcServerLifecycle", GrpcServerLifecycle.class, Mockito::mock) + .withBean("nettyGrpcServerLifecycle", GrpcServerLifecycle.class, Mockito::mock) + .withBean("inProcessGrpcServerLifecycle", GrpcServerLifecycle.class, Mockito::mock); + } + + @Nested + class ServletServerAutoConfigurationTests { + + private WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() + .withConfiguration(autoConfigurations) + .with(GrpcServerAutoConfigurationTests.this::serviceBean) + .withPropertyValues("server.http2.enabled=true"); + + @Test + void whenGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(BindableService.class)) + .run((context) -> assertThat(context).doesNotHaveBean(ServletGrpcServerConfiguration.class) + .doesNotHaveBean(ServletRegistrationBean.class)); + } + + @Test + void whenSpringGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(GrpcServerFactory.class)) + .run((context) -> assertThat(context).doesNotHaveBean(ServletGrpcServerConfiguration.class)); + } + + @Test + void whenNoBindableServicesRegisteredAutoConfigurationIsSkipped() { + new WebApplicationContextRunner().withConfiguration(autoConfigurations) + .run((context) -> assertThat(context).doesNotHaveBean(ServletGrpcServerConfiguration.class) + .doesNotHaveBean(ServletRegistrationBean.class)); + } + + @Test + void whenGrpcServletNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(GrpcServlet.class)) + .withPropertyValues("spring.grpc.server.port=0") + .run((context) -> assertThat(context).doesNotHaveBean(ServletGrpcServerConfiguration.class) + .doesNotHaveBean(ServletRegistrationBean.class)); + } + + @Test + void whenWebApplicationServletIsAutoConfigured() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(GrpcServletRegistration.class)); + } + + @Test + void whenServerBuilderCustomizerIsRegistered() { + ServerBuilderCustomizer customizer = mock(); + this.contextRunner.withBean(ServerBuilderCustomizer.class, () -> customizer) + .run((context) -> then(customizer).should().customize(any(ServletServerBuilder.class))); + } + + @Test + void whenMaxInboundMessageSizeIsSetThenItIsUsed() { + this.contextRunner.withPropertyValues("spring.grpc.server.inbound.message.max-size=10KB") + .run((context) -> assertThat(context).getBean(GrpcServletRegistration.class) + .hasFieldOrPropertyWithValue("servlet.servletAdapter.maxInboundMessageSize", + Math.toIntExact(DataSize.ofKilobytes(10).toBytes()))); + } + + @Test + void whenMaxInboundMessageSizeIsNotSetThenDefaultIsUsed() { + this.contextRunner.run((context) -> assertThat(context).getBean(GrpcServletRegistration.class) + .hasFieldOrPropertyWithValue("servlet.servletAdapter.maxInboundMessageSize", + GrpcUtil.DEFAULT_MAX_MESSAGE_SIZE)); + } + + @Test + void customizersAreAppliedToServletServer() { + AtomicReference applied = new AtomicReference<>(); + ServerBuilderCustomizer customizer = applied::set; + this.contextRunner.withBean(ServerBuilderCustomizer.class, () -> customizer) + .run((context) -> assertThat(applied.get()).isInstanceOf(ServletServerBuilder.class)); + } + + @Test + void whenHttp2EnabledPropertyMissing() { + new WebApplicationContextRunner().withConfiguration(autoConfigurations) + .with(GrpcServerAutoConfigurationTests.this::serviceBean) + .run((context) -> assertThat(context).getFailure() + .hasMessageContaining( + "Configuration property 'server.http2.enabled' should be set to true for gRPC support")); + } + + @Test + void whenHttp2EnabledPropertyFalse() { + new WebApplicationContextRunner().withConfiguration(autoConfigurations) + .with(GrpcServerAutoConfigurationTests.this::serviceBean) + .withPropertyValues("server.http2.enabled=false") + .run((context) -> assertThat(context).getFailure() + .hasMessageContaining( + "Configuration property 'server.http2.enabled' should be set to true for gRPC support")); + } + + @Test + void whenHttp2EnabledPropertyMissingAndValidationDisabled() { + new WebApplicationContextRunner().withConfiguration(autoConfigurations) + .with(GrpcServerAutoConfigurationTests.this::serviceBean) + .withPropertyValues("spring.grpc.server.servlet.validate-http2=false") + .run((context) -> assertThat(context).hasNotFailed()); + } + + } + + @Configuration(proxyBeanMethods = false) + static class ServerBuilderCustomizersConfig { + + static ServerBuilderCustomizer foo = mock(); + + static ServerBuilderCustomizer bar = mock(); + + @Bean + @Order(200) + ServerBuilderCustomizer customizerFoo() { + return foo; + } + + @Bean + @Order(100) + ServerBuilderCustomizer customizerBar() { + return bar; + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerBuilderCustomizersTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerBuilderCustomizersTests.java new file mode 100644 index 00000000000..37689c5bc66 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerBuilderCustomizersTests.java @@ -0,0 +1,155 @@ +/* + * 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.grpc.server.autoconfigure; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; + +import io.grpc.CompressorRegistry; +import io.grpc.DecompressorRegistry; +import io.grpc.ServerBuilder; +import io.grpc.netty.NettyServerBuilder; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.mockito.Mockito; + +import org.springframework.grpc.server.ServerBuilderCustomizer; +import org.springframework.util.unit.DataSize; + +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 GrpcServerBuilderCustomizers}. + * + * @author Chris Bono + * @author Phillip Webb + */ +class GrpcServerBuilderCustomizersTests { + + @Test + void applyWhenEmptyCustomizersDoesNothing() { + ServerBuilder serverBuilder = mock(ServerBuilder.class); + new GrpcServerBuilderCustomizers(Collections.emptyList()).apply(serverBuilder); + then(serverBuilder).shouldHaveNoInteractions(); + } + + @Test + void applyWhenSimpleCustomizer() { + GrpcServerBuilderCustomizers customizers = new GrpcServerBuilderCustomizers( + List.of(new SimpleServerBuilderCustomizer())); + NettyServerBuilder serverBuilder = mock(NettyServerBuilder.class); + customizers.apply(serverBuilder); + then(serverBuilder).should().maxConnectionAge(100L, TimeUnit.SECONDS); + } + + @Test + void applyWhenGenericCustomizersRespectsGeneric() { + List> list = new ArrayList<>(); + list.add(new TestCustomizer<>()); + list.add(new TestNettyServerBuilderCustomizer()); + list.add(new TestShadedNettyServerBuilderCustomizer()); + GrpcServerBuilderCustomizers customizers = new GrpcServerBuilderCustomizers(list); + customizers.apply(mock(ServerBuilder.class)); + assertThat(list.get(0).getCount()).isOne(); + assertThat(list.get(1).getCount()).isZero(); + assertThat(list.get(2).getCount()).isZero(); + customizers.apply(mock(NettyServerBuilder.class)); + assertThat(list.get(0).getCount()).isEqualTo(2); + assertThat(list.get(1).getCount()).isOne(); + assertThat(list.get(2).getCount()).isZero(); + customizers.apply(mock(io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)); + assertThat(list.get(0).getCount()).isEqualTo(3); + assertThat(list.get(1).getCount()).isOne(); + assertThat(list.get(2).getCount()).isOne(); + } + + @Test + void applyWhenHasInjectedBeans() { + CompressorRegistry compressorRegistry = mock(); + DecompressorRegistry decompressorRegistry = mock(); + GrpcServerExecutorProvider executorProvider = mock(); + Executor executor = mock(); + given(executorProvider.getExecutor()).willReturn(executor); + GrpcServerProperties properties = new GrpcServerProperties(); + properties.getInbound().getMessage().setMaxSize(DataSize.ofMegabytes(10)); + GrpcServerBuilderCustomizers customizers = new GrpcServerBuilderCustomizers(properties, compressorRegistry, + decompressorRegistry, executorProvider, List.of(new SimpleServerBuilderCustomizer())); + NettyServerBuilder serverBuilder = mock(NettyServerBuilder.class); + customizers.apply(serverBuilder); + InOrder ordered = Mockito.inOrder(serverBuilder); + then(serverBuilder).should(ordered).compressorRegistry(compressorRegistry); + then(serverBuilder).should(ordered).decompressorRegistry(decompressorRegistry); + then(serverBuilder).should().executor(executor); + then(serverBuilder).should(ordered).maxConnectionAge(100L, TimeUnit.SECONDS); + } + + /** + * Test customizer that will match {@link NettyServerBuilder} and apply a simple + * customization. + */ + static class SimpleServerBuilderCustomizer implements ServerBuilderCustomizer { + + @Override + public void customize(NettyServerBuilder serverBuilder) { + serverBuilder.maxConnectionAge(100, TimeUnit.SECONDS); + } + + } + + /** + * Test customizer that will match all {@link ServerBuilderCustomizer}. + * + * @param the builder type + */ + static class TestCustomizer> implements ServerBuilderCustomizer { + + private int count; + + @Override + public void customize(T serverBuilder) { + this.count++; + } + + int getCount() { + return this.count; + } + + } + + /** + * Test customizer that will match only {@link NettyServerBuilder}. + */ + static class TestNettyServerBuilderCustomizer extends TestCustomizer { + + } + + /** + * Test customizer that will match only + * {@link io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder}. + */ + static class TestShadedNettyServerBuilderCustomizer + extends TestCustomizer { + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerCodecConfigurationTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerCodecConfigurationTests.java new file mode 100644 index 00000000000..dcb780a6850 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerCodecConfigurationTests.java @@ -0,0 +1,92 @@ +/* + * 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.grpc.server.autoconfigure; + +import io.grpc.Compressor; +import io.grpc.CompressorRegistry; +import io.grpc.Decompressor; +import io.grpc.DecompressorRegistry; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GrpcServerCodecConfiguration}. + * + * @author Andrei Lisa + */ +class GrpcServerCodecConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcServerCodecConfiguration.class)); + + @Test + void compressorRegistryWhenHasUserDefinedRegistryDoesNotAutoConfigureBean() { + CompressorRegistry customRegistry = mock(); + this.contextRunner.withBean("customCompressorRegistry", CompressorRegistry.class, () -> customRegistry) + .run((context) -> assertThat(context).getBean(CompressorRegistry.class).isSameAs(customRegistry)); + } + + @Test + void compressorRegistryWhenNoCompressorsAutoConfiguresDefaultInstance() { + this.contextRunner.run((context) -> assertThat(context).getBean(CompressorRegistry.class) + .isSameAs(CompressorRegistry.getDefaultInstance())); + } + + @Test + void compressorRegistryWhenHasCompressorsAutoConfiguresNewInstance() { + Compressor compressor = mock(); + given(compressor.getMessageEncoding()).willReturn("foo"); + this.contextRunner.withBean(Compressor.class, () -> compressor).run((context) -> { + assertThat(context).hasSingleBean(CompressorRegistry.class); + CompressorRegistry registry = context.getBean(CompressorRegistry.class); + assertThat(registry).isNotSameAs(CompressorRegistry.getDefaultInstance()); + assertThat(registry.lookupCompressor("foo")).isSameAs(compressor); + }); + } + + @Test + void decompressorRegistryWhenHasUserDefinedRegistryDoesNotAutoConfigureBean() { + DecompressorRegistry customRegistry = mock(); + this.contextRunner.withBean("customDecompressorRegistry", DecompressorRegistry.class, () -> customRegistry) + .run((context) -> assertThat(context).getBean(DecompressorRegistry.class).isSameAs(customRegistry)); + } + + @Test + void decompressorRegistryWhenNoDecompressorsAutoConfiguresDefaultInstance() { + this.contextRunner.run((context) -> assertThat(context).getBean(DecompressorRegistry.class) + .isSameAs(DecompressorRegistry.getDefaultInstance())); + } + + @Test + void decompressorRegistryWhenHasDecompressorsAutoConfiguresNewInstance() { + Decompressor decompressor = mock(); + given(decompressor.getMessageEncoding()).willReturn("foo"); + this.contextRunner.withBean(Decompressor.class, () -> decompressor).run((context) -> { + assertThat(context).hasSingleBean(DecompressorRegistry.class); + DecompressorRegistry registry = context.getBean(DecompressorRegistry.class); + assertThat(registry).isNotSameAs(DecompressorRegistry.getDefaultInstance()); + assertThat(registry.lookupDecompressor("foo")).isSameAs(decompressor); + }); + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerObservationAutoConfigurationTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerObservationAutoConfigurationTests.java new file mode 100644 index 00000000000..7aee14b5a0d --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerObservationAutoConfigurationTests.java @@ -0,0 +1,133 @@ +/* + * 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.grpc.server.autoconfigure; + +import java.util.List; +import java.util.Map; + +import io.grpc.BindableService; +import io.grpc.ServerInterceptor; +import io.micrometer.context.ContextRegistry; +import io.micrometer.core.instrument.binder.grpc.ObservationGrpcServerInterceptor; +import io.micrometer.core.instrument.kotlin.ObservationCoroutineContextServerInterceptor; +import io.micrometer.observation.ObservationRegistry; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.grpc.server.GlobalServerInterceptor; +import org.springframework.grpc.server.GrpcServerFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for the {@link GrpcServerObservationAutoConfiguration}. + * + * @author Chris Bono + */ +class GrpcServerObservationAutoConfigurationTests { + + private static final AutoConfigurations autoConfigurations = AutoConfigurations + .of(GrpcServerObservationAutoConfiguration.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(autoConfigurations) + .withBean("observationRegistry", ObservationRegistry.class, Mockito::mock); + + @Test + void whenGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(BindableService.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerObservationAutoConfiguration.class)); + } + + @Test + void whenSpringGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(GrpcServerFactory.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerObservationAutoConfiguration.class)); + } + + @Test + void whenObservationRegistryNotOnClasspathAutoConfigurationSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(ObservationRegistry.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerObservationAutoConfiguration.class)); + } + + @Test + void whenObservationGrpcServerInterceptorNotOnClasspathAutoConfigurationSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(ObservationGrpcServerInterceptor.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerObservationAutoConfiguration.class)); + } + + @Test + void whenObservationRegistryNotProvidedThenAutoConfigurationSkipped() { + new ApplicationContextRunner().withConfiguration(autoConfigurations) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerObservationAutoConfiguration.class)); + } + + @Test + void whenObservationPropertyEnabledThenAutoConfigurationNotSkipped() { + this.contextRunner.withPropertyValues("spring.grpc.server.observation.enabled=true") + .run((context) -> assertThat(context).hasSingleBean(GrpcServerObservationAutoConfiguration.class)); + } + + @Test + void whenObservationPropertyDisabledThenAutoConfigurationIsSkipped() { + this.contextRunner.withPropertyValues("spring.grpc.server.observation.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerObservationAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertySetFalseThenAutoConfigurationIsSkipped() { + this.contextRunner.withPropertyValues("spring.grpc.server.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerObservationAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertyNotSetThenAutoConfigurationIsNotSkipped() { + this.contextRunner + .run((context) -> assertThat(context).hasSingleBean(GrpcServerObservationAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertySetTrueThenAutoConfigurationIsNotSkipped() { + this.contextRunner.withPropertyValues("spring.grpc.server.enabled=true") + .run((context) -> assertThat(context).hasSingleBean(GrpcServerObservationAutoConfiguration.class)); + } + + @Test + void whenAllConditionsAreMetThenInterceptorConfiguredAsExpected() { + this.contextRunner.run((context) -> { + assertThat(context).hasSingleBean(ObservationGrpcServerInterceptor.class); + Map annotated = context.getBeansWithAnnotation(GlobalServerInterceptor.class); + List interceptors = context.getBeanProvider(ServerInterceptor.class) + .orderedStream() + .toList(); + assertThat(annotated).hasSize(2); + assertThat(interceptors.get(0)).isInstanceOf(ObservationGrpcServerInterceptor.class); + }); + } + + @Test + void whenMicrometerContextPropagationIsNotOnClasspathCoroutineInterceptorIsNotCreated() { + this.contextRunner.withClassLoader(new FilteredClassLoader(ContextRegistry.class)) + .run((context) -> assertThat(context).hasSingleBean(ObservationGrpcServerInterceptor.class) + .doesNotHaveBean(ObservationCoroutineContextServerInterceptor.class)); + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerPropertiesTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerPropertiesTests.java new file mode 100644 index 00000000000..f9be50f5130 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerPropertiesTests.java @@ -0,0 +1,169 @@ +/* + * 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.grpc.server.autoconfigure; + +import java.net.InetAddress; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +import io.grpc.TlsServerCredentials.ClientAuth; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; +import org.springframework.util.unit.DataSize; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link GrpcServerProperties}. + * + * @author Chris Bono + * @author Phillip Webb + */ +class GrpcServerPropertiesTests { + + private GrpcServerProperties bindProperties(Map map) { + return new Binder(new MapConfigurationPropertySource(map)) + .bind("spring.grpc.server", GrpcServerProperties.class) + .get(); + } + + @Test + void bind() throws Exception { + Map map = new HashMap<>(); + map.put("spring.grpc.server.address", "192.168.0.1"); + GrpcServerProperties properties = bindProperties(map); + assertThat(properties.getAddress()).isEqualTo(InetAddress.getByName("192.168.0.1")); + } + + @Test + void defaultAddressIsNull() { + assertThat(new GrpcServerProperties().getAddress()).isNull(); + } + + @Nested + class ShutdownProperties { + + @Test + void bind() { + Map map = new HashMap<>(); + map.put("spring.grpc.server.shutdown.grace-period", "10m"); + GrpcServerProperties properties = bindProperties(map); + assertThat(properties.getShutdown().getGracePeriod()).isEqualTo(Duration.ofMinutes(10)); + } + + @Test + void bindWithoutUnits() { + Map map = new HashMap<>(); + map.put("spring.grpc.server.shutdown.grace-period", "10"); + GrpcServerProperties properties = bindProperties(map); + assertThat(properties.getShutdown().getGracePeriod()).isEqualTo(Duration.ofSeconds(10)); + } + + } + + @Nested + class InboundProperties { + + @Test + void bind() { + Map map = new HashMap<>(); + map.put("spring.grpc.server.inbound.message.max-size", "20MB"); + map.put("spring.grpc.server.inbound.metadata.max-size", "1MB"); + GrpcServerProperties properties = bindProperties(map); + assertThat(properties.getInbound().getMessage().getMaxSize()).isEqualTo(DataSize.ofMegabytes(20)); + assertThat(properties.getInbound().getMetadata().getMaxSize()).isEqualTo(DataSize.ofMegabytes(1)); + } + + @Test + void bindWithoutUnits() { + Map map = new HashMap<>(); + map.put("spring.grpc.server.inbound.message.max-size", "1048576"); + map.put("spring.grpc.server.inbound.metadata.max-size", "1024"); + GrpcServerProperties properties = bindProperties(map); + assertThat(properties.getInbound().getMessage().getMaxSize()).isEqualTo(DataSize.ofMegabytes(1)); + assertThat(properties.getInbound().getMetadata().getMaxSize()).isEqualTo(DataSize.ofKilobytes(1)); + } + + } + + @Nested + class KeepAliveProperties { + + @Test + void bind() { + Map map = new HashMap<>(); + map.put("spring.grpc.server.keepalive.time", "45m"); + map.put("spring.grpc.server.keepalive.timeout", "40s"); + map.put("spring.grpc.server.keepalive.permit.time", "33s"); + map.put("spring.grpc.server.keepalive.permit.without-calls", "true"); + map.put("spring.grpc.server.keepalive.connection.max-idle-time", "1h"); + map.put("spring.grpc.server.keepalive.connection.max-age", "3h"); + map.put("spring.grpc.server.keepalive.connection.grace-period", "21s"); + GrpcServerProperties.Keepalive properties = bindProperties(map).getKeepalive(); + assertThatPropertiesSetAsExpected(properties); + } + + @Test + void bindWithoutUnits() { + Map map = new HashMap<>(); + map.put("spring.grpc.server.keepalive.time", "2700"); + map.put("spring.grpc.server.keepalive.timeout", "40"); + map.put("spring.grpc.server.keepalive.permit.time", "33"); + map.put("spring.grpc.server.keepalive.permit.without-calls", "true"); + map.put("spring.grpc.server.keepalive.connection.max-idle-time", "3600"); + map.put("spring.grpc.server.keepalive.connection.max-age", "10800"); + map.put("spring.grpc.server.keepalive.connection.grace-period", "21"); + GrpcServerProperties.Keepalive properties = bindProperties(map).getKeepalive(); + assertThatPropertiesSetAsExpected(properties); + } + + private void assertThatPropertiesSetAsExpected(GrpcServerProperties.Keepalive properties) { + assertThat(properties.getTime()).isEqualTo(Duration.ofMinutes(45)); + assertThat(properties.getTimeout()).isEqualTo(Duration.ofSeconds(40)); + assertThat(properties.getPermit().getTime()).isEqualTo(Duration.ofSeconds(33)); + assertThat(properties.getPermit().isWithoutCalls()).isTrue(); + assertThat(properties.getConnection().getMaxIdleTime()).isEqualTo(Duration.ofHours(1)); + assertThat(properties.getConnection().getMaxAge()).isEqualTo(Duration.ofHours(3)); + assertThat(properties.getConnection().getGracePeriod()).isEqualTo(Duration.ofSeconds(21)); + } + + } + + @Nested + class SslProperties { + + @Test + void bind() { + Map map = new HashMap<>(); + map.put("spring.grpc.server.ssl.enabled", "true"); + map.put("spring.grpc.server.ssl.client-auth", "require"); + map.put("spring.grpc.server.ssl.bundle", "test"); + map.put("spring.grpc.server.ssl.secure", "false"); + GrpcServerProperties.Ssl properties = bindProperties(map).getSsl(); + assertThat(properties.getEnabled()).isTrue(); + assertThat(properties.getClientAuth()).isEqualTo(ClientAuth.REQUIRE); + assertThat(properties.getBundle()).isEqualTo("test"); + assertThat(properties.isSecure()).isFalse(); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerServicesAutoConfigurationTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerServicesAutoConfigurationTests.java new file mode 100644 index 00000000000..508101606d7 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerServicesAutoConfigurationTests.java @@ -0,0 +1,104 @@ +/* + * 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.grpc.server.autoconfigure; + +import io.grpc.BindableService; +import io.grpc.protobuf.services.ProtoReflectionServiceV1; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerServicesAutoConfiguration.GrpcServerReflectionServiceConfiguration; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.grpc.server.lifecycle.GrpcServerLifecycle; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link GrpcServerServicesAutoConfiguration}. + * + * @author Haris Zujo + * @author Chris Bono + * @author Andrey Litvitski + * @author Phillip Webb + */ +class GrpcServerServicesAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcServerServicesAutoConfiguration.class)) + .withBean("noopServerLifecycle", GrpcServerLifecycle.class, Mockito::mock) + .withBean(BindableService.class, Mockito::mock); + + @Nested + class GrpcServerReflectionServiceConfigurationTests { + + private final ApplicationContextRunner contextRunner = GrpcServerServicesAutoConfigurationTests.this.contextRunner; + + @Test + void whenAutoConfigurationIsNotSkippedCreatesReflectionServiceBean() { + this.contextRunner.run((context) -> { + assertThat(context).hasSingleBean(GrpcServerReflectionServiceConfiguration.class); + assertThat(context).hasBean("grpcServerReflectionService"); + }); + } + + @Test + void whenGrpcServicesNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(ProtoReflectionServiceV1.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerReflectionServiceConfiguration.class)); + } + + @Test + void whenNoBindableServiceDefinedAutoConfigurationIsSkipped() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcServerServicesAutoConfiguration.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerReflectionServiceConfiguration.class)); + } + + @Test + void whenReflectionEnabledPropertyIsTrueAutoConfigurationIsNotSkipped() { + this.contextRunner.withPropertyValues("spring.grpc.server.reflection.enabled=true") + .run((context) -> assertThat(context).hasBean("grpcServerReflectionService")); + } + + @Test + void whenReflectionEnabledPropertyIsFalseAutoConfigurationIsSkipped() { + this.contextRunner.withPropertyValues("spring.grpc.server.reflection.enabled=false").run((context) -> { + assertThat(context).doesNotHaveBean("grpcServerReflectionService"); + assertThat(context).doesNotHaveBean(GrpcServerReflectionServiceConfiguration.class); + }); + } + + @Test + void whenServerEnabledPropertyIsTrueAutoConfigurationIsNotSkipped() { + this.contextRunner.withPropertyValues("spring.grpc.server.enabled=true") + .run((context) -> assertThat(context).hasBean("grpcServerReflectionService")); + } + + @Test + void whenServerEnabledPropertyIsFalseAutoConfigurationIsSkipped() { + this.contextRunner.withPropertyValues("spring.grpc.server.enabled=false").run((context) -> { + assertThat(context).doesNotHaveBean("grpcServerReflectionService"); + assertThat(context).doesNotHaveBean(GrpcServerReflectionServiceConfiguration.class); + }); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/NettyAddressTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/NettyAddressTests.java new file mode 100644 index 00000000000..c0e392068b9 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/NettyAddressTests.java @@ -0,0 +1,115 @@ +/* + * 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.grpc.server.autoconfigure; + +import java.net.InetAddress; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException; +import org.springframework.boot.context.properties.source.MutuallyExclusiveConfigurationPropertiesException; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerProperties.Netty.Transport; +import org.springframework.grpc.internal.GrpcUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link NettyAddress}. + * + * @author Phillip Webb + */ +class NettyAddressTests { + + @Test + void whenNoTransportAndNoAddressOrPortOrDomainSocketPathBindsToDefault() { + NettyAddress address = new NettyAddress(null, null, null, null); + assertThat(address).hasToString(GrpcUtils.ANY_IP_ADDRESS + ":" + GrpcUtils.DEFAULT_PORT); + } + + @Test + void whenNoTransportAndOnlyPortBindsToAllAddressesUsingPort() { + NettyAddress address = new NettyAddress(null, null, 1234, null); + assertThat(address).hasToString(GrpcUtils.ANY_IP_ADDRESS + ":1234"); + } + + @Test + void whenNoTransportAndOnlyAddressBindsToAddressUsingPort9090() throws Exception { + InetAddress inetAddress = InetAddress.getByName("localhost"); + NettyAddress address = new NettyAddress(null, inetAddress, null, null); + assertThat(address).hasToString("localhost:" + GrpcUtils.DEFAULT_PORT); + } + + @Test + void whenNoTransportAndOnlyAddressWithoutNameBindsToAddressUsingPort9090() throws Exception { + InetAddress inetAddress = InetAddress.getByName("192.168.1.0"); + NettyAddress address = new NettyAddress(null, inetAddress, null, null); + assertThat(address).hasToString("192.168.1.0:" + GrpcUtils.DEFAULT_PORT); + } + + @Test + void whenNoTransportAndOnlyDomainSocketPathBindsToDomainSocket() { + NettyAddress address = new NettyAddress(null, null, null, "/ds"); + assertThat(address).hasToString("unix:/ds"); + } + + @Test + void whenNoTransportAndPortAndDomainSocketPathThrowsException() { + NettyAddress address = new NettyAddress(null, null, 1234, "/ds"); + assertThatExceptionOfType(MutuallyExclusiveConfigurationPropertiesException.class) + .isThrownBy(() -> address.toString()) + .withMessage( + "The configuration properties 'spring.grpc.server.port, spring.grpc.server.netty.domain-socket-path' " + + "are mutually exclusive and 'spring.grpc.server.port, spring.grpc.server.netty.domain-socket-path' " + + "have been configured together"); + } + + @Test + void whenNoTransportAndAddressAndDomainSocketPathThrowsException() throws Exception { + InetAddress inetAddress = InetAddress.getByName("192.168.1.0"); + NettyAddress address = new NettyAddress(null, inetAddress, null, "/ds"); + assertThatExceptionOfType(MutuallyExclusiveConfigurationPropertiesException.class) + .isThrownBy(() -> address.toString()) + .withMessage( + "The configuration properties 'spring.grpc.server.address, spring.grpc.server.netty.domain-socket-path' " + + "are mutually exclusive and 'spring.grpc.server.address, spring.grpc.server.netty.domain-socket-path' " + + "have been configured together"); + } + + @Test + void whenTcpTransportBindsToTcp() throws Exception { + InetAddress inetAddress = InetAddress.getByName("192.168.1.0"); + NettyAddress address = new NettyAddress(Transport.TCP, inetAddress, 1234, "/ds"); + assertThat(address).hasToString("192.168.1.0:1234"); + } + + @Test + void whenDomainSocketTransportAndNoDomainPathThrowsException() { + NettyAddress address = new NettyAddress(Transport.DOMAIN_SOCKET, null, null, ""); + assertThatExceptionOfType(InvalidConfigurationPropertyValueException.class).isThrownBy(() -> address.toString()) + .withMessage("Property spring.grpc.server.netty.domain-socket-path with value '' is invalid: " + + "A path is required when spring.grpc.server.netty.transport is set to 'domain-socket'"); + } + + @Test + void whenDomainSocketTransportAndDomainPathBindsToDomainPath() throws Exception { + InetAddress inetAddress = InetAddress.getByName("192.168.1.0"); + NettyAddress address = new NettyAddress(Transport.DOMAIN_SOCKET, inetAddress, 1234, "/ds"); + assertThat(address).hasToString("unix:/ds"); + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/PropertiesServerBuilderCustomizerTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/PropertiesServerBuilderCustomizerTests.java new file mode 100644 index 00000000000..1a0d74875da --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/PropertiesServerBuilderCustomizerTests.java @@ -0,0 +1,98 @@ +/* + * 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.grpc.server.autoconfigure; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import io.grpc.ServerBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.netty.NettyServerBuilder; +import io.grpc.servlet.jakarta.ServletServerBuilder; +import org.junit.jupiter.api.Test; +import org.mockito.verification.VerificationMode; + +import org.springframework.util.unit.DataSize; + +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +/** + * Tests for {@link PropertiesServerBuilderCustomizer}. + * + * @author Chris Bono + * @author Phillip Webb + */ +class PropertiesServerBuilderCustomizerTests { + + @Test + void customizeWhenNettyServerBuilder() { + NettyServerBuilder builder = mock(); + PropertiesServerBuilderCustomizer customizer = new PropertiesServerBuilderCustomizer<>( + getProperties()); + customizer.customize(builder); + assertExpectedMapping(builder, atLeastOnce()); + } + + @Test + void customizeWhenInProcessServerBuilder() { + InProcessServerBuilder builder = mock(); + PropertiesServerBuilderCustomizer customizer = new PropertiesServerBuilderCustomizer<>( + getProperties()); + customizer.customize(builder); + assertExpectedMapping(builder, never()); + } + + @Test + void customizerWhenServletServerBuilder() { + ServletServerBuilder builder = mock(); + PropertiesServerBuilderCustomizer customizer = new PropertiesServerBuilderCustomizer<>( + getProperties()); + customizer.customize(builder); + assertExpectedMapping(builder, never()); + } + + private GrpcServerProperties getProperties() { + GrpcServerProperties properties = new GrpcServerProperties(); + properties.getInbound().getMessage().setMaxSize(DataSize.ofMegabytes(333)); + properties.getInbound().getMetadata().setMaxSize(DataSize.ofKilobytes(111)); + properties.getKeepalive().setTime(Duration.ofHours(1)); + properties.getKeepalive().setTimeout(Duration.ofSeconds(10)); + properties.getKeepalive().getConnection().setMaxIdleTime(Duration.ofHours(2)); + properties.getKeepalive().getConnection().setMaxAge(Duration.ofHours(3)); + properties.getKeepalive().getConnection().setGracePeriod(Duration.ofSeconds(45)); + properties.getKeepalive().getPermit().setTime(Duration.ofMinutes(7)); + properties.getKeepalive().getPermit().setWithoutCalls(true); + return properties; + } + + private void assertExpectedMapping(ServerBuilder builder, VerificationMode keepAliveMode) { + then(builder).should().maxInboundMessageSize(Math.toIntExact(DataSize.ofMegabytes(333).toBytes())); + then(builder).should().maxInboundMetadataSize(Math.toIntExact(DataSize.ofKilobytes(111).toBytes())); + then(builder).should(keepAliveMode).keepAliveTime(Duration.ofHours(1).toNanos(), TimeUnit.NANOSECONDS); + then(builder).should(keepAliveMode).keepAliveTimeout(Duration.ofSeconds(10).toNanos(), TimeUnit.NANOSECONDS); + then(builder).should(keepAliveMode).maxConnectionIdle(Duration.ofHours(2).toNanos(), TimeUnit.NANOSECONDS); + then(builder).should(keepAliveMode).maxConnectionAge(Duration.ofHours(3).toNanos(), TimeUnit.NANOSECONDS); + then(builder).should(keepAliveMode) + .maxConnectionAgeGrace(Duration.ofSeconds(45).toNanos(), TimeUnit.NANOSECONDS); + then(builder).should(keepAliveMode).permitKeepAliveTime(Duration.ofMinutes(7).toNanos(), TimeUnit.NANOSECONDS); + then(builder).should(keepAliveMode).permitKeepAliveWithoutCalls(true); + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/ServerCredentialsTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/ServerCredentialsTests.java new file mode 100644 index 00000000000..c9794c20037 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/ServerCredentialsTests.java @@ -0,0 +1,138 @@ +/* + * 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.grpc.server.autoconfigure; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.TrustManagerFactory; + +import io.grpc.TlsServerCredentials.ClientAuth; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerProperties.Ssl; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.boot.ssl.SslManagerBundle; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link ServerCredentials}. + * + * @author Phillip Webb + */ +class ServerCredentialsTests { + + private final TrustManagerFactory insecureTrustManagerFactory = mock(); + + private final SslBundles bundles = mock(); + + private final TrustManagerFactory bundleTrustManagerFactory = mock(); + + private final KeyManagerFactory bundleKeyManagerFactory = mock(); + + ServerCredentialsTests() { + SslBundle bundle = mock(); + SslManagerBundle managers = mock(); + given(this.bundles.getBundle("test")).willReturn(bundle); + given(bundle.getManagers()).willReturn(managers); + given(managers.getTrustManagerFactory()).willReturn(this.bundleTrustManagerFactory); + given(managers.getKeyManagerFactory()).willReturn(this.bundleKeyManagerFactory); + } + + @Test + void getWhenNotEnabledAndNoBundleReturnsNullManagers() { + ServerCredentials credentials = get((properties) -> { + }); + assertThat(credentials.keyManagerFactory()).isNull(); + assertThat(credentials.trustManagerFactory()).isNull(); + assertThat(credentials.clientAuth()).isEqualTo(ClientAuth.NONE); + } + + @Test + void getWhenDisabledReturnsNullManagers() { + ServerCredentials credentials = get((properties) -> { + properties.put("spring.grpc.server.ssl.enabled", "false"); + properties.put("spring.grpc.server.ssl.client-auth", "require"); + }); + assertThat(credentials.keyManagerFactory()).isNull(); + assertThat(credentials.trustManagerFactory()).isNull(); + assertThat(credentials.clientAuth()).isEqualTo(ClientAuth.REQUIRE); + } + + @Test + void getWhenEnabledTrueAndNoBundleNameThrowsException() { + assertThatIllegalStateException().isThrownBy(() -> get((properties) -> { + properties.put("spring.grpc.server.ssl.enabled", "true"); + properties.put("spring.grpc.server.ssl.client-auth", "require"); + })).withMessage("SSL bundle-name is requested when 'spring.grpc.server.ssl.enabled' is true"); + } + + @Test + void getWhenHasBundleName() { + ServerCredentials credentials = get((properties) -> { + properties.put("spring.grpc.server.ssl.bundle", "test"); + properties.put("spring.grpc.server.ssl.client-auth", "require"); + }); + assertThat(credentials.keyManagerFactory()).isEqualTo(this.bundleKeyManagerFactory); + assertThat(credentials.trustManagerFactory()).isEqualTo(this.bundleTrustManagerFactory); + assertThat(credentials.clientAuth()).isEqualTo(ClientAuth.REQUIRE); + } + + @Test + void getWhenHasBundleNameAndEnabled() { + ServerCredentials credentials = get((properties) -> { + properties.put("spring.grpc.server.ssl.enabled", "true"); + properties.put("spring.grpc.server.ssl.bundle", "test"); + properties.put("spring.grpc.server.ssl.client-auth", "require"); + }); + assertThat(credentials.keyManagerFactory()).isEqualTo(this.bundleKeyManagerFactory); + assertThat(credentials.trustManagerFactory()).isEqualTo(this.bundleTrustManagerFactory); + assertThat(credentials.clientAuth()).isEqualTo(ClientAuth.REQUIRE); + } + + @Test + void getWhenHasBundleNameAndSecureFalse() { + ServerCredentials credentials = get((properties) -> { + properties.put("spring.grpc.server.ssl.enabled", "true"); + properties.put("spring.grpc.server.ssl.bundle", "test"); + properties.put("spring.grpc.server.ssl.secure", "false"); + }); + assertThat(credentials.keyManagerFactory()).isEqualTo(this.bundleKeyManagerFactory); + assertThat(credentials.trustManagerFactory()).isEqualTo(this.insecureTrustManagerFactory); + assertThat(credentials.clientAuth()).isEqualTo(ClientAuth.NONE); + } + + private ServerCredentials get(Consumer> properties) { + Map map = new HashMap<>(); + properties.accept(map); + Ssl ssl = new Binder(new MapConfigurationPropertySource(map)) + .bind("spring.grpc.server", GrpcServerProperties.class) + .orElseGet(GrpcServerProperties::new) + .getSsl(); + return ServerCredentials.get(ssl, this.bundles, this.insecureTrustManagerFactory); + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponentTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponentTests.java new file mode 100644 index 00000000000..13e14d76476 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponentTests.java @@ -0,0 +1,75 @@ +/* + * 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.grpc.server.autoconfigure.health; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.boot.grpc.server.health.StatusAggregator; +import org.springframework.boot.grpc.server.health.StatusMapper; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link AutoConfiguredHealthCheckedGrpcComponent}. + * + * @author Phillip Webb + */ +@ExtendWith(MockitoExtension.class) +class AutoConfiguredHealthCheckedGrpcComponentTests { + + @Mock + @SuppressWarnings("NullAway.Init") + private StatusAggregator statusAggregator; + + @Mock + @SuppressWarnings("NullAway.Init") + private StatusMapper statusMapper; + + @Test + void isMemberWhenMembershipMatchesAcceptsTrue() { + AutoConfiguredHealthCheckedGrpcComponent component = new AutoConfiguredHealthCheckedGrpcComponent( + (name) -> name.startsWith("a"), this.statusAggregator, this.statusMapper); + assertThat(component.isMember("albert")).isTrue(); + assertThat(component.isMember("arnold")).isTrue(); + } + + @Test + void isMemberWhenMembershipRejectsReturnsTrue() { + AutoConfiguredHealthCheckedGrpcComponent component = new AutoConfiguredHealthCheckedGrpcComponent( + (name) -> name.startsWith("a"), this.statusAggregator, this.statusMapper); + assertThat(component.isMember("bert")).isFalse(); + assertThat(component.isMember("ernie")).isFalse(); + } + + @Test + void getStatusAggregatorReturnsStatusAggregator() { + AutoConfiguredHealthCheckedGrpcComponent component = new AutoConfiguredHealthCheckedGrpcComponent( + (name) -> true, this.statusAggregator, this.statusMapper); + assertThat(component.getStatusAggregator()).isSameAs(this.statusAggregator); + } + + @Test + void getStatusMapperReturnsHttpCodeStatusMapper() { + AutoConfiguredHealthCheckedGrpcComponent component = new AutoConfiguredHealthCheckedGrpcComponent( + (name) -> true, this.statusAggregator, this.statusMapper); + assertThat(component.getStatusMapper()).isSameAs(this.statusMapper); + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponentsTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponentsTests.java new file mode 100644 index 00000000000..e0ece6931d1 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponentsTests.java @@ -0,0 +1,437 @@ +/* + * 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.grpc.server.autoconfigure.health; + +import java.util.Collections; + +import io.grpc.health.v1.HealthCheckResponse.ServingStatus; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.grpc.server.health.HealthCheckedGrpcComponent; +import org.springframework.boot.grpc.server.health.HealthCheckedGrpcComponents; +import org.springframework.boot.grpc.server.health.StatusAggregator; +import org.springframework.boot.grpc.server.health.StatusMapper; +import org.springframework.boot.health.contributor.Status; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link AutoConfiguredHealthCheckedGrpcComponent}. + * + * @author Phillip Webb + */ +class AutoConfiguredHealthCheckedGrpcComponentsTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(AutoConfiguredHealthCheckedGrpcComponentsTestConfiguration.class)); + + @Test + void getServerMatchesAllMembers() { + this.contextRunner.run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + assertThat(server).isNotNull(); + assertThat(server.isMember("a")).isTrue(); + assertThat(server.isMember("b")).isTrue(); + assertThat(server.isMember("C")).isTrue(); + }); + } + + @Test + void getServiceNamesReturnsServiceNames() { + this.contextRunner + .withPropertyValues("spring.grpc.server.health.service.a.include=*", + "spring.grpc.server.health.service.b.include=*") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + assertThat(components.getServiceNames()).containsExactlyInAnyOrder("a", "b"); + }); + } + + @Test + void getServiceWhenServiceExistsReturnsService() { + this.contextRunner.withPropertyValues("spring.grpc.server.health.service.a.include=*").run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent component = components.getService("a"); + assertThat(component).isNotNull(); + }); + } + + @Test + void getServiceWhenServiceDoesNotExistReturnsNull() { + this.contextRunner.withPropertyValues("spring.grpc.server.health.service.a.include=*").run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent component = components.getService("b"); + assertThat(component).isNull(); + }); + } + + @Test + void createWhenNoDefinedBeansAdaptsProperties() { + this.contextRunner + .withPropertyValues("spring.grpc.server.health.status.order=up,down", + "spring.grpc.server.health.status.mapping.down=serving") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + assertThat(server).isNotNull(); + assertThat(server.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN)) + .isEqualTo(Status.UP); + assertThat(server.getStatusMapper().getServingStatus(Status.DOWN)).isEqualTo(ServingStatus.SERVING); + }); + } + + @Test + void createWhenHasStatusAggregatorBeanReturnsInstanceWithAggregatorUsedForAllServices() { + this.contextRunner.withUserConfiguration(CustomStatusAggregatorConfiguration.class) + .withPropertyValues("spring.grpc.server.health.status.order=up,down", + "spring.grpc.server.health.service.a.include=*") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + HealthCheckedGrpcComponent serviceA = components.getService("a"); + assertThat(server).isNotNull(); + assertThat(serviceA).isNotNull(); + assertThat(server.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UNKNOWN); + assertThat(serviceA).isNotNull(); + assertThat(serviceA.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UNKNOWN); + }); + } + + @Test + void createWhenHasStatusAggregatorBeanAndServiceSpecificPropertyReturnsInstanceThatUsesBeanOnlyForUnconfiguredServices() { + this.contextRunner.withUserConfiguration(CustomStatusAggregatorConfiguration.class) + .withPropertyValues("spring.grpc.server.health.service.a.include=*", + "spring.grpc.server.health.service.a.status.order=up,down", + "spring.grpc.server.health.service.b.include=*") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + HealthCheckedGrpcComponent serviceA = components.getService("a"); + HealthCheckedGrpcComponent serviceB = components.getService("b"); + assertThat(server).isNotNull(); + assertThat(serviceA).isNotNull(); + assertThat(serviceB).isNotNull(); + assertThat(server.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UNKNOWN); + assertThat(serviceA).isNotNull(); + assertThat(serviceA.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UP); + assertThat(serviceB).isNotNull(); + assertThat(serviceB.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UNKNOWN); + }); + } + + @Test + void createWhenHasStatusAggregatorPropertyReturnsInstanceWithPropertyUsedForAllServices() { + this.contextRunner + .withPropertyValues("spring.grpc.server.health.status.order=up,down", + "spring.grpc.server.health.service.a.include=*") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + HealthCheckedGrpcComponent serviceA = components.getService("a"); + assertThat(server).isNotNull(); + assertThat(serviceA).isNotNull(); + assertThat(server.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN)) + .isEqualTo(Status.UP); + assertThat(serviceA).isNotNull(); + assertThat(serviceA.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN)) + .isEqualTo(Status.UP); + }); + } + + @Test + void createWhenHasStatusAggregatorPropertyAndServiceSpecificPropertyReturnsInstanceWithPropertyUsedForExpectedServices() { + this.contextRunner + .withPropertyValues("spring.grpc.server.health.status.order=up,down", + "spring.grpc.server.health.service.a.include=*", + "spring.grpc.server.health.service.a.status.order=unknown,up,down", + "spring.grpc.server.health.service.b.include=*") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + HealthCheckedGrpcComponent serviceA = components.getService("a"); + HealthCheckedGrpcComponent serviceB = components.getService("b"); + assertThat(server).isNotNull(); + assertThat(serviceA).isNotNull(); + assertThat(serviceB).isNotNull(); + assertThat(server.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UP); + assertThat(serviceA).isNotNull(); + assertThat(serviceA.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UNKNOWN); + assertThat(serviceB).isNotNull(); + assertThat(serviceB.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UP); + }); + } + + @Test + void createWhenHasStatusAggregatorPropertyAndServiceQualifiedBeanReturnsInstanceWithBeanUsedForExpectedServices() { + this.contextRunner.withUserConfiguration(CustomStatusAggregatorServiceAConfiguration.class) + .withPropertyValues("spring.grpc.server.health.status.order=up,down", + "spring.grpc.server.health.service.a.include=*", + "spring.grpc.server.health.service.a.status.order=up,down", + "spring.grpc.server.health.service.b.include=*") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + HealthCheckedGrpcComponent serviceA = components.getService("a"); + HealthCheckedGrpcComponent serviceB = components.getService("b"); + assertThat(server).isNotNull(); + assertThat(serviceA).isNotNull(); + assertThat(serviceB).isNotNull(); + assertThat(server.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UP); + assertThat(serviceA).isNotNull(); + assertThat(serviceA.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UNKNOWN); + assertThat(serviceB).isNotNull(); + assertThat(serviceB.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UP); + }); + } + + @Test + void createWhenHasServiceSpecificStatusAggregatorPropertyAndServiceQualifiedBeanReturnsInstanceWithBeanUsedForExpectedServices() { + this.contextRunner.withUserConfiguration(CustomStatusAggregatorServiceAConfiguration.class) + .withPropertyValues("spring.grpc.server.health.service.a.include=*", + "spring.grpc.server.health.service.a.status.order=up,down", + "spring.grpc.server.health.service.b.include=*", + "spring.grpc.server.health.service.b.status.order=up,down") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + HealthCheckedGrpcComponent serviceA = components.getService("a"); + HealthCheckedGrpcComponent serviceB = components.getService("b"); + assertThat(server).isNotNull(); + assertThat(serviceA).isNotNull(); + assertThat(serviceB).isNotNull(); + assertThat(server.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.DOWN); + assertThat(serviceA).isNotNull(); + assertThat(serviceA.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UNKNOWN); + assertThat(serviceB).isNotNull(); + assertThat(serviceB.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UP); + }); + } + + @Test + void createWhenHasStatusMapperBeanReturnsInstanceWithMapperUsedForAllServices() { + this.contextRunner.withUserConfiguration(CustomStatusMapperConfiguration.class) + .withPropertyValues("spring.grpc.server.health.status.mapping.down=service-unknown", + "spring.grpc.server.health.service.a.include=*") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + HealthCheckedGrpcComponent serviceA = components.getService("a"); + assertThat(server).isNotNull(); + assertThat(serviceA).isNotNull(); + assertThat(server.getStatusMapper().getServingStatus(Status.DOWN)).isEqualTo(ServingStatus.SERVING); + assertThat(serviceA).isNotNull(); + assertThat(serviceA.getStatusMapper().getServingStatus(Status.DOWN)).isEqualTo(ServingStatus.SERVING); + }); + } + + @Test + void createWhenHasStatusMapperBeanAndServiceSpecificPropertyReturnsInstanceThatUsesBeanOnlyForUnconfiguredServices() { + this.contextRunner.withUserConfiguration(CustomStatusMapperConfiguration.class) + .withPropertyValues("spring.grpc.server.health.service.a.include=*", + "spring.grpc.server.health.service.a.status.mapping.down=service-unknown", + "spring.grpc.server.health.service.b.include=*") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + HealthCheckedGrpcComponent serviceA = components.getService("a"); + HealthCheckedGrpcComponent serviceB = components.getService("b"); + assertThat(server).isNotNull(); + assertThat(serviceA).isNotNull(); + assertThat(serviceB).isNotNull(); + assertThat(server.getStatusMapper().getServingStatus(Status.DOWN)).isEqualTo(ServingStatus.SERVING); + assertThat(serviceA).isNotNull(); + assertThat(serviceA.getStatusMapper().getServingStatus(Status.DOWN)) + .isEqualTo(ServingStatus.SERVICE_UNKNOWN); + assertThat(serviceB).isNotNull(); + assertThat(serviceB.getStatusMapper().getServingStatus(Status.DOWN)).isEqualTo(ServingStatus.SERVING); + }); + } + + @Test + void createWhenHasStatusMapperPropertyReturnsInstanceWithPropertyUsedForAllServices() { + this.contextRunner + .withPropertyValues("spring.grpc.server.health.status.mapping.down=service-unknown", + "spring.grpc.server.health.service.a.include=*") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + HealthCheckedGrpcComponent serviceA = components.getService("a"); + assertThat(server).isNotNull(); + assertThat(serviceA).isNotNull(); + assertThat(server.getStatusMapper().getServingStatus(Status.DOWN)) + .isEqualTo(ServingStatus.SERVICE_UNKNOWN); + assertThat(serviceA).isNotNull(); + assertThat(serviceA.getStatusMapper().getServingStatus(Status.DOWN)) + .isEqualTo(ServingStatus.SERVICE_UNKNOWN); + }); + } + + @Test + void createWhenHasStatusMapperPropertyAndServiceSpecificPropertyReturnsInstanceWithPropertyUsedForExpectedServices() { + this.contextRunner + .withPropertyValues("spring.grpc.server.health.status.mapping.down=service-unknown", + "spring.grpc.server.health.service.a.include=*", + "spring.grpc.server.health.service.a.status.mapping.down=unrecognized", + "spring.grpc.server.health.service.b.include=*") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + HealthCheckedGrpcComponent serviceA = components.getService("a"); + HealthCheckedGrpcComponent serviceB = components.getService("b"); + assertThat(server).isNotNull(); + assertThat(serviceA).isNotNull(); + assertThat(serviceB).isNotNull(); + assertThat(server.getStatusMapper().getServingStatus(Status.DOWN)) + .isEqualTo(ServingStatus.SERVICE_UNKNOWN); + assertThat(serviceA).isNotNull(); + assertThat(serviceA.getStatusMapper().getServingStatus(Status.DOWN)) + .isEqualTo(ServingStatus.UNRECOGNIZED); + assertThat(serviceB).isNotNull(); + assertThat(serviceB.getStatusMapper().getServingStatus(Status.DOWN)) + .isEqualTo(ServingStatus.SERVICE_UNKNOWN); + }); + } + + @Test + void createWhenHasStatusMapperPropertyAndServiceQualifiedBeanReturnsInstanceWithBeanUsedForExpectedServices() { + this.contextRunner.withUserConfiguration(CustomStatusMapperServiceAConfiguration.class) + .withPropertyValues("spring.grpc.server.health.status.mapping.down=service-unknown", + "spring.grpc.server.health.service.a.include=*", + "spring.grpc.server.health.service.a.status.mapping.down=service-unknown", + "spring.grpc.server.health.service.b.include=*") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + HealthCheckedGrpcComponent serviceA = components.getService("a"); + HealthCheckedGrpcComponent serviceB = components.getService("b"); + assertThat(server).isNotNull(); + assertThat(serviceA).isNotNull(); + assertThat(serviceB).isNotNull(); + assertThat(server.getStatusMapper().getServingStatus(Status.DOWN)) + .isEqualTo(ServingStatus.SERVICE_UNKNOWN); + assertThat(serviceA).isNotNull(); + assertThat(serviceA.getStatusMapper().getServingStatus(Status.DOWN)).isEqualTo(ServingStatus.SERVING); + assertThat(serviceB).isNotNull(); + assertThat(serviceB.getStatusMapper().getServingStatus(Status.DOWN)) + .isEqualTo(ServingStatus.SERVICE_UNKNOWN); + }); + } + + @Test + void createWhenHasServiceSpecificStatusMapperPropertyAndServiceQualifiedBeanReturnsInstanceWithBeanUsedForExpectedServices() { + this.contextRunner.withUserConfiguration(CustomStatusMapperServiceAConfiguration.class) + .withPropertyValues("spring.grpc.server.health.service.a.include=*", + "spring.grpc.server.health.service.a.status.mapping.down=service-unknown", + "spring.grpc.server.health.service.b.include=*", + "spring.grpc.server.health.service.b.status.mapping.down=service-unknown") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + HealthCheckedGrpcComponent serviceA = components.getService("a"); + HealthCheckedGrpcComponent serviceB = components.getService("b"); + assertThat(server).isNotNull(); + assertThat(serviceA).isNotNull(); + assertThat(serviceB).isNotNull(); + assertThat(server.getStatusMapper().getServingStatus(Status.DOWN)).isEqualTo(ServingStatus.NOT_SERVING); + assertThat(serviceA).isNotNull(); + assertThat(serviceA.getStatusMapper().getServingStatus(Status.DOWN)).isEqualTo(ServingStatus.SERVING); + assertThat(serviceB).isNotNull(); + assertThat(serviceB.getStatusMapper().getServingStatus(Status.DOWN)) + .isEqualTo(ServingStatus.SERVICE_UNKNOWN); + }); + } + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(GrpcServerHealthProperties.class) + static class AutoConfiguredHealthCheckedGrpcComponentsTestConfiguration { + + @Bean + AutoConfiguredHealthCheckedGrpcComponents healthCheckedGrpcComponents( + ConfigurableApplicationContext applicationContext, GrpcServerHealthProperties properties) { + return new AutoConfiguredHealthCheckedGrpcComponents(applicationContext, properties); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomStatusAggregatorConfiguration { + + @Bean + @Primary + StatusAggregator statusAggregator() { + return StatusAggregator.of(Status.UNKNOWN, Status.UP, Status.DOWN); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomStatusAggregatorServiceAConfiguration { + + @Bean + @Qualifier("a") + StatusAggregator statusAggregator() { + return StatusAggregator.of(Status.UNKNOWN, Status.UP, Status.DOWN); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomStatusMapperConfiguration { + + @Bean + @Primary + StatusMapper httpCodeStatusMapper() { + return StatusMapper.of(Collections.singletonMap(Status.DOWN.getCode(), ServingStatus.SERVING)); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomStatusMapperServiceAConfiguration { + + @Bean + @Qualifier("a") + StatusMapper httpCodeStatusMapper() { + return StatusMapper.of(Collections.singletonMap(Status.DOWN.getCode(), ServingStatus.SERVING)); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfigurationTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfigurationTests.java new file mode 100644 index 00000000000..ad9a30bbeeb --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfigurationTests.java @@ -0,0 +1,458 @@ +/* + * 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.grpc.server.autoconfigure.health; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import io.grpc.BindableService; +import io.grpc.health.v1.HealthCheckResponse.ServingStatus; +import io.grpc.protobuf.services.HealthStatusManager; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.task.TaskSchedulingAutoConfiguration; +import org.springframework.boot.diagnostics.FailureAnalyzedException; +import org.springframework.boot.grpc.server.health.GrpcServerHealth; +import org.springframework.boot.grpc.server.health.HealthCheckedGrpcComponents; +import org.springframework.boot.grpc.server.health.StatusAggregator; +import org.springframework.boot.grpc.server.health.StatusMapper; +import org.springframework.boot.health.autoconfigure.contributor.HealthContributorMembershipValidator; +import org.springframework.boot.health.autoconfigure.registry.HealthContributorRegistryAutoConfiguration; +import org.springframework.boot.health.contributor.CompositeHealthContributor; +import org.springframework.boot.health.contributor.CompositeReactiveHealthContributor; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.boot.health.contributor.ReactiveHealthIndicator; +import org.springframework.boot.health.contributor.Status; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.assertj.ApplicationContextAssertProvider; +import org.springframework.boot.test.context.assertj.AssertableApplicationContext; +import org.springframework.boot.test.context.runner.AbstractApplicationContextRunner; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.server.GrpcServerFactory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GrpcServerHealthAutoConfiguration}. + * + * @author Phillip Webb + * @author Chris Bono + * @author Andrey Litvitski + */ +class GrpcServerHealthAutoConfigurationTests { + + private static final AutoConfigurations autoConfigurations = AutoConfigurations.of( + GrpcServerHealthAutoConfiguration.class, GrpcServerHealthSchedulerAutoConfiguration.class, + HealthContributorRegistryAutoConfiguration.class, TaskSchedulingAutoConfiguration.class); + + private final BindableService service = mock(); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(HealthIndicatorsConfiguration.class) + .withConfiguration(autoConfigurations) + .with(this::serviceBean); + + @Test + void autoConfiguresBeans() { + this.contextRunner.run(this::assertConfigured); + } + + @Test + void whenNoBindableServiceDefinedDoesNotAutoConfigureBeans() { + new ApplicationContextRunner().withConfiguration(autoConfigurations).run(this::assertNotConfigured); + } + + @Test + void whenNoBindableServiceDefinedButHealthEnabledPropertyIsTrueAutoConfiguresBeans() { + new ApplicationContextRunner().withConfiguration(autoConfigurations) + .withPropertyValues("spring.grpc.server.health.enabled=true") + .run(this::assertConfigured); + } + + @Test + void whenGrpcNotOnClasspathDoesNotAutoConfigureBeans() { + this.contextRunner.withClassLoader(new FilteredClassLoader(BindableService.class)) + .run(this::assertNotConfigured); + } + + @Test + void whenSpringGrpcNotOnClasspathDoesNotAutoConfigureBeans() { + this.contextRunner.withClassLoader(new FilteredClassLoader(GrpcServerFactory.class)) + .run(this::assertNotConfigured); + } + + @Test + void whenHealthStatusManagerNotOnClasspathDoesNotAutoConfigureBeans() { + this.contextRunner.withClassLoader(new FilteredClassLoader(HealthStatusManager.class)) + .run(this::assertNotConfigured); + } + + @Test + void whenNoTaskSchedulerDoesNotAutoConfigureHealthScheduler() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcServerHealthAutoConfiguration.class, + HealthContributorRegistryAutoConfiguration.class)) + .with(this::serviceBean) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerHealthScheduler.class)); + } + + @Test + void whenHealthScheduleEnabledPropertyFalseDoesNotAutoConfigureHealthScheduler() { + this.contextRunner.withPropertyValues("spring.grpc.server.health.schedule.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerHealthScheduler.class)); + } + + @Test + void whenHealthScheduleEnabledPropertyTrueDoesAutoConfigureHealthScheduler() { + this.contextRunner.withPropertyValues("spring.grpc.server.health.schedule.enabled=true") + .run((context) -> assertThat(context).hasSingleBean(GrpcServerHealthScheduler.class)); + } + + @Test + void whenHealthEnabledPropertyIsTrueAutoConfiguresBeans() { + this.contextRunner.withPropertyValues("spring.grpc.server.health.enabled=true").run(this::assertConfigured); + } + + @Test + void whenHealthEnabledPropertyIsFalseDoesNotAutoConfigureBeans() { + this.contextRunner.withPropertyValues("spring.grpc.server.health.enabled=false").run(this::assertNotConfigured); + } + + @Test + void whenServerEnabledPropertyIsFalseDoesNotAutoConfigureBeans() { + this.contextRunner.withPropertyValues("spring.grpc.server.enabled=false").run(this::assertNotConfigured); + } + + @Test + void whenServerEnabledPropertyIsTrueAutoConfiguresBeans() { + this.contextRunner.withPropertyValues("spring.grpc.server.enabled=true").run(this::assertConfigured); + } + + @Test + void enterTerminalStateIsCalledOnShutdown() { + AtomicReference manager = new AtomicReference<>(); + this.contextRunner.run((context) -> { + manager.set(context.getBean(HealthStatusManager.class)); + assertTerminalState(manager.get(), false); + }); + assertTerminalState(manager.get(), true); + } + + @Test + void whenHasUserDefinedHealthStatusManagerDoesNotAutoConfigureBean() { + HealthStatusManager customHealthStatusManager = mock(); + this.contextRunner + .withBean("customHealthStatusManager", HealthStatusManager.class, () -> customHealthStatusManager) + .run((context) -> assertThat(context).getBean(HealthStatusManager.class) + .isSameAs(customHealthStatusManager)); + } + + @Test + void whenHasNoHealthContributorRegistryOnlyAutoConfiguresBasicService() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcServerHealthAutoConfiguration.class, + TaskSchedulingAutoConfiguration.class)) + .with(this::serviceBean) + .run((context) -> { + assertThat(context).hasSingleBean(HealthStatusManager.class); + assertThat(context).hasBean("grpcServerHealthService"); + assertThat(context).doesNotHaveBean(GrpcServerHealth.class); + }); + } + + @Test + void createsStatusAggregatorFromProperties() { + this.contextRunner.withPropertyValues("spring.grpc.server.health.status.order=up,down").run((context) -> { + StatusAggregator aggregator = context.getBean(StatusAggregator.class); + assertThat(aggregator.getAggregateStatus(Status.UP, Status.DOWN)).isEqualTo(Status.UP); + }); + } + + @Test + void whenHasStatusAggregatorBeanIgnoresProperties() { + this.contextRunner.withUserConfiguration(StatusAggregatorConfiguration.class) + .withPropertyValues("spring.grpc.server.health.status.order=up,down") + .run((context) -> { + StatusAggregator aggregator = context.getBean(StatusAggregator.class); + assertThat(aggregator.getAggregateStatus(Status.UP, Status.DOWN)).isEqualTo(Status.UNKNOWN); + }); + } + + @Test + void createsStatusMapperFromProperties() { + this.contextRunner.withPropertyValues("spring.grpc.server.health.status.mapping.up=not-serving") + .run((context) -> { + StatusMapper mapper = context.getBean(StatusMapper.class); + assertThat(mapper.getServingStatus(Status.UP)).isEqualTo(ServingStatus.NOT_SERVING); + }); + } + + @Test + void whenHasHttpCodeStatusMapperBeanIgnoresProperties() { + this.contextRunner.withUserConfiguration(StatusMapperConfiguration.class) + .withPropertyValues("spring.grpc.server.health.status.mapping.up=not-serving") + .run((context) -> { + StatusMapper mapper = context.getBean(StatusMapper.class); + assertThat(mapper.getServingStatus(Status.UP)).isEqualTo(ServingStatus.UNRECOGNIZED); + }); + } + + @Test + void createsHealthCheckedGrpcComponents() { + this.contextRunner.withPropertyValues("spring.grpc.server.health.service.test.include=*").run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + assertThat(components).isInstanceOf(AutoConfiguredHealthCheckedGrpcComponents.class); + assertThat(components.getServiceNames()).containsOnly("test"); + }); + } + + @Test + void whenComponentsIncludesContributorThatExistsDoesNotFail() { + this.contextRunner.withUserConfiguration(CompositeHealthIndicatorConfiguration.class) + .withPropertyValues("spring.grpc.server.health.service.test.include=composite/b/c") + .run((context) -> assertThat(context).hasNotFailed()); + } + + @Test + void whenHealthCheckedGrpcComponentsIncludesReactiveContributorThatExists() { + this.contextRunner.withUserConfiguration(CompositeReactiveHealthIndicatorConfiguration.class) + .withPropertyValues("spring.grpc.server.health.service.test.include=composite/b/c") + .run((context) -> assertThat(context).hasNotFailed()); + } + + @Test + void whenHealthCheckedGrpcComponentsIncludesContributorThatDoesNotExistThrowsException() { + this.contextRunner.withUserConfiguration(CompositeHealthIndicatorConfiguration.class) + .withPropertyValues("spring.grpc.server.health.service.test.include=composite/b/c,nope") + .run((context) -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).isInstanceOf(FailureAnalyzedException.class) + .hasMessage("Health contributor 'nope' defined in " + + "'spring.grpc.server.health.service.test.include' does not exist"); + }); + } + + @Test + void whenHealthCheckedGrpcComponentsExcludesContributorThatDoesNotExistThrowsException() { + this.contextRunner + .withPropertyValues("spring.grpc.server.health.service.test.exclude=composite/b/d", + "spring.grpc.server.health.service.test.include=*") + .run((context) -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).isInstanceOf(FailureAnalyzedException.class) + .hasMessage("Health contributor 'composite/b/d' defined in " + + "'spring.grpc.server.health.service.test.exclude' does not exist"); + }); + } + + @Test + void whenHealthCheckedGrpcComponentsIncludesContributorThatDoesNotExistAndValidationDisabledCreatesComponents() { + this.contextRunner + .withPropertyValues("spring.grpc.server.health.services.validate-membership=false", + "spring.grpc.server.health.service.test.include=nope") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + assertThat(components).isInstanceOf(AutoConfiguredHealthCheckedGrpcComponents.class); + assertThat(components.getServiceNames()).containsOnly("test"); + }); + } + + @Test + void whenHasHealthCheckedGrpcComponentsBeanDoesNotCreateAdditional() { + this.contextRunner.withUserConfiguration(HealthCheckedGrpcComponentsConfiguration.class) + .withPropertyValues("spring.grpc.server.health.service.test.include=*") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + assertThat(components.getServiceNames()).containsOnly("mock"); + }); + } + + @Test + void createsGrpcServerHealth() { + this.contextRunner.run((context) -> { + GrpcServerHealth serverHealth = context.getBean(GrpcServerHealth.class); + Map result = new LinkedHashMap<>(); + serverHealth.update(result::put); + assertThat(result).containsExactly(entry("", ServingStatus.SERVING)); + }); + } + + @Test + void whenHasGrpcServerHealthBeanDoesNotCreateAdditional() { + this.contextRunner.withUserConfiguration(GrpcServerHealthConfiguration.class).run((context) -> { + GrpcServerHealth serverHealth = context.getBean(GrpcServerHealth.class); + Map result = new LinkedHashMap<>(); + serverHealth.update(result::put); + assertThat(result).isEmpty(); + }); + } + + @Test + void runWithIndicatorsInParentContextFindsIndicators() { + new ApplicationContextRunner().withUserConfiguration(DownHealthIndicatorConfiguration.class) + .run((parent) -> new ApplicationContextRunner().withConfiguration(autoConfigurations) + .withUserConfiguration(HealthIndicatorsConfiguration.class) + .with(this::serviceBean) + .withParent(parent) + .run((context) -> { + GrpcServerHealth serverHealth = context.getBean(GrpcServerHealth.class); + Map result = new LinkedHashMap<>(); + serverHealth.update(result::put); + assertThat(result).containsExactly(entry("", ServingStatus.NOT_SERVING)); + })); + } + + private void assertTerminalState(HealthStatusManager healthStatusManager, boolean expected) { + assertThat(healthStatusManager).extracting("healthService.terminal").isEqualTo(expected); + } + + private void assertConfigured(AssertableApplicationContext context) { + assertThat(context).hasSingleBean(HealthStatusManager.class); + assertThat(context).hasBean("grpcServerHealthService"); + assertThat(context).hasSingleBean(StatusAggregator.class); + assertThat(context).hasSingleBean(StatusMapper.class); + assertThat(context).hasSingleBean(HealthCheckedGrpcComponents.class); + assertThat(context).hasSingleBean(GrpcServerHealth.class); + assertThat(context).hasSingleBean(HealthContributorMembershipValidator.class); + assertThat(context).hasSingleBean(GrpcServerHealthScheduler.class); + } + + private void assertNotConfigured(AssertableApplicationContext context) { + assertThat(context).doesNotHaveBean(HealthStatusManager.class); + assertThat(context).doesNotHaveBean("grpcServerHealthService"); + assertThat(context).doesNotHaveBean(GrpcServerHealthAutoConfiguration.class); + } + + private , C extends ConfigurableApplicationContext, A extends ApplicationContextAssertProvider> R serviceBean( + R contextRunner) { + return contextRunner.withBean(BindableService.class, () -> this.service); + } + + @Configuration(proxyBeanMethods = false) + static class HealthIndicatorsConfiguration { + + @Bean + HealthIndicator simpleHealthIndicator() { + return () -> Health.up().withDetail("counter", 42).build(); + } + + @Bean + HealthIndicator additionalHealthIndicator() { + return () -> Health.up().build(); + } + + @Bean + ReactiveHealthIndicator reactiveHealthIndicator() { + return () -> Mono.just(Health.up().build()); + } + + } + + @Configuration(proxyBeanMethods = false) + static class DownHealthIndicatorConfiguration { + + @Bean + HealthIndicator downHealthIndicator() { + return () -> Health.down().build(); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CompositeHealthIndicatorConfiguration { + + @Bean + CompositeHealthContributor compositeHealthIndicator() { + return CompositeHealthContributor.fromMap(Map.of("a", createHealthIndicator(), "b", + CompositeHealthContributor.fromMap(Map.of("c", createHealthIndicator())))); + } + + private HealthIndicator createHealthIndicator() { + return () -> Health.up().build(); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CompositeReactiveHealthIndicatorConfiguration { + + @Bean + CompositeReactiveHealthContributor compositeHealthIndicator() { + return CompositeReactiveHealthContributor.fromMap(Map.of("a", createHealthIndicator(), "b", + CompositeReactiveHealthContributor.fromMap(Map.of("c", createHealthIndicator())))); + } + + private ReactiveHealthIndicator createHealthIndicator() { + return () -> Mono.just(Health.up().build()); + } + + } + + @Configuration(proxyBeanMethods = false) + static class StatusAggregatorConfiguration { + + @Bean + StatusAggregator statusAggregator() { + return (statuses) -> Status.UNKNOWN; + } + + } + + @Configuration(proxyBeanMethods = false) + static class StatusMapperConfiguration { + + @Bean + StatusMapper httpCodeStatusMapper() { + return (status) -> ServingStatus.UNRECOGNIZED; + } + + } + + @Configuration(proxyBeanMethods = false) + static class HealthCheckedGrpcComponentsConfiguration { + + @Bean + HealthCheckedGrpcComponents healthCheckedGrpcComponents() { + HealthCheckedGrpcComponents components = mock(); + given(components.getServiceNames()).willReturn(Collections.singleton("mock")); + return components; + } + + } + + @Configuration(proxyBeanMethods = false) + static class GrpcServerHealthConfiguration { + + @Bean + GrpcServerHealth grpcServerHealth() { + return mock(); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthSchedulerTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthSchedulerTests.java new file mode 100644 index 00000000000..c054fc87663 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthSchedulerTests.java @@ -0,0 +1,79 @@ +/* + * 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.grpc.server.autoconfigure.health; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; + +import io.grpc.protobuf.services.HealthStatusManager; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.boot.grpc.server.health.GrpcServerHealth; +import org.springframework.grpc.server.lifecycle.GrpcServerStartedEvent; +import org.springframework.scheduling.TaskScheduler; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; + +/** + * Tests for {@link GrpcServerHealthScheduler}. + * + * @author Phillip Webb + */ +class GrpcServerHealthSchedulerTests { + + @Test + void onApplicationEventWhenEventIsGrpcStartStartsHealth() { + Clock clock = Clock.fixed(Instant.now(), ZoneId.systemDefault()); + GrpcServerHealth serverHealth = mock(); + HealthStatusManager statusManager = mock(); + TaskScheduler taskScheduler = mock(); + Duration period = Duration.ofSeconds(10); + Duration delay = Duration.ofSeconds(30); + GrpcServerHealthScheduler healthScheduler = new GrpcServerHealthScheduler(clock, serverHealth, statusManager, + taskScheduler, period, delay); + then(serverHealth).should(never()).update(statusManager); + healthScheduler.onApplicationEvent(new GrpcServerStartedEvent(mock(), mock(), "localhost", 123)); + Instant startTime = Instant.now(clock).plus(delay); + ArgumentCaptor runnable = ArgumentCaptor.captor(); + then(taskScheduler).should().scheduleAtFixedRate(runnable.capture(), eq(startTime), eq(period)); + then(serverHealth).should(never()).update(statusManager); + runnable.getValue().run(); + then(serverHealth).should().update(statusManager); + } + + @Test + void onApplicationEventWhenEventIsGrpcStartAndCalledTwiceStartsHealthOnlyOnce() { + GrpcServerHealth serverHealth = mock(); + HealthStatusManager statusManager = mock(); + TaskScheduler taskScheduler = mock(); + GrpcServerHealthScheduler healthScheduler = new GrpcServerHealthScheduler(serverHealth, statusManager, + taskScheduler, Duration.ofSeconds(10), Duration.ofSeconds(30)); + then(serverHealth).should(never()).update(statusManager); + healthScheduler.onApplicationEvent(new GrpcServerStartedEvent(mock(), mock(), "localhost", 123)); + healthScheduler.onApplicationEvent(new GrpcServerStartedEvent(mock(), mock(), "localhost", 345)); + then(taskScheduler).should(times(1)).scheduleAtFixedRate(any(), any(), any()); + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcDisableCsrfHttpConfigurerTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcDisableCsrfHttpConfigurerTests.java new file mode 100644 index 00000000000..418b257a765 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcDisableCsrfHttpConfigurerTests.java @@ -0,0 +1,167 @@ +/* + * 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.grpc.server.autoconfigure.security; + +import java.util.HashMap; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.boot.grpc.server.GrpcServletRegistration; +import org.springframework.boot.grpc.server.autoconfigure.security.GrpcDisableCsrfHttpConfigurer.GrpcCsrfRequestMatcher; +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.context.ApplicationContext; +import org.springframework.context.support.StaticApplicationContext; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; +import org.springframework.security.config.ObjectPostProcessor; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; +import org.springframework.security.web.util.matcher.RequestMatcher; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +/** + * Tests for {@link GrpcDisableCsrfHttpConfigurer}. + * + * @author Phillip Webb + */ +class GrpcDisableCsrfHttpConfigurerTests { + + private GrpcDisableCsrfHttpConfigurer configurer = new GrpcDisableCsrfHttpConfigurer(); + + @Test + void initDisablesCsrf() { + ObjectPostProcessor objectPostProcessor = ObjectPostProcessor.identity(); + AuthenticationManagerBuilder authenticationBuilder = new AuthenticationManagerBuilder(objectPostProcessor); + HttpSecurity http = new HttpSecurity(objectPostProcessor, authenticationBuilder, new HashMap<>()); + StaticApplicationContext applicationContext = addApplicationContext(http); + addServiceDiscover(applicationContext); + addGrpcServletRegistration(applicationContext); + CsrfConfigurer csrf = addCsrf(http); + this.configurer.init(http); + ArgumentCaptor matcher = ArgumentCaptor.captor(); + then(csrf).should().requireCsrfProtectionMatcher(matcher.capture()); + assertThat(matcher.getValue()).isSameAs(GrpcCsrfRequestMatcher.INSTANCE); + } + + @Test + void initWhenNoApplicationContextDoesNothing() { + ObjectPostProcessor objectPostProcessor = ObjectPostProcessor.identity(); + AuthenticationManagerBuilder authenticationBuilder = new AuthenticationManagerBuilder(objectPostProcessor); + HttpSecurity http = new HttpSecurity(objectPostProcessor, authenticationBuilder, new HashMap<>()); + CsrfConfigurer csrf = addCsrf(http); + this.configurer.init(http); + then(csrf).should(never()).requireCsrfProtectionMatcher(any()); + } + + @Test + @SuppressWarnings("unchecked") + void initWhenNoCsrfConfigurerDoesNothing() { + ObjectPostProcessor objectPostProcessor = ObjectPostProcessor.identity(); + AuthenticationManagerBuilder authenticationBuilder = new AuthenticationManagerBuilder(objectPostProcessor); + HttpSecurity http = new HttpSecurity(objectPostProcessor, authenticationBuilder, new HashMap<>()); + StaticApplicationContext applicationContext = addApplicationContext(http); + addServiceDiscover(applicationContext); + addGrpcServletRegistration(applicationContext); + this.configurer.init(http); + CsrfConfigurer csrfConfigurer = http.getConfigurer(CsrfConfigurer.class); + assertThat(csrfConfigurer).isNull(); + } + + @Test + void initWhenNoGrpcServiceDiscovererBeanDoesNothing() { + ObjectPostProcessor objectPostProcessor = ObjectPostProcessor.identity(); + AuthenticationManagerBuilder authenticationBuilder = new AuthenticationManagerBuilder(objectPostProcessor); + HttpSecurity http = new HttpSecurity(objectPostProcessor, authenticationBuilder, new HashMap<>()); + StaticApplicationContext applicationContext = addApplicationContext(http); + addGrpcServletRegistration(applicationContext); + CsrfConfigurer csrf = addCsrf(http); + this.configurer.init(http); + then(csrf).should(never()).requireCsrfProtectionMatcher(any()); + } + + @Test + void initWhenNoGrpcServletRegistrationBeanDoesNothing() { + ObjectPostProcessor objectPostProcessor = ObjectPostProcessor.identity(); + AuthenticationManagerBuilder authenticationBuilder = new AuthenticationManagerBuilder(objectPostProcessor); + HttpSecurity http = new HttpSecurity(objectPostProcessor, authenticationBuilder, new HashMap<>()); + StaticApplicationContext applicationContext = addApplicationContext(http); + addServiceDiscover(applicationContext); + CsrfConfigurer csrf = addCsrf(http); + this.configurer.init(http); + then(csrf).should(never()).requireCsrfProtectionMatcher(any()); + } + + @Test + void initWhenEnabledPropertyFalseDoesNothing() { + ObjectPostProcessor objectPostProcessor = ObjectPostProcessor.identity(); + AuthenticationManagerBuilder authenticationBuilder = new AuthenticationManagerBuilder(objectPostProcessor); + HttpSecurity http = new HttpSecurity(objectPostProcessor, authenticationBuilder, new HashMap<>()); + StaticApplicationContext applicationContext = addApplicationContext(http); + TestPropertyValues.of("spring.grpc.server.security.csrf.enabled=false").applyTo(applicationContext); + addServiceDiscover(applicationContext); + addGrpcServletRegistration(applicationContext); + CsrfConfigurer csrf = addCsrf(http); + this.configurer.init(http); + then(csrf).should(never()).requireCsrfProtectionMatcher(any()); + } + + @Test + void initWhenEnabledPropertyTrueDisablesCsrf() { + ObjectPostProcessor objectPostProcessor = ObjectPostProcessor.identity(); + AuthenticationManagerBuilder authenticationBuilder = new AuthenticationManagerBuilder(objectPostProcessor); + HttpSecurity http = new HttpSecurity(objectPostProcessor, authenticationBuilder, new HashMap<>()); + StaticApplicationContext applicationContext = addApplicationContext(http); + TestPropertyValues.of("spring.grpc.server.security.csrf.enabled=true").applyTo(applicationContext); + addServiceDiscover(applicationContext); + addGrpcServletRegistration(applicationContext); + CsrfConfigurer csrf = addCsrf(http); + this.configurer.init(http); + ArgumentCaptor matcher = ArgumentCaptor.captor(); + then(csrf).should().requireCsrfProtectionMatcher(matcher.capture()); + assertThat(matcher.getValue()).isSameAs(GrpcCsrfRequestMatcher.INSTANCE); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private CsrfConfigurer addCsrf(HttpSecurity http) { + CsrfConfigurer csrf = mock(); + http.with((CsrfConfigurer) csrf); + return csrf; + } + + private StaticApplicationContext addApplicationContext(HttpSecurity http) { + StaticApplicationContext applicationContext = new StaticApplicationContext(); + http.setSharedObject(ApplicationContext.class, applicationContext); + return applicationContext; + } + + private void addServiceDiscover(StaticApplicationContext applicationContext) { + GrpcServiceDiscoverer serviceDiscoverer = mock(); + applicationContext.registerBean(GrpcServiceDiscoverer.class, serviceDiscoverer); + } + + private void addGrpcServletRegistration(StaticApplicationContext applicationContext) { + GrpcServletRegistration servletRegistration = mock(); + applicationContext.registerBean(GrpcServletRegistration.class, servletRegistration); + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerOAuth2ResourceServerAutoConfigurationTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerOAuth2ResourceServerAutoConfigurationTests.java new file mode 100644 index 00000000000..f20be2bf4ed --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerOAuth2ResourceServerAutoConfigurationTests.java @@ -0,0 +1,170 @@ +/* + * 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.grpc.server.autoconfigure.security; + +import io.grpc.BindableService; +import io.grpc.ServerServiceDefinition; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.logging.ConditionEvaluationReportLoggingListener; +import org.springframework.boot.context.annotation.UserConfigurations; +import org.springframework.boot.context.event.ApplicationFailedEvent; +import org.springframework.boot.logging.LogLevel; +import org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration; +import org.springframework.boot.security.oauth2.server.resource.autoconfigure.OAuth2ResourceServerAutoConfiguration; +import org.springframework.boot.test.context.assertj.ApplicationContextAssertProvider; +import org.springframework.boot.test.context.runner.AbstractApplicationContextRunner; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.boot.web.context.servlet.AnnotationConfigServletWebApplicationContext; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.server.GlobalServerInterceptor; +import org.springframework.grpc.server.lifecycle.GrpcServerLifecycle; +import org.springframework.grpc.server.security.AuthenticationProcessInterceptor; +import org.springframework.grpc.server.security.GrpcSecurity; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.ObjectPostProcessor; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GrpcServerOAuth2ResourceServerAutoConfiguration}. + * + * @author Chris Bono + */ +class GrpcServerOAuth2ResourceServerAutoConfigurationTests { + + private static final AutoConfigurations autoConfigurations = AutoConfigurations + .of(OAuth2ResourceServerAutoConfiguration.class, GrpcServerOAuth2ResourceServerAutoConfiguration.class); + + private ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(autoConfigurations) + .withUserConfiguration(GrpcSecurityConfiguration.class) + .with(this::serviceBean) + .withBean("noopServerLifecycle", GrpcServerLifecycle.class, Mockito::mock); + + @Test + void jwtConfiguredWhenIssuerIsProvided() { + this.contextRunner + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:9000") + .run((context) -> assertThat(context).hasSingleBean(AuthenticationProcessInterceptor.class)); + } + + @Test + void jwtConfiguredWhenJwkSetIsProvided() { + this.contextRunner + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost:9000") + .run((context) -> assertThat(context).hasSingleBean(AuthenticationProcessInterceptor.class)); + } + + @Test + void customInterceptorWhenJwkSetIsProvided() { + this.contextRunner.withInitializer(ConditionEvaluationReportLoggingListener.forLogLevel(LogLevel.INFO)) + .withConfiguration(UserConfigurations.of(CustomInterceptorConfiguration.class)) + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost:9000") + .run((context) -> assertThat(context).hasSingleBean(AuthenticationProcessInterceptor.class)); + } + + @Test + void notConfiguredWhenIssuerNotProvided() { + this.contextRunner + .run((context) -> assertThat(context).doesNotHaveBean(AuthenticationProcessInterceptor.class)); + } + + @Test + void notConfiguredInWebApplication() { + new WebApplicationContextRunner().withConfiguration(autoConfigurations) + .withConfiguration(AutoConfigurations.of(ServletWebSecurityAutoConfiguration.class, + OAuth2ResourceServerAutoConfiguration.class)) + .with(this::serviceBean) + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:9000") + .run((context) -> assertThat(context).doesNotHaveBean(AuthenticationProcessInterceptor.class)); + } + + @Test + void notConfiguredInWebApplicationWithNoBindableService() { + new WebApplicationContextRunner().withConfiguration(autoConfigurations) + .withConfiguration(AutoConfigurations.of(ServletWebSecurityAutoConfiguration.class, + OAuth2ResourceServerAutoConfiguration.class)) + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:9000") + .run((context) -> assertThat(context).doesNotHaveBean(AuthenticationProcessInterceptor.class)); + } + + private , C extends ConfigurableApplicationContext, A extends ApplicationContextAssertProvider> R serviceBean( + R contextRunner) { + BindableService service = mock(); + ServerServiceDefinition serviceDefinition = ServerServiceDefinition.builder("my-service").build(); + given(service.bindService()).willReturn(serviceDefinition); + return contextRunner.withBean(BindableService.class, () -> service); + } + + static class FailingApplicationFailedEventContext extends AnnotationConfigServletWebApplicationContext { + + @Override + public void refresh() { + try { + super.refresh(); + } + catch (Throwable ex) { + publishEvent(new ApplicationFailedEvent(new SpringApplication(this), new String[0], this, ex)); + throw ex; + } + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomInterceptorConfiguration { + + @Bean + @GlobalServerInterceptor + AuthenticationProcessInterceptor jwtSecurityFilterChain(GrpcSecurity grpc) throws Exception { + return grpc.authorizeRequests((requests) -> requests.allRequests().authenticated()) + .oauth2ResourceServer((resourceServer) -> resourceServer.jwt(Customizer.withDefaults())) + .build(); + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableWebSecurity + static class GrpcSecurityConfiguration { + + @Bean + GrpcSecurity grpcSecurity(ApplicationContext context, ObjectPostProcessor objectPostProcessor, + AuthenticationConfiguration authenticationConfiguration) { + AuthenticationManagerBuilder authenticationManagerBuilder = authenticationConfiguration + .authenticationManagerBuilder(objectPostProcessor, context); + authenticationManagerBuilder + .parentAuthenticationManager(authenticationConfiguration.getAuthenticationManager()); + return new GrpcSecurity(objectPostProcessor, authenticationManagerBuilder, context); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerSecurityAutoConfigurationTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerSecurityAutoConfigurationTests.java new file mode 100644 index 00000000000..f3085d633a2 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerSecurityAutoConfigurationTests.java @@ -0,0 +1,152 @@ +/* + * 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.grpc.server.autoconfigure.security; + +import io.grpc.BindableService; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.logging.ConditionEvaluationReportLoggingListener; +import org.springframework.boot.grpc.server.GrpcServletRegistration; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerExecutorProvider; +import org.springframework.boot.logging.LogLevel; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.server.GrpcServerFactory; +import org.springframework.grpc.server.exception.GrpcExceptionHandler; +import org.springframework.grpc.server.lifecycle.GrpcServerLifecycle; +import org.springframework.grpc.server.security.AuthenticationProcessInterceptor; +import org.springframework.grpc.server.security.GrpcSecurity; +import org.springframework.grpc.server.security.SecurityContextServerInterceptor; +import org.springframework.grpc.server.security.SecurityGrpcExceptionHandler; +import org.springframework.security.config.ObjectPostProcessor; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GrpcServerSecurityAutoConfiguration}. + * + * @author Chris Bono + */ +class GrpcServerSecurityAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcServerSecurityAutoConfiguration.class)) + .withBean("noopServerLifecycle", GrpcServerLifecycle.class, Mockito::mock); + + @Test + void whenSpringSecurityNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(ObjectPostProcessor.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerSecurityAutoConfiguration.class)); + } + + @Test + void whenGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(BindableService.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerSecurityAutoConfiguration.class)); + } + + @Test + void whenSpringGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(GrpcServerFactory.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerSecurityAutoConfiguration.class)); + } + + @Test + void whenSpringGrpcAndSpringSecurityPresentAndUsingGrpcServletCreatesGrpcSecurity() { + new WebApplicationContextRunner() + .withInitializer(ConditionEvaluationReportLoggingListener.forLogLevel(LogLevel.INFO)) + .withConfiguration(AutoConfigurations.of(GrpcServerSecurityAutoConfiguration.class)) + .withUserConfiguration(ServletConfiguration.class) + .run((context) -> { + assertThat(context).hasSingleBean(SecurityContextServerInterceptor.class); + assertThat(context).hasSingleBean(GrpcServerExecutorProvider.class); + }); + } + + @Test + void whenSpringGrpcAndSpringSecurityPresentAndUsingGrpcNativeCreatesGrpcSecurity() { + new ApplicationContextRunner() + .withInitializer(ConditionEvaluationReportLoggingListener.forLogLevel(LogLevel.INFO)) + .withConfiguration(AutoConfigurations.of(GrpcServerSecurityAutoConfiguration.class)) + .withUserConfiguration(NativeConfiguration.class) + .run((context) -> assertThat(context).hasSingleBean(GrpcSecurity.class)); + } + + @Test + void whenServerEnabledPropertySetFalseThenAutoConfigurationIsSkipped() { + this.contextRunner.withPropertyValues("spring.grpc.server.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerSecurityAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertyNotSetThenAutoConfigurationIsNotSkipped() { + this.contextRunner + .run((context) -> assertThat(context).hasSingleBean(GrpcServerSecurityAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertySetTrueThenAutoConfigurationIsNotSkipped() { + this.contextRunner.withPropertyValues("spring.grpc.server.enabled=true") + .run((context) -> assertThat(context).hasSingleBean(GrpcServerSecurityAutoConfiguration.class)); + } + + @Test + void grpcSecurityAutoConfiguredAsExpected() { + this.contextRunner.run((context) -> { + assertThat(context).getBean(GrpcExceptionHandler.class).isInstanceOf(SecurityGrpcExceptionHandler.class); + assertThat(context).getBean(AuthenticationProcessInterceptor.class).isNull(); + }); + } + + @Configuration(proxyBeanMethods = false) + @EnableWebSecurity + static class ServletConfiguration { + + @Bean + GrpcServletRegistration grpcServletRegistration() { + return new GrpcServletRegistration(mock(), mock()); + } + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) { + return http.authorizeHttpRequests((requests) -> requests.anyRequest().permitAll()).build(); + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableMethodSecurity + static class NativeConfiguration { + + @Bean + GrpcServerFactory grpcServerFactory() { + return mock(); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/web/reactive/GrpcRequestTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/web/reactive/GrpcRequestTests.java new file mode 100644 index 00000000000..860e086745c --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/web/reactive/GrpcRequestTests.java @@ -0,0 +1,114 @@ +/* + * 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.grpc.server.autoconfigure.security.web.reactive; + +import io.grpc.BindableService; +import io.grpc.ServerServiceDefinition; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.grpc.server.autoconfigure.security.web.reactive.GrpcRequest.GrpcReactiveRequestMatcher; +import org.springframework.boot.web.context.reactive.GenericReactiveWebApplicationContext; +import org.springframework.context.ApplicationContext; +import org.springframework.grpc.server.service.DefaultGrpcServiceDiscoverer; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; +import org.springframework.http.codec.ServerCodecConfigurer; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.http.server.reactive.MockServerHttpResponse; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.i18n.AcceptHeaderLocaleContextResolver; +import org.springframework.web.server.session.DefaultWebSessionManager; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GrpcRequest}. + * + * @author Dave Syer + * @author Phillip Webb + */ +class GrpcRequestTests { + + private GenericReactiveWebApplicationContext context = new GenericReactiveWebApplicationContext(); + + @BeforeEach + void setup() { + MockService service1 = mock(); + given(service1.bindService()).willReturn(ServerServiceDefinition.builder("my-service").build()); + MockService service2 = mock(); + given(service2.bindService()).willReturn(ServerServiceDefinition.builder("my-other-service").build()); + this.context.registerBean("s1", BindableService.class, () -> service1); + this.context.registerBean("s2", BindableService.class, () -> service2); + this.context.registerBean(GrpcServiceDiscoverer.class, () -> new DefaultGrpcServiceDiscoverer(this.context)); + this.context.refresh(); + } + + @Test + void whenToAnyService() { + GrpcReactiveRequestMatcher matcher = GrpcRequest.toAnyService(); + assertThat(isMatch(matcher, "/my-service/Method")).isTrue(); + assertThat(isMatch(matcher, "/my-service/Other")).isTrue(); + assertThat(isMatch(matcher, "/my-other-service/Other")).isTrue(); + assertThat(isMatch(matcher, "/notaservice")).isFalse(); + } + + @Test + void whenToAnyServiceWithExclude() { + GrpcReactiveRequestMatcher matcher = GrpcRequest.toAnyService().excluding("my-other-service"); + assertThat(isMatch(matcher, "/my-service/Method")).isTrue(); + assertThat(isMatch(matcher, "/my-service/Other")).isTrue(); + assertThat(isMatch(matcher, "/my-other-service/Other")).isFalse(); + assertThat(isMatch(matcher, "/notaservice")).isFalse(); + } + + private boolean isMatch(GrpcReactiveRequestMatcher matcher, String path) { + MockExchange request = mockRequest(path); + MatchResult result = matcher.matches(request).block(); + return (result != null) && result.isMatch(); + } + + private MockExchange mockRequest(String path) { + MockServerHttpRequest servletContext = MockServerHttpRequest.get(path).build(); + MockExchange request = new MockExchange(servletContext, this.context); + return request; + } + + interface MockService extends BindableService { + + } + + static class MockExchange extends DefaultServerWebExchange { + + private ApplicationContext context; + + MockExchange(MockServerHttpRequest request, ApplicationContext context) { + super(request, new MockServerHttpResponse(), new DefaultWebSessionManager(), ServerCodecConfigurer.create(), + new AcceptHeaderLocaleContextResolver()); + this.context = context; + } + + @Override + public ApplicationContext getApplicationContext() { + return this.context; + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/web/servlet/GrpcRequestTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/web/servlet/GrpcRequestTests.java new file mode 100644 index 00000000000..87763de2a49 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/web/servlet/GrpcRequestTests.java @@ -0,0 +1,93 @@ +/* + * 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.grpc.server.autoconfigure.security.web.servlet; + +import io.grpc.BindableService; +import io.grpc.ServerServiceDefinition; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.grpc.server.autoconfigure.security.web.servlet.GrpcRequest.GrpcServletRequestMatcher; +import org.springframework.grpc.server.service.DefaultGrpcServiceDiscoverer; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockServletContext; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.context.support.GenericWebApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Test for {@link GrpcRequest}. + * + * @author Dave Syer + * @author Phillip Webb + */ +class GrpcRequestTests { + + private GenericWebApplicationContext context = new GenericWebApplicationContext(); + + @BeforeEach + void setup() { + MockService service1 = mock(); + given(service1.bindService()).willReturn(ServerServiceDefinition.builder("my-service").build()); + MockService service2 = mock(); + given(service2.bindService()).willReturn(ServerServiceDefinition.builder("my-other-service").build()); + this.context.registerBean("s1", BindableService.class, () -> service1); + this.context.registerBean("s2", BindableService.class, () -> service2); + this.context.registerBean(GrpcServiceDiscoverer.class, () -> new DefaultGrpcServiceDiscoverer(this.context)); + this.context.refresh(); + } + + @Test + void whenToAnyService() { + GrpcServletRequestMatcher matcher = GrpcRequest.toAnyService(); + assertThat(isMatch(matcher, "/my-service/Method")).isTrue(); + assertThat(isMatch(matcher, "/my-service/Other")).isTrue(); + assertThat(isMatch(matcher, "/my-other-service/Other")).isTrue(); + assertThat(isMatch(matcher, "/notaservice")).isFalse(); + } + + @Test + void whenToAnyServiceWithExclude() { + GrpcServletRequestMatcher matcher = GrpcRequest.toAnyService().excluding("my-other-service"); + assertThat(isMatch(matcher, "/my-service/Method")).isTrue(); + assertThat(isMatch(matcher, "/my-service/Other")).isTrue(); + assertThat(isMatch(matcher, "/my-other-service/Other")).isFalse(); + assertThat(isMatch(matcher, "/notaservice")).isFalse(); + } + + private boolean isMatch(GrpcServletRequestMatcher matcher, String path) { + MockHttpServletRequest request = mockRequest(path); + return matcher.matches(request); + } + + private MockHttpServletRequest mockRequest(String path) { + MockServletContext servletContext = new MockServletContext(); + servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, this.context); + MockHttpServletRequest request = new MockHttpServletRequest(servletContext); + request.setRequestURI(path); + return request; + } + + interface MockService extends BindableService { + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/health/GrpcServerHealthTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/health/GrpcServerHealthTests.java new file mode 100644 index 00000000000..9a57df1153a --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/health/GrpcServerHealthTests.java @@ -0,0 +1,293 @@ +/* + * 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.grpc.server.health; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import io.grpc.health.v1.HealthCheckResponse.ServingStatus; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.boot.health.contributor.CompositeHealthContributor; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthContributor; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.boot.health.contributor.ReactiveHealthIndicator; +import org.springframework.boot.health.contributor.Status; +import org.springframework.boot.health.registry.DefaultHealthContributorRegistry; +import org.springframework.boot.health.registry.DefaultReactiveHealthContributorRegistry; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.entry; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GrpcServerHealth}. + * + * @author Phillip Webb + */ +class GrpcServerHealthTests { + + private static final HealthIndicator UP = () -> Health.up().build(); + + private static final HealthIndicator DOWN = () -> Health.down().build(); + + private static final ReactiveHealthIndicator REACTIVE_DOWN = () -> Mono.just(Health.down().build()); + + @Test + @SuppressWarnings("NullAway") // Test null check + void createWhenRegistryIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> new GrpcServerHealth(null, null, mock())) + .withMessage("'registry' must not be null"); + } + + @Test + @SuppressWarnings("NullAway") // Test null check + void createWhenComponentsIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> new GrpcServerHealth(mock(), null, null)) + .withMessage("'components' must not be null"); + } + + @Test + void updateWhenHasServerComponentAndUp() { + assertThat(updateWithServerComponent("test", UP)).containsExactly(entry("", ServingStatus.SERVING)); + } + + @Test + void updateWhenHasServerComponentAndDown() { + assertThat(updateWithServerComponent("test", DOWN)).containsExactly(entry("", ServingStatus.NOT_SERVING)); + } + + @Test + void updateWhenHasServerComponentAndMissing() { + assertThat(updateWithServerComponent("other", UP)).containsExactly(entry("", ServingStatus.UNKNOWN)); + } + + private Map updateWithServerComponent(String indicatorName, HealthIndicator indicator) { + HealthCheckedGrpcComponent server = new TestHealthCheckedGrpcComponent(Set.of("test")); + HealthCheckedGrpcComponents components = new TestHealthCheckedGrpcComponents(server, Collections.emptyMap()); + DefaultHealthContributorRegistry registry = new DefaultHealthContributorRegistry(); + registry.registerContributor(indicatorName, indicator); + GrpcServerHealth health = new GrpcServerHealth(registry, null, components); + Map result = new LinkedHashMap<>(); + health.update(result::put); + return result; + } + + @Test + void updateWhenHasServices() { + Map services = new LinkedHashMap<>(); + services.put("one", new TestHealthCheckedGrpcComponent(Set.of("up"))); + services.put("two", new TestHealthCheckedGrpcComponent(Set.of("down"))); + HealthCheckedGrpcComponents components = new TestHealthCheckedGrpcComponents(null, services); + DefaultHealthContributorRegistry registry = new DefaultHealthContributorRegistry(); + registry.registerContributor("up", UP); + registry.registerContributor("down", DOWN); + GrpcServerHealth health = new GrpcServerHealth(registry, null, components); + Map result = new LinkedHashMap<>(); + health.update(result::put); + assertThat(result).containsExactly(entry("one", ServingStatus.SERVING), + entry("two", ServingStatus.NOT_SERVING)); + } + + @Test + void updateUsesCache() { + Map services = new LinkedHashMap<>(); + services.put("one", new TestHealthCheckedGrpcComponent(Set.of("test"))); + services.put("two", new TestHealthCheckedGrpcComponent(Set.of("test"))); + HealthCheckedGrpcComponents components = new TestHealthCheckedGrpcComponents(null, services); + DefaultHealthContributorRegistry registry = new DefaultHealthContributorRegistry(); + HealthIndicator contributor = mock(); + given(contributor.health(false)).willReturn(Health.up().build(), Health.down().build()); + registry.registerContributor("test", contributor); + GrpcServerHealth health = new GrpcServerHealth(registry, null, components); + Map result = new LinkedHashMap<>(); + health.update(result::put); + assertThat(result).containsExactly(entry("one", ServingStatus.SERVING), entry("two", ServingStatus.SERVING)); + } + + @Test + void updateWhenHasEmptyNamedServicesDoesNotIncludeIt() { + Map services = new LinkedHashMap<>(); + services.put("", new TestHealthCheckedGrpcComponent(Set.of("up"))); + services.put("one", new TestHealthCheckedGrpcComponent(Set.of("up"))); + HealthCheckedGrpcComponents components = new TestHealthCheckedGrpcComponents(null, services); + DefaultHealthContributorRegistry registry = new DefaultHealthContributorRegistry(); + registry.registerContributor("up", UP); + GrpcServerHealth health = new GrpcServerHealth(registry, null, components); + Map result = new LinkedHashMap<>(); + health.update(result::put); + assertThat(result).containsExactly(entry("one", ServingStatus.SERVING)); + } + + @Test + void updateWhenHasFallbackRegistry() { + Map services = new LinkedHashMap<>(); + services.put("one", new TestHealthCheckedGrpcComponent(Set.of("1"))); + services.put("two", new TestHealthCheckedGrpcComponent(Set.of("2"))); + services.put("three", new TestHealthCheckedGrpcComponent(Set.of("3"))); + HealthCheckedGrpcComponents components = new TestHealthCheckedGrpcComponents(null, services); + DefaultHealthContributorRegistry registry = new DefaultHealthContributorRegistry(); + DefaultReactiveHealthContributorRegistry fallbackRegistry = new DefaultReactiveHealthContributorRegistry(); + registry.registerContributor("1", UP); + registry.registerContributor("2", UP); + fallbackRegistry.registerContributor("2", REACTIVE_DOWN); + fallbackRegistry.registerContributor("3", REACTIVE_DOWN); + GrpcServerHealth health = new GrpcServerHealth(registry, fallbackRegistry, components); + Map result = new LinkedHashMap<>(); + health.update(result::put); + assertThat(result).containsExactly(entry("one", ServingStatus.SERVING), entry("two", ServingStatus.SERVING), + entry("three", ServingStatus.NOT_SERVING)); + } + + @Test + void updateWhenHasCompositeContributor() { + Map services = new LinkedHashMap<>(); + services.put("one", new TestHealthCheckedGrpcComponent(Set.of("dbs", "dbs/db1", "dbs/db2"))); + HealthCheckedGrpcComponents components = new TestHealthCheckedGrpcComponents(null, services); + HealthContributor contributor = CompositeHealthContributor.fromMap(Map.of("db1", UP, "db2", DOWN)); + DefaultHealthContributorRegistry registry = new DefaultHealthContributorRegistry(); + registry.registerContributor("dbs", contributor); + GrpcServerHealth health = new GrpcServerHealth(registry, null, components); + Map result = new LinkedHashMap<>(); + health.update(result::put); + assertThat(result).containsExactly(entry("one", ServingStatus.NOT_SERVING)); + } + + @Test + void updateFiltersIndicatorsAndCompositeParentByName() { + Map services = new LinkedHashMap<>(); + services.put("one", new TestHealthCheckedGrpcComponent(Set.of("dbs", "dbs/db1"))); + HealthCheckedGrpcComponents components = new TestHealthCheckedGrpcComponents(null, services); + HealthContributor contributor = CompositeHealthContributor.fromMap(Map.of("db1", UP, "db2", DOWN)); + DefaultHealthContributorRegistry registry = new DefaultHealthContributorRegistry(); + registry.registerContributor("dbs", contributor); + GrpcServerHealth health = new GrpcServerHealth(registry, null, components); + Map result = new LinkedHashMap<>(); + health.update(result::put); + assertThat(result).containsExactly(entry("one", ServingStatus.SERVING)); + } + + @Test + @SuppressWarnings("unchecked") + void updateCallsStatusAggregator() { + StatusAggregator statusAggregator = mock(); + given(statusAggregator.getAggregateStatus((Set) any(Set.class))).willReturn(Status.DOWN); + Map services = new LinkedHashMap<>(); + services.put("one", + new TestHealthCheckedGrpcComponent(Set.of("up"), statusAggregator, StatusMapper.getDefault())); + HealthCheckedGrpcComponents components = new TestHealthCheckedGrpcComponents(null, services); + DefaultHealthContributorRegistry registry = new DefaultHealthContributorRegistry(); + registry.registerContributor("up", UP); + GrpcServerHealth health = new GrpcServerHealth(registry, null, components); + Map result = new LinkedHashMap<>(); + health.update(result::put); + assertThat(result).containsExactly(entry("one", ServingStatus.NOT_SERVING)); + then(statusAggregator).should().getAggregateStatus(Set.of(Status.UP)); + } + + @Test + void updateCallsStatusMapper() { + StatusMapper statusMapper = mock(); + given(statusMapper.getServingStatus(Status.UP)).willReturn(ServingStatus.UNRECOGNIZED); + Map services = new LinkedHashMap<>(); + services.put("one", + new TestHealthCheckedGrpcComponent(Set.of("up"), StatusAggregator.getDefault(), statusMapper)); + HealthCheckedGrpcComponents components = new TestHealthCheckedGrpcComponents(null, services); + DefaultHealthContributorRegistry registry = new DefaultHealthContributorRegistry(); + registry.registerContributor("up", UP); + GrpcServerHealth health = new GrpcServerHealth(registry, null, components); + Map result = new LinkedHashMap<>(); + health.update(result::put); + assertThat(result).containsExactly(entry("one", ServingStatus.UNRECOGNIZED)); + then(statusMapper).should().getServingStatus(Status.UP); + } + + static class TestHealthCheckedGrpcComponents implements HealthCheckedGrpcComponents { + + private final @Nullable HealthCheckedGrpcComponent server; + + private final Map services; + + TestHealthCheckedGrpcComponents(@Nullable HealthCheckedGrpcComponent server, + Map services) { + this.server = server; + this.services = services; + } + + @Override + public @Nullable HealthCheckedGrpcComponent getServer() { + return this.server; + } + + @Override + public Set getServiceNames() { + return this.services.keySet(); + } + + @Override + public @Nullable HealthCheckedGrpcComponent getService(String serviceName) { + return this.services.get(serviceName); + } + + } + + private static class TestHealthCheckedGrpcComponent implements HealthCheckedGrpcComponent { + + private final Set members; + + private final StatusAggregator statusAggregator; + + private final StatusMapper statusMapper; + + TestHealthCheckedGrpcComponent(Set memebers) { + this(memebers, StatusAggregator.getDefault(), StatusMapper.getDefault()); + } + + TestHealthCheckedGrpcComponent(Set memebers, StatusAggregator statusAggregator, + StatusMapper statusMapper) { + this.members = memebers; + this.statusAggregator = statusAggregator; + this.statusMapper = statusMapper; + } + + @Override + public boolean isMember(String healthContributorName) { + return this.members.contains(healthContributorName); + } + + @Override + public StatusAggregator getStatusAggregator() { + return this.statusAggregator; + } + + @Override + public StatusMapper getStatusMapper() { + return this.statusMapper; + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/health/StatusAggregatorTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/health/StatusAggregatorTests.java new file mode 100644 index 00000000000..bbc8a6abe62 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/health/StatusAggregatorTests.java @@ -0,0 +1,71 @@ +/* + * 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.grpc.server.health; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.health.actuate.endpoint.StatusAggregator; +import org.springframework.boot.health.contributor.Status; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link StatusAggregator}. + * + * @author Phillip Webb + */ +class StatusAggregatorTests { + + @Test + void ofAndGetAggregateStatusWhenUsingDefaultInstance() { + StatusAggregator aggregator = StatusAggregator.getDefault(); + Status status = aggregator.getAggregateStatus(Status.DOWN, Status.UP, Status.UNKNOWN, Status.OUT_OF_SERVICE); + assertThat(status).isEqualTo(Status.DOWN); + } + + @Test + void ofAndGetAggregateStatusWhenUsingCustomOrder() { + StatusAggregator aggregator = StatusAggregator.of(Status.UNKNOWN, Status.UP, Status.OUT_OF_SERVICE, + Status.DOWN); + Status status = aggregator.getAggregateStatus(Status.DOWN, Status.UP, Status.UNKNOWN, Status.OUT_OF_SERVICE); + assertThat(status).isEqualTo(Status.UNKNOWN); + } + + @Test + void ofAndGetAggregateStatusWhenHasCustomStatusAndUsingDefaultOrder() { + StatusAggregator aggregator = StatusAggregator.getDefault(); + Status status = aggregator.getAggregateStatus(Status.DOWN, Status.UP, Status.UNKNOWN, Status.OUT_OF_SERVICE, + new Status("CUSTOM")); + assertThat(status).isEqualTo(Status.DOWN); + } + + @Test + void ofAndGetAggregateStatusWhenHasCustomStatusAndUsingCustomOrder() { + StatusAggregator aggregator = StatusAggregator.of("DOWN", "OUT_OF_SERVICE", "UP", "UNKNOWN", "CUSTOM"); + Status status = aggregator.getAggregateStatus(Status.DOWN, Status.UP, Status.UNKNOWN, Status.OUT_OF_SERVICE, + new Status("CUSTOM")); + assertThat(status).isEqualTo(Status.DOWN); + } + + @Test + void ofWithNonUniformCodes() { + StatusAggregator aggregator = StatusAggregator.of("out-of-service", "up"); + Status status = aggregator.getAggregateStatus(Status.UP, Status.OUT_OF_SERVICE); + assertThat(status).isEqualTo(Status.OUT_OF_SERVICE); + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/health/StatusMapperTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/health/StatusMapperTests.java new file mode 100644 index 00000000000..af027f7200c --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/health/StatusMapperTests.java @@ -0,0 +1,64 @@ +/* + * 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.grpc.server.health; + +import java.util.LinkedHashMap; +import java.util.Map; + +import io.grpc.health.v1.HealthCheckResponse.ServingStatus; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.health.contributor.Status; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link StatusMapper}. + * + * @author Phillip Webb + */ +class StatusMapperTests { + + @Test + void createWhenMappingsAreNullUsesDefaultMappings() { + StatusMapper mapper = StatusMapper.of(null); + assertThat(mapper.getServingStatus(Status.UNKNOWN)).isEqualTo(ServingStatus.UNKNOWN); + assertThat(mapper.getServingStatus(Status.UP)).isEqualTo(ServingStatus.SERVING); + assertThat(mapper.getServingStatus(Status.DOWN)).isEqualTo(ServingStatus.NOT_SERVING); + assertThat(mapper.getServingStatus(Status.OUT_OF_SERVICE)).isEqualTo(ServingStatus.NOT_SERVING); + } + + @Test + void getStatusCodeReturnsMappedStatus() { + Map map = new LinkedHashMap<>(); + map.put("up", ServingStatus.UNRECOGNIZED); + map.put("down", ServingStatus.UNKNOWN); + StatusMapper mapper = StatusMapper.of(map); + assertThat(mapper.getServingStatus(Status.UP)).isEqualTo(ServingStatus.UNRECOGNIZED); + assertThat(mapper.getServingStatus(Status.DOWN)).isEqualTo(ServingStatus.UNKNOWN); + assertThat(mapper.getServingStatus(Status.OUT_OF_SERVICE)).isEqualTo(ServingStatus.SERVING); + } + + @Test + void getStatusCodeWhenMappingsAreNotUniformReturnsMappedStatus() { + Map map = new LinkedHashMap<>(); + map.put("out-of-service", ServingStatus.SERVING); + StatusMapper mapper = StatusMapper.of(map); + assertThat(mapper.getServingStatus(Status.OUT_OF_SERVICE)).isEqualTo(ServingStatus.SERVING); + } + +} diff --git a/module/spring-boot-grpc-server/src/test/resources/org/springframework/boot/grpc/server/autoconfigure/test.jks b/module/spring-boot-grpc-server/src/test/resources/org/springframework/boot/grpc/server/autoconfigure/test.jks new file mode 100644 index 00000000000..0fc3e802f75 Binary files /dev/null and b/module/spring-boot-grpc-server/src/test/resources/org/springframework/boot/grpc/server/autoconfigure/test.jks differ diff --git a/module/spring-boot-grpc-test/build.gradle b/module/spring-boot-grpc-test/build.gradle new file mode 100644 index 00000000000..f2d3e8e39a5 --- /dev/null +++ b/module/spring-boot-grpc-test/build.gradle @@ -0,0 +1,43 @@ +/* + * 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. + */ + +plugins { + id "java-library" + id "org.springframework.boot.deployed" + id "org.springframework.boot.optional-dependencies" +} + +description = "Spring Boot gRPC Test" + +dependencies { + api(project(":core:spring-boot-test-autoconfigure")) + api(project(":module:spring-boot-grpc-client")) + api(project(":module:spring-boot-grpc-server")) + + optional(project(":core:spring-boot-autoconfigure")) + optional("io.grpc:grpc-inprocess") + optional("io.grpc:grpc-stub") + optional("org.junit.jupiter:junit-jupiter-api") + + testImplementation(project(":test-support:spring-boot-test-support")) + testImplementation("io.grpc:grpc-netty") + + testRuntimeOnly("ch.qos.logback:logback-classic") +} + +tasks.named("compileTestJava") { + options.nullability.checking = "tests" +} diff --git a/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/AutoConfigureTestGrpcTransport.java b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/AutoConfigureTestGrpcTransport.java new file mode 100644 index 00000000000..b45d6dade89 --- /dev/null +++ b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/AutoConfigureTestGrpcTransport.java @@ -0,0 +1,77 @@ +/* + * 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.grpc.test.autoconfigure; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.grpc.server.GrpcServletRegistration; +import org.springframework.boot.test.context.PropertyMapping; +import org.springframework.grpc.client.GrpcChannelFactory; +import org.springframework.grpc.server.GrpcServerFactory; + +/** + * Annotation that can be applied to a test class to enable test in-process gRPC + * transport. Starts a test in-process gRPC server and configures a + * {@code GrpcChannelFactory} that will connect all targets to it. + * + * @author Dave Syer + * @author Chris Bono + * @author Phillip Webb + * @since 4.1.0 + * @see TestGrpcTransportAutoConfiguration + */ +@Target({ ElementType.TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@ImportAutoConfiguration +public @interface AutoConfigureTestGrpcTransport { + + /** + * Enables auto-configuration of the {@link GrpcServletRegistration}. Defaults to + * {@code false} since servlet registration is unnecessary when using test in-process + * transport. + * @return if servlet support is enabled + */ + @PropertyMapping("spring.grpc.server.servlet.enabled") + boolean enableServlet() default false; + + /** + * Enables auto-configuration of {@link GrpcServerFactory} beans. Defaults to + * {@code false} since additional server factories are unnecessary when using test + * in-process transport. + * @return if server factories are enabled + */ + @PropertyMapping("spring.grpc.server.factory.enabled") + boolean enableServerFactory() default false; + + /** + * Enables auto-configuration of {@link GrpcChannelFactory} beans. Defaults to + * {@code false} since additional channel factories are unnecessary when using test + * in-process transport. + * @return if channel factories are enabled + */ + @PropertyMapping("spring.grpc.client.channelfactory.enabled") + boolean enableChannelFactory() default false; + +} diff --git a/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/GrpcPortInfoApplicationContextInitializer.java b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/GrpcPortInfoApplicationContextInitializer.java new file mode 100644 index 00000000000..76f1e0a3067 --- /dev/null +++ b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/GrpcPortInfoApplicationContextInitializer.java @@ -0,0 +1,106 @@ +/* + * 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.grpc.test.autoconfigure; + +import java.util.HashMap; +import java.util.Map; + +import io.grpc.Server; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ApplicationListener; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.Environment; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.MutablePropertySources; +import org.springframework.core.env.PropertySource; +import org.springframework.grpc.server.GrpcServerFactory; +import org.springframework.grpc.server.InProcessGrpcServerFactory; +import org.springframework.grpc.server.lifecycle.GrpcServerStartedEvent; + +/** + * {@link ApplicationContextInitializer} that sets {@link Environment} properties for the + * ports that {@link Server gRPC servers} are actually listening on. The property + * {@literal "local.grpc.server.port"} can be injected directly into tests using + * {@link Value @Value} or obtained through the {@link Environment}. + *

+ * Properties are automatically propagated up to any parent context. + * + * @author Dave Syer + * @author Chris Bono + * @author Phillip Webb + */ +class GrpcPortInfoApplicationContextInitializer + implements ApplicationContextInitializer { + + @Override + public void initialize(ConfigurableApplicationContext applicationContext) { + applicationContext.addApplicationListener(new Listener(applicationContext)); + } + + private static class Listener implements ApplicationListener { + + private static final String PROPERTY_NAME = "local.grpc.server.port"; + + private static final String PROPERTY_SOURCE_NAME = "server.ports"; + + private final ConfigurableApplicationContext applicationContext; + + Listener(ConfigurableApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + @Override + public void onApplicationEvent(GrpcServerStartedEvent event) { + GrpcServerFactory factory = event.getSource().getFactory(); + if (factory instanceof InProcessGrpcServerFactory || factory instanceof TestGrpcServerFactory + || event.getPort() == -1) { + return; + } + setPortProperty(this.applicationContext, event.getPort()); + } + + private void setPortProperty(ApplicationContext context, int port) { + if (context instanceof ConfigurableApplicationContext configurableContext) { + setPortProperty(configurableContext.getEnvironment(), port); + } + if (context.getParent() != null) { + setPortProperty(context.getParent(), port); + } + } + + private void setPortProperty(ConfigurableEnvironment environment, int port) { + MutablePropertySources sources = environment.getPropertySources(); + PropertySource source = sources.get(PROPERTY_SOURCE_NAME); + if (source == null) { + source = new MapPropertySource(PROPERTY_SOURCE_NAME, new HashMap<>()); + sources.addFirst(source); + } + setPortProperty(port, source); + } + + @SuppressWarnings("unchecked") + private void setPortProperty(int port, PropertySource source) { + ((Map) source.getSource()).put(PROPERTY_NAME, port); + } + + } + +} diff --git a/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/LocalGrpcServerPort.java b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/LocalGrpcServerPort.java new file mode 100644 index 00000000000..41fed407bef --- /dev/null +++ b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/LocalGrpcServerPort.java @@ -0,0 +1,43 @@ +/* + * 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.grpc.test.autoconfigure; + +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.beans.factory.annotation.Value; + +/** + * Annotation at the field or method/constructor parameter level that injects the gRPC + * server port that was allocated at runtime. Provides a convenient alternative for + * @Value("${local.grpc.server.port}"). + * + * @author Dave Syer + * @author Chris Bono + * @author Phillip Webb + * @since 4.1.0 + */ +@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Value("${local.grpc.server.port}") +public @interface LocalGrpcServerPort { + +} diff --git a/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/TestGrpcChannelFactory.java b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/TestGrpcChannelFactory.java new file mode 100644 index 00000000000..e00d3e74c92 --- /dev/null +++ b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/TestGrpcChannelFactory.java @@ -0,0 +1,51 @@ +/* + * 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.grpc.test.autoconfigure; + +import java.util.Collections; + +import io.grpc.ChannelCredentials; +import io.grpc.inprocess.InProcessChannelBuilder; + +import org.springframework.grpc.client.ClientInterceptorsConfigurer; +import org.springframework.grpc.client.DefaultGrpcChannelFactory; +import org.springframework.grpc.client.GrpcChannelFactory; + +/** + * {@link GrpcChannelFactory} for testing with in-process transport. + * + * @author Chris Bono + * @author Phillip Webb + */ +class TestGrpcChannelFactory extends DefaultGrpcChannelFactory { + + TestGrpcChannelFactory(String address, ClientInterceptorsConfigurer interceptorsConfigurer) { + super(Collections.emptyList(), interceptorsConfigurer); + setVirtualTargets((path) -> address); + } + + @Override + public boolean supports(String target) { + return true; + } + + @Override + protected InProcessChannelBuilder newChannelBuilder(String target, ChannelCredentials creds) { + return InProcessChannelBuilder.forName(target); + } + +} diff --git a/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/TestGrpcServerFactory.java b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/TestGrpcServerFactory.java new file mode 100644 index 00000000000..42630cae7ca --- /dev/null +++ b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/TestGrpcServerFactory.java @@ -0,0 +1,43 @@ +/* + * 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.grpc.test.autoconfigure; + +import java.util.Collections; + +import io.grpc.inprocess.InProcessServerBuilder; + +import org.springframework.grpc.server.DefaultGrpcServerFactory; +import org.springframework.grpc.server.GrpcServerFactory; + +/** + * {@link GrpcServerFactory} for testing with in-process transport. + * + * @author Chris Bono + * @author Phillip Webb + */ +class TestGrpcServerFactory extends DefaultGrpcServerFactory { + + TestGrpcServerFactory(String address) { + super(address, Collections.emptyList(), null, null, null); + } + + @Override + protected InProcessServerBuilder newServerBuilder() { + return InProcessServerBuilder.forName(address()); + } + +} diff --git a/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/TestGrpcTransportAutoConfiguration.java b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/TestGrpcTransportAutoConfiguration.java new file mode 100644 index 00000000000..6ef189c4c87 --- /dev/null +++ b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/TestGrpcTransportAutoConfiguration.java @@ -0,0 +1,112 @@ +/* + * 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.grpc.test.autoconfigure; + +import java.util.List; + +import io.grpc.BindableService; +import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.stub.AbstractStub; +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientAutoConfiguration; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerAutoConfiguration; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.grpc.client.ClientInterceptorsConfigurer; +import org.springframework.grpc.client.GrpcChannelFactory; +import org.springframework.grpc.server.GrpcServerFactory; +import org.springframework.grpc.server.InProcessGrpcServerFactory; +import org.springframework.grpc.server.ServerBuilderCustomizer; +import org.springframework.grpc.server.ServerServiceDefinitionFilter; +import org.springframework.grpc.server.lifecycle.GrpcServerLifecycle; +import org.springframework.grpc.server.service.GrpcServiceConfigurer; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; + +/** + * Auto-configuration for an in-process test gRPC transport. + * + * @author Chris Bono + * @author Dave Syer + * @author Andrey Litvitski + * @author Phillip Webb + * @since 4.1.0 + * @see AutoConfigureTestGrpcTransport + */ +@AutoConfiguration(before = { GrpcServerAutoConfiguration.class, GrpcClientAutoConfiguration.class }) +@ConditionalOnClass({ InProcessServerBuilder.class, InProcessGrpcServerFactory.class }) +public final class TestGrpcTransportAutoConfiguration { + + private static final String address = InProcessServerBuilder.generateName(); + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(GrpcServerFactory.class) + @ConditionalOnBean(BindableService.class) + static class TestGrpcServerTransportAutoConfiguration { + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + TestGrpcServerFactory testGrpcServerFactory(GrpcServiceDiscoverer serviceDiscoverer, + GrpcServiceConfigurer serviceConfigurer, + List> customizers, + @Nullable ServerServiceDefinitionFilter serviceFilter) { + TestGrpcServerFactory factory = new TestGrpcServerFactory(address); + serviceDiscoverer.findServices() + .stream() + .map((spec) -> serviceConfigurer.configure(spec, factory)) + .forEach(factory::addService); + return factory; + } + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + GrpcServerLifecycle testGrpcServerLifecycle(TestGrpcServerFactory testGrpcServerFactory, + GrpcServerProperties properties, ApplicationEventPublisher eventPublisher) { + return new GrpcServerLifecycle(testGrpcServerFactory, properties.getShutdown().getGracePeriod(), + eventPublisher); + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass({ AbstractStub.class, GrpcChannelFactory.class }) + static class TestGrpcClientTransportAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + ClientInterceptorsConfigurer grpcClientInterceptorsConfigurer(ApplicationContext applicationContext) { + return new ClientInterceptorsConfigurer(applicationContext); + } + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + TestGrpcChannelFactory testGrpcChannelFactory(ClientInterceptorsConfigurer interceptorsConfigurer) { + return new TestGrpcChannelFactory(address, interceptorsConfigurer); + } + + } + +} diff --git a/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/package-info.java b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/package-info.java new file mode 100644 index 00000000000..c6785eb5dd7 --- /dev/null +++ b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/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. + */ + +/** + * Auto-configuration for Spring gRPC tests. + */ +@NullMarked +package org.springframework.boot.grpc.test.autoconfigure; + +import org.jspecify.annotations.NullMarked; diff --git a/module/spring-boot-grpc-test/src/main/resources/META-INF/spring.factories b/module/spring-boot-grpc-test/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000000..a9432eff747 --- /dev/null +++ b/module/spring-boot-grpc-test/src/main/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +# Application Context Initializers +org.springframework.context.ApplicationContextInitializer=\ +org.springframework.boot.grpc.test.autoconfigure.GrpcPortInfoApplicationContextInitializer diff --git a/module/spring-boot-grpc-test/src/main/resources/META-INF/spring/org.springframework.boot.grpc.test.autoconfigure.AutoConfigureTestGrpcTransport.imports b/module/spring-boot-grpc-test/src/main/resources/META-INF/spring/org.springframework.boot.grpc.test.autoconfigure.AutoConfigureTestGrpcTransport.imports new file mode 100644 index 00000000000..dcf95ea3d0b --- /dev/null +++ b/module/spring-boot-grpc-test/src/main/resources/META-INF/spring/org.springframework.boot.grpc.test.autoconfigure.AutoConfigureTestGrpcTransport.imports @@ -0,0 +1 @@ +org.springframework.boot.grpc.test.autoconfigure.TestGrpcTransportAutoConfiguration \ No newline at end of file diff --git a/module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/AutoConfigureTestGrpcTransportOverrideTests.java b/module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/AutoConfigureTestGrpcTransportOverrideTests.java new file mode 100644 index 00000000000..fc2f363c347 --- /dev/null +++ b/module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/AutoConfigureTestGrpcTransportOverrideTests.java @@ -0,0 +1,44 @@ +/* + * 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.grpc.test.autoconfigure; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.env.Environment; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link AutoConfigureTestGrpcTransport} with override attributes. + * + * @author Phillip Webb + */ +@ExtendWith(SpringExtension.class) +@AutoConfigureTestGrpcTransport(enableServlet = true, enableServerFactory = true, enableChannelFactory = true) +class AutoConfigureTestGrpcTransportOverrideTests { + + @Test + void setsEnabledPropertiesToTrue(@Autowired Environment environment) { + assertThat(environment.getProperty("spring.grpc.server.servlet.enabled", Boolean.class)).isTrue(); + assertThat(environment.getProperty("spring.grpc.server.factory.enabled", Boolean.class)).isTrue(); + assertThat(environment.getProperty("spring.grpc.client.channelfactory.enabled", Boolean.class)).isTrue(); + } + +} diff --git a/module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/AutoConfigureTestGrpcTransportTests.java b/module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/AutoConfigureTestGrpcTransportTests.java new file mode 100644 index 00000000000..86150d104d4 --- /dev/null +++ b/module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/AutoConfigureTestGrpcTransportTests.java @@ -0,0 +1,44 @@ +/* + * 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.grpc.test.autoconfigure; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.env.Environment; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link AutoConfigureTestGrpcTransport}. + * + * @author Phillip Webb + */ +@ExtendWith(SpringExtension.class) +@AutoConfigureTestGrpcTransport +class AutoConfigureTestGrpcTransportTests { + + @Test + void setsEnabledPropertiesToFalse(@Autowired Environment environment) { + assertThat(environment.getProperty("spring.grpc.server.servlet.enabled", Boolean.class)).isFalse(); + assertThat(environment.getProperty("spring.grpc.server.factory.enabled", Boolean.class)).isFalse(); + assertThat(environment.getProperty("spring.grpc.client.channelfactory.enabled", Boolean.class)).isFalse(); + } + +} diff --git a/module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/GrpcPortInfoApplicationContextInitializerTests.java b/module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/GrpcPortInfoApplicationContextInitializerTests.java new file mode 100644 index 00000000000..bb5ffdb80cc --- /dev/null +++ b/module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/GrpcPortInfoApplicationContextInitializerTests.java @@ -0,0 +1,92 @@ +/* + * 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.grpc.test.autoconfigure; + +import io.grpc.Server; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.server.GrpcServerFactory; +import org.springframework.grpc.server.InProcessGrpcServerFactory; +import org.springframework.grpc.server.NettyGrpcServerFactory; +import org.springframework.grpc.server.lifecycle.GrpcServerLifecycle; +import org.springframework.grpc.server.lifecycle.GrpcServerStartedEvent; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GrpcPortInfoApplicationContextInitializer}. + * + * @author Phillip Webb + */ +class GrpcPortInfoApplicationContextInitializerTests { + + private static final String PORT_PROPERTY = "local.grpc.server.port"; + + @Test + void whenServerHasAddressInitializerSetsPortProperty() { + NettyGrpcServerFactory factory = mock(); + testListener(factory, 65535, "65535"); + } + + @Test + void whenServerHasNoAddressInitializerSetsNoPortProperty() { + NettyGrpcServerFactory factory = mock(); + testListener(factory, -1, null); + } + + @Test + void whenInProcessGrpcServerFactorySetsNoPortProperty() { + InProcessGrpcServerFactory factory = mock(); + testListener(factory, 65535, null); + } + + @Test + void whenTestGrpcServerFactorySetsNoPortProperty() { + TestGrpcServerFactory factory = mock(); + testListener(factory, 65535, null); + } + + private void testListener(GrpcServerFactory factory, int port, @Nullable String expected) { + try (ConfigurableApplicationContext context = new AnnotationConfigApplicationContext(Config.class)) { + context.getBean(GrpcPortInfoApplicationContextInitializer.class).initialize(context); + GrpcServerLifecycle lifecyle = mock(); + Server server = mock(); + given(lifecyle.getFactory()).willReturn(factory); + GrpcServerStartedEvent event = new GrpcServerStartedEvent(lifecyle, server, "localhost", port); + context.publishEvent(event); + assertThat(context.getEnvironment().getProperty(PORT_PROPERTY)).isEqualTo(expected); + } + } + + @Configuration(proxyBeanMethods = false) + static class Config { + + @Bean + GrpcPortInfoApplicationContextInitializer grpcPortInfoApplicationContextInitializer() { + return new GrpcPortInfoApplicationContextInitializer(); + } + + } + +} diff --git a/module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/TestGrpcChannelFactoryTests.java b/module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/TestGrpcChannelFactoryTests.java new file mode 100644 index 00000000000..021d28b4b39 --- /dev/null +++ b/module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/TestGrpcChannelFactoryTests.java @@ -0,0 +1,47 @@ +/* + * 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.grpc.test.autoconfigure; + +import io.grpc.ManagedChannel; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link TestGrpcChannelFactory}. + * + * @author Phillip Webb + */ +class TestGrpcChannelFactoryTests { + + private TestGrpcChannelFactory factory = new TestGrpcChannelFactory("test", mock()); + + @Test + void supportsReturnsTrue() { + assertThat(this.factory.supports("inprocess:test")).isTrue(); + assertThat(this.factory.supports("static:*:0")).isTrue(); + } + + @Test + void createChannelReturnsInProcessChannel() { + ManagedChannel channel = this.factory.createChannel("static:*:0"); + assertThat(channel).extracting("delegate.originalTransportFactory") + .satisfies((transportFactory) -> assertThat(transportFactory.getClass().getName()).contains("InProcess")); + } + +} diff --git a/module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/TestGrpcServerFactoryTests.java b/module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/TestGrpcServerFactoryTests.java new file mode 100644 index 00000000000..27c367a80e7 --- /dev/null +++ b/module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/TestGrpcServerFactoryTests.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.grpc.test.autoconfigure; + +import io.grpc.Server; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link TestGrpcServerFactory}. + * + * @author Phillip Webb + */ +class TestGrpcServerFactoryTests { + + private TestGrpcServerFactory factory = new TestGrpcServerFactory("test"); + + @Test + void createServerReturnsInProcessServer() { + Server server = this.factory.createServer(); + assertThat(server).extracting("transportServer") + .satisfies((transportServer) -> assertThat(transportServer.getClass().getName()).contains("InProcess")); + } + +} diff --git a/module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/TestGrpcTransportAutoConfigurationTests.java b/module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/TestGrpcTransportAutoConfigurationTests.java new file mode 100644 index 00000000000..48dc8d8cea1 --- /dev/null +++ b/module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/TestGrpcTransportAutoConfigurationTests.java @@ -0,0 +1,107 @@ +/* + * 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.grpc.test.autoconfigure; + +import io.grpc.BindableService; +import io.grpc.ServerServiceDefinition; +import io.grpc.stub.AbstractStub; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientAutoConfiguration; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerAutoConfiguration; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerServicesAutoConfiguration; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.grpc.client.GrpcChannelFactory; +import org.springframework.grpc.server.GrpcServerFactory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link TestGrpcTransportAutoConfiguration}. + * + * @author Chris Bono + * @author Phillip Webb + */ +class TestGrpcTransportAutoConfigurationTests { + + private static final AutoConfigurations autoConfiguration = AutoConfigurations.of( + TestGrpcTransportAutoConfiguration.class, GrpcServerAutoConfiguration.class, + GrpcServerServicesAutoConfiguration.class, SslAutoConfiguration.class, GrpcClientAutoConfiguration.class); + + private final BindableService service = mock(); + + private final ServerServiceDefinition serviceDefinition = ServerServiceDefinition.builder("my-service").build(); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(autoConfiguration) + .withBean(BindableService.class, () -> this.service); + + @BeforeEach + void setup() { + given(this.service.bindService()).willReturn(this.serviceDefinition); + } + + @Test + void createsTestGrpcServerFactoryBean() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(TestGrpcServerFactory.class)); + } + + @Test + void createsGrpcServerLifecycleBean() { + this.contextRunner.run((context) -> assertThat(context).hasBean("testGrpcServerLifecycle")); + } + + @Test + void whenNoBindableServiceDoesNotCreateServerBeans() { + new ApplicationContextRunner().withConfiguration(autoConfiguration).run((context) -> { + assertThat(context).doesNotHaveBean(TestGrpcServerFactory.class); + assertThat(context).doesNotHaveBean("testGrpcServerLifecycle"); + }); + } + + @Test + void whenNoGrpcServerFactoryClassDoesNotCreateServerBeans() { + this.contextRunner.withClassLoader(new FilteredClassLoader(GrpcServerFactory.class)).run((context) -> { + assertThat(context).doesNotHaveBean(TestGrpcServerFactory.class); + assertThat(context).doesNotHaveBean("testGrpcServerLifecycle"); + }); + } + + @Test + void createsTestGrpcChannelFactoryBean() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(TestGrpcChannelFactory.class)); + } + + @Test + void whenNoAbstractStubClassDoesNotCreateClientBeans() { + this.contextRunner.withClassLoader(new FilteredClassLoader(AbstractStub.class)) + .run((context) -> assertThat(context).doesNotHaveBean(TestGrpcChannelFactory.class)); + } + + @Test + void whenNoGrpcChannelFactoryClassDoesNotCreateClientBeans() { + this.contextRunner.withClassLoader(new FilteredClassLoader(GrpcChannelFactory.class)) + .run((context) -> assertThat(context).doesNotHaveBean(TestGrpcChannelFactory.class)); + } + +} diff --git a/module/spring-boot-health/src/main/java/org/springframework/boot/health/actuate/endpoint/HttpCodeStatusMapper.java b/module/spring-boot-health/src/main/java/org/springframework/boot/health/actuate/endpoint/HttpCodeStatusMapper.java index f5f2cc14e6b..fea88387b14 100644 --- a/module/spring-boot-health/src/main/java/org/springframework/boot/health/actuate/endpoint/HttpCodeStatusMapper.java +++ b/module/spring-boot-health/src/main/java/org/springframework/boot/health/actuate/endpoint/HttpCodeStatusMapper.java @@ -16,7 +16,12 @@ package org.springframework.boot.health.actuate.endpoint; +import java.util.Map; + +import org.jspecify.annotations.Nullable; + import org.springframework.boot.health.contributor.Status; +import org.springframework.util.CollectionUtils; /** * Strategy used to map a {@link Status health status} to an HTTP status code. @@ -30,7 +35,10 @@ public interface HttpCodeStatusMapper { /** * An {@link HttpCodeStatusMapper} instance using default mappings. + * @deprecated since 4.1.0 for removal in 4.3.0 in favor of #getDefault() */ + @SuppressWarnings("removal") + @Deprecated(since = "4.1.0", forRemoval = true) HttpCodeStatusMapper DEFAULT = new SimpleHttpCodeStatusMapper(); /** @@ -41,4 +49,26 @@ public interface HttpCodeStatusMapper { */ int getStatusCode(Status status); + /** + * Create a new {@link HttpCodeStatusMapper} with the specified mappings. + * @param mappings the mappings to use or {@code null} to use the default mappings + * @return a {@link HttpCodeStatusMapper} or {@link #getDefault()} + * @since 4.1.0 + */ + @SuppressWarnings("removal") + static HttpCodeStatusMapper of(@Nullable Map mappings) { + return CollectionUtils.isEmpty(mappings) ? SimpleHttpCodeStatusMapper.DEFAULT_MAPPINGS + : new SimpleHttpCodeStatusMapper(mappings); + } + + /** + * Return an {@link HttpCodeStatusMapper} instance using default mappings. + * @return a mapper using default mappings + * @since 4.1.0 + */ + @SuppressWarnings("removal") + static HttpCodeStatusMapper getDefault() { + return SimpleHttpCodeStatusMapper.DEFAULT_MAPPINGS; + } + } diff --git a/module/spring-boot-health/src/main/java/org/springframework/boot/health/actuate/endpoint/SimpleHttpCodeStatusMapper.java b/module/spring-boot-health/src/main/java/org/springframework/boot/health/actuate/endpoint/SimpleHttpCodeStatusMapper.java index 74a7664f228..f6284ef85f1 100644 --- a/module/spring-boot-health/src/main/java/org/springframework/boot/health/actuate/endpoint/SimpleHttpCodeStatusMapper.java +++ b/module/spring-boot-health/src/main/java/org/springframework/boot/health/actuate/endpoint/SimpleHttpCodeStatusMapper.java @@ -25,6 +25,7 @@ import org.jspecify.annotations.Nullable; import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse; import org.springframework.boot.health.contributor.Status; +import org.springframework.lang.Contract; import org.springframework.util.CollectionUtils; /** @@ -34,15 +35,18 @@ import org.springframework.util.CollectionUtils; * @author Stephane Nicoll * @author Phillip Webb * @since 4.0.0 + * @deprecated since 4.1.0 for removal in 4.3.0 in favor of + * {@link HttpCodeStatusMapper#of} */ +@Deprecated(since = "4.1.0", forRemoval = true) public class SimpleHttpCodeStatusMapper implements HttpCodeStatusMapper { - private static final Map DEFAULT_MAPPINGS; + static final SimpleHttpCodeStatusMapper DEFAULT_MAPPINGS; static { - Map defaultMappings = new HashMap<>(); - defaultMappings.put(Status.DOWN.getCode(), WebEndpointResponse.STATUS_SERVICE_UNAVAILABLE); - defaultMappings.put(Status.OUT_OF_SERVICE.getCode(), WebEndpointResponse.STATUS_SERVICE_UNAVAILABLE); - DEFAULT_MAPPINGS = getUniformMappings(defaultMappings); + Map mappings = new HashMap<>(); + mappings.put(Status.DOWN.getCode(), WebEndpointResponse.STATUS_SERVICE_UNAVAILABLE); + mappings.put(Status.OUT_OF_SERVICE.getCode(), WebEndpointResponse.STATUS_SERVICE_UNAVAILABLE); + DEFAULT_MAPPINGS = new SimpleHttpCodeStatusMapper(mappings); } private final Map mappings; @@ -51,7 +55,7 @@ public class SimpleHttpCodeStatusMapper implements HttpCodeStatusMapper { * Create a new {@link SimpleHttpCodeStatusMapper} instance using default mappings. */ public SimpleHttpCodeStatusMapper() { - this(null); + this((Map) null); } /** @@ -59,7 +63,7 @@ public class SimpleHttpCodeStatusMapper implements HttpCodeStatusMapper { * @param mappings the mappings to use or {@code null} to use the default mappings */ public SimpleHttpCodeStatusMapper(@Nullable Map mappings) { - this.mappings = CollectionUtils.isEmpty(mappings) ? DEFAULT_MAPPINGS : getUniformMappings(mappings); + this.mappings = CollectionUtils.isEmpty(mappings) ? DEFAULT_MAPPINGS.mappings : getUniformMappings(mappings); } @Override @@ -79,18 +83,13 @@ public class SimpleHttpCodeStatusMapper implements HttpCodeStatusMapper { return Collections.unmodifiableMap(result); } + @Contract("!null -> !null") private static @Nullable String getUniformCode(@Nullable String code) { - if (code == null) { - return null; - } - StringBuilder builder = new StringBuilder(); - for (int i = 0; i < code.length(); i++) { - char ch = code.charAt(i); - if (Character.isAlphabetic(ch) || Character.isDigit(ch)) { - builder.append(Character.toLowerCase(ch)); - } - } - return builder.toString(); + return (code != null) ? code.codePoints() + .filter(Character::isLetterOrDigit) + .map(Character::toLowerCase) + .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) + .toString() : null; } } diff --git a/module/spring-boot-health/src/main/java/org/springframework/boot/health/actuate/endpoint/SimpleStatusAggregator.java b/module/spring-boot-health/src/main/java/org/springframework/boot/health/actuate/endpoint/SimpleStatusAggregator.java index 671efed0209..2c3dde2213e 100644 --- a/module/spring-boot-health/src/main/java/org/springframework/boot/health/actuate/endpoint/SimpleStatusAggregator.java +++ b/module/spring-boot-health/src/main/java/org/springframework/boot/health/actuate/endpoint/SimpleStatusAggregator.java @@ -16,9 +16,7 @@ package org.springframework.boot.health.actuate.endpoint; -import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Set; @@ -36,42 +34,39 @@ import org.springframework.util.ObjectUtils; * * @author Phillip Webb * @since 4.0.0 + * @deprecated since 4.1.0 for removal in 4.3.0 in favor of {@link StatusAggregator#of} */ +@Deprecated(since = "4.1.0", forRemoval = true) public class SimpleStatusAggregator implements StatusAggregator { - private static final List DEFAULT_ORDER; - - static final StatusAggregator INSTANCE; - - static { - List defaultOrder = new ArrayList<>(); - defaultOrder.add(Status.DOWN.getCode()); - defaultOrder.add(Status.OUT_OF_SERVICE.getCode()); - defaultOrder.add(Status.UP.getCode()); - defaultOrder.add(Status.UNKNOWN.getCode()); - DEFAULT_ORDER = Collections.unmodifiableList(getUniformCodes(defaultOrder.stream())); - INSTANCE = new SimpleStatusAggregator(); - } + static final SimpleStatusAggregator DEFAULT_ORDER = new SimpleStatusAggregator(); private final List order; - private final Comparator comparator = new StatusComparator(); + private final Comparator comparator = Comparator.comparingInt(this::orderIndex) + .thenComparing(Status::getCode); public SimpleStatusAggregator() { - this.order = DEFAULT_ORDER; + this(Status.DEFAULT_ORDER.stream().map(Status::getCode)); } public SimpleStatusAggregator(Status... order) { - this.order = ObjectUtils.isEmpty(order) ? DEFAULT_ORDER - : getUniformCodes(Arrays.stream(order).map(Status::getCode)); + this.order = ObjectUtils.isEmpty(order) ? DEFAULT_ORDER.order + : Arrays.stream(order).map(SimpleStatusAggregator::getUniformCode).toList(); } public SimpleStatusAggregator(String... order) { - this.order = ObjectUtils.isEmpty(order) ? DEFAULT_ORDER : getUniformCodes(Arrays.stream(order)); + this.order = ObjectUtils.isEmpty(order) ? DEFAULT_ORDER.order + : Arrays.stream(order).map(SimpleStatusAggregator::getUniformCode).toList(); } public SimpleStatusAggregator(List order) { - this.order = CollectionUtils.isEmpty(order) ? DEFAULT_ORDER : getUniformCodes(order.stream()); + this.order = CollectionUtils.isEmpty(order) ? DEFAULT_ORDER.order + : order.stream().map(SimpleStatusAggregator::getUniformCode).toList(); + } + + SimpleStatusAggregator(Stream order) { + this.order = order.map(SimpleStatusAggregator::getUniformCode).toList(); } @Override @@ -80,41 +75,24 @@ public class SimpleStatusAggregator implements StatusAggregator { } private boolean contains(Status status) { - return this.order.contains(getUniformCode(status.getCode())); + return this.order.contains(getUniformCode(status)); } - private static List getUniformCodes(Stream codes) { - return codes.map(SimpleStatusAggregator::getUniformCode).toList(); + private int orderIndex(Status status) { + return this.order.indexOf(getUniformCode(status)); + } + + private static @Nullable String getUniformCode(Status status) { + return getUniformCode(status.getCode()); } @Contract("!null -> !null") private static @Nullable String getUniformCode(@Nullable String code) { - if (code == null) { - return null; - } - StringBuilder builder = new StringBuilder(); - for (int i = 0; i < code.length(); i++) { - char ch = code.charAt(i); - if (Character.isAlphabetic(ch) || Character.isDigit(ch)) { - builder.append(Character.toLowerCase(ch)); - } - } - return builder.toString(); - } - - /** - * {@link Comparator} used to order {@link Status}. - */ - private final class StatusComparator implements Comparator { - - @Override - public int compare(Status s1, Status s2) { - List order = SimpleStatusAggregator.this.order; - int i1 = order.indexOf(getUniformCode(s1.getCode())); - int i2 = order.indexOf(getUniformCode(s2.getCode())); - return (i1 < i2) ? -1 : (i1 != i2) ? 1 : s1.getCode().compareTo(s2.getCode()); - } - + return (code != null) ? code.codePoints() + .filter(Character::isLetterOrDigit) + .map(Character::toLowerCase) + .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) + .toString() : null; } } diff --git a/module/spring-boot-health/src/main/java/org/springframework/boot/health/actuate/endpoint/StatusAggregator.java b/module/spring-boot-health/src/main/java/org/springframework/boot/health/actuate/endpoint/StatusAggregator.java index c3a5907feaf..07b9dbeb3c7 100644 --- a/module/spring-boot-health/src/main/java/org/springframework/boot/health/actuate/endpoint/StatusAggregator.java +++ b/module/spring-boot-health/src/main/java/org/springframework/boot/health/actuate/endpoint/StatusAggregator.java @@ -18,16 +18,18 @@ package org.springframework.boot.health.actuate.endpoint; import java.util.Arrays; import java.util.LinkedHashSet; +import java.util.List; import java.util.Set; import org.springframework.boot.health.contributor.Health; import org.springframework.boot.health.contributor.Status; +import org.springframework.util.ObjectUtils; /** * Strategy used to aggregate {@link Status} instances. *

* This is required in order to combine subsystem states expressed through - * {@link Health#getStatus()} into one state for the entire system. + * {@link Health#getStatus()} into one state for the health endpoint. * * @author Phillip Webb * @since 4.0.0 @@ -35,14 +37,6 @@ import org.springframework.boot.health.contributor.Status; @FunctionalInterface public interface StatusAggregator { - /** - * Return {@link StatusAggregator} instance using default ordering rules. - * @return a {@code StatusAggregator} with default ordering rules. - */ - static StatusAggregator getDefault() { - return SimpleStatusAggregator.INSTANCE; - } - /** * Return the aggregate status for the given set of statuses. * @param statuses the statuses to aggregate @@ -59,4 +53,50 @@ public interface StatusAggregator { */ Status getAggregateStatus(Set statuses); + /** + * Return a {@link StatusAggregator} backed by the given ordered status list. + * @param order the status order + * @return a {@link StatusAggregator} instance or {@link #getDefault()} if no order is + * provided + * @since 4.1.0 + */ + @SuppressWarnings("removal") + static StatusAggregator of(Status... order) { + return (!ObjectUtils.isEmpty(order)) ? new SimpleStatusAggregator(Arrays.stream(order).map(Status::getCode)) + : getDefault(); + } + + /** + * Return a {@link StatusAggregator} backed by the given ordered status list. + * @param order the status order + * @return a {@link StatusAggregator} instance or {@link #getDefault()} if no order is + * provided + * @since 4.1.0 + */ + @SuppressWarnings("removal") + static StatusAggregator of(String... order) { + return (!ObjectUtils.isEmpty(order)) ? new SimpleStatusAggregator(Arrays.stream(order)) : getDefault(); + } + + /** + * Return a {@link StatusAggregator} backed by the given ordered status list. + * @param order the status order + * @return a {@link StatusAggregator} instance or {@link #getDefault()} if no order is + * provided + * @since 4.1.0 + */ + @SuppressWarnings("removal") + static StatusAggregator of(List order) { + return (!ObjectUtils.isEmpty(order)) ? new SimpleStatusAggregator(order.stream()) : getDefault(); + } + + /** + * Return {@link StatusAggregator} instance using default ordering rules. + * @return a {@code StatusAggregator} with default ordering rules. + */ + @SuppressWarnings("removal") + static StatusAggregator getDefault() { + return SimpleStatusAggregator.DEFAULT_ORDER; + } + } diff --git a/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/AutoConfiguredHealthEndpointGroup.java b/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/AutoConfiguredHealthEndpointGroup.java index df56619a28f..584d1333f0b 100644 --- a/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/AutoConfiguredHealthEndpointGroup.java +++ b/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/AutoConfiguredHealthEndpointGroup.java @@ -17,7 +17,6 @@ package org.springframework.boot.health.autoconfigure.actuate.endpoint; import java.util.Collection; -import java.util.function.Predicate; import org.jspecify.annotations.Nullable; @@ -27,9 +26,10 @@ import org.springframework.boot.health.actuate.endpoint.AdditionalHealthEndpoint import org.springframework.boot.health.actuate.endpoint.HealthEndpointGroup; import org.springframework.boot.health.actuate.endpoint.HttpCodeStatusMapper; import org.springframework.boot.health.actuate.endpoint.StatusAggregator; +import org.springframework.boot.health.autoconfigure.contributor.HealthContributorMembership; /** - * Auto-configured {@link HealthEndpointGroup} backed by {@link HealthProperties}. + * Auto-configured {@link HealthEndpointGroup}. * * @author Phillip Webb * @author Andy Wilkinson @@ -37,7 +37,7 @@ import org.springframework.boot.health.actuate.endpoint.StatusAggregator; */ class AutoConfiguredHealthEndpointGroup implements HealthEndpointGroup { - private final Predicate members; + private final HealthContributorMembership membership; private final StatusAggregator statusAggregator; @@ -53,7 +53,7 @@ class AutoConfiguredHealthEndpointGroup implements HealthEndpointGroup { /** * Create a new {@link AutoConfiguredHealthEndpointGroup} instance. - * @param members a predicate used to test for group membership + * @param membership used to test for group membership * @param statusAggregator the status aggregator to use * @param httpCodeStatusMapper the HTTP code status mapper to use * @param showComponents the show components setting @@ -61,10 +61,10 @@ class AutoConfiguredHealthEndpointGroup implements HealthEndpointGroup { * @param roles the roles to match * @param additionalPath the additional path to use for this group */ - AutoConfiguredHealthEndpointGroup(Predicate members, StatusAggregator statusAggregator, + AutoConfiguredHealthEndpointGroup(HealthContributorMembership membership, StatusAggregator statusAggregator, HttpCodeStatusMapper httpCodeStatusMapper, @Nullable Show showComponents, Show showDetails, Collection roles, @Nullable AdditionalHealthEndpointPath additionalPath) { - this.members = members; + this.membership = membership; this.statusAggregator = statusAggregator; this.httpCodeStatusMapper = httpCodeStatusMapper; this.showComponents = showComponents; @@ -75,7 +75,7 @@ class AutoConfiguredHealthEndpointGroup implements HealthEndpointGroup { @Override public boolean isMember(String name) { - return this.members.test(name); + return this.membership.isMember(name); } @Override diff --git a/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/AutoConfiguredHealthEndpointGroups.java b/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/AutoConfiguredHealthEndpointGroups.java index 68f45c8b264..d92291352f4 100644 --- a/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/AutoConfiguredHealthEndpointGroups.java +++ b/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/AutoConfiguredHealthEndpointGroups.java @@ -23,7 +23,6 @@ import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.TreeMap; -import java.util.function.Predicate; import java.util.function.Supplier; import java.util.stream.Stream; @@ -43,11 +42,10 @@ import org.springframework.boot.health.actuate.endpoint.HealthEndpoint; import org.springframework.boot.health.actuate.endpoint.HealthEndpointGroup; import org.springframework.boot.health.actuate.endpoint.HealthEndpointGroups; import org.springframework.boot.health.actuate.endpoint.HttpCodeStatusMapper; -import org.springframework.boot.health.actuate.endpoint.SimpleHttpCodeStatusMapper; -import org.springframework.boot.health.actuate.endpoint.SimpleStatusAggregator; import org.springframework.boot.health.actuate.endpoint.StatusAggregator; import org.springframework.boot.health.autoconfigure.actuate.endpoint.HealthEndpointProperties.Group; import org.springframework.boot.health.autoconfigure.actuate.endpoint.HealthProperties.Status; +import org.springframework.boot.health.autoconfigure.contributor.HealthContributorMembership; import org.springframework.context.ApplicationContext; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.util.CollectionUtils; @@ -61,8 +59,6 @@ import org.springframework.util.ObjectUtils; */ class AutoConfiguredHealthEndpointGroups implements HealthEndpointGroups, AdditionalPathsMapper { - private static final Predicate ALL = (name) -> true; - private final HealthEndpointGroup primaryGroup; private final Map groups; @@ -78,22 +74,18 @@ class AutoConfiguredHealthEndpointGroups implements HealthEndpointGroups, Additi Show showComponents = properties.getShowComponents(); Show showDetails = properties.getShowDetails(); Set roles = properties.getRoles(); - StatusAggregator statusAggregator = getNonQualifiedBean(beanFactory, StatusAggregator.class); - if (statusAggregator == null) { - statusAggregator = new SimpleStatusAggregator(properties.getStatus().getOrder()); - } - HttpCodeStatusMapper httpCodeStatusMapper = getNonQualifiedBean(beanFactory, HttpCodeStatusMapper.class); - if (httpCodeStatusMapper == null) { - httpCodeStatusMapper = new SimpleHttpCodeStatusMapper(properties.getStatus().getHttpMapping()); - } - this.primaryGroup = new AutoConfiguredHealthEndpointGroup(ALL, statusAggregator, httpCodeStatusMapper, - showComponents, showDetails, roles, null); - this.groups = createGroups(properties.getGroup(), beanFactory, statusAggregator, httpCodeStatusMapper, - showComponents, showDetails, roles); + StatusAggregator statusAggregator = getNonQualifiedBean(beanFactory, StatusAggregator.class, + () -> StatusAggregator.of(properties.getStatus().getOrder())); + HttpCodeStatusMapper statusMapper = getNonQualifiedBean(beanFactory, HttpCodeStatusMapper.class, + () -> HttpCodeStatusMapper.of(properties.getStatus().getHttpMapping())); + this.primaryGroup = new AutoConfiguredHealthEndpointGroup(HealthContributorMembership.always(), + statusAggregator, statusMapper, showComponents, showDetails, roles, null); + this.groups = createGroups(properties.getGroup(), beanFactory, statusAggregator, statusMapper, showComponents, + showDetails, roles); } private Map createGroups(Map groupProperties, BeanFactory beanFactory, - StatusAggregator defaultStatusAggregator, HttpCodeStatusMapper defaultHttpCodeStatusMapper, + StatusAggregator defaultStatusAggregator, HttpCodeStatusMapper defaultStatusMapper, @Nullable Show defaultShowComponents, Show defaultShowDetails, Set defaultRoles) { Map groups = new TreeMap<>(); groupProperties.forEach((groupName, group) -> { @@ -102,29 +94,30 @@ class AutoConfiguredHealthEndpointGroups implements HealthEndpointGroups, Additi : defaultShowComponents; Show showDetails = (group.getShowDetails() != null) ? group.getShowDetails() : defaultShowDetails; Set roles = !CollectionUtils.isEmpty(group.getRoles()) ? group.getRoles() : defaultRoles; - StatusAggregator statusAggregator = getQualifiedBean(beanFactory, StatusAggregator.class, groupName, () -> { - if (!CollectionUtils.isEmpty(status.getOrder())) { - return new SimpleStatusAggregator(status.getOrder()); - } - return defaultStatusAggregator; - }); - HttpCodeStatusMapper httpCodeStatusMapper = getQualifiedBean(beanFactory, HttpCodeStatusMapper.class, - groupName, () -> { - if (!CollectionUtils.isEmpty(status.getHttpMapping())) { - return new SimpleHttpCodeStatusMapper(status.getHttpMapping()); - } - return defaultHttpCodeStatusMapper; - }); - Predicate members = new IncludeExcludeGroupMemberPredicate(group.getInclude(), group.getExclude()); + StatusAggregator statusAggregator = getQualifiedBean(beanFactory, StatusAggregator.class, groupName, + () -> createStatusAggregator(status.getOrder(), defaultStatusAggregator)); + HttpCodeStatusMapper statusMapper = getQualifiedBean(beanFactory, HttpCodeStatusMapper.class, groupName, + () -> createStatusMapper(status.getHttpMapping(), defaultStatusMapper)); + HealthContributorMembership membership = HealthContributorMembership.byIncludeExclude(group.getInclude(), + group.getExclude()); AdditionalHealthEndpointPath additionalPath = (group.getAdditionalPath() != null) ? AdditionalHealthEndpointPath.from(group.getAdditionalPath()) : null; - groups.put(groupName, new AutoConfiguredHealthEndpointGroup(members, statusAggregator, httpCodeStatusMapper, + groups.put(groupName, new AutoConfiguredHealthEndpointGroup(membership, statusAggregator, statusMapper, showComponents, showDetails, roles, additionalPath)); }); return Collections.unmodifiableMap(groups); } - private @Nullable T getNonQualifiedBean(ListableBeanFactory beanFactory, Class type) { + private StatusAggregator createStatusAggregator(List order, StatusAggregator defaultStatusAggregator) { + return (!CollectionUtils.isEmpty(order)) ? StatusAggregator.of(order) : defaultStatusAggregator; + } + + private HttpCodeStatusMapper createStatusMapper(Map mapping, + HttpCodeStatusMapper defaultStatusMapper) { + return (!CollectionUtils.isEmpty(mapping)) ? HttpCodeStatusMapper.of(mapping) : defaultStatusMapper; + } + + private T getNonQualifiedBean(ListableBeanFactory beanFactory, Class type, Supplier fallback) { List candidates = new ArrayList<>(); for (String beanName : BeanFactoryUtils.beanNamesForTypeIncludingAncestors(beanFactory, type)) { String[] aliases = beanFactory.getAliases(beanName); @@ -135,7 +128,7 @@ class AutoConfiguredHealthEndpointGroups implements HealthEndpointGroups, Additi } } if (candidates.isEmpty()) { - return null; + return fallback.get(); } if (candidates.size() == 1) { return beanFactory.getBean(candidates.get(0), type); diff --git a/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/AvailabilityProbesHealthEndpointGroup.java b/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/AvailabilityProbesHealthEndpointGroup.java index 8ae893c94d6..6c0feef871b 100644 --- a/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/AvailabilityProbesHealthEndpointGroup.java +++ b/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/AvailabilityProbesHealthEndpointGroup.java @@ -67,7 +67,7 @@ class AvailabilityProbesHealthEndpointGroup implements HealthEndpointGroup { @Override public HttpCodeStatusMapper getHttpCodeStatusMapper() { - return HttpCodeStatusMapper.DEFAULT; + return HttpCodeStatusMapper.getDefault(); } @Override diff --git a/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/HealthEndpointConfiguration.java b/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/HealthEndpointConfiguration.java index 43f74c19a8f..563fb771262 100644 --- a/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/HealthEndpointConfiguration.java +++ b/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/HealthEndpointConfiguration.java @@ -16,14 +16,8 @@ package org.springframework.boot.health.autoconfigure.actuate.endpoint; -import java.util.Set; -import java.util.function.BiFunction; - -import org.jspecify.annotations.Nullable; - import org.springframework.beans.BeansException; import org.springframework.beans.factory.ObjectProvider; -import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -31,17 +25,13 @@ import org.springframework.boot.health.actuate.endpoint.HealthEndpoint; import org.springframework.boot.health.actuate.endpoint.HealthEndpointGroups; import org.springframework.boot.health.actuate.endpoint.HealthEndpointGroupsPostProcessor; import org.springframework.boot.health.actuate.endpoint.HttpCodeStatusMapper; -import org.springframework.boot.health.actuate.endpoint.SimpleHttpCodeStatusMapper; -import org.springframework.boot.health.actuate.endpoint.SimpleStatusAggregator; import org.springframework.boot.health.actuate.endpoint.StatusAggregator; -import org.springframework.boot.health.contributor.HealthContributors; -import org.springframework.boot.health.contributor.ReactiveHealthContributors; +import org.springframework.boot.health.autoconfigure.contributor.HealthContributorMembershipValidator; import org.springframework.boot.health.registry.HealthContributorRegistry; import org.springframework.boot.health.registry.ReactiveHealthContributorRegistry; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.util.CollectionUtils; /** * Configuration for {@link HealthEndpoint} infrastructure beans. @@ -52,16 +42,18 @@ import org.springframework.util.CollectionUtils; @Configuration(proxyBeanMethods = false) class HealthEndpointConfiguration { + private static final String VALIDATE_MEMBERSHIP_PROPERTY = "management.endpoint.health.validate-group-membership"; + @Bean @ConditionalOnMissingBean StatusAggregator healthStatusAggregator(HealthEndpointProperties properties) { - return new SimpleStatusAggregator(properties.getStatus().getOrder()); + return StatusAggregator.of(properties.getStatus().getOrder()); } @Bean @ConditionalOnMissingBean HttpCodeStatusMapper healthHttpCodeStatusMapper(HealthEndpointProperties properties) { - return new SimpleHttpCodeStatusMapper(properties.getStatus().getHttpMapping()); + return HttpCodeStatusMapper.of(properties.getStatus().getHttpMapping()); } @Bean @@ -78,12 +70,17 @@ class HealthEndpointConfiguration { } @Bean - @ConditionalOnBooleanProperty(name = "management.endpoint.health.validate-group-membership", matchIfMissing = true) - HealthEndpointGroupMembershipValidator healthEndpointGroupMembershipValidator(HealthEndpointProperties properties, + @ConditionalOnBooleanProperty(name = VALIDATE_MEMBERSHIP_PROPERTY, matchIfMissing = true) + HealthContributorMembershipValidator healthEndpointGroupMembershipValidator(HealthEndpointProperties properties, HealthContributorRegistry healthContributorRegistry, ObjectProvider reactiveHealthContributorRegistry) { - return new HealthEndpointGroupMembershipValidator(properties, healthContributorRegistry, - reactiveHealthContributorRegistry.getIfAvailable()); + return new HealthContributorMembershipValidator(healthContributorRegistry, + reactiveHealthContributorRegistry.getIfAvailable(), VALIDATE_MEMBERSHIP_PROPERTY, + (members) -> properties.getGroup().forEach((groupName, group) -> { + String property = "management.endpoint.health.group." + groupName; + members.member(property + ".include".formatted(groupName), group.getInclude()); + members.member(property + ".exclude".formatted(groupName), group.getExclude()); + })); } @Bean @@ -131,93 +128,4 @@ class HealthEndpointConfiguration { } - /** - * {@link SmartInitializingSingleton} that validates health endpoint group membership, - * throwing a {@link NoSuchHealthContributorException} if an included or excluded - * contributor does not exist. - */ - static class HealthEndpointGroupMembershipValidator implements SmartInitializingSingleton { - - private final HealthEndpointProperties properties; - - private final HealthContributorRegistry registry; - - @Nullable ReactiveHealthContributorRegistry fallbackRegistry; - - HealthEndpointGroupMembershipValidator(HealthEndpointProperties properties, HealthContributorRegistry registry, - @Nullable ReactiveHealthContributorRegistry fallbackRegistry) { - this.properties = properties; - this.registry = registry; - this.fallbackRegistry = fallbackRegistry; - } - - @Override - public void afterSingletonsInstantiated() { - validateGroups(); - } - - private void validateGroups() { - this.properties.getGroup().forEach((name, group) -> { - validate(group.getInclude(), "Included", name); - validate(group.getExclude(), "Excluded", name); - }); - } - - private void validate(@Nullable Set names, String type, String group) { - if (CollectionUtils.isEmpty(names)) { - return; - } - for (String name : names) { - if ("*".equals(name)) { - return; - } - String[] path = name.split("/"); - if (!contributorExists(path)) { - throw new NoSuchHealthContributorException(type, name, group); - } - } - } - - private boolean contributorExists(String[] path) { - return contributorExistsInMainRegistry(path) || contributorExistsInFallbackRegistry(path); - } - - private boolean contributorExistsInMainRegistry(String[] path) { - return contributorExists(path, this.registry, HealthContributors.class, HealthContributors::getContributor); - } - - private boolean contributorExistsInFallbackRegistry(String[] path) { - return contributorExists(path, this.fallbackRegistry, ReactiveHealthContributors.class, - ReactiveHealthContributors::getContributor); - } - - @SuppressWarnings("unchecked") - private boolean contributorExists(String[] path, @Nullable Object registry, Class collectionType, - BiFunction getFromCollection) { - int pathOffset = 0; - Object contributor = registry; - while (pathOffset < path.length) { - if (contributor == null || !collectionType.isInstance(contributor)) { - return false; - } - contributor = getFromCollection.apply((C) contributor, path[pathOffset]); - pathOffset++; - } - return (contributor != null); - } - - /** - * Thrown when a contributor that does not exist is included in or excluded from a - * group. - */ - static class NoSuchHealthContributorException extends RuntimeException { - - NoSuchHealthContributorException(String type, String name, String group) { - super(type + " health contributor '" + name + "' in group '" + group + "' does not exist"); - } - - } - - } - } diff --git a/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/NoSuchHealthContributorFailureAnalyzer.java b/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/NoSuchHealthContributorFailureAnalyzer.java deleted file mode 100644 index 30a240de7fc..00000000000 --- a/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/NoSuchHealthContributorFailureAnalyzer.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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.health.autoconfigure.actuate.endpoint; - -import org.springframework.boot.diagnostics.AbstractFailureAnalyzer; -import org.springframework.boot.diagnostics.FailureAnalysis; -import org.springframework.boot.health.autoconfigure.actuate.endpoint.HealthEndpointConfiguration.HealthEndpointGroupMembershipValidator.NoSuchHealthContributorException; - -/** - * An {@link AbstractFailureAnalyzer} that performs analysis of failures caused by a - * {@link NoSuchHealthContributorException}. - * - * @author Moritz Halbritter - */ -class NoSuchHealthContributorFailureAnalyzer extends AbstractFailureAnalyzer { - - @Override - protected FailureAnalysis analyze(Throwable rootFailure, NoSuchHealthContributorException cause) { - return new FailureAnalysis(cause.getMessage(), "Update your application to correct the invalid configuration.\n" - + "You can also set 'management.endpoint.health.validate-group-membership' to false to disable the validation.", - cause); - } - -} diff --git a/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/contributor/HealthContributorMembership.java b/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/contributor/HealthContributorMembership.java new file mode 100644 index 00000000000..451d8855c56 --- /dev/null +++ b/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/contributor/HealthContributorMembership.java @@ -0,0 +1,59 @@ +/* + * 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.health.autoconfigure.contributor; + +import java.util.Set; + +import org.jspecify.annotations.Nullable; + +/** + * Determines if a health contributor is a member of a group or collection. + * + * @author Phillip Webb + * @author Madhura Bhave + * @since 4.1.0 + */ +@FunctionalInterface +public interface HealthContributorMembership { + + /** + * Returns {@code true} if the given contributor is a member. + * @param name the contributor name + * @return {@code true} if the contributor is a member + */ + boolean isMember(String name); + + /** + * Return a {@link HealthContributorMembership} that always matches. + * @return a {@link HealthContributorMembership} instance that always matches + */ + static HealthContributorMembership always() { + return (name) -> true; + } + + /** + * Return a {@link HealthContributorMembership} instance that matches based on include + * and exclude patterns. Support {@code '*'} patterns and {@code '/'} separated names. + * @param include the include patterns or {@code null} + * @param exclude the exclude patterns of {@code null} + * @return a new {@link HealthContributorMembership} instance + */ + static HealthContributorMembership byIncludeExclude(@Nullable Set include, @Nullable Set exclude) { + return new IncludeExcludeHealthContributorMembership(include, exclude); + } + +} diff --git a/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/contributor/HealthContributorMembershipValidator.java b/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/contributor/HealthContributorMembershipValidator.java new file mode 100644 index 00000000000..e6180c7da42 --- /dev/null +++ b/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/contributor/HealthContributorMembershipValidator.java @@ -0,0 +1,140 @@ +/* + * 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.health.autoconfigure.contributor; + +import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.Consumer; + +import org.jspecify.annotations.Nullable; + +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.boot.diagnostics.FailureAnalyzedException; +import org.springframework.boot.health.contributor.HealthContributors; +import org.springframework.boot.health.contributor.ReactiveHealthContributors; +import org.springframework.boot.health.registry.HealthContributorRegistry; +import org.springframework.boot.health.registry.ReactiveHealthContributorRegistry; +import org.springframework.util.CollectionUtils; + +/** + * {@link SmartInitializingSingleton} that validates health membership, throwing a + * {@link FailureAnalyzedException} if an included or excluded contributor does not exist. + * This implementation supports the same include/exclude patterns as + * {@link HealthContributorMembership#byIncludeExclude(Set, Set)} + * + * @author Andy Wilkinson + * @author Phillip Webb + * @since 4.1.0 + */ +public class HealthContributorMembershipValidator implements SmartInitializingSingleton { + + private final HealthContributorRegistry registry; + + private final @Nullable ReactiveHealthContributorRegistry fallbackRegistry; + + private final String disableValidationProperty; + + private final Consumer members; + + /** + * Create a new {@link HealthContributorMembershipValidator} instance. + * @param registry the health registry + * @param fallbackRegistry the fallback registry or {@code null} + * @param disableValidationProperty the property that can be used to disable + * validation + * @param members consumer used to provide the members + */ + public HealthContributorMembershipValidator(HealthContributorRegistry registry, + @Nullable ReactiveHealthContributorRegistry fallbackRegistry, String disableValidationProperty, + Consumer members) { + this.registry = registry; + this.fallbackRegistry = fallbackRegistry; + this.disableValidationProperty = disableValidationProperty; + this.members = members; + } + + @Override + public void afterSingletonsInstantiated() { + validateMembers(); + } + + private void validateMembers() { + this.members.accept(this::validate); + } + + private void validate(String property, @Nullable Set names) { + if (CollectionUtils.isEmpty(names)) { + return; + } + for (String name : names) { + if ("*".equals(name)) { + return; + } + String[] path = name.split("/"); + if (!contributorExists(path)) { + String description = "Health contributor '%s' defined in '%s' does not exist".formatted(name, property); + String action = "Update your application to correct the invalid configuration.\nYou can also set '%s' to false to disable the validation." + .formatted(this.disableValidationProperty); + throw new FailureAnalyzedException(description, action); + } + } + } + + private boolean contributorExists(String[] path) { + return contributorExistsInMainRegistry(path) || contributorExistsInFallbackRegistry(path); + } + + private boolean contributorExistsInMainRegistry(String[] path) { + return contributorExists(path, this.registry, HealthContributors.class, HealthContributors::getContributor); + } + + private boolean contributorExistsInFallbackRegistry(String[] path) { + return contributorExists(path, this.fallbackRegistry, ReactiveHealthContributors.class, + ReactiveHealthContributors::getContributor); + } + + @SuppressWarnings("unchecked") + private boolean contributorExists(String[] path, @Nullable Object registry, Class collectionType, + BiFunction getFromCollection) { + int pathOffset = 0; + Object contributor = registry; + while (pathOffset < path.length) { + if (contributor == null || !collectionType.isInstance(contributor)) { + return false; + } + contributor = getFromCollection.apply((C) contributor, path[pathOffset]); + pathOffset++; + } + return (contributor != null); + } + + /** + * Callback used to provide member information. + */ + @FunctionalInterface + public interface Members { + + /** + * Provide members. + * @param property the property providing the members + * @param members the members + */ + void member(String property, @Nullable Set members); + + } + +} diff --git a/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/IncludeExcludeGroupMemberPredicate.java b/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/contributor/IncludeExcludeHealthContributorMembership.java similarity index 88% rename from module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/IncludeExcludeGroupMemberPredicate.java rename to module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/contributor/IncludeExcludeHealthContributorMembership.java index 9cd8b187453..4ad8c7f021f 100644 --- a/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/IncludeExcludeGroupMemberPredicate.java +++ b/module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/contributor/IncludeExcludeHealthContributorMembership.java @@ -14,12 +14,11 @@ * limitations under the License. */ -package org.springframework.boot.health.autoconfigure.actuate.endpoint; +package org.springframework.boot.health.autoconfigure.contributor; import java.util.Collections; import java.util.LinkedHashSet; import java.util.Set; -import java.util.function.Predicate; import java.util.stream.Collectors; import org.jspecify.annotations.Nullable; @@ -32,19 +31,19 @@ import org.springframework.lang.Contract; * @author Phillip Webb * @author Madhura Bhave */ -class IncludeExcludeGroupMemberPredicate implements Predicate { +class IncludeExcludeHealthContributorMembership implements HealthContributorMembership { private final Set include; private final Set exclude; - IncludeExcludeGroupMemberPredicate(@Nullable Set include, @Nullable Set exclude) { + IncludeExcludeHealthContributorMembership(@Nullable Set include, @Nullable Set exclude) { this.include = clean(include); this.exclude = clean(exclude); } @Override - public boolean test(String name) { + public boolean isMember(String name) { name = clean(name); return isIncluded(name) && !isExcluded(name); } diff --git a/module/spring-boot-health/src/main/java/org/springframework/boot/health/contributor/Status.java b/module/spring-boot-health/src/main/java/org/springframework/boot/health/contributor/Status.java index 07fbfefd78d..3aab863ecf3 100644 --- a/module/spring-boot-health/src/main/java/org/springframework/boot/health/contributor/Status.java +++ b/module/spring-boot-health/src/main/java/org/springframework/boot/health/contributor/Status.java @@ -16,6 +16,8 @@ package org.springframework.boot.health.contributor; +import java.util.List; + import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; @@ -60,6 +62,12 @@ public final class Status { */ public static final Status OUT_OF_SERVICE = new Status("OUT_OF_SERVICE"); + /** + * The default order use to prioritize standard status instances. + * @since 4.1.0 + */ + public static final List DEFAULT_ORDER = List.of(DOWN, OUT_OF_SERVICE, UP, UNKNOWN); + private final String code; private final String description; diff --git a/module/spring-boot-health/src/main/resources/META-INF/spring.factories b/module/spring-boot-health/src/main/resources/META-INF/spring.factories deleted file mode 100644 index ec9d4ccc7fe..00000000000 --- a/module/spring-boot-health/src/main/resources/META-INF/spring.factories +++ /dev/null @@ -1,3 +0,0 @@ -# Failure Analyzers -org.springframework.boot.diagnostics.FailureAnalyzer=\ -org.springframework.boot.health.autoconfigure.actuate.endpoint.NoSuchHealthContributorFailureAnalyzer diff --git a/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/HttpCodeStatusMapperTests.java b/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/HttpCodeStatusMapperTests.java new file mode 100644 index 00000000000..6f4f6cebd7b --- /dev/null +++ b/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/HttpCodeStatusMapperTests.java @@ -0,0 +1,65 @@ +/* + * 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.health.actuate.endpoint; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse; +import org.springframework.boot.health.contributor.Status; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link HttpCodeStatusMapper}. + * + * @author Phillip Webb + */ +class HttpCodeStatusMapperTests { + + @Test + void createWhenMappingsAreNullUsesDefaultMappings() { + HttpCodeStatusMapper mapper = HttpCodeStatusMapper.of(null); + assertThat(mapper.getStatusCode(Status.UNKNOWN)).isEqualTo(WebEndpointResponse.STATUS_OK); + assertThat(mapper.getStatusCode(Status.UP)).isEqualTo(WebEndpointResponse.STATUS_OK); + assertThat(mapper.getStatusCode(Status.DOWN)).isEqualTo(WebEndpointResponse.STATUS_SERVICE_UNAVAILABLE); + assertThat(mapper.getStatusCode(Status.OUT_OF_SERVICE)) + .isEqualTo(WebEndpointResponse.STATUS_SERVICE_UNAVAILABLE); + } + + @Test + void getStatusCodeReturnsMappedStatus() { + Map map = new LinkedHashMap<>(); + map.put("up", 123); + map.put("down", 456); + HttpCodeStatusMapper mapper = HttpCodeStatusMapper.of(map); + assertThat(mapper.getStatusCode(Status.UP)).isEqualTo(123); + assertThat(mapper.getStatusCode(Status.DOWN)).isEqualTo(456); + assertThat(mapper.getStatusCode(Status.OUT_OF_SERVICE)).isEqualTo(200); + } + + @Test + void getStatusCodeWhenMappingsAreNotUniformReturnsMappedStatus() { + Map map = new LinkedHashMap<>(); + map.put("out-of-service", 123); + HttpCodeStatusMapper mapper = HttpCodeStatusMapper.of(map); + assertThat(mapper.getStatusCode(Status.OUT_OF_SERVICE)).isEqualTo(123); + } + +} diff --git a/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/SimpleHttpCodeStatusMapperTests.java b/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/SimpleHttpCodeStatusMapperTests.java index 6d6d8ee1511..00f83ab78a7 100644 --- a/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/SimpleHttpCodeStatusMapperTests.java +++ b/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/SimpleHttpCodeStatusMapperTests.java @@ -31,6 +31,7 @@ import static org.assertj.core.api.Assertions.assertThat; * * @author Phillip Webb */ +@SuppressWarnings("removal") class SimpleHttpCodeStatusMapperTests { @Test diff --git a/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/SimpleStatusAggregatorTests.java b/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/SimpleStatusAggregatorTests.java index f29b3450712..901cd2c08ec 100644 --- a/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/SimpleStatusAggregatorTests.java +++ b/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/SimpleStatusAggregatorTests.java @@ -28,6 +28,7 @@ import static org.assertj.core.api.Assertions.assertThat; * @author Phillip Webb * @author Christian Dupuis */ +@SuppressWarnings("removal") class SimpleStatusAggregatorTests { @Test diff --git a/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/StatusAggregatorTests.java b/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/StatusAggregatorTests.java new file mode 100644 index 00000000000..2de07d4d1cd --- /dev/null +++ b/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/StatusAggregatorTests.java @@ -0,0 +1,70 @@ +/* + * 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.health.actuate.endpoint; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.health.contributor.Status; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link StatusAggregator}. + * + * @author Phillip Webb + */ +class StatusAggregatorTests { + + @Test + void ofAndGetAggregateStatusWhenUsingDefaultInstance() { + StatusAggregator aggregator = StatusAggregator.getDefault(); + Status status = aggregator.getAggregateStatus(Status.DOWN, Status.UP, Status.UNKNOWN, Status.OUT_OF_SERVICE); + assertThat(status).isEqualTo(Status.DOWN); + } + + @Test + void ofAndGetAggregateStatusWhenUsingCustomOrder() { + StatusAggregator aggregator = StatusAggregator.of(Status.UNKNOWN, Status.UP, Status.OUT_OF_SERVICE, + Status.DOWN); + Status status = aggregator.getAggregateStatus(Status.DOWN, Status.UP, Status.UNKNOWN, Status.OUT_OF_SERVICE); + assertThat(status).isEqualTo(Status.UNKNOWN); + } + + @Test + void ofAndGetAggregateStatusWhenHasCustomStatusAndUsingDefaultOrder() { + StatusAggregator aggregator = StatusAggregator.getDefault(); + Status status = aggregator.getAggregateStatus(Status.DOWN, Status.UP, Status.UNKNOWN, Status.OUT_OF_SERVICE, + new Status("CUSTOM")); + assertThat(status).isEqualTo(Status.DOWN); + } + + @Test + void ofAndGetAggregateStatusWhenHasCustomStatusAndUsingCustomOrder() { + StatusAggregator aggregator = StatusAggregator.of("DOWN", "OUT_OF_SERVICE", "UP", "UNKNOWN", "CUSTOM"); + Status status = aggregator.getAggregateStatus(Status.DOWN, Status.UP, Status.UNKNOWN, Status.OUT_OF_SERVICE, + new Status("CUSTOM")); + assertThat(status).isEqualTo(Status.DOWN); + } + + @Test + void ofWithNonUniformCodes() { + StatusAggregator aggregator = StatusAggregator.of("out-of-service", "up"); + Status status = aggregator.getAggregateStatus(Status.UP, Status.OUT_OF_SERVICE); + assertThat(status).isEqualTo(Status.OUT_OF_SERVICE); + } + +} diff --git a/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/TestHealthEndpointGroup.java b/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/TestHealthEndpointGroup.java index 7b3a323beae..e367bc89570 100644 --- a/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/TestHealthEndpointGroup.java +++ b/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/TestHealthEndpointGroup.java @@ -29,9 +29,9 @@ import org.springframework.boot.actuate.endpoint.SecurityContext; */ class TestHealthEndpointGroup implements HealthEndpointGroup { - private final StatusAggregator statusAggregator = new SimpleStatusAggregator(); + private final StatusAggregator statusAggregator = StatusAggregator.getDefault(); - private final HttpCodeStatusMapper httpCodeStatusMapper = new SimpleHttpCodeStatusMapper(); + private final HttpCodeStatusMapper httpCodeStatusMapper = HttpCodeStatusMapper.getDefault(); private final Predicate memberPredicate; diff --git a/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/AutoConfiguredHealthEndpointGroupTests.java b/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/AutoConfiguredHealthEndpointGroupTests.java index e981cf9efd0..d246773584d 100644 --- a/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/AutoConfiguredHealthEndpointGroupTests.java +++ b/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/AutoConfiguredHealthEndpointGroupTests.java @@ -61,7 +61,7 @@ class AutoConfiguredHealthEndpointGroupTests { private Principal principal; @Test - void isMemberWhenMemberPredicateMatchesAcceptsTrue() { + void isMemberWhenMembershipMatchesAcceptsTrue() { AutoConfiguredHealthEndpointGroup group = new AutoConfiguredHealthEndpointGroup((name) -> name.startsWith("a"), this.statusAggregator, this.httpCodeStatusMapper, null, Show.ALWAYS, Collections.emptySet(), null); assertThat(group.isMember("albert")).isTrue(); @@ -69,7 +69,7 @@ class AutoConfiguredHealthEndpointGroupTests { } @Test - void isMemberWhenMemberPredicateRejectsReturnsTrue() { + void isMemberWhenMembershipRejectsReturnsTrue() { AutoConfiguredHealthEndpointGroup group = new AutoConfiguredHealthEndpointGroup((name) -> name.startsWith("a"), this.statusAggregator, this.httpCodeStatusMapper, null, Show.ALWAYS, Collections.emptySet(), null); assertThat(group.isMember("bert")).isFalse(); diff --git a/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/AutoConfiguredHealthEndpointGroupsTests.java b/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/AutoConfiguredHealthEndpointGroupsTests.java index f8c6dbcc216..6866e63ec1e 100644 --- a/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/AutoConfiguredHealthEndpointGroupsTests.java +++ b/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/AutoConfiguredHealthEndpointGroupsTests.java @@ -31,8 +31,6 @@ import org.springframework.boot.health.actuate.endpoint.HealthEndpoint; import org.springframework.boot.health.actuate.endpoint.HealthEndpointGroup; import org.springframework.boot.health.actuate.endpoint.HealthEndpointGroups; import org.springframework.boot.health.actuate.endpoint.HttpCodeStatusMapper; -import org.springframework.boot.health.actuate.endpoint.SimpleHttpCodeStatusMapper; -import org.springframework.boot.health.actuate.endpoint.SimpleStatusAggregator; import org.springframework.boot.health.actuate.endpoint.StatusAggregator; import org.springframework.boot.health.contributor.Status; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -394,7 +392,7 @@ class AutoConfiguredHealthEndpointGroupsTests { @Bean @Primary StatusAggregator statusAggregator() { - return new SimpleStatusAggregator(Status.UNKNOWN, Status.UP, Status.DOWN); + return StatusAggregator.of(Status.UNKNOWN, Status.UP, Status.DOWN); } } @@ -405,7 +403,7 @@ class AutoConfiguredHealthEndpointGroupsTests { @Bean @Qualifier("a") StatusAggregator statusAggregator() { - return new SimpleStatusAggregator(Status.UNKNOWN, Status.UP, Status.DOWN); + return StatusAggregator.of(Status.UNKNOWN, Status.UP, Status.DOWN); } } @@ -416,7 +414,7 @@ class AutoConfiguredHealthEndpointGroupsTests { @Bean @Primary HttpCodeStatusMapper httpCodeStatusMapper() { - return new SimpleHttpCodeStatusMapper(Collections.singletonMap(Status.DOWN.getCode(), 200)); + return HttpCodeStatusMapper.of(Collections.singletonMap(Status.DOWN.getCode(), 200)); } } @@ -427,7 +425,7 @@ class AutoConfiguredHealthEndpointGroupsTests { @Bean @Qualifier("a") HttpCodeStatusMapper httpCodeStatusMapper() { - return new SimpleHttpCodeStatusMapper(Collections.singletonMap(Status.DOWN.getCode(), 200)); + return HttpCodeStatusMapper.of(Collections.singletonMap(Status.DOWN.getCode(), 200)); } } diff --git a/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/AvailabilityProbesHealthEndpointGroupTests.java b/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/AvailabilityProbesHealthEndpointGroupTests.java index 35bce043018..dec2eae6276 100644 --- a/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/AvailabilityProbesHealthEndpointGroupTests.java +++ b/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/AvailabilityProbesHealthEndpointGroupTests.java @@ -63,7 +63,7 @@ class AvailabilityProbesHealthEndpointGroupTests { @Test void getHttpCodeStatusMapperReturnsDefaultHttpCodeStatusMapper() { - assertThat(this.group.getHttpCodeStatusMapper()).isEqualTo(HttpCodeStatusMapper.DEFAULT); + assertThat(this.group.getHttpCodeStatusMapper()).isEqualTo(HttpCodeStatusMapper.getDefault()); } } diff --git a/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/HealthEndpointAutoConfigurationTests.java b/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/HealthEndpointAutoConfigurationTests.java index 960723cbe38..1187b943c45 100644 --- a/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/HealthEndpointAutoConfigurationTests.java +++ b/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/HealthEndpointAutoConfigurationTests.java @@ -30,6 +30,7 @@ import org.springframework.boot.actuate.endpoint.SecurityContext; import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse; import org.springframework.boot.actuate.endpoint.web.WebServerNamespace; import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.diagnostics.FailureAnalyzedException; import org.springframework.boot.health.actuate.endpoint.CompositeHealthDescriptor; import org.springframework.boot.health.actuate.endpoint.HealthDescriptor; import org.springframework.boot.health.actuate.endpoint.HealthEndpoint; @@ -40,7 +41,6 @@ import org.springframework.boot.health.actuate.endpoint.HttpCodeStatusMapper; import org.springframework.boot.health.actuate.endpoint.IndicatedHealthDescriptor; import org.springframework.boot.health.actuate.endpoint.ReactiveHealthEndpointWebExtension; import org.springframework.boot.health.actuate.endpoint.StatusAggregator; -import org.springframework.boot.health.autoconfigure.actuate.endpoint.HealthEndpointConfiguration.HealthEndpointGroupMembershipValidator.NoSuchHealthContributorException; import org.springframework.boot.health.autoconfigure.contributor.HealthContributorAutoConfiguration; import org.springframework.boot.health.autoconfigure.registry.HealthContributorRegistryAutoConfiguration; import org.springframework.boot.health.contributor.CompositeHealthContributor; @@ -166,8 +166,9 @@ class HealthEndpointAutoConfigurationTests { .withPropertyValues("management.endpoint.health.group.ready.include=composite/b/c,nope") .run((context) -> { assertThat(context).hasFailed(); - assertThat(context.getStartupFailure()).isInstanceOf(NoSuchHealthContributorException.class) - .hasMessage("Included health contributor 'nope' in group 'ready' does not exist"); + assertThat(context.getStartupFailure()).isInstanceOf(FailureAnalyzedException.class) + .hasMessage("Health contributor 'nope' defined in " + + "'management.endpoint.health.group.ready.include' does not exist"); }); } @@ -178,8 +179,9 @@ class HealthEndpointAutoConfigurationTests { "management.endpoint.health.group.ready.include=*") .run((context) -> { assertThat(context).hasFailed(); - assertThat(context.getStartupFailure()).isInstanceOf(NoSuchHealthContributorException.class) - .hasMessage("Excluded health contributor 'composite/b/d' in group 'ready' does not exist"); + assertThat(context.getStartupFailure()).isInstanceOf(FailureAnalyzedException.class) + .hasMessage("Health contributor 'composite/b/d' defined in " + + "'management.endpoint.health.group.ready.exclude' does not exist"); }); } diff --git a/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/IncludeExcludeGroupMemberPredicateTests.java b/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/IncludeExcludeGroupMemberPredicateTests.java deleted file mode 100644 index 324ae758d28..00000000000 --- a/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/IncludeExcludeGroupMemberPredicateTests.java +++ /dev/null @@ -1,144 +0,0 @@ -/* - * 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.health.autoconfigure.actuate.endpoint; - -import java.util.Arrays; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.Set; -import java.util.function.Predicate; - -import org.jspecify.annotations.Nullable; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Tests for {@link IncludeExcludeGroupMemberPredicate}. - * - * @author Phillip Webb - * @author Madhura Bhave - */ -class IncludeExcludeGroupMemberPredicateTests { - - @Test - void testWhenEmptyIncludeAndExcludeAcceptsAll() { - Predicate predicate = new IncludeExcludeGroupMemberPredicate(null, null); - assertThat(predicate).accepts("a", "b", "c"); - } - - @Test - void testWhenStarIncludeAndEmptyExcludeAcceptsAll() { - Predicate predicate = include("*").exclude(); - assertThat(predicate).accepts("a", "b", "c"); - } - - @Test - void testWhenEmptyIncludeAndNonEmptyExcludeAcceptsAllButExclude() { - Predicate predicate = new IncludeExcludeGroupMemberPredicate(null, Collections.singleton("c")); - assertThat(predicate).accepts("a", "b"); - } - - @Test - void testWhenStarIncludeAndSpecificExcludeDoesNotAcceptExclude() { - Predicate predicate = include("*").exclude("c"); - assertThat(predicate).accepts("a", "b").rejects("c"); - } - - @Test - void testWhenSpecificIncludeAcceptsOnlyIncluded() { - Predicate predicate = include("a", "b").exclude(); - assertThat(predicate).accepts("a", "b").rejects("c"); - } - - @Test - void testWhenSpecifiedIncludeAndSpecifiedExcludeAcceptsAsExpected() { - Predicate predicate = include("a", "b", "c").exclude("c"); - assertThat(predicate).accepts("a", "b").rejects("c", "d"); - } - - @Test - void testWhenSpecifiedIncludeAndStarExcludeRejectsAll() { - Predicate predicate = include("a", "b", "c").exclude("*"); - assertThat(predicate).rejects("a", "b", "c", "d"); - } - - @Test - void testWhenCamelCaseIncludeAcceptsOnlyIncluded() { - Predicate predicate = include("myEndpoint").exclude(); - assertThat(predicate).accepts("myEndpoint").rejects("d"); - } - - @Test - void testWhenHyphenCaseIncludeAcceptsOnlyIncluded() { - Predicate predicate = include("my-endpoint").exclude(); - assertThat(predicate).accepts("my-endpoint").rejects("d"); - } - - @Test - void testWhenExtraWhitespaceAcceptsTrimmedVersion() { - Predicate predicate = include(" myEndpoint ").exclude(); - assertThat(predicate).accepts("myEndpoint").rejects("d"); - } - - @Test - void testWhenSpecifiedIncludeWithSlash() { - Predicate predicate = include("test/a").exclude(); - assertThat(predicate).accepts("test/a").rejects("test").rejects("test/b"); - } - - @Test - void specifiedIncludeShouldIncludeNested() { - Predicate predicate = include("test").exclude(); - assertThat(predicate).accepts("test/a/d").accepts("test/b").rejects("foo"); - } - - @Test - void specifiedIncludeShouldNotIncludeExcludedNested() { - Predicate predicate = include("test").exclude("test/b"); - assertThat(predicate).accepts("test/a").rejects("test/b").rejects("foo"); - } - - @Test // gh-29251 - void specifiedExcludeShouldExcludeNestedChildren() { - Predicate predicate = include("*").exclude("test"); - assertThat(predicate).rejects("test").rejects("test/a").rejects("test/a").accepts("other"); - } - - private Builder include(String... include) { - return new Builder(include); - } - - private static class Builder { - - private final String[] include; - - Builder(String[] include) { - this.include = include; - } - - Predicate exclude(String... exclude) { - return new IncludeExcludeGroupMemberPredicate(asSet(this.include), asSet(exclude)); - } - - private @Nullable Set asSet(String @Nullable [] names) { - return (names != null) ? new LinkedHashSet<>(Arrays.asList(names)) : null; - } - - } - -} diff --git a/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/NoSuchHealthContributorFailureAnalyzerTests.java b/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/NoSuchHealthContributorFailureAnalyzerTests.java deleted file mode 100644 index a4483fe9c46..00000000000 --- a/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/NoSuchHealthContributorFailureAnalyzerTests.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * 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.health.autoconfigure.actuate.endpoint; - -import java.util.concurrent.atomic.AtomicReference; - -import org.junit.jupiter.api.Test; - -import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.diagnostics.FailureAnalysis; -import org.springframework.boot.health.autoconfigure.actuate.endpoint.HealthEndpointConfiguration.HealthEndpointGroupMembershipValidator.NoSuchHealthContributorException; -import org.springframework.boot.health.autoconfigure.registry.HealthContributorRegistryAutoConfiguration; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Tests for {@link NoSuchHealthContributorFailureAnalyzer}. - * - * @author Moritz Halbritter - */ -class NoSuchHealthContributorFailureAnalyzerTests { - - private final ApplicationContextRunner runner = new ApplicationContextRunner().withConfiguration(AutoConfigurations - .of(HealthEndpointAutoConfiguration.class, HealthContributorRegistryAutoConfiguration.class)); - - @Test - void analyzesMissingRequiredConfiguration() throws Throwable { - FailureAnalysis analysis = new NoSuchHealthContributorFailureAnalyzer().analyze(createFailure()); - assertThat(analysis).isNotNull(); - assertThat(analysis.getDescription()) - .isEqualTo("Included health contributor 'dummy' in group 'readiness' does not exist"); - assertThat(analysis.getAction()).isEqualTo("Update your application to correct the invalid configuration.\n" - + "You can also set 'management.endpoint.health.validate-group-membership' to false to disable the validation."); - } - - private Throwable createFailure() throws Throwable { - AtomicReference failure = new AtomicReference<>(); - this.runner.withPropertyValues("management.endpoint.health.group.readiness.include=dummy").run((context) -> { - assertThat(context).hasFailed(); - Throwable startupFailure = context.getStartupFailure(); - assertThat(startupFailure).isNotNull(); - failure.set(startupFailure); - }); - Throwable throwable = failure.get(); - if (throwable instanceof NoSuchHealthContributorException) { - return throwable; - } - throw throwable; - } - -} diff --git a/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/contributor/HealthContributorMembershipTests.java b/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/contributor/HealthContributorMembershipTests.java new file mode 100644 index 00000000000..f180d154ca6 --- /dev/null +++ b/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/contributor/HealthContributorMembershipTests.java @@ -0,0 +1,155 @@ +/* + * 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.health.autoconfigure.contributor; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.function.Predicate; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link HealthContributorMembership}. + * + * @author Phillip Webb + * @author Madhura Bhave + */ +class HealthContributorMembershipTests { + + @Nested + class ByIncludeExcludeTests { + + @Test + void isMemberWhenEmptyIncludeAndExcludeAcceptsAll() { + HealthContributorMembership membership = HealthContributorMembership.byIncludeExclude(null, null); + assertThat(asPredicate(membership)).accepts("a", "b", "c"); + } + + @Test + void isMemberWhenStarIncludeAndEmptyExcludeAcceptsAll() { + HealthContributorMembership membership = include("*").exclude(); + assertThat(asPredicate(membership)).accepts("a", "b", "c"); + } + + @Test + void isMemberWhenEmptyIncludeAndNonEmptyExcludeAcceptsAllButExclude() { + HealthContributorMembership membership = HealthContributorMembership.byIncludeExclude(null, + Collections.singleton("c")); + assertThat(asPredicate(membership)).accepts("a", "b"); + } + + @Test + void isMemberWhenStarIncludeAndSpecificExcludeDoesNotAcceptExclude() { + HealthContributorMembership membership = include("*").exclude("c"); + assertThat(asPredicate(membership)).accepts("a", "b").rejects("c"); + } + + @Test + void isMemberWhenSpecificIncludeAcceptsOnlyIncluded() { + HealthContributorMembership membership = include("a", "b").exclude(); + assertThat(asPredicate(membership)).accepts("a", "b").rejects("c"); + } + + @Test + void isMemberWhenSpecifiedIncludeAndSpecifiedExcludeAcceptsAsExpected() { + HealthContributorMembership membership = include("a", "b", "c").exclude("c"); + assertThat(asPredicate(membership)).accepts("a", "b").rejects("c", "d"); + } + + @Test + void isMemberWhenSpecifiedIncludeAndStarExcludeRejectsAll() { + HealthContributorMembership membership = include("a", "b", "c").exclude("*"); + assertThat(asPredicate(membership)).rejects("a", "b", "c", "d"); + } + + @Test + void isMemberWhenCamelCaseIncludeAcceptsOnlyIncluded() { + HealthContributorMembership membership = include("myEndpoint").exclude(); + assertThat(asPredicate(membership)).accepts("myEndpoint").rejects("d"); + } + + @Test + void isMemberWhenHyphenCaseIncludeAcceptsOnlyIncluded() { + HealthContributorMembership membership = include("my-endpoint").exclude(); + assertThat(asPredicate(membership)).accepts("my-endpoint").rejects("d"); + } + + @Test + void isMemberWhenExtraWhitespaceAcceptsTrimmedVersion() { + HealthContributorMembership membership = include(" myEndpoint ").exclude(); + assertThat(asPredicate(membership)).accepts("myEndpoint").rejects("d"); + } + + @Test + void isMemberWhenSpecifiedIncludeWithSlash() { + HealthContributorMembership membership = include("test/a").exclude(); + assertThat(asPredicate(membership)).accepts("test/a").rejects("test").rejects("test/b"); + } + + @Test + void specifiedIncludeShouldIncludeNested() { + HealthContributorMembership membership = include("test").exclude(); + assertThat(asPredicate(membership)).accepts("test/a/d").accepts("test/b").rejects("foo"); + } + + @Test + void specifiedIncludeShouldNotIncludeExcludedNested() { + HealthContributorMembership membership = include("test").exclude("test/b"); + assertThat(asPredicate(membership)).accepts("test/a").rejects("test/b").rejects("foo"); + } + + @Test // gh-29251 + void specifiedExcludeShouldExcludeNestedChildren() { + HealthContributorMembership membership = include("*").exclude("test"); + assertThat(asPredicate(membership)).rejects("test").rejects("test/a").rejects("test/a").accepts("other"); + } + + private Predicate asPredicate(HealthContributorMembership membership) { + return membership::isMember; + } + + private Builder include(String... include) { + return new Builder(include); + } + + private static class Builder { + + private final String[] include; + + Builder(String[] include) { + this.include = include; + } + + HealthContributorMembership exclude(String... exclude) { + return HealthContributorMembership.byIncludeExclude(asSet(this.include), asSet(exclude)); + } + + private @Nullable Set asSet(String @Nullable [] names) { + return (names != null) ? new LinkedHashSet<>(Arrays.asList(names)) : null; + } + + } + + } + +} diff --git a/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/contributor/HealthContributorMembershipValidatorTests.java b/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/contributor/HealthContributorMembershipValidatorTests.java new file mode 100644 index 00000000000..23bf207fc13 --- /dev/null +++ b/module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/contributor/HealthContributorMembershipValidatorTests.java @@ -0,0 +1,97 @@ +/* + * 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.health.autoconfigure.contributor; + +import java.util.Collections; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.diagnostics.FailureAnalyzedException; +import org.springframework.boot.health.contributor.CompositeHealthContributor; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.boot.health.contributor.ReactiveHealthContributor; +import org.springframework.boot.health.registry.DefaultHealthContributorRegistry; +import org.springframework.boot.health.registry.DefaultReactiveHealthContributorRegistry; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link HealthContributorMembershipValidator}. + * + * @author Phillip Webb + */ +class HealthContributorMembershipValidatorTests { + + private static final HealthIndicator UP = () -> Health.up().build(); + + private final DefaultHealthContributorRegistry registry = new DefaultHealthContributorRegistry(); + + private final DefaultReactiveHealthContributorRegistry fallbackRegistry = new DefaultReactiveHealthContributorRegistry(); + + @Test + void validateWhenNoNamesDoesNotThrow() { + HealthContributorMembershipValidator validator = new HealthContributorMembershipValidator(this.registry, null, + "my.disable-validation", (members) -> members.member("include", Collections.emptySet())); + validator.afterSingletonsInstantiated(); + } + + @Test + void validateWhenNameIncludesAsteriskDoesNotThrow() { + HealthContributorMembershipValidator validator = new HealthContributorMembershipValidator(this.registry, null, + "my.disable-validation", (members) -> members.member("include", Set.of("*"))); + validator.afterSingletonsInstantiated(); + } + + @Test + void validateWhenContributorDoesNotExistThrows() { + HealthContributorMembershipValidator validator = new HealthContributorMembershipValidator(this.registry, null, + "my.disable-validation", (members) -> members.member("include", Set.of("test"))); + assertThatExceptionOfType(FailureAnalyzedException.class) + .isThrownBy(() -> validator.afterSingletonsInstantiated()) + .withMessage("Health contributor 'test' defined in 'include' does not exist") + .satisfies((ex) -> assertThat(ex.analysis().getAction()).contains("my.disable-validation")); + } + + @Test + void validateWhenContributorExistsInRegistryDoesNotThrow() { + this.registry.registerContributor("test", UP); + HealthContributorMembershipValidator validator = new HealthContributorMembershipValidator(this.registry, null, + "my.disable-validation", (members) -> members.member("include", Set.of("test"))); + validator.afterSingletonsInstantiated(); + } + + @Test + void validateWhenContributorExistsInFallbackRegistryDoesNotThrow() { + this.fallbackRegistry.registerContributor("test", ReactiveHealthContributor.adapt(UP)); + HealthContributorMembershipValidator validator = new HealthContributorMembershipValidator(this.registry, + this.fallbackRegistry, "my.disable-validation", (members) -> members.member("include", Set.of("test"))); + validator.afterSingletonsInstantiated(); + } + + @Test + void validateWhenNestedContributorExistsInRegistryDoesNotThrow() { + this.registry.registerContributor("test", CompositeHealthContributor.fromMap(Map.of("nested", UP))); + HealthContributorMembershipValidator validator = new HealthContributorMembershipValidator(this.registry, null, + "my.disable-validation", (members) -> members.member("include", Set.of("test/nested"))); + validator.afterSingletonsInstantiated(); + } + +} diff --git a/module/spring-boot-test-classic-modules/build.gradle b/module/spring-boot-test-classic-modules/build.gradle index 0b3932661ea..72e340c4f00 100644 --- a/module/spring-boot-test-classic-modules/build.gradle +++ b/module/spring-boot-test-classic-modules/build.gradle @@ -67,6 +67,9 @@ dependencies { api(project(":module:spring-boot-graphql-test")) { transitive = false } + api(project(":module:spring-boot-grpc-test")) { + transitive = false + } api(project(":module:spring-boot-jdbc-test")) { transitive = false } diff --git a/platform/spring-boot-dependencies/build.gradle b/platform/spring-boot-dependencies/build.gradle index c97bd74ada1..4f5925d72a9 100644 --- a/platform/spring-boot-dependencies/build.gradle +++ b/platform/spring-boot-dependencies/build.gradle @@ -562,6 +562,30 @@ bom { site("https://groovy-lang.org") } } + library("Grpc Java", "1.78.0") { + group("io.grpc") { + bom("grpc-bom") + } + links { + github("https://github.com/grpc/grpc-java") + docs("https://grpc.io/docs/languages/java/") + releaseNotes("https://github.com/grpc/grpc-java/releases/tag/v{version}") + } + } + library("Grpc Kotlin", "1.5.0") { + group("io.grpc") { + modules = [ + "grpc-kotlin-stub" { + exclude group: "javax.annotation", module: "javax.annotation-api" + } + ] + } + links { + github("https://github.com/grpc/grpc-kotlin") + docs("https://grpc.io/docs/languages/kotlin/") + releaseNotes("https://github.com/grpc/grpc-kotlin/releases/tag/v{version}") + } + } library("Gson", "2.13.2") { group("com.google.code.gson") { modules = [ @@ -1817,6 +1841,17 @@ bom { releaseNotes("https://github.com/oracle/oracle-r2dbc/releases/tag/{version}") } } + library("Protobuf Maven Plugin", "5.0.1") { + group("io.github.ascopes") { + plugins = [ + "protobuf-maven-plugin" + ] + } + links { + site("https://github.com/ascopes/protobuf-maven-plugin") + releaseNotes("https://github.com/ascopes/protobuf-maven-plugin/tag/v{version}") + } + } library("Pooled JMS", "3.2.2") { group("org.messaginghub") { modules = [ @@ -1883,6 +1918,27 @@ bom { releaseNotes("https://qpid.apache.org/releases/qpid-protonj2-{version}/release-notes.html") } } + library("Protobuf Common Protos", "2.65.0") { + group("com.google.api.grpc") { + modules = [ + "proto-google-common-protos" + ] + } + links { + github("https://github.com/googleapis/sdk-platform-java") + releaseNotes("https://github.com/googleapis/sdk-platform-java/releases/tag/v-{version}") + } + } + library("Protobuf Java", "4.34.0") { + group("com.google.protobuf") { + bom("protobuf-bom") + } + links { + site("https://protobuf.dev") + github("https://github.com/protocolbuffers/protobuf") + releaseNotes("https://github.com/protocolbuffers/protobuf/releases/tag/v{version}") + } + } library("Quartz", "2.5.2") { group("org.quartz-scheduler") { modules = [ @@ -2107,6 +2163,9 @@ bom { "spring-boot-freemarker", "spring-boot-graphql", "spring-boot-graphql-test", + "spring-boot-grpc-client", + "spring-boot-grpc-server", + "spring-boot-grpc-test", "spring-boot-groovy-templates", "spring-boot-gson", "spring-boot-h2console", @@ -2237,6 +2296,9 @@ bom { "spring-boot-starter-freemarker-test", "spring-boot-starter-graphql", "spring-boot-starter-graphql-test", + "spring-boot-starter-grpc-client", + "spring-boot-starter-grpc-server", + "spring-boot-starter-grpc-test", "spring-boot-starter-groovy-templates", "spring-boot-starter-groovy-templates-test", "spring-boot-starter-gson", @@ -2525,6 +2587,23 @@ bom { releaseNotes("https://github.com/spring-projects/spring-graphql/releases/tag/v{version}") } } + library("Spring gRPC", "1.1.0-SNAPSHOT") { + considerSnapshots() + group("org.springframework.grpc") { + modules = [ + "spring-grpc-core" + ] + } + links { + site("https://spring.io/projects/spring-grpc") + github("https://github.com/spring-projects/spring-grpc") + javadoc(version -> "https://docs.spring.io/spring-grpc/docs/%s/api" + .formatted(version.forMajorMinorGeneration()), "org.springframework.grpc") + docs(version -> "https://docs.spring.io/spring-grpc/docs/%s/reference" + .formatted(version.forMajorMinorGeneration())) + releaseNotes("https://github.com/spring-projects/spring-grpc/releases/tag/v{version}") + } + } library("Spring HATEOAS", "3.1.0-M1") { considerSnapshots() group("org.springframework.hateoas") { diff --git a/platform/spring-boot-internal-dependencies/build.gradle b/platform/spring-boot-internal-dependencies/build.gradle index cfdbbf36db8..06f29d39e9c 100644 --- a/platform/spring-boot-internal-dependencies/build.gradle +++ b/platform/spring-boot-internal-dependencies/build.gradle @@ -94,11 +94,6 @@ bom { ] } } - library("gRPC", "1.73.0") { - group("io.grpc") { - bom("grpc-bom") - } - } library("Janino", "3.1.12") { group("org.codehaus.janino") { bom("janino") { @@ -224,6 +219,13 @@ bom { ] } } + library("Protobuf Gradle Plugin", "${protobufGradlePluginVersion}") { + group("com.google.protobuf") { + modules = [ + "protobuf-gradle-plugin" + ] + } + } library("Simple JNDI", "0.25.0") { group("com.github.h-thurow") { modules = [ diff --git a/settings.gradle b/settings.gradle index b7ceb4956e0..94d968336d5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -120,6 +120,9 @@ include "module:spring-boot-flyway" include "module:spring-boot-freemarker" include "module:spring-boot-graphql" include "module:spring-boot-graphql-test" +include "module:spring-boot-grpc-client" +include "module:spring-boot-grpc-server" +include "module:spring-boot-grpc-test" include "module:spring-boot-groovy-templates" include "module:spring-boot-gson" include "module:spring-boot-h2console" @@ -268,6 +271,9 @@ include "starter:spring-boot-starter-freemarker" include "starter:spring-boot-starter-freemarker-test" include "starter:spring-boot-starter-graphql" include "starter:spring-boot-starter-graphql-test" +include "starter:spring-boot-starter-grpc-client" +include "starter:spring-boot-starter-grpc-server" +include "starter:spring-boot-starter-grpc-test" include "starter:spring-boot-starter-groovy-templates" include "starter:spring-boot-starter-groovy-templates-test" include "starter:spring-boot-starter-gson" @@ -421,6 +427,14 @@ include ":smoke-test:spring-boot-smoke-test-data-rest" include ":smoke-test:spring-boot-smoke-test-devtools" include ":smoke-test:spring-boot-smoke-test-flyway" include ":smoke-test:spring-boot-smoke-test-graphql" +include ":smoke-test:spring-boot-smoke-test-grpc-client" +include ":smoke-test:spring-boot-smoke-test-grpc-client-test" +include ":smoke-test:spring-boot-smoke-test-grpc-server" +include ":smoke-test:spring-boot-smoke-test-grpc-server-netty-shaded" +include ":smoke-test:spring-boot-smoke-test-grpc-server-oauth" +include ":smoke-test:spring-boot-smoke-test-grpc-server-servlet" +include ":smoke-test:spring-boot-smoke-test-grpc-server-secure" +include ":smoke-test:spring-boot-smoke-test-grpc-server-test" include ":smoke-test:spring-boot-smoke-test-hateoas" include ":smoke-test:spring-boot-smoke-test-hibernate" include ":smoke-test:spring-boot-smoke-test-integration" diff --git a/smoke-test/spring-boot-smoke-test-grpc-client-test/build.gradle b/smoke-test/spring-boot-smoke-test-grpc-client-test/build.gradle new file mode 100644 index 00000000000..9f70966c6ee --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-client-test/build.gradle @@ -0,0 +1,63 @@ +/* + * 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. + */ + +plugins { + id "java" + id "com.google.protobuf" version "${protobufGradlePluginVersion}" + id "org.springframework.boot.docker-test" +} + +description = "Spring Boot gRPC client and test smoke test" + +dependencies { + implementation(project(":starter:spring-boot-starter-grpc-client")) + + testImplementation(project(":starter:spring-boot-starter-grpc-test")) +} + +def dependenciesBom = project(":platform:spring-boot-dependencies").extensions.getByName("bom") +def grpcJava = dependenciesBom.getLibrary("Grpc Java") +def protobufJava = dependenciesBom.getLibrary("Protobuf Java") + +tasks.named("compileTestJava") { + options.nullability.checking = "tests" +} + +nullability { + requireExplicitNullMarking = false +} + +configurations.named { it.startsWith("protobufToolsLocator_") || it.toLowerCase().endsWith("protopath") }.all { + extendsFrom(configurations.dependencyManagement) +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:${protobufJava.version}" + } + plugins { + grpc { + artifact = "io.grpc:protoc-gen-grpc-java:${grpcJava.version}" + } + } + generateProtoTasks { + all()*.plugins { + grpc { + option '@generated=omit' + } + } + } +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-client-test/src/main/java/smoketest/grpcclienttest/SampleGrpcClientTestApplication.java b/smoke-test/spring-boot-smoke-test-grpc-client-test/src/main/java/smoketest/grpcclienttest/SampleGrpcClientTestApplication.java new file mode 100644 index 00000000000..ecf355e3a8e --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-client-test/src/main/java/smoketest/grpcclienttest/SampleGrpcClientTestApplication.java @@ -0,0 +1,46 @@ +/* + * 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 smoketest.grpcclienttest; + +import smoketest.grpcclienttest.proto.HelloReply; +import smoketest.grpcclienttest.proto.HelloRequest; +import smoketest.grpcclienttest.proto.HelloWorldGrpc.HelloWorldBlockingStub; + +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.grpc.client.ImportGrpcClients; + +@SpringBootApplication +@ImportGrpcClients(types = HelloWorldBlockingStub.class) +public class SampleGrpcClientTestApplication { + + @Bean + ApplicationRunner applicationRunner(HelloWorldBlockingStub hello) { + return (args) -> { + HelloRequest request = HelloRequest.newBuilder().setName("Spring").build(); + HelloReply reply = hello.sayHello(request); + System.out.println(">>> " + reply.getMessage()); + }; + } + + public static void main(String[] args) { + SpringApplication.run(SampleGrpcClientTestApplication.class, args); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-client-test/src/main/java/smoketest/grpcclienttest/package-info.java b/smoke-test/spring-boot-smoke-test-grpc-client-test/src/main/java/smoketest/grpcclienttest/package-info.java new file mode 100644 index 00000000000..ae7921291b1 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-client-test/src/main/java/smoketest/grpcclienttest/package-info.java @@ -0,0 +1,20 @@ +/* + * 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. + */ + +@NullMarked +package smoketest.grpcclienttest; + +import org.jspecify.annotations.NullMarked; diff --git a/smoke-test/spring-boot-smoke-test-grpc-client-test/src/main/proto/hello.proto b/smoke-test/spring-boot-smoke-test-grpc-client-test/src/main/proto/hello.proto new file mode 100644 index 00000000000..ee3a0789364 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-client-test/src/main/proto/hello.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +option java_package = "smoketest.grpcclienttest.proto"; +option java_multiple_files = true; + +service HelloWorld { + rpc SayHello (HelloRequest) returns (HelloReply) {} + rpc StreamHello(HelloRequest) returns (stream HelloReply) {} +} + +message HelloRequest { + string name = 1; +} + +message HelloReply { + string message = 1; +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-client-test/src/test/java/smoketest/grpcclienttest/SampleGrpcClientTestApplicationTests.java b/smoke-test/spring-boot-smoke-test-grpc-client-test/src/test/java/smoketest/grpcclienttest/SampleGrpcClientTestApplicationTests.java new file mode 100644 index 00000000000..621a17753ff --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-client-test/src/test/java/smoketest/grpcclienttest/SampleGrpcClientTestApplicationTests.java @@ -0,0 +1,58 @@ +/* + * 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 smoketest.grpcclienttest; + +import io.grpc.stub.StreamObserver; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import smoketest.grpcclienttest.SampleGrpcClientTestApplicationTests.HelloWorldService; +import smoketest.grpcclienttest.proto.HelloReply; +import smoketest.grpcclienttest.proto.HelloRequest; +import smoketest.grpcclienttest.proto.HelloWorldGrpc; + +import org.springframework.boot.grpc.test.autoconfigure.AutoConfigureTestGrpcTransport; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.context.annotation.Import; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@AutoConfigureTestGrpcTransport +@Import(HelloWorldService.class) +@ExtendWith(OutputCaptureExtension.class) +class SampleGrpcClientTestApplicationTests { + + @Test + void callsService(CapturedOutput output) { + assertThat(output).contains(">>> Test hello 'Spring'"); + } + + static class HelloWorldService extends HelloWorldGrpc.HelloWorldImplBase { + + @Override + public void sayHello(HelloRequest request, StreamObserver responseObserver) { + String message = "Test hello '%s'".formatted(request.getName()); + HelloReply reply = HelloReply.newBuilder().setMessage(message).build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } + + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-client/build.gradle b/smoke-test/spring-boot-smoke-test-grpc-client/build.gradle new file mode 100644 index 00000000000..a0e75c6aff3 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-client/build.gradle @@ -0,0 +1,63 @@ +/* + * 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. + */ + +plugins { + id "java" + id "com.google.protobuf" version "${protobufGradlePluginVersion}" + id "org.springframework.boot.docker-test" +} + +description = "Spring Boot gRPC client smoke test" + +dependencies { + implementation(project(":starter:spring-boot-starter-grpc-client")) + + testImplementation(project(":starter:spring-boot-starter-test")) +} + +def dependenciesBom = project(":platform:spring-boot-dependencies").extensions.getByName("bom") +def grpcJava = dependenciesBom.getLibrary("Grpc Java") +def protobufJava = dependenciesBom.getLibrary("Protobuf Java") + +tasks.named("compileTestJava") { + options.nullability.checking = "tests" +} + +nullability { + requireExplicitNullMarking = false +} + +configurations.named { it.startsWith("protobufToolsLocator_") || it.toLowerCase().endsWith("protopath") }.all { + extendsFrom(configurations.dependencyManagement) +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:${protobufJava.version}" + } + plugins { + grpc { + artifact = "io.grpc:protoc-gen-grpc-java:${grpcJava.version}" + } + } + generateProtoTasks { + all()*.plugins { + grpc { + option '@generated=omit' + } + } + } +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-client/src/main/java/smoketest/grpcclient/SampleGrpcClientApplication.java b/smoke-test/spring-boot-smoke-test-grpc-client/src/main/java/smoketest/grpcclient/SampleGrpcClientApplication.java new file mode 100644 index 00000000000..98480abf487 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-client/src/main/java/smoketest/grpcclient/SampleGrpcClientApplication.java @@ -0,0 +1,46 @@ +/* + * 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 smoketest.grpcclient; + +import smoketest.grpcclient.proto.HelloReply; +import smoketest.grpcclient.proto.HelloRequest; +import smoketest.grpcclient.proto.HelloWorldGrpc.HelloWorldBlockingStub; + +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.grpc.client.ImportGrpcClients; + +@SpringBootApplication +@ImportGrpcClients(types = HelloWorldBlockingStub.class) +public class SampleGrpcClientApplication { + + @Bean + ApplicationRunner applicationRunner(HelloWorldBlockingStub hello) { + return (args) -> { + HelloRequest request = HelloRequest.newBuilder().setName("Spring").build(); + HelloReply reply = hello.sayHello(request); + System.out.println(">>> " + reply.getMessage()); + }; + } + + public static void main(String[] args) { + SpringApplication.run(SampleGrpcClientApplication.class, args); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-client/src/main/java/smoketest/grpcclient/package-info.java b/smoke-test/spring-boot-smoke-test-grpc-client/src/main/java/smoketest/grpcclient/package-info.java new file mode 100644 index 00000000000..56b3d8318d7 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-client/src/main/java/smoketest/grpcclient/package-info.java @@ -0,0 +1,20 @@ +/* + * 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. + */ + +@NullMarked +package smoketest.grpcclient; + +import org.jspecify.annotations.NullMarked; diff --git a/smoke-test/spring-boot-smoke-test-grpc-client/src/main/proto/hello.proto b/smoke-test/spring-boot-smoke-test-grpc-client/src/main/proto/hello.proto new file mode 100644 index 00000000000..8c0bed63c61 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-client/src/main/proto/hello.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +option java_package = "smoketest.grpcclient.proto"; +option java_multiple_files = true; + +service HelloWorld { + rpc SayHello (HelloRequest) returns (HelloReply) {} + rpc StreamHello(HelloRequest) returns (stream HelloReply) {} +} + +message HelloRequest { + string name = 1; +} + +message HelloReply { + string message = 1; +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-client/src/main/resources/application.yaml b/smoke-test/spring-boot-smoke-test-grpc-client/src/main/resources/application.yaml new file mode 100644 index 00000000000..0ec63d46668 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-client/src/main/resources/application.yaml @@ -0,0 +1,6 @@ +spring: + grpc: + client: + channel: + default: + target: "static://localhost:9090" diff --git a/smoke-test/spring-boot-smoke-test-grpc-client/src/test/java/smoketest/grpcclient/SampleGrpcClientApplicationTests.java b/smoke-test/spring-boot-smoke-test-grpc-client/src/test/java/smoketest/grpcclient/SampleGrpcClientApplicationTests.java new file mode 100644 index 00000000000..a9020ba2c96 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-client/src/test/java/smoketest/grpcclient/SampleGrpcClientApplicationTests.java @@ -0,0 +1,89 @@ +/* + * 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 smoketest.grpcclient; + +import java.time.Duration; +import java.util.Collections; +import java.util.Map; + +import io.grpc.stub.StreamObserver; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import smoketest.grpcclient.SampleGrpcClientApplicationTests.MockServerInitializer; +import smoketest.grpcclient.proto.HelloReply; +import smoketest.grpcclient.proto.HelloRequest; +import smoketest.grpcclient.proto.HelloWorldGrpc; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.env.MapPropertySource; +import org.springframework.grpc.server.NettyGrpcServerFactory; +import org.springframework.grpc.server.lifecycle.GrpcServerLifecycle; +import org.springframework.test.context.ContextConfiguration; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ContextConfiguration(initializers = MockServerInitializer.class) +@ExtendWith(OutputCaptureExtension.class) +class SampleGrpcClientApplicationTests { + + @Test + void applicationRunsAndCallsGrpcServer(CapturedOutput output) { + assertThat(output).contains(">>> Hello 'Spring'"); + } + + static class MockServerInitializer implements ApplicationContextInitializer { + + @Override + public void initialize(ConfigurableApplicationContext applicationContext) { + NettyGrpcServerFactory serverFactory = new NettyGrpcServerFactory("*:0", Collections.emptyList(), null, + null, null); + HelloWorldService helloWorldService = new HelloWorldService(); + serverFactory.addService(helloWorldService.bindService()); + GrpcServerLifecycle lifecycle = new GrpcServerLifecycle(serverFactory, Duration.ofSeconds(30), + applicationContext); + lifecycle.start(); + String target = "static://localhost:%s".formatted(lifecycle.getPort()); + applicationContext.getEnvironment() + .getPropertySources() + .addFirst(new MapPropertySource("grpc", Map.of("spring.grpc.client.channel.default.target", target))); + applicationContext.getBeanFactory().registerSingleton("grpcServerLifecyce", lifecycle); + } + + } + + static class HelloWorldService extends HelloWorldGrpc.HelloWorldImplBase { + + @Override + public void sayHello(HelloRequest request, StreamObserver responseObserver) { + HelloReply reply = HelloReply.newBuilder().setMessage("Hello '%s'".formatted(request.getName())).build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } + + @Override + public void streamHello(HelloRequest request, StreamObserver responseObserver) { + throw new IllegalStateException(); + } + + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-netty-shaded/build.gradle b/smoke-test/spring-boot-smoke-test-grpc-server-netty-shaded/build.gradle new file mode 100644 index 00000000000..2b52f147aae --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-netty-shaded/build.gradle @@ -0,0 +1,67 @@ +/* + * 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. + */ + +plugins { + id "java" + id "com.google.protobuf" version "${protobufGradlePluginVersion}" + id "org.springframework.boot.docker-test" +} + +description = "Spring Boot gRPC server netty shaded smoke test" + +dependencies { + implementation(project(":starter:spring-boot-starter-grpc-server")) { + exclude(group: "io.grpc", module: "grpc-netty-shaded") + } + implementation("io.grpc:grpc-netty-shaded") + + dockerTestImplementation(project(":starter:spring-boot-starter-test")) + dockerTestImplementation("org.testcontainers:testcontainers-junit-jupiter") +} + +def dependenciesBom = project(":platform:spring-boot-dependencies").extensions.getByName("bom") +def grpcJava = dependenciesBom.getLibrary("Grpc Java") +def protobufJava = dependenciesBom.getLibrary("Protobuf Java") + +tasks.named("compileTestJava") { + options.nullability.checking = "tests" +} + +nullability { + requireExplicitNullMarking = false +} + +configurations.named { it.startsWith("protobufToolsLocator_") || it.toLowerCase().endsWith("protopath") }.all { + extendsFrom(configurations.dependencyManagement) +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:${protobufJava.version}" + } + plugins { + grpc { + artifact = "io.grpc:protoc-gen-grpc-java:${grpcJava.version}" + } + } + generateProtoTasks { + all()*.plugins { + grpc { + option '@generated=omit' + } + } + } +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-netty-shaded/src/dockerTest/java/smoketest/grpcservernettyshaded/SampleGrpcServerNettyShadedApplicationTests.java b/smoke-test/spring-boot-smoke-test-grpc-server-netty-shaded/src/dockerTest/java/smoketest/grpcservernettyshaded/SampleGrpcServerNettyShadedApplicationTests.java new file mode 100644 index 00000000000..e058762d2dc --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-netty-shaded/src/dockerTest/java/smoketest/grpcservernettyshaded/SampleGrpcServerNettyShadedApplicationTests.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 smoketest.grpcservernettyshaded; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.startupcheck.IndefiniteWaitOneShotStartupCheckStrategy; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; +import smoketest.grpcservernettyshaded.SampleGrpcServerNettyShadedApplicationTests.GrpcServerStartedEventListener; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationListener; +import org.springframework.context.annotation.Import; +import org.springframework.grpc.server.lifecycle.GrpcServerStartedEvent; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for Spring gRPC with a shaded netty server. + * + * @author Phillip Webb + */ +@SpringBootTest(properties = "spring.grpc.server.port=0") +@Testcontainers(disabledWithoutDocker = true) +@Import(GrpcServerStartedEventListener.class) +class SampleGrpcServerNettyShadedApplicationTests { + + @Autowired + private GrpcServerStartedEventListener startedEventListener; + + @Test + @SuppressWarnings("resource") + void test() { + String address = "host.docker.internal:" + this.startedEventListener.getPort(); + try (GenericContainer container = new GenericContainer<>( + DockerImageName.parse("fullstorydev/grpcurl:v1.9.3")) + .withCommand("-d", "{\"name\": \"spring\"}", "--plaintext", address, "HelloWorld/SayHello") + .withStartupCheckStrategy(new IndefiniteWaitOneShotStartupCheckStrategy())) { + container.start(); + assertThat(container.getLogs()).contains("\"message\": \"Hello 'spring'\""); + } + + } + + static class GrpcServerStartedEventListener implements ApplicationListener { + + private int port; + + @Override + public void onApplicationEvent(GrpcServerStartedEvent event) { + this.port = event.getPort(); + } + + int getPort() { + return this.port; + } + + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-netty-shaded/src/main/java/smoketest/grpcservernettyshaded/HelloWorldService.java b/smoke-test/spring-boot-smoke-test-grpc-server-netty-shaded/src/main/java/smoketest/grpcservernettyshaded/HelloWorldService.java new file mode 100644 index 00000000000..d17147df037 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-netty-shaded/src/main/java/smoketest/grpcservernettyshaded/HelloWorldService.java @@ -0,0 +1,68 @@ +/* + * 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 smoketest.grpcservernettyshaded; + +import io.grpc.stub.StreamObserver; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import smoketest.grpcservernettyshaded.proto.HelloReply; +import smoketest.grpcservernettyshaded.proto.HelloRequest; +import smoketest.grpcservernettyshaded.proto.HelloWorldGrpc; + +import org.springframework.stereotype.Service; +import org.springframework.util.Assert; + +@Service +public class HelloWorldService extends HelloWorldGrpc.HelloWorldImplBase { + + private static Log logger = LogFactory.getLog(HelloWorldService.class); + + @Override + public void sayHello(HelloRequest request, StreamObserver responseObserver) { + String name = request.getName(); + logger.info("sayHello " + name); + Assert.isTrue(!name.startsWith("error"), () -> "Bad name: " + name); + Assert.state(!name.startsWith("internal"), "Internal error"); + String message = "Hello '%s'".formatted(name); + HelloReply reply = HelloReply.newBuilder().setMessage(message).build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } + + @Override + public void streamHello(HelloRequest request, StreamObserver responseObserver) { + String name = request.getName(); + logger.info("streamHello " + name); + int count = 0; + while (count < 10) { + String message = "Hello(" + count + ") '%s'".formatted(name); + HelloReply reply = HelloReply.newBuilder().setMessage(message).build(); + responseObserver.onNext(reply); + count++; + try { + Thread.sleep(100L); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + responseObserver.onError(ex); + return; + } + } + responseObserver.onCompleted(); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-netty-shaded/src/main/java/smoketest/grpcservernettyshaded/SampleGrpcServerNettyShadedApplication.java b/smoke-test/spring-boot-smoke-test-grpc-server-netty-shaded/src/main/java/smoketest/grpcservernettyshaded/SampleGrpcServerNettyShadedApplication.java new file mode 100644 index 00000000000..67aa1184c13 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-netty-shaded/src/main/java/smoketest/grpcservernettyshaded/SampleGrpcServerNettyShadedApplication.java @@ -0,0 +1,29 @@ +/* + * 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 smoketest.grpcservernettyshaded; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SampleGrpcServerNettyShadedApplication { + + public static void main(String[] args) { + SpringApplication.run(SampleGrpcServerNettyShadedApplication.class, args); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-netty-shaded/src/main/java/smoketest/grpcservernettyshaded/package-info.java b/smoke-test/spring-boot-smoke-test-grpc-server-netty-shaded/src/main/java/smoketest/grpcservernettyshaded/package-info.java new file mode 100644 index 00000000000..6d4a3f27ec9 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-netty-shaded/src/main/java/smoketest/grpcservernettyshaded/package-info.java @@ -0,0 +1,20 @@ +/* + * 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. + */ + +@NullMarked +package smoketest.grpcservernettyshaded; + +import org.jspecify.annotations.NullMarked; diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-netty-shaded/src/main/proto/hello.proto b/smoke-test/spring-boot-smoke-test-grpc-server-netty-shaded/src/main/proto/hello.proto new file mode 100644 index 00000000000..7d56b5fc5df --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-netty-shaded/src/main/proto/hello.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +option java_package = "smoketest.grpcservernettyshaded.proto"; +option java_multiple_files = true; + +service HelloWorld { + rpc SayHello (HelloRequest) returns (HelloReply) {} + rpc StreamHello(HelloRequest) returns (stream HelloReply) {} +} + +message HelloRequest { + string name = 1; +} + +message HelloReply { + string message = 1; +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-oauth/build.gradle b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/build.gradle new file mode 100644 index 00000000000..758cc967819 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/build.gradle @@ -0,0 +1,68 @@ +/* + * 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. + */ + +plugins { + id "java" + id "com.google.protobuf" version "${protobufGradlePluginVersion}" + id "org.springframework.boot.docker-test" +} + +description = "Spring Boot gRPC server oauth smoke test" + +dependencies { + implementation(project(":starter:spring-boot-starter-oauth2-authorization-server")) + implementation(project(":starter:spring-boot-starter-oauth2-resource-server")) + implementation(project(":starter:spring-boot-starter-grpc-server")) + + testImplementation(project(":starter:spring-boot-starter-grpc-client")) + testImplementation(project(":starter:spring-boot-starter-grpc-test")) + testImplementation(project(":starter:spring-boot-starter-oauth2-client")) + testImplementation(project(":starter:spring-boot-starter-test")) +} + +def dependenciesBom = project(":platform:spring-boot-dependencies").extensions.getByName("bom") +def grpcJava = dependenciesBom.getLibrary("Grpc Java") +def protobufJava = dependenciesBom.getLibrary("Protobuf Java") + +tasks.named("compileTestJava") { + options.nullability.checking = "tests" +} + +nullability { + requireExplicitNullMarking = false +} + +configurations.named { it.startsWith("protobufToolsLocator_") || it.toLowerCase().endsWith("protopath") }.all { + extendsFrom(configurations.dependencyManagement) +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:${protobufJava.version}" + } + plugins { + grpc { + artifact = "io.grpc:protoc-gen-grpc-java:${grpcJava.version}" + } + } + generateProtoTasks { + all()*.plugins { + grpc { + option '@generated=omit' + } + } + } +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/HelloWorldService.java b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/HelloWorldService.java new file mode 100644 index 00000000000..e7f8ca8ab90 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/HelloWorldService.java @@ -0,0 +1,60 @@ +/* + * 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 smoketest.grpcserveroauth; + +import io.grpc.stub.StreamObserver; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import smoketest.grpcserveroauth.proto.HelloReply; +import smoketest.grpcserveroauth.proto.HelloRequest; +import smoketest.grpcserveroauth.proto.HelloWorldGrpc; + +import org.springframework.stereotype.Service; +import org.springframework.util.Assert; + +@Service +public class HelloWorldService extends HelloWorldGrpc.HelloWorldImplBase { + + private static Log logger = LogFactory.getLog(HelloWorldService.class); + + @Override + public void sayHelloProfileScope(HelloRequest request, StreamObserver responseObserver) { + sayHello("sayHelloProfileScope", request, responseObserver); + } + + @Override + public void sayHelloEmailScope(HelloRequest request, StreamObserver responseObserver) { + sayHello("sayHelloEmailScope", request, responseObserver); + } + + @Override + public void sayHelloAuthenticated(HelloRequest request, StreamObserver responseObserver) { + sayHello("sayHelloAuthenticated", request, responseObserver); + } + + public void sayHello(String methodName, HelloRequest request, StreamObserver responseObserver) { + String name = request.getName(); + logger.info(methodName + " " + name); + Assert.isTrue(!name.startsWith("error"), () -> "Bad name: " + name); + Assert.state(!name.startsWith("internal"), "Internal error"); + String message = "%s '%s'".formatted(methodName, name); + HelloReply reply = HelloReply.newBuilder().setMessage(message).build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/SampleGrpcServerOAuthApplication.java b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/SampleGrpcServerOAuthApplication.java new file mode 100644 index 00000000000..66cce5e1310 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/SampleGrpcServerOAuthApplication.java @@ -0,0 +1,29 @@ +/* + * 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 smoketest.grpcserveroauth; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SampleGrpcServerOAuthApplication { + + public static void main(String[] args) { + SpringApplication.run(SampleGrpcServerOAuthApplication.class, args); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/SecurityConfiguration.java b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/SecurityConfiguration.java new file mode 100644 index 00000000000..6f7405ed52f --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/SecurityConfiguration.java @@ -0,0 +1,61 @@ +/* + * 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 smoketest.grpcserveroauth; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.server.GlobalServerInterceptor; +import org.springframework.grpc.server.security.AuthenticationProcessInterceptor; +import org.springframework.grpc.server.security.GrpcSecurity; +import org.springframework.grpc.server.security.RequestMapperConfigurer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +import static org.springframework.security.config.Customizer.withDefaults; + +@Configuration(proxyBeanMethods = false) +public class SecurityConfiguration { + + @Bean + @GlobalServerInterceptor + AuthenticationProcessInterceptor securityInterceptor(GrpcSecurity grpcSecurity) throws Exception { + return grpcSecurity.authorizeRequests(this::authorizeGrpcRequests) + .oauth2ResourceServer((resourceServer) -> resourceServer.jwt(withDefaults())) + .build(); + } + + private void authorizeGrpcRequests(RequestMapperConfigurer requests) { + requests.methods("HelloWorld/SayHelloProfileScope").hasAuthority("SCOPE_profile"); + requests.methods("HelloWorld/SayHelloEmailScope").hasAuthority("SCOPE_email"); + requests.methods("HelloWorld/SayHelloAuthenticated").authenticated(); + requests.methods("grpc.*/*").permitAll(); + requests.allRequests().denyAll(); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) { + http.oauth2AuthorizationServer((authorizationServer) -> { + http.securityMatcher(authorizationServer.getEndpointsMatcher()); + authorizationServer.oidc(withDefaults()); + }); + http.oauth2ResourceServer((resourceServer) -> resourceServer.jwt(withDefaults())); + http.authorizeHttpRequests((requests) -> requests.anyRequest().permitAll()); + http.csrf((csrf) -> csrf.disable()); + return http.build(); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/package-info.java b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/package-info.java new file mode 100644 index 00000000000..ab81b93206f --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/package-info.java @@ -0,0 +1,20 @@ +/* + * 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. + */ + +@NullMarked +package smoketest.grpcserveroauth; + +import org.jspecify.annotations.NullMarked; diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/proto/hello.proto b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/proto/hello.proto new file mode 100644 index 00000000000..e443b360171 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/proto/hello.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +option java_package = "smoketest.grpcserveroauth.proto"; +option java_multiple_files = true; + +service HelloWorld { + rpc SayHelloProfileScope (HelloRequest) returns (HelloReply) {} + rpc SayHelloEmailScope (HelloRequest) returns (HelloReply) {} + rpc SayHelloAuthenticated (HelloRequest) returns (HelloReply) {} +} + +message HelloRequest { + string name = 1; +} + +message HelloReply { + string message = 1; +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/resources/application.yaml b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/resources/application.yaml new file mode 100644 index 00000000000..c9257e42c1e --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/resources/application.yaml @@ -0,0 +1,21 @@ +logging.level.org.springframework.security: TRACE +spring: + security: + oauth2: + authorizationserver: + client: + oidc-client: + registration: + client-id: "spring" + client-secret: "{noop}secret" + client-authentication-methods: + - "client_secret_basic" + authorization-grant-types: + - "client_credentials" + - "refresh_token" + scopes: + - "openid" + - "profile" + resourceserver: + jwt: + jwk-set-uri: http://localhost:8080/oauth2/jwks diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/test/java/smoketest/grpcserveroauth/SampleGrpcServerOAuthApplicationTests.java b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/test/java/smoketest/grpcserveroauth/SampleGrpcServerOAuthApplicationTests.java new file mode 100644 index 00000000000..9bfeb8bab90 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/test/java/smoketest/grpcserveroauth/SampleGrpcServerOAuthApplicationTests.java @@ -0,0 +1,220 @@ +/* + * 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 smoketest.grpcserveroauth; + +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +import io.grpc.ManagedChannelBuilder; +import io.grpc.Status.Code; +import io.grpc.StatusRuntimeException; +import io.grpc.reflection.v1.ServerReflectionGrpc.ServerReflectionStub; +import io.grpc.reflection.v1.ServerReflectionRequest; +import io.grpc.reflection.v1.ServerReflectionResponse; +import io.grpc.stub.StreamObserver; +import org.awaitility.Awaitility; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import smoketest.grpcserveroauth.proto.HelloReply; +import smoketest.grpcserveroauth.proto.HelloRequest; +import smoketest.grpcserveroauth.proto.HelloWorldGrpc.HelloWorldBlockingStub; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; +import org.springframework.grpc.client.ImportGrpcClients; +import org.springframework.grpc.client.interceptor.security.BearerTokenAuthenticationInterceptor; +import org.springframework.security.oauth2.client.endpoint.OAuth2ClientCredentialsGrantRequest; +import org.springframework.security.oauth2.client.endpoint.RestClientClientCredentialsTokenResponseClient; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.SupplierClientRegistrationRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder.JwkSetUriJwtDecoderBuilder; +import org.springframework.security.oauth2.jwt.SupplierJwtDecoder; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.util.function.SingletonSupplier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, + properties = { "spring.grpc.server.port=0", + "spring.grpc.client.channel.default.target=static://localhost:${local.grpc.server.port}", + "spring.grpc.client.channel.oauth.target=static://localhost:${local.grpc.server.port}" }) +@DirtiesContext +class SampleGrpcServerOAuthApplicationTests { + + @Autowired + @Qualifier("unauthenticatedHelloWorldBlockingStub") + private HelloWorldBlockingStub unuathenticated; + + @Autowired + @Qualifier("oauthHelloWorldBlockingStub") + private HelloWorldBlockingStub oauth; + + @Autowired + private ServerReflectionStub reflection; + + @Test + void whenUnauthenticatedStub() { + HelloWorldBlockingStub stub = this.unuathenticated; + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> invoke(stub::sayHelloProfileScope)) + .satisfies(statusCode(Code.UNAUTHENTICATED)); + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> invoke(stub::sayHelloEmailScope)) + .satisfies(statusCode(Code.UNAUTHENTICATED)); + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> invoke(stub::sayHelloAuthenticated)) + .satisfies(statusCode(Code.UNAUTHENTICATED)); + assertCanInvokeReflection(); + } + + @Test + void whenOAuth() { + HelloWorldBlockingStub stub = this.oauth; + assertThat(invoke(stub::sayHelloProfileScope)).isEqualTo("sayHelloProfileScope 'Spring'"); + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> invoke(stub::sayHelloEmailScope)) + .satisfies(statusCode(Code.PERMISSION_DENIED)); + assertThat(invoke(stub::sayHelloAuthenticated)).isEqualTo("sayHelloAuthenticated 'Spring'"); + assertCanInvokeReflection(); + } + + private String invoke(Function method) { + HelloRequest request = HelloRequest.newBuilder().setName("Spring").build(); + return method.apply(request).getMessage(); + } + + private void assertCanInvokeReflection() { + ObservedResponse response = invokeReflection(); + assertThat(response.getValue()).isNotNull(); + assertThat(response.getError()).isNull(); + } + + private ObservedResponse invokeReflection() { + ObservedResponse response = new ObservedResponse<>(); + StreamObserver request = this.reflection.serverReflectionInfo(response); + request.onNext(ServerReflectionRequest.newBuilder().setListServices("").build()); + request.onCompleted(); + response.await(); + return response; + } + + private Consumer statusCode(Code expected) { + return (ex) -> assertThat(ex).extracting("status.code").isEqualTo(expected); + } + + @TestConfiguration(proxyBeanMethods = false) + @ImportGrpcClients(types = ServerReflectionStub.class) + @ImportGrpcClients(prefix = "unauthenticated", types = HelloWorldBlockingStub.class) + @ImportGrpcClients(target = "oauth", prefix = "oauth", types = HelloWorldBlockingStub.class) + static class GrpcClientTestConfiguration { + + @Bean + > GrpcChannelBuilderCustomizer channelSecurityCustomizer( + ObjectProvider clientRegistrationRepository) { + return GrpcChannelBuilderCustomizer.matching("oauth", (builder) -> { + Supplier tokenSupplier = SingletonSupplier + .of(() -> token(clientRegistrationRepository.getObject())); + builder.intercept(new BearerTokenAuthenticationInterceptor(tokenSupplier)); + }); + } + + private String token(ClientRegistrationRepository clientRegistrationRepository) { + RestClientClientCredentialsTokenResponseClient client = new RestClientClientCredentialsTokenResponseClient(); + ClientRegistration registration = clientRegistrationRepository.findByRegistrationId("spring"); + OAuth2ClientCredentialsGrantRequest request = new OAuth2ClientCredentialsGrantRequest(registration); + return client.getTokenResponse(request).getAccessToken().getTokenValue(); + } + + @Bean + ClientRegistrationRepository lazyClientRegistrationRepository(Environment environment) { + return new SupplierClientRegistrationRepository(() -> getClientRegistrationRepository(environment)); + } + + private InMemoryClientRegistrationRepository getClientRegistrationRepository(Environment environment) { + return new InMemoryClientRegistrationRepository(ClientRegistration.withRegistrationId("spring") + .clientId("spring") + .clientSecret("secret") + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .scope("profile") + .tokenUri(environment.resolvePlaceholders("http://localhost:${local.server.port}/oauth2/token")) + .build()); + } + + @Bean + SupplierJwtDecoder lazyJwtDecoder(Environment environment) { + return new SupplierJwtDecoder(() -> getJwtDecoder(environment)); + } + + private JwtDecoder getJwtDecoder(Environment environment) { + JwkSetUriJwtDecoderBuilder builder = NimbusJwtDecoder + .withJwkSetUri(environment.resolvePlaceholders("http://localhost:${local.server.port}/oauth2/jwks")); + builder.jwsAlgorithms((algorithms) -> algorithms.add(SignatureAlgorithm.from("RS256"))); + return builder.build(); + } + + } + + static class ObservedResponse implements StreamObserver { + + private volatile @Nullable T value; + + private volatile @Nullable Throwable error; + + @Override + public synchronized void onNext(T value) { + this.value = value; + } + + @Override + public synchronized void onError(Throwable error) { + this.error = error; + } + + @Override + public void onCompleted() { + } + + void await() { + Awaitility.await().until(this::hasResponse); + } + + private synchronized boolean hasResponse() { + return this.value != null || this.error != null; + } + + @Nullable T getValue() { + return this.value; + } + + @Nullable Throwable getError() { + return this.error; + } + + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-secure/build.gradle b/smoke-test/spring-boot-smoke-test-grpc-server-secure/build.gradle new file mode 100644 index 00000000000..a4ec289d2f2 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-secure/build.gradle @@ -0,0 +1,66 @@ +/* + * 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. + */ + +plugins { + id "java" + id "com.google.protobuf" version "${protobufGradlePluginVersion}" + id "org.springframework.boot.docker-test" +} + +description = "Spring Boot gRPC server secure smoke test" + +dependencies { + implementation(project(":starter:spring-boot-starter-grpc-server")) + implementation(project(":starter:spring-boot-starter-security")) + + testImplementation(project(":starter:spring-boot-starter-grpc-client")) + testImplementation(project(":starter:spring-boot-starter-grpc-test")) + testImplementation(project(":starter:spring-boot-starter-test")) +} + +def dependenciesBom = project(":platform:spring-boot-dependencies").extensions.getByName("bom") +def grpcJava = dependenciesBom.getLibrary("Grpc Java") +def protobufJava = dependenciesBom.getLibrary("Protobuf Java") + +tasks.named("compileTestJava") { + options.nullability.checking = "tests" +} + +nullability { + requireExplicitNullMarking = false +} + +configurations.named { it.startsWith("protobufToolsLocator_") || it.toLowerCase().endsWith("protopath") }.all { + extendsFrom(configurations.dependencyManagement) +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:${protobufJava.version}" + } + plugins { + grpc { + artifact = "io.grpc:protoc-gen-grpc-java:${grpcJava.version}" + } + } + generateProtoTasks { + all()*.plugins { + grpc { + option '@generated=omit' + } + } + } +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/HelloWorldService.java b/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/HelloWorldService.java new file mode 100644 index 00000000000..3330d1a1555 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/HelloWorldService.java @@ -0,0 +1,68 @@ +/* + * 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 smoketest.grpcserversecure; + +import io.grpc.stub.StreamObserver; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import smoketest.grpcserversecure.proto.HelloReply; +import smoketest.grpcserversecure.proto.HelloRequest; +import smoketest.grpcserversecure.proto.HelloWorldGrpc; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Service; +import org.springframework.util.Assert; + +@Service +public class HelloWorldService extends HelloWorldGrpc.HelloWorldImplBase { + + private static Log logger = LogFactory.getLog(HelloWorldService.class); + + @Override + public void sayHelloAdmin(HelloRequest request, StreamObserver responseObserver) { + sayHello("sayHelloAdmin", request, responseObserver); + } + + @Override + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + public void sayHelloAdminAnnotated(HelloRequest request, StreamObserver responseObserver) { + sayHello("sayHelloAdminAnnotated", request, responseObserver); + } + + @Override + public void sayHelloUser(HelloRequest request, StreamObserver responseObserver) { + sayHello("sayHelloUser", request, responseObserver); + } + + @Override + @PreAuthorize("hasAuthority('ROLE_USER')") + public void sayHelloUserAnnotated(HelloRequest request, StreamObserver responseObserver) { + sayHello("sayHelloUserAnnotated", request, responseObserver); + } + + public void sayHello(String methodName, HelloRequest request, StreamObserver responseObserver) { + String name = request.getName(); + logger.info(methodName + " " + name); + Assert.isTrue(!name.startsWith("error"), () -> "Bad name: " + name); + Assert.state(!name.startsWith("internal"), "Internal error"); + String message = "%s '%s'".formatted(methodName, name); + HelloReply reply = HelloReply.newBuilder().setMessage(message).build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/SampleGrpcServerSecureApplication.java b/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/SampleGrpcServerSecureApplication.java new file mode 100644 index 00000000000..9f92bc74883 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/SampleGrpcServerSecureApplication.java @@ -0,0 +1,29 @@ +/* + * 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 smoketest.grpcserversecure; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SampleGrpcServerSecureApplication { + + public static void main(String[] args) { + SpringApplication.run(SampleGrpcServerSecureApplication.class, args); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/SecurityConfiguration.java b/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/SecurityConfiguration.java new file mode 100644 index 00000000000..7beaa3c3dd8 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/SecurityConfiguration.java @@ -0,0 +1,64 @@ +/* + * 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 smoketest.grpcserversecure; + +import io.grpc.ServerInterceptor; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.server.GlobalServerInterceptor; +import org.springframework.grpc.server.security.GrpcSecurity; +import org.springframework.grpc.server.security.RequestMapperConfigurer; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; + +import static org.springframework.security.config.Customizer.withDefaults; + +@Configuration(proxyBeanMethods = false) +public class SecurityConfiguration { + + @Bean + InMemoryUserDetailsManager inMemoryUserDetailsManager() { + UserDetails user = user("user", "{noop}userpassword", "ROLE_USER"); + UserDetails admin = user("admin", "{noop}adminpassword", "ROLE_ADMIN"); + return new InMemoryUserDetailsManager(user, admin); + } + + private UserDetails user(String username, String password, String authority) { + return User.withUsername(username).password(password).authorities(authority).build(); + } + + @Bean + @GlobalServerInterceptor + ServerInterceptor securityInterceptor(GrpcSecurity security) throws Exception { + return security.authorizeRequests(this::authorizeRequests) + .httpBasic(withDefaults()) + .preauth(withDefaults()) + .build(); + } + + private void authorizeRequests(RequestMapperConfigurer requests) { + requests.methods("HelloWorld/SayHelloAdmin").hasAuthority("ROLE_ADMIN"); + requests.methods("HelloWorld/SayHelloAdminAnnotated").hasAuthority("ROLE_ADMIN"); + requests.methods("HelloWorld/SayHelloUser").hasAuthority("ROLE_USER"); + requests.methods("HelloWorld/SayHelloUserAnnotated").hasAuthority("ROLE_USER"); + requests.methods("grpc.*/*").permitAll(); + requests.allRequests().denyAll(); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/package-info.java b/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/package-info.java new file mode 100644 index 00000000000..7f019548742 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/package-info.java @@ -0,0 +1,20 @@ +/* + * 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. + */ + +@NullMarked +package smoketest.grpcserversecure; + +import org.jspecify.annotations.NullMarked; diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/proto/hello.proto b/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/proto/hello.proto new file mode 100644 index 00000000000..700ee4ac930 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/proto/hello.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +option java_package = "smoketest.grpcserversecure.proto"; +option java_multiple_files = true; + +service HelloWorld { + rpc SayHelloAdmin (HelloRequest) returns (HelloReply) {} + rpc SayHelloAdminAnnotated (HelloRequest) returns (HelloReply) {} + rpc SayHelloUser (HelloRequest) returns (HelloReply) {} + rpc SayHelloUserAnnotated (HelloRequest) returns (HelloReply) {} +} + +message HelloRequest { + string name = 1; +} + +message HelloReply { + string message = 1; +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/test/java/smoketest/grpcserversecure/SampleGrpcServerSecureApplicationTests.java b/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/test/java/smoketest/grpcserversecure/SampleGrpcServerSecureApplicationTests.java new file mode 100644 index 00000000000..4c7dab0e33d --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-secure/src/test/java/smoketest/grpcserversecure/SampleGrpcServerSecureApplicationTests.java @@ -0,0 +1,192 @@ +/* + * 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 smoketest.grpcserversecure; + +import java.util.function.Consumer; +import java.util.function.Function; + +import io.grpc.ManagedChannelBuilder; +import io.grpc.Status.Code; +import io.grpc.StatusRuntimeException; +import io.grpc.reflection.v1.ServerReflectionGrpc.ServerReflectionStub; +import io.grpc.reflection.v1.ServerReflectionRequest; +import io.grpc.reflection.v1.ServerReflectionResponse; +import io.grpc.stub.StreamObserver; +import org.awaitility.Awaitility; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import smoketest.grpcserversecure.proto.HelloReply; +import smoketest.grpcserversecure.proto.HelloRequest; +import smoketest.grpcserversecure.proto.HelloWorldGrpc.HelloWorldBlockingStub; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; +import org.springframework.grpc.client.ImportGrpcClients; +import org.springframework.grpc.client.interceptor.security.BasicAuthenticationInterceptor; +import org.springframework.test.annotation.DirtiesContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +@SpringBootTest(properties = { "spring.grpc.server.port=0", + "spring.grpc.client.channel.default.target=static://localhost:${local.grpc.server.port}", + "spring.grpc.client.channel.user.target=static://localhost:${local.grpc.server.port}", + "spring.grpc.client.channel.admin.target=static://localhost:${local.grpc.server.port}" }) +@DirtiesContext +class SampleGrpcServerSecureApplicationTests { + + @Autowired + @Qualifier("unauthenticatedHelloWorldBlockingStub") + private HelloWorldBlockingStub unuathenticated; + + @Autowired + @Qualifier("userAuthenticatedHelloWorldBlockingStub") + private HelloWorldBlockingStub userAuthenticated; + + @Autowired + @Qualifier("adminAuthenticatedHelloWorldBlockingStub") + private HelloWorldBlockingStub adminAuthenticated; + + @Autowired + private ServerReflectionStub reflection; + + @Test + void whenUnauthenticatedStub() { + HelloWorldBlockingStub stub = this.unuathenticated; + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> invoke(stub::sayHelloUser)) + .satisfies(statusCode(Code.UNAUTHENTICATED)); + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> invoke(stub::sayHelloUserAnnotated)) + .satisfies(statusCode(Code.UNAUTHENTICATED)); + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> invoke(stub::sayHelloAdmin)) + .satisfies(statusCode(Code.UNAUTHENTICATED)); + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> invoke(stub::sayHelloAdminAnnotated)) + .satisfies(statusCode(Code.UNAUTHENTICATED)); + assertCanInvokeReflection(); + } + + @Test + void whenUserAuthenticatedStub() { + HelloWorldBlockingStub stub = this.userAuthenticated; + assertThat(invoke(stub::sayHelloUser)).isEqualTo("sayHelloUser 'Spring'"); + assertThat(invoke(stub::sayHelloUserAnnotated)).isEqualTo("sayHelloUserAnnotated 'Spring'"); + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> invoke(stub::sayHelloAdmin)) + .satisfies(statusCode(Code.PERMISSION_DENIED)); + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> invoke(stub::sayHelloAdminAnnotated)) + .satisfies(statusCode(Code.PERMISSION_DENIED)); + assertCanInvokeReflection(); + } + + @Test + void whenAdminAuthenticatedStub() { + HelloWorldBlockingStub stub = this.adminAuthenticated; + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> invoke(stub::sayHelloUser)) + .satisfies(statusCode(Code.PERMISSION_DENIED)); + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> invoke(stub::sayHelloUserAnnotated)) + .satisfies(statusCode(Code.PERMISSION_DENIED)); + assertThat(invoke(stub::sayHelloAdmin)).isEqualTo("sayHelloAdmin 'Spring'"); + assertThat(invoke(stub::sayHelloAdminAnnotated)).isEqualTo("sayHelloAdminAnnotated 'Spring'"); + assertCanInvokeReflection(); + } + + private String invoke(Function method) { + HelloRequest request = HelloRequest.newBuilder().setName("Spring").build(); + return method.apply(request).getMessage(); + } + + private void assertCanInvokeReflection() { + ObservedResponse response = invokeReflection(); + assertThat(response.getValue()).isNotNull(); + assertThat(response.getError()).isNull(); + } + + private ObservedResponse invokeReflection() { + ObservedResponse response = new ObservedResponse<>(); + StreamObserver request = this.reflection.serverReflectionInfo(response); + request.onNext(ServerReflectionRequest.newBuilder().setListServices("").build()); + request.onCompleted(); + response.await(); + return response; + } + + private Consumer statusCode(Code expected) { + return (ex) -> assertThat(ex).extracting("status.code").isEqualTo(expected); + } + + @TestConfiguration(proxyBeanMethods = false) + @ImportGrpcClients(types = ServerReflectionStub.class) + @ImportGrpcClients(prefix = "unauthenticated", types = HelloWorldBlockingStub.class) + @ImportGrpcClients(target = "user", prefix = "userAuthenticated", types = HelloWorldBlockingStub.class) + @ImportGrpcClients(target = "admin", prefix = "adminAuthenticated", types = HelloWorldBlockingStub.class) + static class GrpcClientTestConfiguration { + + @Bean + > GrpcChannelBuilderCustomizer channelSecurityCustomizer() { + return (target, builder) -> { + if ("user".equals(target)) { + builder.intercept(new BasicAuthenticationInterceptor("user", "userpassword")); + } + if ("admin".equals(target)) { + builder.intercept(new BasicAuthenticationInterceptor("admin", "adminpassword")); + } + }; + } + + } + + static class ObservedResponse implements StreamObserver { + + private volatile @Nullable T value; + + private volatile @Nullable Throwable error; + + @Override + public synchronized void onNext(T value) { + this.value = value; + } + + @Override + public synchronized void onError(Throwable error) { + this.error = error; + } + + @Override + public void onCompleted() { + } + + void await() { + Awaitility.await().until(this::hasResponse); + } + + private synchronized boolean hasResponse() { + return this.value != null || this.error != null; + } + + @Nullable T getValue() { + return this.value; + } + + @Nullable Throwable getError() { + return this.error; + } + + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-servlet/build.gradle b/smoke-test/spring-boot-smoke-test-grpc-server-servlet/build.gradle new file mode 100644 index 00000000000..c9389b5491d --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-servlet/build.gradle @@ -0,0 +1,69 @@ +/* + * 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. + */ + +plugins { + id "java" + id "com.google.protobuf" version "${protobufGradlePluginVersion}" + id "org.springframework.boot.docker-test" +} + +description = "Spring Boot gRPC server servlet smoke test" + + +dependencies { + implementation(project(":starter:spring-boot-starter-grpc-server")) { + exclude(group: "io.grpc", module: "grpc-netty-shaded") + } + implementation(project(":starter:spring-boot-starter-tomcat")) + implementation("io.grpc:grpc-servlet-jakarta") + + dockerTestImplementation(project(":starter:spring-boot-starter-test")) + dockerTestImplementation("org.testcontainers:testcontainers-junit-jupiter") +} + +def dependenciesBom = project(":platform:spring-boot-dependencies").extensions.getByName("bom") +def grpcJava = dependenciesBom.getLibrary("Grpc Java") +def protobufJava = dependenciesBom.getLibrary("Protobuf Java") + +tasks.named("compileTestJava") { + options.nullability.checking = "tests" +} + +nullability { + requireExplicitNullMarking = false +} + +configurations.named { it.startsWith("protobufToolsLocator_") || it.toLowerCase().endsWith("protopath") }.all { + extendsFrom(configurations.dependencyManagement) +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:${protobufJava.version}" + } + plugins { + grpc { + artifact = "io.grpc:protoc-gen-grpc-java:${grpcJava.version}" + } + } + generateProtoTasks { + all()*.plugins { + grpc { + option '@generated=omit' + } + } + } +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-servlet/src/dockerTest/java/smoketest/grpcserverservlet/SampleGrpcServerServletApplicationTests.java b/smoke-test/spring-boot-smoke-test-grpc-server-servlet/src/dockerTest/java/smoketest/grpcserverservlet/SampleGrpcServerServletApplicationTests.java new file mode 100644 index 00000000000..b2aaa1a2ab3 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-servlet/src/dockerTest/java/smoketest/grpcserverservlet/SampleGrpcServerServletApplicationTests.java @@ -0,0 +1,57 @@ +/* + * 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 smoketest.grpcserverservlet; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.startupcheck.IndefiniteWaitOneShotStartupCheckStrategy; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for Spring gRPC with a servlet server. + * + * @author Phillip Webb + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@Testcontainers(disabledWithoutDocker = true) +class SampleGrpcServerServletApplicationTests { + + @LocalServerPort + private String localServerPort; + + @Test + @SuppressWarnings("resource") + void test() { + String address = "host.docker.internal:" + this.localServerPort; + try (GenericContainer container = new GenericContainer<>( + DockerImageName.parse("fullstorydev/grpcurl:v1.9.3")) + .withCommand("-d", "{\"name\": \"spring\"}", "--plaintext", address, "HelloWorld/SayHello") + .withStartupCheckStrategy(new IndefiniteWaitOneShotStartupCheckStrategy())) { + container.start(); + assertThat(container.getLogs()).contains("\"message\": \"Hello 'spring'\""); + } + + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-servlet/src/main/java/smoketest/grpcserverservlet/HelloWorldService.java b/smoke-test/spring-boot-smoke-test-grpc-server-servlet/src/main/java/smoketest/grpcserverservlet/HelloWorldService.java new file mode 100644 index 00000000000..37e4ca23620 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-servlet/src/main/java/smoketest/grpcserverservlet/HelloWorldService.java @@ -0,0 +1,68 @@ +/* + * 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 smoketest.grpcserverservlet; + +import io.grpc.stub.StreamObserver; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import smoketest.grpcserverservlet.proto.HelloReply; +import smoketest.grpcserverservlet.proto.HelloRequest; +import smoketest.grpcserverservlet.proto.HelloWorldGrpc; + +import org.springframework.stereotype.Service; +import org.springframework.util.Assert; + +@Service +public class HelloWorldService extends HelloWorldGrpc.HelloWorldImplBase { + + private static Log logger = LogFactory.getLog(HelloWorldService.class); + + @Override + public void sayHello(HelloRequest request, StreamObserver responseObserver) { + String name = request.getName(); + logger.info("sayHello " + name); + Assert.isTrue(!name.startsWith("error"), () -> "Bad name: " + name); + Assert.state(!name.startsWith("internal"), "Internal error"); + String message = "Hello '%s'".formatted(name); + HelloReply reply = HelloReply.newBuilder().setMessage(message).build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } + + @Override + public void streamHello(HelloRequest request, StreamObserver responseObserver) { + String name = request.getName(); + logger.info("streamHello " + name); + int count = 0; + while (count < 10) { + String message = "Hello(" + count + ") '%s'".formatted(name); + HelloReply reply = HelloReply.newBuilder().setMessage(message).build(); + responseObserver.onNext(reply); + count++; + try { + Thread.sleep(100L); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + responseObserver.onError(ex); + return; + } + } + responseObserver.onCompleted(); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-servlet/src/main/java/smoketest/grpcserverservlet/SampleGrpcServerServletApplication.java b/smoke-test/spring-boot-smoke-test-grpc-server-servlet/src/main/java/smoketest/grpcserverservlet/SampleGrpcServerServletApplication.java new file mode 100644 index 00000000000..87a8ba6b03f --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-servlet/src/main/java/smoketest/grpcserverservlet/SampleGrpcServerServletApplication.java @@ -0,0 +1,29 @@ +/* + * 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 smoketest.grpcserverservlet; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SampleGrpcServerServletApplication { + + public static void main(String[] args) { + SpringApplication.run(SampleGrpcServerServletApplication.class, args); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-servlet/src/main/java/smoketest/grpcserverservlet/package-info.java b/smoke-test/spring-boot-smoke-test-grpc-server-servlet/src/main/java/smoketest/grpcserverservlet/package-info.java new file mode 100644 index 00000000000..a86ba9557b4 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-servlet/src/main/java/smoketest/grpcserverservlet/package-info.java @@ -0,0 +1,20 @@ +/* + * 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. + */ + +@NullMarked +package smoketest.grpcserverservlet; + +import org.jspecify.annotations.NullMarked; diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-servlet/src/main/proto/hello.proto b/smoke-test/spring-boot-smoke-test-grpc-server-servlet/src/main/proto/hello.proto new file mode 100644 index 00000000000..8ba784139a7 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-servlet/src/main/proto/hello.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +option java_package = "smoketest.grpcserverservlet.proto"; +option java_multiple_files = true; + +service HelloWorld { + rpc SayHello (HelloRequest) returns (HelloReply) {} + rpc StreamHello(HelloRequest) returns (stream HelloReply) {} +} + +message HelloRequest { + string name = 1; +} + +message HelloReply { + string message = 1; +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-servlet/src/main/resources/application.properties b/smoke-test/spring-boot-smoke-test-grpc-server-servlet/src/main/resources/application.properties new file mode 100644 index 00000000000..63158dce242 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-servlet/src/main/resources/application.properties @@ -0,0 +1 @@ +server.http2.enabled=true diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-test/build.gradle b/smoke-test/spring-boot-smoke-test-grpc-server-test/build.gradle new file mode 100644 index 00000000000..8f28729142e --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-test/build.gradle @@ -0,0 +1,63 @@ +/* + * 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. + */ + +plugins { + id "java" + id "com.google.protobuf" version "${protobufGradlePluginVersion}" + id "org.springframework.boot.docker-test" +} + +description = "Spring Boot gRPC server and test smoke test" + +dependencies { + implementation(project(":starter:spring-boot-starter-grpc-server")) + + testImplementation(project(":starter:spring-boot-starter-grpc-test")) +} + +def dependenciesBom = project(":platform:spring-boot-dependencies").extensions.getByName("bom") +def grpcJava = dependenciesBom.getLibrary("Grpc Java") +def protobufJava = dependenciesBom.getLibrary("Protobuf Java") + +tasks.named("compileTestJava") { + options.nullability.checking = "tests" +} + +nullability { + requireExplicitNullMarking = false +} + +configurations.named { it.startsWith("protobufToolsLocator_") || it.toLowerCase().endsWith("protopath") }.all { + extendsFrom(configurations.dependencyManagement) +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:${protobufJava.version}" + } + plugins { + grpc { + artifact = "io.grpc:protoc-gen-grpc-java:${grpcJava.version}" + } + } + generateProtoTasks { + all()*.plugins { + grpc { + option '@generated=omit' + } + } + } +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-test/src/main/java/smoketest/grpcservertest/HelloWorldService.java b/smoke-test/spring-boot-smoke-test-grpc-server-test/src/main/java/smoketest/grpcservertest/HelloWorldService.java new file mode 100644 index 00000000000..abcd239e3e3 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-test/src/main/java/smoketest/grpcservertest/HelloWorldService.java @@ -0,0 +1,70 @@ +/* + * 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 smoketest.grpcservertest; + +import io.grpc.stub.StreamObserver; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import smoketest.grpcservertest.proto.HelloReply; +import smoketest.grpcservertest.proto.HelloRequest; +import smoketest.grpcservertest.proto.HelloWorldGrpc; + +import org.springframework.grpc.server.advice.GrpcAdvice; +import org.springframework.stereotype.Service; +import org.springframework.util.Assert; + +@Service +@GrpcAdvice +public class HelloWorldService extends HelloWorldGrpc.HelloWorldImplBase { + + private static Log logger = LogFactory.getLog(HelloWorldService.class); + + @Override + public void sayHello(HelloRequest request, StreamObserver responseObserver) { + String name = request.getName(); + logger.info("sayHello " + name); + Assert.isTrue(!name.startsWith("error"), () -> "Bad name: " + name); + Assert.state(!name.startsWith("internal"), "Internal error"); + String message = "Hello '%s'".formatted(name); + HelloReply reply = HelloReply.newBuilder().setMessage(message).build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } + + @Override + public void streamHello(HelloRequest request, StreamObserver responseObserver) { + String name = request.getName(); + logger.info("streamHello " + name); + int count = 0; + while (count < 10) { + String message = "Hello(" + count + ") '%s'".formatted(name); + HelloReply reply = HelloReply.newBuilder().setMessage(message).build(); + responseObserver.onNext(reply); + count++; + try { + Thread.sleep(100L); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + responseObserver.onError(ex); + return; + } + } + responseObserver.onCompleted(); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-test/src/main/java/smoketest/grpcservertest/SampleGrpcServerTestApplication.java b/smoke-test/spring-boot-smoke-test-grpc-server-test/src/main/java/smoketest/grpcservertest/SampleGrpcServerTestApplication.java new file mode 100644 index 00000000000..ae8a12f2097 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-test/src/main/java/smoketest/grpcservertest/SampleGrpcServerTestApplication.java @@ -0,0 +1,29 @@ +/* + * 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 smoketest.grpcservertest; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SampleGrpcServerTestApplication { + + public static void main(String[] args) { + SpringApplication.run(SampleGrpcServerTestApplication.class, args); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-test/src/main/java/smoketest/grpcservertest/StandardExceptionHandler.java b/smoke-test/spring-boot-smoke-test-grpc-server-test/src/main/java/smoketest/grpcservertest/StandardExceptionHandler.java new file mode 100644 index 00000000000..762d51a705d --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-test/src/main/java/smoketest/grpcservertest/StandardExceptionHandler.java @@ -0,0 +1,41 @@ +/* + * 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 smoketest.grpcservertest; + +import io.grpc.Status; +import io.grpc.Status.Code; +import io.grpc.StatusException; +import org.jspecify.annotations.Nullable; + +import org.springframework.grpc.server.exception.GrpcExceptionHandler; +import org.springframework.stereotype.Component; + +@Component +public class StandardExceptionHandler implements GrpcExceptionHandler { + + @Override + public @Nullable StatusException handleException(Throwable exception) { + if (exception instanceof IllegalArgumentException) { + return new StatusException(Status.fromCode(Code.INVALID_ARGUMENT).withDescription(exception.getMessage())); + } + if (exception instanceof IllegalStateException) { + return new StatusException(Status.fromCode(Code.INTERNAL).withDescription(exception.getMessage())); + } + return null; + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-test/src/main/java/smoketest/grpcservertest/package-info.java b/smoke-test/spring-boot-smoke-test-grpc-server-test/src/main/java/smoketest/grpcservertest/package-info.java new file mode 100644 index 00000000000..9fe37fa1c63 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-test/src/main/java/smoketest/grpcservertest/package-info.java @@ -0,0 +1,20 @@ +/* + * 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. + */ + +@NullMarked +package smoketest.grpcservertest; + +import org.jspecify.annotations.NullMarked; diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-test/src/main/proto/hello.proto b/smoke-test/spring-boot-smoke-test-grpc-server-test/src/main/proto/hello.proto new file mode 100644 index 00000000000..fdfb5c0456c --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-test/src/main/proto/hello.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +option java_package = "smoketest.grpcservertest.proto"; +option java_multiple_files = true; + +service HelloWorld { + rpc SayHello (HelloRequest) returns (HelloReply) {} + rpc StreamHello(HelloRequest) returns (stream HelloReply) {} +} + +message HelloRequest { + string name = 1; +} + +message HelloReply { + string message = 1; +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-test/src/test/java/smoketest/grpcservertest/SampleGrpcServerTestApplicationIntegrationTests.java b/smoke-test/spring-boot-smoke-test-grpc-server-test/src/test/java/smoketest/grpcservertest/SampleGrpcServerTestApplicationIntegrationTests.java new file mode 100644 index 00000000000..ba9956f9fdb --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-test/src/test/java/smoketest/grpcservertest/SampleGrpcServerTestApplicationIntegrationTests.java @@ -0,0 +1,54 @@ +/* + * 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 smoketest.grpcservertest; + +import io.grpc.ManagedChannel; +import io.grpc.netty.NettyChannelBuilder; +import org.junit.jupiter.api.Test; +import smoketest.grpcservertest.proto.HelloReply; +import smoketest.grpcservertest.proto.HelloRequest; +import smoketest.grpcservertest.proto.HelloWorldGrpc; +import smoketest.grpcservertest.proto.HelloWorldGrpc.HelloWorldBlockingStub; + +import org.springframework.boot.grpc.test.autoconfigure.LocalGrpcServerPort; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(properties = "spring.grpc.server.port=0") +class SampleGrpcServerTestApplicationIntegrationTests { + + @LocalGrpcServerPort + private int port; + + @Test + void callsService() { + ManagedChannel channel = NettyChannelBuilder.forTarget("localhost:%s".formatted(this.port)) + .usePlaintext() + .build(); + try { + HelloWorldBlockingStub hello = HelloWorldGrpc.newBlockingStub(channel); + HelloRequest request = HelloRequest.newBuilder().setName("Spring").build(); + HelloReply reply = hello.sayHello(request); + assertThat(reply.getMessage()).isEqualTo("Hello 'Spring'"); + } + finally { + channel.shutdown(); + } + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server-test/src/test/java/smoketest/grpcservertest/SampleGrpcServerTestApplicationTests.java b/smoke-test/spring-boot-smoke-test-grpc-server-test/src/test/java/smoketest/grpcservertest/SampleGrpcServerTestApplicationTests.java new file mode 100644 index 00000000000..c62593e6f73 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server-test/src/test/java/smoketest/grpcservertest/SampleGrpcServerTestApplicationTests.java @@ -0,0 +1,63 @@ +/* + * 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 smoketest.grpcservertest; + +import io.grpc.Status.Code; +import io.grpc.StatusRuntimeException; +import org.junit.jupiter.api.Test; +import smoketest.grpcservertest.proto.HelloReply; +import smoketest.grpcservertest.proto.HelloRequest; +import smoketest.grpcservertest.proto.HelloWorldGrpc.HelloWorldBlockingStub; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.grpc.test.autoconfigure.AutoConfigureTestGrpcTransport; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.grpc.client.ImportGrpcClients; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +@SpringBootTest +@AutoConfigureTestGrpcTransport +@ImportGrpcClients(types = HelloWorldBlockingStub.class) +class SampleGrpcServerTestApplicationTests { + + @Autowired + private HelloWorldBlockingStub hello; + + @Test + void sayHello() { + HelloRequest request = HelloRequest.newBuilder().setName("Spring").build(); + HelloReply reply = this.hello.sayHello(request); + assertThat(reply.getMessage()).isEqualTo("Hello 'Spring'"); + } + + @Test + void sayHelloWhenBadName() { + HelloRequest request = HelloRequest.newBuilder().setName("errorThing").build(); + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> this.hello.sayHello(request)) + .satisfies((ex) -> assertThat(ex.getStatus().getCode()).isEqualTo(Code.INVALID_ARGUMENT)); + } + + @Test + void sayHelloWhenInternalError() { + HelloRequest request = HelloRequest.newBuilder().setName("internal").build(); + assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> this.hello.sayHello(request)) + .satisfies((ex) -> assertThat(ex.getStatus().getCode()).isEqualTo(Code.INTERNAL)); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server/build.gradle b/smoke-test/spring-boot-smoke-test-grpc-server/build.gradle new file mode 100644 index 00000000000..2f9fe70d0fa --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server/build.gradle @@ -0,0 +1,64 @@ +/* + * 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. + */ + +plugins { + id "java" + id "com.google.protobuf" version "${protobufGradlePluginVersion}" + id "org.springframework.boot.docker-test" +} + +description = "Spring Boot gRPC server smoke test" + +dependencies { + implementation(project(":starter:spring-boot-starter-grpc-server")) + + dockerTestImplementation(project(":starter:spring-boot-starter-test")) + dockerTestImplementation("org.testcontainers:testcontainers-junit-jupiter") +} + +def dependenciesBom = project(":platform:spring-boot-dependencies").extensions.getByName("bom") +def grpcJava = dependenciesBom.getLibrary("Grpc Java") +def protobufJava = dependenciesBom.getLibrary("Protobuf Java") + +tasks.named("compileTestJava") { + options.nullability.checking = "tests" +} + +nullability { + requireExplicitNullMarking = false +} + +configurations.named { it.startsWith("protobufToolsLocator_") || it.toLowerCase().endsWith("protopath") }.all { + extendsFrom(configurations.dependencyManagement) +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:${protobufJava.version}" + } + plugins { + grpc { + artifact = "io.grpc:protoc-gen-grpc-java:${grpcJava.version}" + } + } + generateProtoTasks { + all()*.plugins { + grpc { + option '@generated=omit' + } + } + } +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server/src/dockerTest/java/smoketest/grpcserver/SampleGrpcServerApplicationTests.java b/smoke-test/spring-boot-smoke-test-grpc-server/src/dockerTest/java/smoketest/grpcserver/SampleGrpcServerApplicationTests.java new file mode 100644 index 00000000000..0e34103552b --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server/src/dockerTest/java/smoketest/grpcserver/SampleGrpcServerApplicationTests.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 smoketest.grpcserver; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.startupcheck.IndefiniteWaitOneShotStartupCheckStrategy; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; +import smoketest.grpcserver.SampleGrpcServerApplicationTests.GrpcServerStartedEventListener; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationListener; +import org.springframework.context.annotation.Import; +import org.springframework.grpc.server.lifecycle.GrpcServerStartedEvent; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for the default Spring gRPC netty server. + * + * @author Phillip Webb + */ +@SpringBootTest(properties = "spring.grpc.server.port=0") +@Testcontainers(disabledWithoutDocker = true) +@Import(GrpcServerStartedEventListener.class) +class SampleGrpcServerApplicationTests { + + @Autowired + private GrpcServerStartedEventListener startedEventListener; + + @Test + @SuppressWarnings("resource") + void test() { + String address = "host.docker.internal:" + this.startedEventListener.getPort(); + try (GenericContainer container = new GenericContainer<>( + DockerImageName.parse("fullstorydev/grpcurl:v1.9.3")) + .withCommand("-d", "{\"name\": \"spring\"}", "--plaintext", address, "HelloWorld/SayHello") + .withStartupCheckStrategy(new IndefiniteWaitOneShotStartupCheckStrategy())) { + container.start(); + assertThat(container.getLogs()).contains("\"message\": \"Hello 'spring'\""); + } + + } + + static class GrpcServerStartedEventListener implements ApplicationListener { + + private int port; + + @Override + public void onApplicationEvent(GrpcServerStartedEvent event) { + this.port = event.getPort(); + } + + int getPort() { + return this.port; + } + + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server/src/main/java/smoketest/grpcserver/HelloWorldService.java b/smoke-test/spring-boot-smoke-test-grpc-server/src/main/java/smoketest/grpcserver/HelloWorldService.java new file mode 100644 index 00000000000..e8d5de4c01e --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server/src/main/java/smoketest/grpcserver/HelloWorldService.java @@ -0,0 +1,68 @@ +/* + * 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 smoketest.grpcserver; + +import io.grpc.stub.StreamObserver; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import smoketest.grpcserver.proto.HelloReply; +import smoketest.grpcserver.proto.HelloRequest; +import smoketest.grpcserver.proto.HelloWorldGrpc; + +import org.springframework.stereotype.Service; +import org.springframework.util.Assert; + +@Service +public class HelloWorldService extends HelloWorldGrpc.HelloWorldImplBase { + + private static Log logger = LogFactory.getLog(HelloWorldService.class); + + @Override + public void sayHello(HelloRequest request, StreamObserver responseObserver) { + String name = request.getName(); + logger.info("sayHello " + name); + Assert.isTrue(!name.startsWith("error"), () -> "Bad name: " + name); + Assert.state(!name.startsWith("internal"), "Internal error"); + String message = "Hello '%s'".formatted(name); + HelloReply reply = HelloReply.newBuilder().setMessage(message).build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } + + @Override + public void streamHello(HelloRequest request, StreamObserver responseObserver) { + String name = request.getName(); + logger.info("streamHello " + name); + int count = 0; + while (count < 10) { + String message = "Hello(" + count + ") '%s'".formatted(name); + HelloReply reply = HelloReply.newBuilder().setMessage(message).build(); + responseObserver.onNext(reply); + count++; + try { + Thread.sleep(100L); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + responseObserver.onError(ex); + return; + } + } + responseObserver.onCompleted(); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server/src/main/java/smoketest/grpcserver/SampleGrpcServerApplication.java b/smoke-test/spring-boot-smoke-test-grpc-server/src/main/java/smoketest/grpcserver/SampleGrpcServerApplication.java new file mode 100644 index 00000000000..749d395252d --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server/src/main/java/smoketest/grpcserver/SampleGrpcServerApplication.java @@ -0,0 +1,29 @@ +/* + * 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 smoketest.grpcserver; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SampleGrpcServerApplication { + + public static void main(String[] args) { + SpringApplication.run(SampleGrpcServerApplication.class, args); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc-server/src/main/java/smoketest/grpcserver/package-info.java b/smoke-test/spring-boot-smoke-test-grpc-server/src/main/java/smoketest/grpcserver/package-info.java new file mode 100644 index 00000000000..7d6c0d0e9c1 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server/src/main/java/smoketest/grpcserver/package-info.java @@ -0,0 +1,20 @@ +/* + * 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. + */ + +@NullMarked +package smoketest.grpcserver; + +import org.jspecify.annotations.NullMarked; diff --git a/smoke-test/spring-boot-smoke-test-grpc-server/src/main/proto/hello.proto b/smoke-test/spring-boot-smoke-test-grpc-server/src/main/proto/hello.proto new file mode 100644 index 00000000000..636901c419d --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc-server/src/main/proto/hello.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +option java_package = "smoketest.grpcserver.proto"; +option java_multiple_files = true; + +service HelloWorld { + rpc SayHello (HelloRequest) returns (HelloReply) {} + rpc StreamHello(HelloRequest) returns (stream HelloReply) {} +} + +message HelloRequest { + string name = 1; +} + +message HelloReply { + string message = 1; +} diff --git a/starter/spring-boot-starter-grpc-client/build.gradle b/starter/spring-boot-starter-grpc-client/build.gradle new file mode 100644 index 00000000000..cdc21b419ed --- /dev/null +++ b/starter/spring-boot-starter-grpc-client/build.gradle @@ -0,0 +1,28 @@ +/* + * 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. + */ + +plugins { + id "org.springframework.boot.starter" +} + +description = "Starter for using Spring gRPC client" + +dependencies { + api(project(":starter:spring-boot-starter")) + api(project(":module:spring-boot-grpc-client")) + api("io.grpc:grpc-netty") + api("io.grpc:grpc-stub") +} diff --git a/starter/spring-boot-starter-grpc-server/build.gradle b/starter/spring-boot-starter-grpc-server/build.gradle new file mode 100644 index 00000000000..24f9a1f88a3 --- /dev/null +++ b/starter/spring-boot-starter-grpc-server/build.gradle @@ -0,0 +1,28 @@ +/* + * 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. + */ + +plugins { + id "org.springframework.boot.starter" +} + +description = "Starter for using Spring gRPC server" + +dependencies { + api(project(":starter:spring-boot-starter")) + api(project(":module:spring-boot-grpc-server")) + api("io.grpc:grpc-netty") + api("io.grpc:grpc-services") +} diff --git a/starter/spring-boot-starter-grpc-test/build.gradle b/starter/spring-boot-starter-grpc-test/build.gradle new file mode 100644 index 00000000000..d0f2bf871d4 --- /dev/null +++ b/starter/spring-boot-starter-grpc-test/build.gradle @@ -0,0 +1,29 @@ +/* + * 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. + */ + +plugins { + id "org.springframework.boot.starter" +} + +description = "Starter for testing gRPC" + +dependencies { + api(project(":starter:spring-boot-starter-test")) + + api(project(":module:spring-boot-grpc-test")) + api("io.grpc:grpc-inprocess") + api("io.grpc:grpc-stub") +} diff --git a/starter/spring-boot-starter-parent/build.gradle b/starter/spring-boot-starter-parent/build.gradle index 986865eadad..dc612f80b93 100644 --- a/starter/spring-boot-starter-parent/build.gradle +++ b/starter/spring-boot-starter-parent/build.gradle @@ -65,6 +65,29 @@ publishing.publications.withType(MavenPublication) { } pluginManagement { plugins { + plugin { + delegate.groupId('io.github.ascopes') + delegate.artifactId('protobuf-maven-plugin') + configuration { + delegate.protoc('${protobuf-java.version}') + plugins { + plugin(kind: 'binary-maven') { + delegate.groupId('io.grpc') + delegate.artifactId('protoc-gen-grpc-java') + delegate.version('${grpc-java.version}') + delegate.options('@generated=omit') + } + } + } + executions { + execution { + delegate.id('generate') + goals { + delegate.goal('generate') + } + } + } + } plugin { delegate.groupId('org.jetbrains.kotlin') delegate.artifactId('kotlin-maven-plugin')