Add Spring gRPC support

Closes gh-49540
This commit is contained in:
Phillip Webb
2026-03-19 15:12:31 -07:00
227 changed files with 16690 additions and 512 deletions
@@ -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")
@@ -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`.
@@ -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> versionAlignment = List.of(
protocDependency.alignVersionWith("com.google.protobuf", "protobuf-java-util"),
grpcDependency.alignVersionWith("io.grpc", "grpc-util"));
@Override
public Class<? extends Plugin<? extends Project>> 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<ExecutableLocator> 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<String> 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();
}
}
}
@@ -148,7 +148,7 @@ public class SpringBootPlugin implements Plugin<Project> {
List<PluginApplicationAction> 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)));
@@ -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");
}
}
@@ -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;
}
@@ -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"
}
}
}
@@ -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"
}
}
@@ -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 }}" }
}
}
@@ -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<UpgradeHandler> action) {
action.execute(this.upgradeHandler);
}
@@ -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");
@@ -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
@@ -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) {
@@ -110,6 +110,9 @@ dependencies {
implementation(project(path: ":module:spring-boot-data-redis-test"))
implementation(project(path: ":module:spring-boot-devtools"))
implementation(project(path: ":module:spring-boot-graphql-test"))
implementation(project(path: ":module:spring-boot-grpc-client"))
implementation(project(path: ":module:spring-boot-grpc-server"))
implementation(project(path: ":module:spring-boot-grpc-test"))
implementation(project(path: ":module:spring-boot-health"))
implementation(project(path: ":module:spring-boot-hibernate"))
implementation(project(path: ":module:spring-boot-http-converter"))
@@ -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.
+1
View File
@@ -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
@@ -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<String> memberPredicate;
@@ -97,6 +97,12 @@ dependencies {
api(project(":module:spring-boot-graphql")) {
transitive = false
}
api(project(":module:spring-boot-grpc-client")) {
transitive = false
}
api(project(":module:spring-boot-grpc-server")) {
transitive = false
}
api(project(":module:spring-boot-groovy-templates")) {
transitive = false
}
@@ -0,0 +1,55 @@
/*
* Copyright 2012-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the License);
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id "java-library"
id "org.springframework.boot.auto-configuration"
id "org.springframework.boot.configuration-properties"
id "org.springframework.boot.deployed"
id "org.springframework.boot.optional-dependencies"
}
description = "Spring Boot gRPC Client"
dependencies {
api(project(":core:spring-boot"))
api("org.springframework.grpc:spring-grpc-core")
optional(project(":core:spring-boot-autoconfigure"))
optional(project(":module:spring-boot-micrometer-observation"))
optional("io.grpc:grpc-grpclb")
optional("io.grpc:grpc-stub")
optional("io.grpc:grpc-netty")
optional("io.grpc:grpc-netty-shaded")
optional("io.grpc:grpc-inprocess")
optional("io.grpc:grpc-kotlin-stub") {
exclude group: "javax.annotation", module: "javax.annotation-api"
}
optional("io.grpc:grpc-xds") {
exclude group: "javax.annotation", module: "javax.annotation-api"
}
optional("io.micrometer:micrometer-core")
testImplementation(project(":core:spring-boot-test"))
testImplementation(project(":test-support:spring-boot-test-support"))
testImplementation("org.yaml:snakeyaml")
testRuntimeOnly("ch.qos.logback:logback-classic")
}
tasks.named("compileTestJava") {
options.nullability.checking = "tests"
}
@@ -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<GrpcChannelFactory> 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 {
}
}
}
@@ -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 {
}
@@ -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<GrpcChannelBuilderCustomizer<?>> customizers;
GrpcChannelBuilderCustomizers(GrpcClientProperties grpcClientProperties,
ObjectProvider<CompressorRegistry> compressorRegistry,
ObjectProvider<DecompressorRegistry> decompressorRegistry,
ObjectProvider<GrpcChannelBuilderCustomizer<?>> customizers,
ObjectProvider<GrpcClientDefaultServiceConfigCustomizer> defaultServiceConfigCustomizers) {
this(grpcClientProperties, compressorRegistry.getIfAvailable(), decompressorRegistry.getIfAvailable(),
customizers.orderedStream().toList(), defaultServiceConfigCustomizers.orderedStream().toList());
}
GrpcChannelBuilderCustomizers(List<? extends GrpcChannelBuilderCustomizer<?>> customizers) {
this(null, null, null, customizers, Collections.emptyList());
}
GrpcChannelBuilderCustomizers(@Nullable GrpcClientProperties grpcClientProperties,
@Nullable CompressorRegistry compressorRegistry, @Nullable DecompressorRegistry decompressorRegistry,
List<? extends GrpcChannelBuilderCustomizer<?>> customizers,
List<? extends GrpcClientDefaultServiceConfigCustomizer> defaultServiceConfigCustomizers) {
List<GrpcChannelBuilderCustomizer<?>> 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 <B extends ManagedChannelBuilder<B>, T> void addCustomizer(
List<GrpcChannelBuilderCustomizer<?>> customizers, @Nullable T bean, BiConsumer<B, T> action) {
if (bean != null) {
GrpcChannelBuilderCustomizer<B> customizer = (target, builder) -> action.accept(builder, bean);
customizers.add(customizer);
}
}
private <B extends ManagedChannelBuilder<B>> GrpcChannelBuilderCustomizer<B> customizeDefaultServiceConfig(
@Nullable GrpcClientProperties properties,
List<? extends GrpcClientDefaultServiceConfigCustomizer> customizers) {
PropertiesGrpcClientDefaultServiceConfigCustomizer propertiesCustomizer = (properties != null)
? new PropertiesGrpcClientDefaultServiceConfigCustomizer(properties) : null;
return (target, builder) -> {
Map<String, Object> defaultServiceConfig = new LinkedHashMap<>();
if (propertiesCustomizer != null) {
propertiesCustomizer.customize(target, defaultServiceConfig);
}
customizers.forEach((customizer) -> customizer.customize(target, defaultServiceConfig));
if (!defaultServiceConfig.isEmpty()) {
builder.defaultServiceConfig(defaultServiceConfig);
}
};
}
<T extends ManagedChannelBuilder<T>> List<GrpcChannelBuilderCustomizer<T>> forFactory() {
return List.of(this::apply);
}
@SuppressWarnings("unchecked")
<T extends ManagedChannelBuilder<?>> void apply(String target, T builder) {
LambdaSafe.callbacks(GrpcChannelBuilderCustomizer.class, this.customizers, builder)
.withLogger(GrpcChannelBuilderCustomizers.class)
.invoke((customizer) -> customizer.customize(target, builder));
}
}
@@ -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);
}
@@ -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
<T extends ManagedChannelBuilder<T>> PropertiesGrpcChannelBuilderCustomizer<T> grpcClientPropertiesChannelCustomizer(
GrpcClientProperties properties) {
return new PropertiesGrpcChannelBuilderCustomizer<>(properties);
}
@Bean
GrpcChannelBuilderCustomizers grpcDefaultServicesChannelBuilderCustomizer(GrpcClientProperties grpcClientProperties,
ObjectProvider<CompressorRegistry> compressorRegistry,
ObjectProvider<DecompressorRegistry> decompressorRegistry,
ObjectProvider<GrpcChannelBuilderCustomizer<?>> customizers,
ObjectProvider<GrpcClientDefaultServiceConfigCustomizer> 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();
}
}
}
@@ -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<Compressor> 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<Decompressor> decompressors) {
if (decompressors.isEmpty()) {
return DecompressorRegistry.getDefaultInstance();
}
DecompressorRegistry registry = DecompressorRegistry.emptyInstance();
for (Decompressor decompressor : decompressors) {
registry = registry.with(decompressor, false);
}
return registry;
}
}
@@ -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}.
* <p>
* 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<String, Object> defaultServiceConfig);
}
@@ -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> GrpcClientObservationConvention) {
ObservationGrpcClientInterceptor interceptor = new ObservationGrpcClientInterceptor(observationRegistry);
GrpcClientObservationConvention.ifAvailable(interceptor::setCustomConvention);
return interceptor;
}
}
@@ -0,0 +1,382 @@
/*
* Copyright 2012-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.grpc.client.autoconfigure;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.LinkedHashMap;
import java.util.Map;
import org.jspecify.annotations.Nullable;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.Name;
import org.springframework.boot.convert.DurationUnit;
import org.springframework.util.unit.DataSize;
/**
* Configuration properties for gRPC clients.
*
* @author Chris Bono
* @author Phillip Webb
* @since 4.1.0
*/
@ConfigurationProperties("spring.grpc.client")
public class GrpcClientProperties {
/**
* Map of channel configured by name.
*/
private final Map<String, Channel> channel = new LinkedHashMap<>();
public Map<String, Channel> getChannel() {
return this.channel;
}
/**
* Channel Properties.
*/
public static class Channel {
static final String DEFAULT_TARGET = "static://localhost:9090";
/**
* The channel target address.
*/
private String target = DEFAULT_TARGET;
/**
* The custom User-Agent for the channel.
*/
private @Nullable String userAgent;
/**
* Bypass certificate validation for easier testing (so the remote certificate
* could be anonymous). Should not be set in production.
*/
private boolean bypassCertificateValidation;
/**
* Service config for the channel.
*/
private @Nullable ServiceConfig serviceConfig;
private final Inbound inbound = new Inbound();
@Name("default")
private final Default defaultProperties = new Default();
private final Idle idle = new Idle();
private final Keepalive keepalive = new Keepalive();
private final Ssl ssl = new Ssl();
private final Health health = new Health();
public String getTarget() {
return this.target;
}
public void setTarget(String target) {
this.target = target;
}
public @Nullable String getUserAgent() {
return this.userAgent;
}
public void setUserAgent(@Nullable String userAgent) {
this.userAgent = userAgent;
}
public boolean isBypassCertificateValidation() {
return this.bypassCertificateValidation;
}
public void setBypassCertificateValidation(boolean bypassCertificateValidation) {
this.bypassCertificateValidation = bypassCertificateValidation;
}
public @Nullable ServiceConfig getServiceConfig() {
return this.serviceConfig;
}
public void setServiceConfig(@Nullable ServiceConfig serviceConfig) {
this.serviceConfig = serviceConfig;
}
public Inbound getInbound() {
return this.inbound;
}
public Default getDefault() {
return this.defaultProperties;
}
public Idle getIdle() {
return this.idle;
}
public Keepalive getKeepalive() {
return this.keepalive;
}
public Ssl getSsl() {
return this.ssl;
}
public Health getHealth() {
return this.health;
}
/**
* In-bound properties.
*/
public static class Inbound {
private final Message message = new Message();
private final Metadata metadata = new Metadata();
public Message getMessage() {
return this.message;
}
public Metadata getMetadata() {
return this.metadata;
}
/**
* In-bound message properties.
*/
public static class Message {
/**
* Maximum message size allowed to be received by the channel. Set to '-1'
* to use the highest possible limit (not recommended).
*/
private DataSize maxSize = DataSize.ofBytes(4194304);
public DataSize getMaxSize() {
return this.maxSize;
}
public void setMaxSize(DataSize maxSize) {
this.maxSize = maxSize;
}
}
/**
* In-bound metadata properties.
*/
public static class Metadata {
/**
* Maximum metadata size allowed to be received by the channel. Set to
* '-1' to use the highest possible limit (not recommended).
*/
private DataSize maxSize = DataSize.ofBytes(8192);
public DataSize getMaxSize() {
return this.maxSize;
}
public void setMaxSize(DataSize maxSize) {
this.maxSize = maxSize;
}
}
}
/**
* Properties for client defaults.
*/
public static class Default {
/**
* The default deadline for RPCs performed on this channel.
*/
private @Nullable Duration deadline;
/**
* The load balancing policy the channel should use.
*/
private String loadBalancingPolicy = "round_robin";
public @Nullable Duration getDeadline() {
return this.deadline;
}
public void setDeadline(@Nullable Duration deadline) {
this.deadline = deadline;
}
public String getLoadBalancingPolicy() {
return this.loadBalancingPolicy;
}
public void setLoadBalancingPolicy(String loadBalancingPolicy) {
this.loadBalancingPolicy = loadBalancingPolicy;
}
}
/**
* Idle properties.
*/
public static class Idle {
/**
* The duration without ongoing RPCs before going to idle mode.
*/
@DurationUnit(ChronoUnit.SECONDS)
private Duration timeout = Duration.ofSeconds(20);
public Duration getTimeout() {
return this.timeout;
}
public void setTimeout(Duration timeout) {
this.timeout = timeout;
}
}
/**
* Keep-alive properties.
*/
public static class Keepalive {
/**
* The delay before sending a keepAlive. Note that shorter intervals increase
* the network burden for the server and this value can not be lower than
* 'permitKeepAliveTime' on the server.
*/
@DurationUnit(ChronoUnit.SECONDS)
private Duration time = Duration.ofMinutes(5);
/**
* The default timeout for a keepAlives ping request.
*/
@DurationUnit(ChronoUnit.SECONDS)
private Duration timeout = Duration.ofSeconds(20);
/**
* Whether a keepAlive will be performed when there are no outstanding RPC on
* a connection.
*/
private boolean withoutCalls;
public Duration getTime() {
return this.time;
}
public void setTime(Duration time) {
this.time = time;
}
public Duration getTimeout() {
return this.timeout;
}
public void setTimeout(Duration timeout) {
this.timeout = timeout;
}
public boolean isWithoutCalls() {
return this.withoutCalls;
}
public void setWithoutCalls(boolean withoutCalls) {
this.withoutCalls = withoutCalls;
}
}
/**
* Health properties.
*/
public static class Health {
/**
* Whether to enable client-side health check for the channel.
*/
private boolean enabled;
/**
* Name of the service to check health on.
*/
private @Nullable String serviceName;
public boolean isEnabled() {
return this.enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public @Nullable String getServiceName() {
return this.serviceName;
}
public void setServiceName(String serviceName) {
this.serviceName = serviceName;
}
}
/**
* SSL properties.
*/
public static class Ssl {
/**
* Whether to enable SSL support. Enabled automatically if "bundle" is
* provided unless specified otherwise.
*/
private @Nullable Boolean enabled;
/**
* SSL bundle name.
*/
private @Nullable String bundle;
public @Nullable Boolean getEnabled() {
return this.enabled;
}
public void setEnabled(@Nullable Boolean enabled) {
this.enabled = enabled;
}
public @Nullable String getBundle() {
return this.bundle;
}
public void setBundle(@Nullable String bundle) {
this.bundle = bundle;
}
}
}
}
@@ -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<ClientInterceptorFilter> interceptorFilter,
ObjectProvider<GrpcChannelFactoryCustomizer> channelFactoryCustomizers) {
InProcessGrpcChannelFactory factory = new InProcessGrpcChannelFactory(
grpcChannelBuilderCustomizers.forFactory(), interceptorsConfigurer);
interceptorFilter.ifAvailable(factory::setInterceptorFilter);
channelFactoryCustomizers.orderedStream().forEach((customizer) -> customizer.customize(factory));
return factory;
}
}
@@ -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<GrpcChannelFactoryCustomizer> 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;
}
}
@@ -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);
}
}
@@ -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 <T> the type of the builder
* @param properties the properties to map
* @author David Syer
* @author Chris Bono
* @author Phillip Webb
*/
record PropertiesGrpcChannelBuilderCustomizer<T extends ManagedChannelBuilder<T>>(
GrpcClientProperties properties) implements GrpcChannelBuilderCustomizer<T> {
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<Duration> durationProperty(BiConsumer<Long, TimeUnit> 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;
}
}
@@ -0,0 +1,77 @@
/*
* Copyright 2012-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.grpc.client.autoconfigure;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import org.jspecify.annotations.Nullable;
import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel;
import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel.Health;
import org.springframework.util.Assert;
/**
* {@link GrpcClientDefaultServiceConfigCustomizer} to apply {@link GrpcClientProperties}.
*
* @author Chris Bono
* @author Phillip Webb
* @param properties the client properties
*/
record PropertiesGrpcClientDefaultServiceConfigCustomizer(
GrpcClientProperties properties) implements GrpcClientDefaultServiceConfigCustomizer {
@Override
public void customize(String target, Map<String, Object> defaultServiceConfig) {
Channel channel = this.properties.getChannel().get(target);
channel = (channel != null) ? channel : this.properties.getChannel().get("default");
if (channel == null) {
return;
}
applyServiceConfig(channel.getServiceConfig(), defaultServiceConfig);
applyHealth(channel.getHealth(), defaultServiceConfig);
}
private void applyServiceConfig(@Nullable ServiceConfig serviceConfig, Map<String, Object> defaultServiceConfig) {
if (serviceConfig != null) {
serviceConfig.applyTo(defaultServiceConfig);
}
}
private void applyHealth(Health health, Map<String, Object> defaultServiceConfig) {
if (!health.isEnabled()) {
return;
}
String serviceName = (health.getServiceName() != null) ? health.getServiceName() : "";
Map<String, Object> 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<String, Object> cloneOrCreateHealthCheckConfig(Map<String, Object> defaultServiceConfig) {
Map<String, Object> healthCheckConfig = (Map<String, Object>) defaultServiceConfig
.get(ServiceConfig.HEALTH_CHECK_CONFIG_KEY);
return new LinkedHashMap<>((healthCheckConfig != null) ? healthCheckConfig : Collections.emptyMap());
}
}
@@ -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;
}
}
@@ -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}.
* <p>
* The configuration provided here is a subset of the canonical <a href=
* "https://github.com/grpc/grpc-proto/blob/master/grpc/service_config/service_config.proto">service_config.proto</a>
* 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<LoadBalancingConfig> loadbalancing, @Nullable List<MethodConfig> 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<String, Object> 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 <T> Adapter<List<T>, @Nullable List<Map<String, Object>>> listOf(Function<T, Map<String, Object>> 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<String, Object> grpcJavaConfig() {
LinkedHashMap<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<LoadBalancingConfig> 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<String, Object> 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> 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<Map<String, Object>> grpcJavaConfigs(List<MethodConfig> methodConfigs) {
return (!CollectionUtils.isEmpty(methodConfigs))
? methodConfigs.stream().map(MethodConfig::grpcJavaConfig).toList() : null;
}
Map<String, Object> 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<String, Object> 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<Status.Code> retryableStatusCodes) {
Map<String, Object> 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<Status.Code> nonFatalStatusCodes) {
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> asMap) {
GrpcJavaConfig() {
this(new LinkedHashMap<>());
}
<T> Consumer<T> in(String key) {
return (value) -> this.asMap.put(key, value);
}
}
}
@@ -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<GrpcChannelFactoryCustomizer> 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;
}
}
@@ -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;
@@ -0,0 +1,29 @@
{
"groups": [],
"properties": [
{
"name": "spring.grpc.client.channelfactory.enabled",
"type": "java.lang.Boolean",
"description": "Whether to enable gRPC channel factory bean auto-configuration.",
"defaultValue": true
},
{
"name": "spring.grpc.client.enabled",
"type": "java.lang.Boolean",
"description": "Whether to enable gRPC client auto-configuration.",
"defaultValue": true
},
{
"name": "spring.grpc.client.inprocess.enabled",
"type": "java.lang.Boolean",
"description": "Whether to configure the in-process channel factory.",
"defaultValue": true
},
{
"name": "spring.grpc.client.observation.enabled",
"type": "java.lang.Boolean",
"description": "Whether to enable Observations on the client.",
"defaultValue": true
}
]
}
@@ -0,0 +1,3 @@
org.springframework.boot.grpc.client.autoconfigure.CompositeChannelFactoryAutoConfiguration
org.springframework.boot.grpc.client.autoconfigure.GrpcClientAutoConfiguration
org.springframework.boot.grpc.client.autoconfigure.GrpcClientObservationAutoConfiguration
@@ -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;
}
}
}
@@ -0,0 +1,276 @@
/*
* Copyright 2012-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.grpc.client.autoconfigure;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import io.grpc.CompressorRegistry;
import io.grpc.DecompressorRegistry;
import io.grpc.ManagedChannelBuilder;
import io.grpc.netty.NettyChannelBuilder;
import org.junit.jupiter.api.Test;
import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.Channel;
import org.springframework.boot.grpc.client.autoconfigure.ServiceConfig.HealthCheckConfig;
import org.springframework.grpc.client.GrpcChannelBuilderCustomizer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
/**
* Tests for {@link GrpcChannelBuilderCustomizers}.
*
* @author Chris Bono
* @author Phillip Webb
*/
class GrpcChannelBuilderCustomizersTests {
@Test
void applyWhenHasProperties() {
GrpcClientProperties properties = new GrpcClientProperties();
Channel channel = new Channel();
channel.setUserAgent("spring-boot");
properties.getChannel().put("target", channel);
GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(properties, null, null,
Collections.emptyList(), Collections.emptyList());
NettyChannelBuilder builder = mock(NettyChannelBuilder.class);
customizers.apply("target", builder);
then(builder).should().userAgent("spring-boot");
}
@Test
void applyWhenHasCompressorRegistry() {
CompressorRegistry compressorRegistry = mock();
GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(null, compressorRegistry, null,
Collections.emptyList(), Collections.emptyList());
NettyChannelBuilder builder = mock(NettyChannelBuilder.class);
customizers.apply("target", builder);
then(builder).should().compressorRegistry(compressorRegistry);
}
@Test
void applyWhenHasDecompressorRegistry() {
DecompressorRegistry decompressorRegistry = mock();
GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(null, null, decompressorRegistry,
Collections.emptyList(), Collections.emptyList());
NettyChannelBuilder builder = mock(NettyChannelBuilder.class);
customizers.apply("target", builder);
then(builder).should().decompressorRegistry(decompressorRegistry);
}
@Test
void applyWhenEmptyCustomizersDoesNothing() {
NettyChannelBuilder builder = mock(NettyChannelBuilder.class);
new GrpcChannelBuilderCustomizers(Collections.emptyList()).apply("target", builder);
then(builder).shouldHaveNoInteractions();
}
@Test
void applyWhenSimpleChannelBuilder() {
GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(
List.of(new SimpleChannelBuilderCustomizer()));
NettyChannelBuilder builder = mock(NettyChannelBuilder.class);
customizers.apply("target", builder);
then(builder).should().flowControlWindow(100);
}
@Test
@SuppressWarnings("unchecked")
void applyWhenGenericCustomizersRespectsGeneric() {
List<TestCustomizer<?>> 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<String, Object> 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<String, Object> 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<String, Object> expected = new LinkedHashMap<>();
expected.put("healthCheckConfig", Map.of("serviceName", "testdefaultservice"));
then(builder).should().defaultServiceConfig(expected);
}
@Test
void applyWhenHasServiceConfig() {
GrpcClientProperties properties = new GrpcClientProperties();
Channel channel = new Channel();
ServiceConfig serviceConfig = new ServiceConfig(null, null, null, new HealthCheckConfig("test"));
channel.setServiceConfig(serviceConfig);
properties.getChannel().put("default", channel);
GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(properties, null, null,
Collections.emptyList(), Collections.emptyList());
NettyChannelBuilder builder = mock(NettyChannelBuilder.class);
customizers.apply("target", builder);
Map<String, Object> expected = new LinkedHashMap<>();
expected.put("healthCheckConfig", Map.of("serviceName", "test"));
then(builder).should().defaultServiceConfig(expected);
}
@Test
void applyWhenHasClashingServiceConfigAndHealth() {
GrpcClientProperties properties = new GrpcClientProperties();
Channel channel = new Channel();
channel.getHealth().setEnabled(true);
channel.getHealth().setServiceName("fromhealth");
ServiceConfig serviceConfig = new ServiceConfig(null, null, null, new HealthCheckConfig("fromservice"));
channel.setServiceConfig(serviceConfig);
properties.getChannel().put("default", channel);
GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(properties, null, null,
Collections.emptyList(), Collections.emptyList());
NettyChannelBuilder builder = mock(NettyChannelBuilder.class);
assertThatIllegalStateException().isThrownBy(() -> customizers.apply("target", builder))
.withMessage("Unable to change health check config service name from 'fromservice' to 'fromhealth'");
}
@Test
void applyWhenHealthEnabledAndNoServiceNameAddsHealthConfig() {
GrpcClientProperties properties = new GrpcClientProperties();
Channel channel = new Channel();
channel.getHealth().setEnabled(true);
properties.getChannel().put("target", channel);
GrpcChannelBuilderCustomizers customizers = new GrpcChannelBuilderCustomizers(properties, null, null,
Collections.emptyList(), Collections.emptyList());
NettyChannelBuilder builder = mock(NettyChannelBuilder.class);
customizers.apply("target", builder);
Map<String, Object> 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<NettyChannelBuilder> {
@Override
public void customize(String target, NettyChannelBuilder channelBuilder) {
channelBuilder.flowControlWindow(100);
}
}
/**
* Test customizer that will match any {@link GrpcChannelBuilderCustomizer}.
*
* @param <T> the builder type
*/
static class TestCustomizer<T extends ManagedChannelBuilder<T>> implements GrpcChannelBuilderCustomizer<T> {
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<NettyChannelBuilder> {
}
/**
* Test customizer that will match only
* {@link io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder}.
*/
static class TestShadedNettyChannelBuilderCustomizer
extends TestCustomizer<io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder> {
}
}
@@ -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<String, ?> 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 <T extends ManagedChannelBuilder<T>> void channelFactoryAutoConfiguredWithCustomizers(
ApplicationContextRunner contextRunner, ManagedChannelBuilder<T> 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)
<T extends ManagedChannelBuilder<T>> GrpcChannelBuilderCustomizer<T> customizerOne() {
return (target, builder) -> builder.keepAliveTime(40L, TimeUnit.SECONDS);
}
@Bean
@Order(200)
<T extends ManagedChannelBuilder<T>> GrpcChannelBuilderCustomizer<T> customizerTwo() {
return (target, builder) -> builder.keepAliveTime(50L, TimeUnit.SECONDS);
}
}
}
@@ -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<String, Object> annotated = context.getBeansWithAnnotation(GlobalClientInterceptor.class);
assertThat(annotated).hasEntrySatisfying("grpcClientObservationInterceptor",
ObservationGrpcClientInterceptor.class::isInstance);
});
}
}
@@ -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);
}
}
@@ -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);
});
}
}
@@ -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;
}
}
@@ -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
<T extends ManagedChannelBuilder<T>> void customizeWhenHasMatchingChannel() {
GrpcClientProperties properties = new GrpcClientProperties();
properties.getChannel().put("test", createTestChannelProperties());
GrpcChannelBuilderCustomizer<T> customizer = new PropertiesGrpcChannelBuilderCustomizer<>(properties);
T builder = mock();
customizer.customize("test", builder);
assertMapped(builder);
}
@Test
<T extends ManagedChannelBuilder<T>> void customizeWhenHasDefaultChannel() {
GrpcClientProperties properties = new GrpcClientProperties();
properties.getChannel().put("default", createTestChannelProperties());
GrpcChannelBuilderCustomizer<T> customizer = new PropertiesGrpcChannelBuilderCustomizer<>(properties);
T builder = mock();
customizer.customize("test", builder);
assertMapped(builder);
}
@Test
<T extends ManagedChannelBuilder<T>> void customizeWhenHasNoMatchAndNoDefault() {
GrpcClientProperties properties = new GrpcClientProperties();
properties.getChannel().put("other", createTestChannelProperties());
GrpcChannelBuilderCustomizer<T> customizer = new PropertiesGrpcChannelBuilderCustomizer<>(properties);
T builder = mock();
customizer.customize("test", builder);
assertMappedStockDefaults(builder);
}
@Test
<T extends ManagedChannelBuilder<T>> void customizeWhenChannelDoesNotSupportLoadBalancingDoesNotMapDefaultLoadBalancer() {
assertNoLoadBalancerMappedBasedOnChannel("unix:test");
assertNoLoadBalancerMappedBasedOnChannel("in-process:test");
}
private <T extends ManagedChannelBuilder<T>> void assertNoLoadBalancerMappedBasedOnChannel(String target) {
T builder = getBuilder((channelProperties) -> {
channelProperties.setTarget(target);
channelProperties.getDefault().setLoadBalancingPolicy("testlbp");
});
then(builder).should(never()).defaultLoadBalancingPolicy(any());
}
@Test
<T extends ManagedChannelBuilder<T>> void customizeWhenTargetDoesNotSupportLoadBalancingDoesNotMapDefaultLoadBalancer() {
GrpcChannelBuilderCustomizer<?> customizer = getCustomizer(
(channelProperties) -> channelProperties.setTarget("static://localhost:1234"));
assertNoLoadBalancerMappedBasedOnTarget(customizer, "unix:test");
assertNoLoadBalancerMappedBasedOnTarget(customizer, "in-process:test");
}
private <T extends ManagedChannelBuilder<T>> void assertNoLoadBalancerMappedBasedOnTarget(
GrpcChannelBuilderCustomizer<T> customizer, String target) {
T builder = mock();
customizer.customize(target, builder);
then(builder).should(never()).defaultLoadBalancingPolicy(any());
}
@Test
<T extends ManagedChannelBuilder<T>> void customizeWhenMaxMessageSizeIsMinusOneUsesMaxValue() {
T builder = getBuilder(
(channelProperties) -> channelProperties.getInbound().getMessage().setMaxSize(DataSize.ofBytes(-1)));
then(builder).should().maxInboundMessageSize(Integer.MAX_VALUE);
}
@Test
<T extends ManagedChannelBuilder<T>> 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
<T extends ManagedChannelBuilder<T>> void customizeWhenMaxMessageSizeIsNegativeAndNotMinusOneThrowsException() {
assertThatIllegalStateException().isThrownBy(() -> getBuilder(
(channelProperties) -> channelProperties.getInbound().getMessage().setMaxSize(DataSize.ofBytes(-2))))
.withMessage("Unsupported max size value -2B");
}
@Test
<T extends ManagedChannelBuilder<T>> void customizeWhenMaxMetadataSizeIsMinusOneUsesMaxValue() {
T builder = getBuilder(
(channelProperties) -> channelProperties.getInbound().getMetadata().setMaxSize(DataSize.ofBytes(-1)));
then(builder).should().maxInboundMetadataSize(Integer.MAX_VALUE);
}
@Test
<T extends ManagedChannelBuilder<T>> 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
<T extends ManagedChannelBuilder<T>> void customizeWhenMaxMetadataSizeIsNegativeAndNotMinusOneThrowsException() {
assertThatIllegalStateException().isThrownBy(() -> getBuilder(
(channelProperties) -> channelProperties.getInbound().getMetadata().setMaxSize(DataSize.ofBytes(-2))))
.withMessage("Unsupported max size value -2B");
}
private <T extends ManagedChannelBuilder<T>> T getBuilder(Consumer<Channel> setup) {
GrpcChannelBuilderCustomizer<T> customizer = getCustomizer(setup);
T builder = mock();
customizer.customize("test", builder);
return builder;
}
private <T extends ManagedChannelBuilder<T>> GrpcChannelBuilderCustomizer<T> getCustomizer(
Consumer<Channel> setup) {
GrpcClientProperties properties = new GrpcClientProperties();
Channel channelProperties = new Channel();
setup.accept(channelProperties);
properties.getChannel().put("test", channelProperties);
GrpcChannelBuilderCustomizer<T> 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 <T extends ManagedChannelBuilder<T>> void assertMapped(T builder) {
then(builder).should().userAgent("testua");
then(builder).should().maxInboundMessageSize(10);
then(builder).should().maxInboundMetadataSize(20);
ArgumentCaptor<ClientInterceptor[]> 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 <T extends ManagedChannelBuilder<T>> 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();
}
}
@@ -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;
}
}
@@ -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<String, Object> map = bindAndGetAsMap();
assertThat(map).containsKey("loadBalancingConfig");
List<Map<String, ?>> 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<String, Object> map = bindAndGetAsMap();
assertThat(map).containsKey("loadBalancingConfig");
List<Map<String, ?>> 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<String, Object> map = bindAndGetAsMap();
assertThat(map).containsKey("loadBalancingConfig");
List<Map<String, ?>> 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<String, Object> map = bindAndGetAsMap();
assertThat(map).containsKey("loadBalancingConfig");
List<Map<String, ?>> 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<String, Object> map = bindAndGetAsMap();
assertThat(map).containsKey("loadBalancingConfig");
List<Map<String, ?>> 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<String, Object> map = bindAndGetAsMap();
assertThat(map).containsKey("loadBalancingConfig");
List<Map<String, ?>> 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<String, Object> map = bindAndGetAsMap();
assertThat(map).containsKey("loadBalancingConfig");
List<Map<String, ?>> 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<String, Object> map = bindAndGetAsMap();
assertThat(map).containsKey("methodConfig");
Map<String, ?> 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<String, Object> map = bindAndGetAsMap();
Map<String, ?> 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<String, Object> map = bindAndGetAsMap();
Map<String, ?> 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<String, Object> 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<String, Object> map = bindAndGetAsMap();
assertThat(map).containsKey("healthCheckConfig");
Map<String, Object> healthCheckedService = (Map<String, Object>) ServiceConfigUtil.getHealthCheckedService(map);
assertThat(healthCheckedService).hasSize(1).containsEntry("serviceName", "test");
}
private PolicySelection getLoadBalancingPolicySelection(List<Map<String, ?>> rawConfigs) {
List<LbConfig> 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<String, ?> getServiceMethodMap(Map<String, Object> 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<String, ?>) serviceMethodMap;
}
private Map<String, Object> bindAndGetAsMap() throws Exception {
Map<String, Object> 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();
}
}
@@ -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>) DummyBlockingStub::new, channel);
}
public static class DummyBlockingStub extends AbstractBlockingStub<DummyBlockingStub> {
protected DummyBlockingStub(Channel channel, CallOptions callOptions) {
super(channel, callOptions);
}
@Override
protected DummyBlockingStub build(Channel channel, CallOptions callOptions) {
return new DummyBlockingStub(channel, callOptions);
}
}
}
@@ -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>) DummyBlockingV2Stub::new, channel);
}
public static class DummyBlockingV2Stub extends AbstractBlockingStub<DummyBlockingV2Stub> {
protected DummyBlockingV2Stub(Channel channel, CallOptions callOptions) {
super(channel, callOptions);
}
@Override
protected DummyBlockingV2Stub build(Channel channel, CallOptions callOptions) {
return new DummyBlockingV2Stub(channel, callOptions);
}
}
}
@@ -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;
@@ -0,0 +1,60 @@
/*
* Copyright 2012-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the License);
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id "java-library"
id "org.springframework.boot.auto-configuration"
id "org.springframework.boot.configuration-properties"
id "org.springframework.boot.deployed"
id "org.springframework.boot.optional-dependencies"
}
description = "Spring Boot gRPC Server"
dependencies {
api(project(":core:spring-boot"))
api("org.springframework.grpc:spring-grpc-core")
optional(project(":core:spring-boot-autoconfigure"))
optional(project(":module:spring-boot-health"))
optional(project(":module:spring-boot-micrometer-observation"))
optional(project(":module:spring-boot-security"))
optional(project(":module:spring-boot-security-oauth2-resource-server"))
optional("com.fasterxml.jackson.core:jackson-annotations")
optional("io.projectreactor:reactor-core")
optional("io.grpc:grpc-servlet-jakarta")
optional("io.grpc:grpc-services")
optional("io.grpc:grpc-netty")
optional("io.grpc:grpc-netty-shaded")
optional("io.grpc:grpc-inprocess")
optional("io.grpc:grpc-kotlin-stub") {
exclude group: "javax.annotation", module: "javax.annotation-api"
}
optional("io.micrometer:micrometer-core")
optional("io.micrometer:context-propagation")
optional("jakarta.servlet:jakarta.servlet-api")
testImplementation(project(":core:spring-boot-test"))
testImplementation(project(":test-support:spring-boot-test-support"))
testImplementation(testFixtures(project(":core:spring-boot-autoconfigure")))
testImplementation("org.springframework:spring-web")
testRuntimeOnly("ch.qos.logback:logback-classic")
}
tasks.named("compileTestJava") {
options.nullability.checking = "tests"
}
@@ -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<Dynamic> {
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<ServletServerBuilder> serverBuilderCustomizer) {
Assert.notNull(serviceDiscoverer, "'serviceDiscoverer' must not be null");
Assert.notNull(serviceConfigurer, "'serviceConfigurer' must not be null");
ServletServerBuilder builder = new ServletServerBuilder();
List<String> 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);
}
}
@@ -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 {
}
@@ -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:
* <ul>
* <li>There are no {@link GrpcServletRegistration} beans.</li>
* <li>There are no {@link GrpcServerFactory} beans (ignoring
* {@link InProcessGrpcServerFactory} beans)</li>
* </ul>
*
* @author Phillip Webb
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.TYPE, ElementType.METHOD })
@Documented
@Conditional(MissingNetworkGrpcServerCondition.class)
@interface ConditionalOnMissingNetworkGrpcServer {
}
@@ -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> compressorRegistry,
ObjectProvider<DecompressorRegistry> decompressorRegistry,
ObjectProvider<GrpcServerExecutorProvider> executorProvider,
ObjectProvider<ServerBuilderCustomizer<?>> 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<GrpcExceptionHandler> 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 {
}
}
@@ -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<ServerBuilderCustomizer<?>> customizers;
GrpcServerBuilderCustomizers(GrpcServerProperties grpcServerProperties,
ObjectProvider<CompressorRegistry> compressorRegistry,
ObjectProvider<DecompressorRegistry> decompressorRegistry,
ObjectProvider<GrpcServerExecutorProvider> executorProvider,
ObjectProvider<ServerBuilderCustomizer<?>> customizers) {
this(grpcServerProperties, compressorRegistry.getIfAvailable(), decompressorRegistry.getIfAvailable(),
executorProvider.getIfAvailable(), customizers.orderedStream().toList());
}
GrpcServerBuilderCustomizers(List<? extends ServerBuilderCustomizer<?>> customizers) {
this(null, null, null, null, customizers);
}
GrpcServerBuilderCustomizers(@Nullable GrpcServerProperties grpcServerProperties,
@Nullable CompressorRegistry compressorRegistry, @Nullable DecompressorRegistry decompressorRegistry,
@Nullable GrpcServerExecutorProvider executorProvider,
List<? extends ServerBuilderCustomizer<?>> customizers) {
List<ServerBuilderCustomizer<?>> 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 <B extends ServerBuilder<B>, T> void addCustomizer(List<ServerBuilderCustomizer<?>> customizers,
@Nullable T bean, BiConsumer<B, T> action) {
if (bean != null) {
ServerBuilderCustomizer<B> customizer = (builder) -> action.accept(builder, bean);
customizers.add(customizer);
}
}
<T extends ServerBuilder<T>> List<ServerBuilderCustomizer<T>> forFactory() {
return List.of(this::apply);
}
@SuppressWarnings("unchecked")
<T extends ServerBuilder<?>> void apply(T builder) {
LambdaSafe.callbacks(ServerBuilderCustomizer.class, this.customizers, builder)
.withLogger(GrpcServerBuilderCustomizers.class)
.invoke((customizer) -> customizer.customize(builder));
}
}
@@ -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<Compressor> 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<Decompressor> decompressors) {
if (decompressors.isEmpty()) {
return DecompressorRegistry.getDefaultInstance();
}
DecompressorRegistry registry = DecompressorRegistry.emptyInstance();
for (Decompressor decompressor : decompressors) {
registry = registry.with(decompressor, false);
}
return registry;
}
}
@@ -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();
}
@@ -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);
}
@@ -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);
}
}
}
@@ -0,0 +1,495 @@
/*
* Copyright 2012-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.grpc.server.autoconfigure;
import java.net.InetAddress;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import io.grpc.TlsServerCredentials.ClientAuth;
import org.jspecify.annotations.Nullable;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.convert.DataSizeUnit;
import org.springframework.boot.convert.DurationUnit;
import org.springframework.util.unit.DataSize;
import org.springframework.util.unit.DataUnit;
/**
* {@link ConfigurationProperties Properties} for Spring gRPC servers.
*
* @author Chris Bono
* @author Phillip Webb
* @since 4.1.0
*/
@ConfigurationProperties("spring.grpc.server")
public class GrpcServerProperties {
/**
* Port on which the gRPC server should listen. Use '0' to bind to a dynamic port.
*/
private @Nullable Integer port;
/**
* Network address to which the gRPC server should bind.
*/
private @Nullable InetAddress address;
private final Shutdown shutdown = new Shutdown();
private final Inbound inbound = new Inbound();
private final Inprocess inprocess = new Inprocess();
private final Keepalive keepalive = new Keepalive();
private final Ssl ssl = new Ssl();
private final Netty netty = new Netty();
private final Servlet servlet = new Servlet();
public @Nullable Integer getPort() {
return this.port;
}
public void setPort(@Nullable Integer port) {
this.port = port;
}
public @Nullable InetAddress getAddress() {
return this.address;
}
public void setAddress(@Nullable InetAddress address) {
this.address = address;
}
public Shutdown getShutdown() {
return this.shutdown;
}
public Inbound getInbound() {
return this.inbound;
}
public Inprocess getInprocess() {
return this.inprocess;
}
public Keepalive getKeepalive() {
return this.keepalive;
}
public Ssl getSsl() {
return this.ssl;
}
public Netty getNetty() {
return this.netty;
}
public Servlet getServlet() {
return this.servlet;
}
/**
* Server shutdown properties.
*/
public static class Shutdown {
/**
* Maximum time to wait for the server to gracefully shutdown. When the value is
* negative, the server waits forever. When the value is 0, the server will force
* shutdown immediately. The default is 30 seconds.
*/
@DurationUnit(ChronoUnit.SECONDS)
private Duration gracePeriod = Duration.ofSeconds(30);
public Duration getGracePeriod() {
return this.gracePeriod;
}
public void setGracePeriod(Duration gracePeriod) {
this.gracePeriod = gracePeriod;
}
}
/**
* In-bound properties.
*/
public static class Inbound {
private final Message message = new Message();
private final Metadata metadata = new Metadata();
public Message getMessage() {
return this.message;
}
public Metadata getMetadata() {
return this.metadata;
}
/**
* In-bound message properties.
*/
public static class Message {
/**
* Maximum message size allowed to be received by the server (default 4MiB).
*/
@DataSizeUnit(DataUnit.BYTES)
private DataSize maxSize = DataSize.ofBytes(4194304);
public DataSize getMaxSize() {
return this.maxSize;
}
public void setMaxSize(DataSize maxSize) {
this.maxSize = maxSize;
}
}
/**
* In-bound metadata properties.
*/
public static class Metadata {
/**
* Maximum metadata size allowed to be received by the server (default 8KiB).
*/
@DataSizeUnit(DataUnit.BYTES)
private DataSize maxSize = DataSize.ofBytes(8192);
public DataSize getMaxSize() {
return this.maxSize;
}
public void setMaxSize(DataSize maxSize) {
this.maxSize = maxSize;
}
}
}
/**
* In-process gRPC properties.
*/
public static class Inprocess {
/**
* The name of the in-process server or null to not start the in-process server.
*/
private @Nullable String name;
public @Nullable String getName() {
return this.name;
}
public void setName(@Nullable String name) {
this.name = name;
}
}
/**
* Keep-alive properties.
*/
public static class Keepalive {
/**
* Duration without read activity before sending a keep alive ping (default 2h).
*/
@DurationUnit(ChronoUnit.SECONDS)
private @Nullable Duration time = Duration.ofHours(2);
/**
* Maximum time to wait for read activity after sending a keep alive ping. If
* sender does not receive an acknowledgment within this time, it will close the
* connection (default 20s).
*/
@DurationUnit(ChronoUnit.SECONDS)
private @Nullable Duration timeout = Duration.ofSeconds(20);
private final Permit permit = new Permit();
private final Connection connection = new Connection();
public @Nullable Duration getTime() {
return this.time;
}
public void setTime(@Nullable Duration time) {
this.time = time;
}
public @Nullable Duration getTimeout() {
return this.timeout;
}
public void setTimeout(@Nullable Duration timeout) {
this.timeout = timeout;
}
public Permit getPermit() {
return this.permit;
}
public Connection getConnection() {
return this.connection;
}
/**
* Keep-alive permit properties.
*/
public static class Permit {
/**
* Maximum keep-alive time clients are permitted to configure (default 5m).
*/
@DurationUnit(ChronoUnit.SECONDS)
private @Nullable Duration time = Duration.ofMinutes(5);
/**
* Whether clients are permitted to send keep alive pings when there are no
* outstanding RPCs on the connection (default false).
*/
private boolean withoutCalls;
public @Nullable Duration getTime() {
return this.time;
}
public void setTime(@Nullable Duration time) {
this.time = time;
}
public boolean isWithoutCalls() {
return this.withoutCalls;
}
public void setWithoutCalls(boolean withoutCalls) {
this.withoutCalls = withoutCalls;
}
}
/**
* Keep-alive connection properties.
*/
public static class Connection {
/**
* Maximum time a connection can remain idle before being gracefully
* terminated (default infinite).
*/
@DurationUnit(ChronoUnit.SECONDS)
private @Nullable Duration maxIdleTime;
/**
* Maximum time a connection may exist before being gracefully terminated
* (default infinite).
*/
@DurationUnit(ChronoUnit.SECONDS)
private @Nullable Duration maxAge;
/**
* Maximum time for graceful connection termination (default infinite).
*/
@DurationUnit(ChronoUnit.SECONDS)
private @Nullable Duration gracePeriod = Duration.ofSeconds(30);
public @Nullable Duration getMaxIdleTime() {
return this.maxIdleTime;
}
public void setMaxIdleTime(@Nullable Duration maxIdleTime) {
this.maxIdleTime = maxIdleTime;
}
public @Nullable Duration getMaxAge() {
return this.maxAge;
}
public void setMaxAge(@Nullable Duration maxAge) {
this.maxAge = maxAge;
}
public @Nullable Duration getGracePeriod() {
return this.gracePeriod;
}
public void setGracePeriod(@Nullable Duration gracePeriod) {
this.gracePeriod = gracePeriod;
}
}
}
/**
* SSL properties.
*/
public static class Ssl {
/**
* Whether to enable SSL support.
*/
private @Nullable Boolean enabled;
/**
* Client authentication mode.
*/
private ClientAuth clientAuth = ClientAuth.NONE;
/**
* SSL bundle name. Should match a bundle configured in spring.ssl.bundle.
*/
private @Nullable String bundle;
/**
* Flag to indicate that client authentication is secure (i.e. certificates are
* checked). Do not set this to false in production.
*/
private boolean secure = true;
public @Nullable Boolean getEnabled() {
return this.enabled;
}
public void setEnabled(@Nullable Boolean enabled) {
this.enabled = enabled;
}
public @Nullable String getBundle() {
return this.bundle;
}
public void setBundle(@Nullable String bundle) {
this.bundle = bundle;
}
public void setClientAuth(ClientAuth clientAuth) {
this.clientAuth = clientAuth;
}
public ClientAuth getClientAuth() {
return this.clientAuth;
}
public void setSecure(boolean secure) {
this.secure = secure;
}
public boolean isSecure() {
return this.secure;
}
}
/**
* Netty server properties.
*/
public static class Netty {
/**
* Transport mechanism used for Netty and Netty Shaded servers. If not specified
* will the appropriate transport will be picked based on the
* 'deomain-socket-path' or 'address/port'.
*/
private @Nullable Transport transport;
/**
* Path of the domain socket that should be used.
*/
private @Nullable String domainSocketPath;
public @Nullable Transport getTransport() {
return this.transport;
}
public void setTransport(@Nullable Transport transport) {
this.transport = transport;
}
public @Nullable String getDomainSocketPath() {
return this.domainSocketPath;
}
public void setDomainSocketPath(@Nullable String domainSocketPath) {
this.domainSocketPath = domainSocketPath;
}
public enum Transport {
/**
* TCP transport.
*/
TCP,
/**
* Domain socket transport.
*/
DOMAIN_SOCKET
}
}
/**
* Servlet properties.
*/
public static class Servlet {
/**
* Whether to use a servlet server in a servlet-based web application. When the
* value is false, a native gRPC server will be created as long as one is
* available, and it will listen on its own port. Should only be needed if the
* GrpcServlet is on the classpath.
*/
private boolean enabled;
/**
* Whether to validate that HTTP/2 is enabled. Validation may need to be skipped
* if your servlet container is not configured using properties.
*/
private boolean validateHttp2 = true;
public boolean isEnabled() {
return this.enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public boolean isValidateHttp2() {
return this.validateHttp2;
}
public void setValidateHttp2(boolean validateHttp2) {
this.validateHttp2 = validateHttp2;
}
}
}
@@ -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();
}
}
}
@@ -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<GrpcServerFactoryCustomizer> 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);
}
}
@@ -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 {
}
}
@@ -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());
}
}
@@ -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<GrpcServerFactoryCustomizer> 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);
}
}
@@ -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 <T> the type of server builder
* @param properties the properties to map
* @author Chris Bono
* @author Phillip Webb
*/
record PropertiesServerBuilderCustomizer<T extends ServerBuilder<T>>(
GrpcServerProperties properties) implements ServerBuilderCustomizer<T> {
@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<Duration> durationProperty(BiConsumer<Long, TimeUnit> 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;
}
}
}
@@ -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);
}
}
@@ -0,0 +1,61 @@
/*
* Copyright 2012-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.grpc.server.autoconfigure;
import io.grpc.servlet.jakarta.GrpcServlet;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.diagnostics.FailureAnalyzedException;
import org.springframework.boot.grpc.server.GrpcServletRegistration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.grpc.server.service.GrpcServiceConfigurer;
import org.springframework.grpc.server.service.GrpcServiceDiscoverer;
/**
* {@link Configuration @Configuration} for a Servlet gRPC server.
*
* @author David Syer
* @author Chris Bono
* @author Toshiaki Maki
* @author Phillip Webb
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnClass(GrpcServlet.class)
@ConditionalOnMissingNetworkGrpcServer
@ConditionalOnBooleanProperty(name = "spring.grpc.server.servlet.enabled", matchIfMissing = true)
class ServletGrpcServerConfiguration {
@Bean
GrpcServletRegistration grpcServletRegistration(Environment environment, GrpcServerProperties properties,
GrpcServiceDiscoverer serviceDiscoverer, GrpcServiceConfigurer serviceConfigurer,
GrpcServerBuilderCustomizers grpcServerBuilderCustomizers) {
if (properties.getServlet().isValidateHttp2()
&& !Boolean.TRUE.equals(environment.getProperty("server.http2.enabled", Boolean.class))) {
throw new FailureAnalyzedException(
"Configuration property 'server.http2.enabled' should be set to true for gRPC support",
"Update your application to correct the invalid configuration.\n"
+ "You can also set 'spring.grpc.server.servlet.validate-http2' to false to disable the validation.");
}
return new GrpcServletRegistration(serviceDiscoverer, serviceConfigurer, grpcServerBuilderCustomizers::apply);
}
}
@@ -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<GrpcServerFactoryCustomizer> 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);
}
}
@@ -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;
}
}
@@ -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<String, HealthCheckedGrpcComponent> 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<String, HealthCheckedGrpcComponent> createServices(Map<String, Service> serviceProperties,
BeanFactory beanFactory, StatusAggregator defaultStatusAggregator, StatusMapper defaultStatusMapper) {
Map<String, HealthCheckedGrpcComponent> 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<String> order, StatusAggregator defaultStatusAggregator) {
return (!CollectionUtils.isEmpty(order)) ? StatusAggregator.of(order) : defaultStatusAggregator;
}
private StatusMapper createStatusMapper(Map<String, ServingStatus> mapping, StatusMapper defaultStatusMapper) {
return (!CollectionUtils.isEmpty(mapping)) ? StatusMapper.of(mapping) : defaultStatusMapper;
}
private <T> T getNonQualifiedBean(ListableBeanFactory beanFactory, Class<T> type, Supplier<T> fallback) {
List<String> 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> T getQualifiedBean(BeanFactory beanFactory, Class<T> type, String qualifier, Supplier<T> 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<String> getServiceNames() {
return this.services.keySet();
}
@Override
public @Nullable HealthCheckedGrpcComponent getService(String serviceName) {
return this.services.get(serviceName);
}
}
@@ -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> 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> 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 {
}
}
}
@@ -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<String, Service> 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<String, Service> 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<String> include;
/**
* Health indicator IDs that should be excluded or '*' for all.
*/
private @Nullable Set<String> exclude;
/**
* Status configuration.
*/
@NestedConfigurationProperty
private final Status status = new Status();
public Status getStatus() {
return this.status;
}
public @Nullable Set<String> getInclude() {
return this.include;
}
public void setInclude(@Nullable Set<String> include) {
this.include = include;
}
public @Nullable Set<String> getExclude() {
return this.exclude;
}
public void setExclude(@Nullable Set<String> exclude) {
this.exclude = exclude;
}
}
/**
* Status properties for the group.
*/
public static class Status {
/**
* List of health statuses in order of severity.
*/
private List<String> 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<String, ServingStatus> mapping = new HashMap<>();
public List<String> getOrder() {
return this.order;
}
public void setOrder(List<String> statusOrder) {
if (!CollectionUtils.isEmpty(statusOrder)) {
this.order = statusOrder;
}
}
public Map<String, ServingStatus> 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;
}
}
}
@@ -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<GrpcServerStartedEvent> {
private static final Log logger = LogFactory.getLog(GrpcServerHealthScheduler.class);
private final SingletonSupplier<ScheduledFuture<?>> 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();
}
}
@@ -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());
}
}
@@ -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;
@@ -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;
@@ -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.
* <p>
* 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.
* </p>
*
* @author Dave Syer
* @see AbstractHttpConfigurer
* @see HttpSecurity
*/
class GrpcDisableCsrfHttpConfigurer extends AbstractHttpConfigurer<GrpcDisableCsrfHttpConfigurer, HttpSecurity> {
@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<HttpSecurity> 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);
}
}
}
@@ -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();
}
}
@@ -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<Object> 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();
}
}
}
@@ -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;
@@ -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:
*
* <pre class="code">
* GrpcReactiveRequest.toAnyService().excluding("my-service")
* </pre>
* @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<GrpcServiceDiscoverer> {
private static final ServerWebExchangeMatcher EMPTY_MATCHER = (exchange) -> MatchResult.notMatch();
private final Set<String> excludes;
private volatile @Nullable ServerWebExchangeMatcher delegate;
private GrpcReactiveRequestMatcher(Set<String> 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<String> services) {
Assert.notNull(services, "'services' must not be null");
Set<String> excludes = new LinkedHashSet<>(this.excludes);
excludes.addAll(services);
return new GrpcReactiveRequestMatcher(excludes);
}
@Override
protected void initialized(Supplier<GrpcServiceDiscoverer> context) {
this.delegate = createDelegate(context.get());
}
private ServerWebExchangeMatcher createDelegate(GrpcServiceDiscoverer serviceDiscoverer) {
List<ServerWebExchangeMatcher> delegateMatchers = getDelegateMatchers(serviceDiscoverer);
return (!CollectionUtils.isEmpty(delegateMatchers)) ? new OrServerWebExchangeMatcher(delegateMatchers)
: EMPTY_MATCHER;
}
private List<ServerWebExchangeMatcher> getDelegateMatchers(GrpcServiceDiscoverer serviceDiscoverer) {
return getPatterns(serviceDiscoverer).map(this::getDelegateMatcher).toList();
}
private Stream<String> 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<MatchResult> matches(ServerWebExchange exchange, Supplier<GrpcServiceDiscoverer> context) {
Assert.state(this.delegate != null, "'delegate' must not be null");
return this.delegate.matches(exchange);
}
}
}
@@ -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;
@@ -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:
*
* <pre class="code">
* GrpcServletRequest.toAnyService().excluding("my-service")
* </pre>
* @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<GrpcServiceDiscoverer> {
private final Set<String> excludes;
private volatile @Nullable RequestMatcher delegate;
private GrpcServletRequestMatcher(Set<String> 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<String> services) {
Assert.notNull(services, "'services' must not be null");
Set<String> excludes = new LinkedHashSet<>(this.excludes);
excludes.addAll(services);
return new GrpcServletRequestMatcher(excludes);
}
@Override
protected void initialized(Supplier<GrpcServiceDiscoverer> context) {
this.delegate = createDelegate(context.get());
}
private @Nullable RequestMatcher createDelegate(GrpcServiceDiscoverer grpcServiceDiscoverer) {
List<RequestMatcher> delegateMatchers = getDelegateMatchers(grpcServiceDiscoverer);
return (!CollectionUtils.isEmpty(delegateMatchers)) ? new OrRequestMatcher(delegateMatchers)
: EMPTY_MATCHER;
}
private List<RequestMatcher> getDelegateMatchers(GrpcServiceDiscoverer serviceDiscoverer) {
return getPatterns(serviceDiscoverer).map(this::getDelegateMatcher).toList();
}
private Stream<String> 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<GrpcServiceDiscoverer> context) {
Assert.state(this.delegate != null, "'delegate' must not be null");
return this.delegate.matches(request);
}
}
}
@@ -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;
@@ -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<String, ServingStatus> 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<Status> 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<Status> 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<String, Health> health = new HashMap<>();
Health getHealth(String name, HealthIndicator indicator) {
return this.health.computeIfAbsent(name, (key) -> indicator.health(false));
}
}
}
@@ -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();
}
@@ -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<String> 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);
}
@@ -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<String, ServingStatus> 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<String, ServingStatus> mappings;
SimpleServingStatusMapper(Map<String, ServingStatus> 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<String, ServingStatus> getUniformMappings(Map<String, ServingStatus> mappings) {
Map<String, ServingStatus> result = new LinkedHashMap<>();
for (Map.Entry<String, ServingStatus> 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;
}
}
@@ -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<String> order;
private final Comparator<Status> comparator = Comparator.comparingInt(this::orderIndex)
.thenComparing(Status::getCode);
SimpleStatusAggregator(Stream<String> order) {
this.order = order.map(SimpleStatusAggregator::getUniformCode).toList();
}
@Override
public Status getAggregateStatus(Set<Status> 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;
}
}
@@ -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.
* <p>
* 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<Status> 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<String> 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;
}
}
@@ -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<String, ServingStatus> 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;
}
}
@@ -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;
@@ -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;
@@ -0,0 +1,29 @@
{
"groups": [],
"properties": [
{
"name": "spring.grpc.server.enabled",
"type": "java.lang.Boolean",
"description": "Whether to enable gRPC server auto-configuration.",
"defaultValue": true
},
{
"name": "spring.grpc.server.factory.enabled",
"type": "java.lang.Boolean",
"description": "Whether to enable gRPC server factory bean auto-configuration.",
"defaultValue": true
},
{
"name": "spring.grpc.server.observation.enabled",
"type": "java.lang.Boolean",
"description": "Whether to enable Observations on the server.",
"defaultValue": true
},
{
"name": "spring.grpc.server.reflection.enabled",
"type": "java.lang.Boolean",
"description": "Whether to enable Reflection on the gRPC server.",
"defaultValue": true
}
]
}
@@ -0,0 +1,2 @@
org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer=\
org.springframework.boot.grpc.server.autoconfigure.security.GrpcDisableCsrfHttpConfigurer
@@ -0,0 +1,7 @@
org.springframework.boot.grpc.server.autoconfigure.GrpcServerAutoConfiguration
org.springframework.boot.grpc.server.autoconfigure.GrpcServerObservationAutoConfiguration
org.springframework.boot.grpc.server.autoconfigure.GrpcServerServicesAutoConfiguration
org.springframework.boot.grpc.server.autoconfigure.health.GrpcServerHealthAutoConfiguration
org.springframework.boot.grpc.server.autoconfigure.health.GrpcServerHealthSchedulerAutoConfiguration
org.springframework.boot.grpc.server.autoconfigure.security.GrpcServerOAuth2ResourceServerAutoConfiguration
org.springframework.boot.grpc.server.autoconfigure.security.GrpcServerSecurityAutoConfiguration

Some files were not shown because too many files have changed in this diff Show More