From b0c00d76023422bb18f824088ee03aa7a72ad4be Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Mon, 9 Mar 2026 12:34:33 -0700 Subject: [PATCH 01/12] Add dependency management for gRPC libraries Closes gh-49670 --- .../boot/build/bom/BomExtension.java | 7 +++ .../DocumentConfigurationProperties.java | 5 ++ gradle.properties | 1 + .../spring-boot-dependencies/build.gradle | 62 +++++++++++++++++++ .../build.gradle | 5 -- 5 files changed, 75 insertions(+), 5 deletions(-) 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/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/platform/spring-boot-dependencies/build.gradle b/platform/spring-boot-dependencies/build.gradle index c97bd74ada1..1c5ec06915a 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 = [ @@ -1883,6 +1907,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 = [ @@ -2525,6 +2570,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..dde74779646 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") { From 32bc51907c0b9b5267a4d808abfd4510b359ece3 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Mon, 2 Mar 2026 14:01:03 -0800 Subject: [PATCH 02/12] Provide protobuf-maven-plugin configuration in starter parent Update `spring-boot-starter-parent` to include sensible configuration for the `protobuf-maven-plugin`. Closes gh-49353 --- .../spring-boot-dependencies/build.gradle | 11 +++++++++ .../spring-boot-starter-parent/build.gradle | 23 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/platform/spring-boot-dependencies/build.gradle b/platform/spring-boot-dependencies/build.gradle index 1c5ec06915a..a8ff76a10ed 100644 --- a/platform/spring-boot-dependencies/build.gradle +++ b/platform/spring-boot-dependencies/build.gradle @@ -1841,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 = [ 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') From a052376237b06cd988257c84c86b6f06d8e14d6a Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Wed, 4 Mar 2026 10:12:02 +0000 Subject: [PATCH 03/12] Configure protobuf when com.google.protobuf plugin is applied Update spring-boot-gradle plugin to apply protobuf configuration when the `com.google.protobuf` plugin is detected. Closes gh-49369 --- .../spring-boot-gradle-plugin/build.gradle | 1 + .../modules/gradle-plugin/pages/reacting.adoc | 11 ++ .../gradle/plugin/ProtobufPluginAction.java | 157 ++++++++++++++++++ .../boot/gradle/plugin/SpringBootPlugin.java | 2 +- .../ProtobufPluginActionIntegrationTests.java | 81 +++++++++ .../testkit/PluginClasspathGradleBuild.java | 5 + ...OfGrpcPluginDependencyWhenSpecified.gradle | 37 +++++ ...sionOfProtocDependencyWhenSpecified.gradle | 35 ++++ ...rotobufPluginActionIntegrationTests.gradle | 63 +++++++ .../antora-asciidoc-attributes.properties | 1 + .../build.gradle | 7 + 11 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 build-plugin/spring-boot-gradle-plugin/src/main/java/org/springframework/boot/gradle/plugin/ProtobufPluginAction.java create mode 100644 build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/plugin/ProtobufPluginActionIntegrationTests.java create mode 100644 build-plugin/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/ProtobufPluginActionIntegrationTests-usesVersionOfGrpcPluginDependencyWhenSpecified.gradle create mode 100644 build-plugin/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/ProtobufPluginActionIntegrationTests-usesVersionOfProtocDependencyWhenSpecified.gradle create mode 100644 build-plugin/spring-boot-gradle-plugin/src/test/resources/org/springframework/boot/gradle/plugin/ProtobufPluginActionIntegrationTests.gradle 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/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/platform/spring-boot-internal-dependencies/build.gradle b/platform/spring-boot-internal-dependencies/build.gradle index dde74779646..06f29d39e9c 100644 --- a/platform/spring-boot-internal-dependencies/build.gradle +++ b/platform/spring-boot-internal-dependencies/build.gradle @@ -219,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 = [ From e61bb6df5bedde613e58bdc07c956f60a4d501a7 Mon Sep 17 00:00:00 2001 From: Chris Bono Date: Mon, 9 Mar 2026 12:37:30 -0700 Subject: [PATCH 04/12] Add Spring gRPC server support Add support for Spring gRPC server applications. Closes gh-49044 Co-authored-by: Phillip Webb --- documentation/spring-boot-docs/build.gradle | 1 + .../build.gradle | 3 + module/spring-boot-grpc-server/build.gradle | 52 ++ .../grpc/server/GrpcServletRegistration.java | 116 +++++ ...ConditionalOnGrpcServerFactoryEnabled.java | 40 ++ ...ConditionalOnMissingNetworkGrpcServer.java | 47 ++ .../GrpcServerAutoConfiguration.java | 107 ++++ .../GrpcServerBuilderCustomizers.java | 89 ++++ .../GrpcServerCodecConfiguration.java | 80 +++ .../GrpcServerExecutorProvider.java | 36 ++ .../GrpcServerFactoryCustomizer.java | 37 ++ .../autoconfigure/GrpcServerProperties.java | 452 +++++++++++++++++ .../GrpcServerServicesAutoConfiguration.java | 58 +++ .../InProcessGrpcServerConfiguration.java | 71 +++ .../MissingNetworkGrpcServerCondition.java | 47 ++ .../server/autoconfigure/NettyAddress.java | 94 ++++ .../NettyGrpcServerConfiguration.java | 74 +++ .../PropertiesServerBuilderCustomizer.java | 89 ++++ .../autoconfigure/ServerCredentials.java | 66 +++ .../ServletGrpcServerConfiguration.java | 51 ++ .../ShadedNettyGrpcServerConfiguration.java | 74 +++ .../server/autoconfigure/package-info.java | 23 + .../boot/grpc/server/package-info.java | 23 + ...itional-spring-configuration-metadata.json | 29 ++ .../main/resources/META-INF/spring.factories | 2 + ...ot.autoconfigure.AutoConfiguration.imports | 2 + .../server/GrpcServletRegistrationTests.java | 152 ++++++ .../GrpcServerAutoConfigurationTests.java | 465 ++++++++++++++++++ .../GrpcServerBuilderCustomizersTests.java | 155 ++++++ .../GrpcServerCodecConfigurationTests.java | 92 ++++ .../GrpcServerPropertiesTests.java | 169 +++++++ ...cServerServicesAutoConfigurationTests.java | 104 ++++ .../autoconfigure/NettyAddressTests.java | 115 +++++ ...ropertiesServerBuilderCustomizerTests.java | 98 ++++ .../autoconfigure/ServerCredentialsTests.java | 138 ++++++ .../boot/grpc/server/autoconfigure/test.jks | Bin 0 -> 1276 bytes .../spring-boot-dependencies/build.gradle | 2 + settings.gradle | 5 + .../build.gradle | 67 +++ ...GrpcServerNettyShadedApplicationTests.java | 76 +++ .../HelloWorldService.java | 68 +++ ...ampleGrpcServerNettyShadedApplication.java | 29 ++ .../grpcservernettyshaded/package-info.java | 20 + .../src/main/proto/hello.proto | 17 + .../build.gradle | 69 +++ ...mpleGrpcServerServletApplicationTests.java | 57 +++ .../grpcserverservlet/HelloWorldService.java | 68 +++ .../SampleGrpcServerServletApplication.java | 29 ++ .../grpcserverservlet/package-info.java | 20 + .../src/main/proto/hello.proto | 17 + .../src/main/resources/application.properties | 1 + .../build.gradle | 64 +++ .../SampleGrpcServerApplicationTests.java | 76 +++ .../grpcserver/HelloWorldService.java | 68 +++ .../SampleGrpcServerApplication.java | 29 ++ .../smoketest/grpcserver/package-info.java | 20 + .../src/main/proto/hello.proto | 17 + .../build.gradle | 28 ++ 58 files changed, 4098 insertions(+) create mode 100644 module/spring-boot-grpc-server/build.gradle create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/GrpcServletRegistration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ConditionalOnGrpcServerFactoryEnabled.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ConditionalOnMissingNetworkGrpcServer.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerAutoConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerBuilderCustomizers.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerCodecConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerExecutorProvider.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerFactoryCustomizer.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerProperties.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerServicesAutoConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/InProcessGrpcServerConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/MissingNetworkGrpcServerCondition.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/NettyAddress.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/NettyGrpcServerConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/PropertiesServerBuilderCustomizer.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ServerCredentials.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ServletGrpcServerConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ShadedNettyGrpcServerConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/package-info.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/package-info.java create mode 100644 module/spring-boot-grpc-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json create mode 100644 module/spring-boot-grpc-server/src/main/resources/META-INF/spring.factories create mode 100644 module/spring-boot-grpc-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/GrpcServletRegistrationTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerAutoConfigurationTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerBuilderCustomizersTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerCodecConfigurationTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerPropertiesTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerServicesAutoConfigurationTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/NettyAddressTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/PropertiesServerBuilderCustomizerTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/ServerCredentialsTests.java create mode 100644 module/spring-boot-grpc-server/src/test/resources/org/springframework/boot/grpc/server/autoconfigure/test.jks create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-netty-shaded/build.gradle create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-netty-shaded/src/dockerTest/java/smoketest/grpcservernettyshaded/SampleGrpcServerNettyShadedApplicationTests.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-netty-shaded/src/main/java/smoketest/grpcservernettyshaded/HelloWorldService.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-netty-shaded/src/main/java/smoketest/grpcservernettyshaded/SampleGrpcServerNettyShadedApplication.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-netty-shaded/src/main/java/smoketest/grpcservernettyshaded/package-info.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-netty-shaded/src/main/proto/hello.proto create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-servlet/build.gradle create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-servlet/src/dockerTest/java/smoketest/grpcserverservlet/SampleGrpcServerServletApplicationTests.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-servlet/src/main/java/smoketest/grpcserverservlet/HelloWorldService.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-servlet/src/main/java/smoketest/grpcserverservlet/SampleGrpcServerServletApplication.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-servlet/src/main/java/smoketest/grpcserverservlet/package-info.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-servlet/src/main/proto/hello.proto create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-servlet/src/main/resources/application.properties create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server/build.gradle create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server/src/dockerTest/java/smoketest/grpcserver/SampleGrpcServerApplicationTests.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server/src/main/java/smoketest/grpcserver/HelloWorldService.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server/src/main/java/smoketest/grpcserver/SampleGrpcServerApplication.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server/src/main/java/smoketest/grpcserver/package-info.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server/src/main/proto/hello.proto create mode 100644 starter/spring-boot-starter-grpc-server/build.gradle diff --git a/documentation/spring-boot-docs/build.gradle b/documentation/spring-boot-docs/build.gradle index e89fb1017b0..48fc323e992 100644 --- a/documentation/spring-boot-docs/build.gradle +++ b/documentation/spring-boot-docs/build.gradle @@ -110,6 +110,7 @@ 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-server")) 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/module/spring-boot-autoconfigure-classic-modules/build.gradle b/module/spring-boot-autoconfigure-classic-modules/build.gradle index 3075fd4970f..b1050d6f593 100644 --- a/module/spring-boot-autoconfigure-classic-modules/build.gradle +++ b/module/spring-boot-autoconfigure-classic-modules/build.gradle @@ -97,6 +97,9 @@ dependencies { api(project(":module:spring-boot-graphql")) { 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-server/build.gradle b/module/spring-boot-grpc-server/build.gradle new file mode 100644 index 00000000000..cd26fcf32e5 --- /dev/null +++ b/module/spring-boot-grpc-server/build.gradle @@ -0,0 +1,52 @@ +/* + * 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("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("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/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..c3f97ea2a50 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerProperties.java @@ -0,0 +1,452 @@ +/* + * 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(); + + 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; + } + + /** + * 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 + + } + + } + +} 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..95e6ab7e436 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ServletGrpcServerConfiguration.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.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.grpc.server.GrpcServletRegistration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +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(GrpcServiceDiscoverer serviceDiscoverer, + GrpcServiceConfigurer serviceConfigurer, GrpcServerBuilderCustomizers grpcServerBuilderCustomizers) { + 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/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/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..d99721c35be --- /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.reflection.enabled", + "type": "java.lang.Boolean", + "description": "Whether to enable Reflection on the gRPC server.", + "defaultValue": true + }, + { + "name": "spring.grpc.server.servlet.enabled", + "type": "java.lang.Boolean", + "description": "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", + "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..b5ab2390842 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +org.springframework.boot.grpc.server.autoconfigure.GrpcServerAutoConfiguration +org.springframework.boot.grpc.server.autoconfigure.GrpcServerServicesAutoConfiguration 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..b0066cb0d19 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerAutoConfigurationTests.java @@ -0,0 +1,465 @@ +/* + * 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); + + @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)); + } + + } + + @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/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/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 0000000000000000000000000000000000000000..0fc3e802f75461dd074facb9611d350db4d5960f GIT binary patch literal 1276 zcmezO_TO6u1_mZ5W@O+hNi8nXP0YzmEM{O}OjTL=XFE`?-k{cikBv*4jgf^>i%F1? zk(GfZ`?F{4vBFug6<%MmmXJ+)mEgQoslXV6!5_H^yana6V1?kw1w zbE0Bi&GHlLHzfosgjrwL)qKcc5I;l0LF?W2lpQg%-cHp$l(#o)?Jkath1@gQN{hG8 zig@wKv#0R7vd_QC=JG%%Ffy=4=$RT=0v*d`(8R=M(8RcU0W%XL6BCP-)w&Y~JZv0V zZ64=rS(uqv84M~6g$xAPm_u3EggJBalM{0?@{3DgVjNh+*s+LlVG-lTBF2m)W*{fd zYiMC$VQ64zW@K(?5e4L0B5?=MWswHLZ0z7LVq$~_7BeF|vl9agPmO-znfkD()@R+bGrT$(AXmN24#zv*XRX z#$UNu(Lmln78u;Jd@N!tBKmU@J0!OJc3G%!N>OO@P1n+F-CorAVRmOQaA8six!iWP z)M3lXpnJ*TI=kIlH(Yxia-ls?xvctEx&P5B6()tKm`>%bo~@fX9{j%TtMU1G!|pw& zZ6BRjIqQ^`bIxR@OmMno&8^H%tpq36Esh&T(+MJ_la+#pV>+3scS% 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-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/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-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") +} From c35b21adb40fc7e5a267523fd542f6946f60af94 Mon Sep 17 00:00:00 2001 From: Chris Bono Date: Tue, 10 Feb 2026 18:50:58 -0800 Subject: [PATCH 05/12] Add Spring gRPC client support Add support for Spring gRPC client applications. Closes gh-49045 Co-authored-by: Phillip Webb --- documentation/spring-boot-docs/build.gradle | 1 + .../build.gradle | 3 + module/spring-boot-grpc-client/build.gradle | 53 +++ ...positeChannelFactoryAutoConfiguration.java | 73 ++++ ...onalOnGrpcClientChannelFactoryEnabled.java | 40 ++ .../GrpcChannelBuilderCustomizers.java | 113 +++++ .../GrpcChannelFactoryCustomizer.java | 38 ++ .../GrpcClientAutoConfiguration.java | 98 +++++ .../GrpcClientCodecConfiguration.java | 83 ++++ ...cClientDefaultServiceConfigCustomizer.java | 49 +++ .../autoconfigure/GrpcClientProperties.java | 369 ++++++++++++++++ .../InProcessGrpcClientConfiguration.java | 56 +++ .../NettyGrpcClientConfiguration.java | 60 +++ .../PropertiesChannelCredentialsProvider.java | 65 +++ ...ropertiesGrpcChannelBuilderCustomizer.java | 94 +++++ ...cClientDefaultServiceConfigCustomizer.java | 44 ++ .../PropertiesVirtualTargets.java | 59 +++ .../ShadedNettyGrpcClientConfiguration.java | 58 +++ .../client/autoconfigure/package-info.java | 23 + ...itional-spring-configuration-metadata.json | 23 + ...ot.autoconfigure.AutoConfiguration.imports | 2 + ...eChannelFactoryAutoConfigurationTests.java | 140 +++++++ .../GrpcChannelBuilderCustomizersTests.java | 242 +++++++++++ .../GrpcClientAutoConfigurationTests.java | 396 ++++++++++++++++++ .../GrpcClientPropertiesTests.java | 158 +++++++ .../GrpcCodecConfigurationTests.java | 100 +++++ ...ertiesChannelCredentialsProviderTests.java | 179 ++++++++ ...tiesGrpcChannelBuilderCustomizerTests.java | 209 +++++++++ .../PropertiesVirtualTargetsTests.java | 129 ++++++ .../test/scan/DummyBlockingGrpc.java | 46 ++ .../test/scan/DummyBlockingV2Grpc.java | 46 ++ .../autoconfigure/test/scan/package-info.java | 20 + .../spring-boot-dependencies/build.gradle | 2 + settings.gradle | 3 + .../build.gradle | 63 +++ .../SampleGrpcClientApplication.java | 46 ++ .../smoketest/grpcclient/package-info.java | 20 + .../src/main/proto/hello.proto | 17 + .../src/main/resources/application.yaml | 6 + .../SampleGrpcClientApplicationTests.java | 89 ++++ .../build.gradle | 28 ++ 41 files changed, 3343 insertions(+) create mode 100644 module/spring-boot-grpc-client/build.gradle create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/CompositeChannelFactoryAutoConfiguration.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ConditionalOnGrpcClientChannelFactoryEnabled.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelBuilderCustomizers.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelFactoryCustomizer.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfiguration.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientCodecConfiguration.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientDefaultServiceConfigCustomizer.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientProperties.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/InProcessGrpcClientConfiguration.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/NettyGrpcClientConfiguration.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesChannelCredentialsProvider.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcChannelBuilderCustomizer.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcClientDefaultServiceConfigCustomizer.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesVirtualTargets.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ShadedNettyGrpcClientConfiguration.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/package-info.java create mode 100644 module/spring-boot-grpc-client/src/main/resources/META-INF/additional-spring-configuration-metadata.json create mode 100644 module/spring-boot-grpc-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/CompositeChannelFactoryAutoConfigurationTests.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelBuilderCustomizersTests.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfigurationTests.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientPropertiesTests.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcCodecConfigurationTests.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesChannelCredentialsProviderTests.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcChannelBuilderCustomizerTests.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesVirtualTargetsTests.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/test/scan/DummyBlockingGrpc.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/test/scan/DummyBlockingV2Grpc.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/test/scan/package-info.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-client/build.gradle create mode 100644 smoke-test/spring-boot-smoke-test-grpc-client/src/main/java/smoketest/grpcclient/SampleGrpcClientApplication.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-client/src/main/java/smoketest/grpcclient/package-info.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-client/src/main/proto/hello.proto create mode 100644 smoke-test/spring-boot-smoke-test-grpc-client/src/main/resources/application.yaml create mode 100644 smoke-test/spring-boot-smoke-test-grpc-client/src/test/java/smoketest/grpcclient/SampleGrpcClientApplicationTests.java create mode 100644 starter/spring-boot-starter-grpc-client/build.gradle diff --git a/documentation/spring-boot-docs/build.gradle b/documentation/spring-boot-docs/build.gradle index 48fc323e992..f18dcbcec10 100644 --- a/documentation/spring-boot-docs/build.gradle +++ b/documentation/spring-boot-docs/build.gradle @@ -110,6 +110,7 @@ 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-health")) implementation(project(path: ":module:spring-boot-hibernate")) diff --git a/module/spring-boot-autoconfigure-classic-modules/build.gradle b/module/spring-boot-autoconfigure-classic-modules/build.gradle index b1050d6f593..2b45c0d9ec7 100644 --- a/module/spring-boot-autoconfigure-classic-modules/build.gradle +++ b/module/spring-boot-autoconfigure-classic-modules/build.gradle @@ -97,6 +97,9 @@ 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 } diff --git a/module/spring-boot-grpc-client/build.gradle b/module/spring-boot-grpc-client/build.gradle new file mode 100644 index 00000000000..4bf990aff43 --- /dev/null +++ b/module/spring-boot-grpc-client/build.gradle @@ -0,0 +1,53 @@ +/* + * 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-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" + } + + 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/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..f015938d1be --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientProperties.java @@ -0,0 +1,369 @@ +/* + * 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; + + 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 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..58026ae3e93 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/PropertiesGrpcClientDefaultServiceConfigCustomizer.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.client.autoconfigure; + +import java.util.Map; + +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel; + +/** + * {@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 && channel.getHealth().isEnabled()) { + String serviceName = channel.getHealth().getServiceName(); + Map healthCheckConfig = Map.of("serviceName", (serviceName != null) ? serviceName : ""); + defaultServiceConfig.put("healthCheckConfig", healthCheckConfig); + } + } + +} 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/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..0b2cfcc4bd2 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,23 @@ +{ + "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 + } + ] +} 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..e2d9c74b8c4 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +org.springframework.boot.grpc.client.autoconfigure.CompositeChannelFactoryAutoConfiguration +org.springframework.boot.grpc.client.autoconfigure.GrpcClientAutoConfiguration 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..d49b4bfc585 --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelBuilderCustomizersTests.java @@ -0,0 +1,242 @@ +/* + * 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.grpc.client.GrpcChannelBuilderCustomizer; + +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 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 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/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/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/platform/spring-boot-dependencies/build.gradle b/platform/spring-boot-dependencies/build.gradle index ceba585781f..3569519a5d6 100644 --- a/platform/spring-boot-dependencies/build.gradle +++ b/platform/spring-boot-dependencies/build.gradle @@ -2163,6 +2163,7 @@ bom { "spring-boot-freemarker", "spring-boot-graphql", "spring-boot-graphql-test", + "spring-boot-grpc-client", "spring-boot-grpc-server", "spring-boot-groovy-templates", "spring-boot-gson", @@ -2294,6 +2295,7 @@ 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-groovy-templates", "spring-boot-starter-groovy-templates-test", diff --git a/settings.gradle b/settings.gradle index da12a2937fd..8b44e0b5e4e 100644 --- a/settings.gradle +++ b/settings.gradle @@ -120,6 +120,7 @@ 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-groovy-templates" include "module:spring-boot-gson" @@ -269,6 +270,7 @@ 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-groovy-templates" include "starter:spring-boot-starter-groovy-templates-test" @@ -423,6 +425,7 @@ 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-server" include ":smoke-test:spring-boot-smoke-test-grpc-server-netty-shaded" include ":smoke-test:spring-boot-smoke-test-grpc-server-servlet" 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/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") +} From be3a1b71178d4c674d4636a236ebea9d850f21b4 Mon Sep 17 00:00:00 2001 From: Chris Bono Date: Thu, 12 Feb 2026 16:15:13 -0800 Subject: [PATCH 06/12] Add Spring gRPC test support Add a testing module that can be used for testing Spring gRPC server or client applications. Closes gh-49046 Co-authored-by: Phillip Webb --- documentation/spring-boot-docs/build.gradle | 1 + .../reference/pages/testing/test-modules.adoc | 3 + module/spring-boot-grpc-test/build.gradle | 43 +++++++ .../AutoConfigureTestGrpcTransport.java | 77 ++++++++++++ ...PortInfoApplicationContextInitializer.java | 106 +++++++++++++++++ .../autoconfigure/LocalGrpcServerPort.java | 43 +++++++ .../autoconfigure/TestGrpcChannelFactory.java | 51 ++++++++ .../autoconfigure/TestGrpcServerFactory.java | 43 +++++++ .../TestGrpcTransportAutoConfiguration.java | 112 ++++++++++++++++++ .../grpc/test/autoconfigure/package-info.java | 23 ++++ .../main/resources/META-INF/spring.factories | 3 + ...ure.AutoConfigureTestGrpcTransport.imports | 1 + ...nfigureTestGrpcTransportOverrideTests.java | 44 +++++++ .../AutoConfigureTestGrpcTransportTests.java | 44 +++++++ ...nfoApplicationContextInitializerTests.java | 92 ++++++++++++++ .../TestGrpcChannelFactoryTests.java | 47 ++++++++ .../TestGrpcServerFactoryTests.java | 40 +++++++ ...stGrpcTransportAutoConfigurationTests.java | 107 +++++++++++++++++ .../build.gradle | 3 + .../spring-boot-dependencies/build.gradle | 2 + settings.gradle | 4 + .../build.gradle | 63 ++++++++++ .../SampleGrpcClientTestApplication.java | 46 +++++++ .../grpcclienttest/package-info.java | 20 ++++ .../src/main/proto/hello.proto | 17 +++ .../SampleGrpcClientTestApplicationTests.java | 58 +++++++++ .../build.gradle | 63 ++++++++++ .../grpcservertest/HelloWorldService.java | 70 +++++++++++ .../SampleGrpcServerTestApplication.java | 29 +++++ .../StandardExceptionHandler.java | 41 +++++++ .../grpcservertest/package-info.java | 20 ++++ .../src/main/proto/hello.proto | 17 +++ ...ServerTestApplicationIntegrationTests.java | 54 +++++++++ .../SampleGrpcServerTestApplicationTests.java | 63 ++++++++++ .../build.gradle | 29 +++++ 35 files changed, 1479 insertions(+) create mode 100644 module/spring-boot-grpc-test/build.gradle create mode 100644 module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/AutoConfigureTestGrpcTransport.java create mode 100644 module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/GrpcPortInfoApplicationContextInitializer.java create mode 100644 module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/LocalGrpcServerPort.java create mode 100644 module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/TestGrpcChannelFactory.java create mode 100644 module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/TestGrpcServerFactory.java create mode 100644 module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/TestGrpcTransportAutoConfiguration.java create mode 100644 module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/package-info.java create mode 100644 module/spring-boot-grpc-test/src/main/resources/META-INF/spring.factories create mode 100644 module/spring-boot-grpc-test/src/main/resources/META-INF/spring/org.springframework.boot.grpc.test.autoconfigure.AutoConfigureTestGrpcTransport.imports create mode 100644 module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/AutoConfigureTestGrpcTransportOverrideTests.java create mode 100644 module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/AutoConfigureTestGrpcTransportTests.java create mode 100644 module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/GrpcPortInfoApplicationContextInitializerTests.java create mode 100644 module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/TestGrpcChannelFactoryTests.java create mode 100644 module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/TestGrpcServerFactoryTests.java create mode 100644 module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/TestGrpcTransportAutoConfigurationTests.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-client-test/build.gradle create mode 100644 smoke-test/spring-boot-smoke-test-grpc-client-test/src/main/java/smoketest/grpcclienttest/SampleGrpcClientTestApplication.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-client-test/src/main/java/smoketest/grpcclienttest/package-info.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-client-test/src/main/proto/hello.proto create mode 100644 smoke-test/spring-boot-smoke-test-grpc-client-test/src/test/java/smoketest/grpcclienttest/SampleGrpcClientTestApplicationTests.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-test/build.gradle create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-test/src/main/java/smoketest/grpcservertest/HelloWorldService.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-test/src/main/java/smoketest/grpcservertest/SampleGrpcServerTestApplication.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-test/src/main/java/smoketest/grpcservertest/StandardExceptionHandler.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-test/src/main/java/smoketest/grpcservertest/package-info.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-test/src/main/proto/hello.proto create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-test/src/test/java/smoketest/grpcservertest/SampleGrpcServerTestApplicationIntegrationTests.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-test/src/test/java/smoketest/grpcservertest/SampleGrpcServerTestApplicationTests.java create mode 100644 starter/spring-boot-starter-grpc-test/build.gradle diff --git a/documentation/spring-boot-docs/build.gradle b/documentation/spring-boot-docs/build.gradle index f18dcbcec10..fe01de87d6f 100644 --- a/documentation/spring-boot-docs/build.gradle +++ b/documentation/spring-boot-docs/build.gradle @@ -112,6 +112,7 @@ dependencies { 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/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-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 3569519a5d6..4f5925d72a9 100644 --- a/platform/spring-boot-dependencies/build.gradle +++ b/platform/spring-boot-dependencies/build.gradle @@ -2165,6 +2165,7 @@ bom { "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", @@ -2297,6 +2298,7 @@ bom { "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", diff --git a/settings.gradle b/settings.gradle index 8b44e0b5e4e..28dffcc3a6a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -122,6 +122,7 @@ 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" @@ -272,6 +273,7 @@ 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" @@ -426,9 +428,11 @@ 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-servlet" +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-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/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") +} From b650f3c5581b3e50c01ba5405dbad1827053a6fc Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Sun, 22 Feb 2026 11:02:25 -0800 Subject: [PATCH 07/12] Rework spring-boot-health for reuse Update `spring-boot-health` to allow for better reuse outside of the actuator module. See gh-49048 --- .../HealthEndpointDocumentationTests.java | 6 +- .../health/TestHealthEndpointGroup.java | 6 +- .../endpoint/HttpCodeStatusMapper.java | 30 ++++ .../endpoint/SimpleHttpCodeStatusMapper.java | 35 ++-- .../endpoint/SimpleStatusAggregator.java | 78 ++++----- .../actuate/endpoint/StatusAggregator.java | 58 ++++++- .../AutoConfiguredHealthEndpointGroup.java | 14 +- .../AutoConfiguredHealthEndpointGroups.java | 63 ++++--- ...AvailabilityProbesHealthEndpointGroup.java | 2 +- .../endpoint/HealthEndpointConfiguration.java | 120 ++------------ ...oSuchHealthContributorFailureAnalyzer.java | 38 ----- .../HealthContributorMembership.java | 59 +++++++ .../HealthContributorMembershipValidator.java | 140 ++++++++++++++++ ...deExcludeHealthContributorMembership.java} | 9 +- .../boot/health/contributor/Status.java | 8 + .../main/resources/META-INF/spring.factories | 3 - .../endpoint/HttpCodeStatusMapperTests.java | 65 ++++++++ .../SimpleHttpCodeStatusMapperTests.java | 1 + .../endpoint/SimpleStatusAggregatorTests.java | 1 + .../endpoint/StatusAggregatorTests.java | 70 ++++++++ .../endpoint/TestHealthEndpointGroup.java | 4 +- ...utoConfiguredHealthEndpointGroupTests.java | 4 +- ...toConfiguredHealthEndpointGroupsTests.java | 10 +- ...abilityProbesHealthEndpointGroupTests.java | 2 +- .../HealthEndpointAutoConfigurationTests.java | 12 +- ...cludeExcludeGroupMemberPredicateTests.java | 144 ---------------- ...HealthContributorFailureAnalyzerTests.java | 66 -------- .../HealthContributorMembershipTests.java | 155 ++++++++++++++++++ ...thContributorMembershipValidatorTests.java | 97 +++++++++++ 29 files changed, 794 insertions(+), 506 deletions(-) delete mode 100644 module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/NoSuchHealthContributorFailureAnalyzer.java create mode 100644 module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/contributor/HealthContributorMembership.java create mode 100644 module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/contributor/HealthContributorMembershipValidator.java rename module/spring-boot-health/src/main/java/org/springframework/boot/health/autoconfigure/{actuate/endpoint/IncludeExcludeGroupMemberPredicate.java => contributor/IncludeExcludeHealthContributorMembership.java} (88%) delete mode 100644 module/spring-boot-health/src/main/resources/META-INF/spring.factories create mode 100644 module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/HttpCodeStatusMapperTests.java create mode 100644 module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/StatusAggregatorTests.java delete mode 100644 module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/IncludeExcludeGroupMemberPredicateTests.java delete mode 100644 module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/actuate/endpoint/NoSuchHealthContributorFailureAnalyzerTests.java create mode 100644 module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/contributor/HealthContributorMembershipTests.java create mode 100644 module/spring-boot-health/src/test/java/org/springframework/boot/health/autoconfigure/contributor/HealthContributorMembershipValidatorTests.java 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/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-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(); + } + +} From 67ac57fb77560dcd86ac100da5ba318869a73b58 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Sun, 22 Feb 2026 11:02:36 -0800 Subject: [PATCH 08/12] Add Spring gRPC server health check support Add integration between Spring Boot's health module and gRPC health checking service support. Closes gh-49048 Co-authored-by: Chris Bono --- module/spring-boot-grpc-server/build.gradle | 3 + ...oConfiguredHealthCheckedGrpcComponent.java | 59 +++ ...ConfiguredHealthCheckedGrpcComponents.java | 144 ++++++ .../GrpcServerHealthAutoConfiguration.java | 142 ++++++ .../health/GrpcServerHealthProperties.java | 248 ++++++++++ .../health/GrpcServerHealthScheduler.java | 69 +++ ...erverHealthSchedulerAutoConfiguration.java | 58 +++ .../autoconfigure/health/package-info.java | 23 + .../grpc/server/health/GrpcServerHealth.java | 122 +++++ .../health/HealthCheckedGrpcComponent.java | 46 ++ .../health/HealthCheckedGrpcComponents.java | 54 +++ .../health/SimpleServingStatusMapper.java | 79 +++ .../server/health/SimpleStatusAggregator.java | 74 +++ .../grpc/server/health/StatusAggregator.java | 95 ++++ .../boot/grpc/server/health/StatusMapper.java | 62 +++ .../boot/grpc/server/health/package-info.java | 23 + ...ot.autoconfigure.AutoConfiguration.imports | 2 + ...iguredHealthCheckedGrpcComponentTests.java | 75 +++ ...guredHealthCheckedGrpcComponentsTests.java | 437 +++++++++++++++++ ...rpcServerHealthAutoConfigurationTests.java | 458 ++++++++++++++++++ .../GrpcServerHealthSchedulerTests.java | 79 +++ .../server/health/GrpcServerHealthTests.java | 293 +++++++++++ .../server/health/StatusAggregatorTests.java | 71 +++ .../grpc/server/health/StatusMapperTests.java | 64 +++ 24 files changed, 2780 insertions(+) create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponent.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponents.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthProperties.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthScheduler.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthSchedulerAutoConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/package-info.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/GrpcServerHealth.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/HealthCheckedGrpcComponent.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/HealthCheckedGrpcComponents.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/SimpleServingStatusMapper.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/SimpleStatusAggregator.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/StatusAggregator.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/StatusMapper.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/package-info.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponentTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponentsTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfigurationTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthSchedulerTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/health/GrpcServerHealthTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/health/StatusAggregatorTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/health/StatusMapperTests.java diff --git a/module/spring-boot-grpc-server/build.gradle b/module/spring-boot-grpc-server/build.gradle index cd26fcf32e5..3f730227c6a 100644 --- a/module/spring-boot-grpc-server/build.gradle +++ b/module/spring-boot-grpc-server/build.gradle @@ -29,6 +29,9 @@ dependencies { api("org.springframework.grpc:spring-grpc-core") optional(project(":core:spring-boot-autoconfigure")) + optional(project(":module:spring-boot-health")) + 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") 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/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/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 index b5ab2390842..c060e23ab8d 100644 --- 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 @@ -1,2 +1,4 @@ org.springframework.boot.grpc.server.autoconfigure.GrpcServerAutoConfiguration org.springframework.boot.grpc.server.autoconfigure.GrpcServerServicesAutoConfiguration +org.springframework.boot.grpc.server.autoconfigure.health.GrpcServerHealthAutoConfiguration +org.springframework.boot.grpc.server.autoconfigure.health.GrpcServerHealthSchedulerAutoConfiguration \ No newline at end of file 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/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); + } + +} From d6b28375c027331c99f1734a7232b72059acea42 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Tue, 24 Feb 2026 22:38:40 -0800 Subject: [PATCH 09/12] Validate 'server.http2.enabled' for gRPC Servlet applications Throw an error is a Spring gRPC Servlet based server application has forgotten to configure HTTP/2 support. There is also the option of suppressing the error if HTTP/2 has been configured directly and not using the configuration property. Closes gh-49050 --- .../autoconfigure/GrpcServerProperties.java | 43 +++++++++++++++++++ .../ServletGrpcServerConfiguration.java | 14 +++++- ...itional-spring-configuration-metadata.json | 6 --- .../GrpcServerAutoConfigurationTests.java | 30 ++++++++++++- 4 files changed, 84 insertions(+), 9 deletions(-) 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 index c3f97ea2a50..0cd2266f106 100644 --- 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 @@ -61,6 +61,8 @@ public class GrpcServerProperties { private final Netty netty = new Netty(); + private final Servlet servlet = new Servlet(); + public @Nullable Integer getPort() { return this.port; } @@ -101,6 +103,10 @@ public class GrpcServerProperties { return this.netty; } + public Servlet getServlet() { + return this.servlet; + } + /** * Server shutdown properties. */ @@ -449,4 +455,41 @@ public class GrpcServerProperties { } + /** + * 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/ServletGrpcServerConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ServletGrpcServerConfiguration.java index 95e6ab7e436..5ac366e658a 100644 --- 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 @@ -21,9 +21,11 @@ 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; @@ -43,8 +45,16 @@ import org.springframework.grpc.server.service.GrpcServiceDiscoverer; class ServletGrpcServerConfiguration { @Bean - GrpcServletRegistration grpcServletRegistration(GrpcServiceDiscoverer serviceDiscoverer, - GrpcServiceConfigurer serviceConfigurer, GrpcServerBuilderCustomizers grpcServerBuilderCustomizers) { + 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/resources/META-INF/additional-spring-configuration-metadata.json b/module/spring-boot-grpc-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json index d99721c35be..12640064b22 100644 --- 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 @@ -18,12 +18,6 @@ "type": "java.lang.Boolean", "description": "Whether to enable Reflection on the gRPC server.", "defaultValue": true - }, - { - "name": "spring.grpc.server.servlet.enabled", - "type": "java.lang.Boolean", - "description": "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", - "defaultValue": true } ] } 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 index b0066cb0d19..497715b3f44 100644 --- 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 @@ -374,7 +374,8 @@ class GrpcServerAutoConfigurationTests { private WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() .withConfiguration(autoConfigurations) - .with(GrpcServerAutoConfigurationTests.this::serviceBean); + .with(GrpcServerAutoConfigurationTests.this::serviceBean) + .withPropertyValues("server.http2.enabled=true"); @Test void whenGrpcNotOnClasspathAutoConfigurationIsSkipped() { @@ -439,6 +440,33 @@ class GrpcServerAutoConfigurationTests { .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) From 1b41cb20c18b6a7f8bb7a87c68d9bc1971792c2a Mon Sep 17 00:00:00 2001 From: Chris Bono Date: Wed, 25 Feb 2026 20:50:52 -0800 Subject: [PATCH 10/12] Add Spring gRPC client and server observation support Update client and server code to support micrometer observations. Closes gh-49056 Co-authored-by: Phillip Webb --- module/spring-boot-grpc-client/build.gradle | 1 + ...rpcClientObservationAutoConfiguration.java | 62 ++++++++ ...itional-spring-configuration-metadata.json | 6 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + ...ientObservationAutoConfigurationTests.java | 112 +++++++++++++++ module/spring-boot-grpc-server/build.gradle | 3 + ...rpcServerObservationAutoConfiguration.java | 73 ++++++++++ ...itional-spring-configuration-metadata.json | 6 + ...ot.autoconfigure.AutoConfiguration.imports | 3 +- ...rverObservationAutoConfigurationTests.java | 133 ++++++++++++++++++ 10 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientObservationAutoConfiguration.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientObservationAutoConfigurationTests.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerObservationAutoConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerObservationAutoConfigurationTests.java diff --git a/module/spring-boot-grpc-client/build.gradle b/module/spring-boot-grpc-client/build.gradle index 4bf990aff43..52bf7893628 100644 --- a/module/spring-boot-grpc-client/build.gradle +++ b/module/spring-boot-grpc-client/build.gradle @@ -40,6 +40,7 @@ dependencies { 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")) 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/resources/META-INF/additional-spring-configuration-metadata.json b/module/spring-boot-grpc-client/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 0b2cfcc4bd2..262cb1f229f 100644 --- 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 @@ -18,6 +18,12 @@ "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 index e2d9c74b8c4..7d3249ab92f 100644 --- 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 @@ -1,2 +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/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-server/build.gradle b/module/spring-boot-grpc-server/build.gradle index 3f730227c6a..581e145e640 100644 --- a/module/spring-boot-grpc-server/build.gradle +++ b/module/spring-boot-grpc-server/build.gradle @@ -30,6 +30,7 @@ dependencies { optional(project(":core:spring-boot-autoconfigure")) optional(project(":module:spring-boot-health")) + optional(project(":module:spring-boot-micrometer-observation")) optional("com.fasterxml.jackson.core:jackson-annotations") optional("io.projectreactor:reactor-core") optional("io.grpc:grpc-servlet-jakarta") @@ -40,6 +41,8 @@ dependencies { 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")) 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/resources/META-INF/additional-spring-configuration-metadata.json b/module/spring-boot-grpc-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 12640064b22..a49d405d6c2 100644 --- 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 @@ -13,6 +13,12 @@ "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", 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 index c060e23ab8d..00f402214f4 100644 --- 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 @@ -1,4 +1,5 @@ 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 \ No newline at end of file +org.springframework.boot.grpc.server.autoconfigure.health.GrpcServerHealthSchedulerAutoConfiguration 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)); + } + +} From 1cb8d02ed7d61308ec88925de9701474662aa2fa Mon Sep 17 00:00:00 2001 From: Chris Bono Date: Sun, 15 Mar 2026 20:05:29 -0700 Subject: [PATCH 11/12] Add Spring gRPC server and client security support Add auto-configuration to integrate gRPC server applications with Spring Security. This commit provides both standard Spring Security support as well as OAuth support. Closes gh-49047 Co-authored-by: Phillip Webb --- module/spring-boot-grpc-server/build.gradle | 2 + .../GrpcDisableCsrfHttpConfigurer.java | 86 +++++++ ...OAuth2ResourceServerAutoConfiguration.java | 76 ++++++ .../GrpcServerSecurityAutoConfiguration.java | 127 ++++++++++ .../autoconfigure/security/package-info.java | 23 ++ .../security/web/reactive/GrpcRequest.java | 148 ++++++++++++ .../security/web/reactive/package-info.java | 23 ++ .../security/web/servlet/GrpcRequest.java | 148 ++++++++++++ .../security/web/servlet/package-info.java | 23 ++ ...ot.autoconfigure.AutoConfiguration.imports | 2 + .../GrpcDisableCsrfHttpConfigurerTests.java | 167 +++++++++++++ ...2ResourceServerAutoConfigurationTests.java | 170 ++++++++++++++ ...cServerSecurityAutoConfigurationTests.java | 152 ++++++++++++ .../web/reactive/GrpcRequestTests.java | 114 +++++++++ .../web/servlet/GrpcRequestTests.java | 93 ++++++++ settings.gradle | 2 + .../build.gradle | 68 ++++++ .../grpcserveroauth/HelloWorldService.java | 60 +++++ .../SampleGrpcServerOAuthApplication.java | 29 +++ .../SecurityConfiguration.java | 61 +++++ .../grpcserveroauth/package-info.java | 20 ++ .../src/main/proto/hello.proto | 18 ++ .../src/main/resources/application.yaml | 21 ++ ...SampleGrpcServerOAuthApplicationTests.java | 220 ++++++++++++++++++ .../build.gradle | 66 ++++++ .../grpcserversecure/HelloWorldService.java | 68 ++++++ .../SampleGrpcServerSecureApplication.java | 29 +++ .../SecurityConfiguration.java | 64 +++++ .../grpcserversecure/package-info.java | 20 ++ .../src/main/proto/hello.proto | 19 ++ ...ampleGrpcServerSecureApplicationTests.java | 192 +++++++++++++++ 31 files changed, 2311 insertions(+) create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcDisableCsrfHttpConfigurer.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerOAuth2ResourceServerAutoConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerSecurityAutoConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/package-info.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/reactive/GrpcRequest.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/reactive/package-info.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/servlet/GrpcRequest.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/web/servlet/package-info.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcDisableCsrfHttpConfigurerTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerOAuth2ResourceServerAutoConfigurationTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServerSecurityAutoConfigurationTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/web/reactive/GrpcRequestTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/web/servlet/GrpcRequestTests.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-oauth/build.gradle create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/HelloWorldService.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/SampleGrpcServerOAuthApplication.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/SecurityConfiguration.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/java/smoketest/grpcserveroauth/package-info.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/proto/hello.proto create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/main/resources/application.yaml create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-oauth/src/test/java/smoketest/grpcserveroauth/SampleGrpcServerOAuthApplicationTests.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-secure/build.gradle create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/HelloWorldService.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/SampleGrpcServerSecureApplication.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/SecurityConfiguration.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/java/smoketest/grpcserversecure/package-info.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-secure/src/main/proto/hello.proto create mode 100644 smoke-test/spring-boot-smoke-test-grpc-server-secure/src/test/java/smoketest/grpcserversecure/SampleGrpcServerSecureApplicationTests.java diff --git a/module/spring-boot-grpc-server/build.gradle b/module/spring-boot-grpc-server/build.gradle index 581e145e640..28f6200c30e 100644 --- a/module/spring-boot-grpc-server/build.gradle +++ b/module/spring-boot-grpc-server/build.gradle @@ -31,6 +31,8 @@ dependencies { 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") 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/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 index 00f402214f4..1b4dd838523 100644 --- 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 @@ -3,3 +3,5 @@ org.springframework.boot.grpc.server.autoconfigure.GrpcServerObservationAutoConf 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/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/settings.gradle b/settings.gradle index 28dffcc3a6a..94d968336d5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -431,7 +431,9 @@ 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" 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; + } + + } + +} From f75fea3601ec2113922160b9ba13baae87271df3 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Thu, 19 Mar 2026 11:27:15 -0700 Subject: [PATCH 12/12] Add type-safe gRPC service config properties Update `GrpcClientProperties` to include type-safe service config properties commonly configured items. Closes gh-49540 --- module/spring-boot-grpc-client/build.gradle | 1 + .../autoconfigure/GrpcClientProperties.java | 13 + ...cClientDefaultServiceConfigCustomizer.java | 41 +- .../client/autoconfigure/ServiceConfig.java | 430 ++++++++++++++++++ .../GrpcChannelBuilderCustomizersTests.java | 34 ++ .../autoconfigure/ServiceConfigTests.java | 393 ++++++++++++++++ 6 files changed, 908 insertions(+), 4 deletions(-) create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ServiceConfig.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/ServiceConfigTests.java diff --git a/module/spring-boot-grpc-client/build.gradle b/module/spring-boot-grpc-client/build.gradle index 52bf7893628..6a2d64dd9aa 100644 --- a/module/spring-boot-grpc-client/build.gradle +++ b/module/spring-boot-grpc-client/build.gradle @@ -30,6 +30,7 @@ dependencies { 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") 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 index f015938d1be..e57ecece11b 100644 --- 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 @@ -70,6 +70,11 @@ public class GrpcClientProperties { */ private boolean bypassCertificateValidation; + /** + * Service config for the channel. + */ + private @Nullable ServiceConfig serviceConfig; + private final Inbound inbound = new Inbound(); @Name("default") @@ -107,6 +112,14 @@ public class GrpcClientProperties { 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; } 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 index 58026ae3e93..c33f9f5782e 100644 --- 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 @@ -16,9 +16,15 @@ 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}. @@ -34,11 +40,38 @@ record PropertiesGrpcClientDefaultServiceConfigCustomizer( 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 && channel.getHealth().isEnabled()) { - String serviceName = channel.getHealth().getServiceName(); - Map healthCheckConfig = Map.of("serviceName", (serviceName != null) ? serviceName : ""); - defaultServiceConfig.put("healthCheckConfig", healthCheckConfig); + 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/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/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 index d49b4bfc585..ac59391bca2 100644 --- 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 @@ -29,9 +29,11 @@ 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; @@ -169,6 +171,38 @@ class GrpcChannelBuilderCustomizersTests { 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(); 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(); + } + +}