diff --git a/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java b/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java index d6070017d25c..9e3a28955a36 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java @@ -58,6 +58,7 @@ void documentConfigurationProperties() throws IOException { Snippets snippets = new Snippets(this.configurationPropertyMetadata); 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); @@ -159,6 +160,10 @@ private void dataMigrationPrefixes(Config prefix) { prefix.accept("spring.sql.init"); } + private void grpcPrefixes(Config prefix) { + prefix.accept("spring.grpc"); + } + private void integrationPrefixes(Config prefix) { prefix.accept("spring.activemq"); prefix.accept("spring.artemis"); diff --git a/config/checkstyle/checkstyle-suppressions.xml b/config/checkstyle/checkstyle-suppressions.xml index eac6829276e7..085130a1c007 100644 --- a/config/checkstyle/checkstyle-suppressions.xml +++ b/config/checkstyle/checkstyle-suppressions.xml @@ -50,6 +50,7 @@ + diff --git a/documentation/spring-boot-docs/build.gradle b/documentation/spring-boot-docs/build.gradle index 5bf08fc75605..c205696ab8b7 100644 --- a/documentation/spring-boot-docs/build.gradle +++ b/documentation/spring-boot-docs/build.gradle @@ -102,6 +102,8 @@ dependencies { implementation(project(path: ":module:spring-boot-data-redis-test")) implementation(project(path: ":module:spring-boot-devtools")) implementation(project(path: ":module:spring-boot-graphql-test")) + implementation(project(path: ":module:spring-boot-grpc-client")) + implementation(project(path: ":module:spring-boot-grpc-server")) implementation(project(path: ":module:spring-boot-health")) implementation(project(path: ":module:spring-boot-hibernate")) implementation(project(path: ":module:spring-boot-http-converter")) @@ -195,6 +197,7 @@ dependencies { implementation("org.springframework.data:spring-data-r2dbc") implementation("org.springframework.graphql:spring-graphql") implementation("org.springframework.graphql:spring-graphql-test") + implementation("org.springframework.grpc:spring-grpc-core") implementation("org.springframework.kafka:spring-kafka") implementation("org.springframework.kafka:spring-kafka-test") implementation("org.springframework.pulsar:spring-pulsar") diff --git a/module/spring-boot-grpc-client/build.gradle b/module/spring-boot-grpc-client/build.gradle new file mode 100644 index 000000000000..1f00daa289fb --- /dev/null +++ b/module/spring-boot-grpc-client/build.gradle @@ -0,0 +1,67 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id "java-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") + + compileOnly("com.fasterxml.jackson.core:jackson-annotations") + + optional(project(":core:spring-boot-autoconfigure")) + optional(project(":module:spring-boot-actuator")) + optional(project(":module:spring-boot-actuator-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-client")) + optional(project(":module:spring-boot-security-oauth2-resource-server")) + optional("io.grpc:grpc-servlet-jakarta") + 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.micrometer:micrometer-core") + optional("io.netty:netty-transport-native-epoll") + optional("io.projectreactor:reactor-core") + optional("jakarta.servlet:jakarta.servlet-api") + optional("org.springframework:spring-web") + optional("org.springframework.security:spring-security-config") + optional("org.springframework.security:spring-security-oauth2-client") + optional("org.springframework.security:spring-security-oauth2-resource-server") + optional("org.springframework.security:spring-security-oauth2-jose") + optional("org.springframework.security:spring-security-web") + + testCompileOnly("com.fasterxml.jackson.core:jackson-annotations") + + testImplementation(project(":core:spring-boot-test")) + testImplementation(project(":test-support:spring-boot-test-support")) + + testRuntimeOnly("ch.qos.logback:logback-classic") +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ChannelBuilderCustomizers.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ChannelBuilderCustomizers.java new file mode 100644 index 000000000000..fe0eadd59146 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ChannelBuilderCustomizers.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.client.autoconfigure; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import io.grpc.ManagedChannelBuilder; + +import org.springframework.boot.util.LambdaSafe; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; + +/** + * Invokes the available {@link GrpcChannelBuilderCustomizer} instances for a given + * {@link ManagedChannelBuilder}. + * + * @author Chris Bono + */ +class ChannelBuilderCustomizers { + + private final List> customizers; + + ChannelBuilderCustomizers(List> customizers) { + this.customizers = (customizers != null) ? new ArrayList<>(customizers) : Collections.emptyList(); + } + + /** + * Customize the specified {@link ManagedChannelBuilder}. Locates all + * {@link GrpcChannelBuilderCustomizer} beans able to handle the specified instance + * and invoke {@link GrpcChannelBuilderCustomizer#customize} on them. + * @param the type of channel builder + * @param authority the target authority of the channel + * @param channelBuilder the builder to customize + * @return the customized builder + */ + @SuppressWarnings("unchecked") + > T customize(String authority, T channelBuilder) { + LambdaSafe.callbacks(GrpcChannelBuilderCustomizer.class, this.customizers, channelBuilder) + .withLogger(ChannelBuilderCustomizers.class) + .invoke((customizer) -> customizer.customize(authority, channelBuilder)); + return channelBuilder; + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ClientPropertiesChannelBuilderCustomizer.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ClientPropertiesChannelBuilderCustomizer.java new file mode 100644 index 000000000000..280eaeed79db --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ClientPropertiesChannelBuilderCustomizer.java @@ -0,0 +1,83 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.client.autoconfigure; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +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.ChannelConfig; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; +import org.springframework.grpc.client.interceptor.DefaultDeadlineSetupClientInterceptor; +import org.springframework.util.unit.DataSize; + +/** + * A {@link GrpcChannelBuilderCustomizer} that maps {@link GrpcClientProperties client + * properties} to a channel builder. + * + * @param the type of the builder + * @author David Syer + * @author Chris Bono + */ +class ClientPropertiesChannelBuilderCustomizer> + implements GrpcChannelBuilderCustomizer { + + private final GrpcClientProperties properties; + + ClientPropertiesChannelBuilderCustomizer(GrpcClientProperties properties) { + this.properties = properties; + } + + @Override + public void customize(String authority, T builder) { + ChannelConfig channel = this.properties.getChannel(authority); + PropertyMapper mapper = PropertyMapper.get(); + mapper.from(channel.getUserAgent()).to(builder::userAgent); + if (!authority.startsWith("unix:") && !authority.startsWith("in-process:")) { + mapper.from(channel.getDefaultLoadBalancingPolicy()).to(builder::defaultLoadBalancingPolicy); + } + mapper.from(channel.getMaxInboundMessageSize()).asInt(DataSize::toBytes).to(builder::maxInboundMessageSize); + mapper.from(channel.getMaxInboundMetadataSize()).asInt(DataSize::toBytes).to(builder::maxInboundMetadataSize); + mapper.from(channel.getKeepAliveTime()).to(durationProperty(builder::keepAliveTime)); + mapper.from(channel.getKeepAliveTimeout()).to(durationProperty(builder::keepAliveTimeout)); + mapper.from(channel.getIdleTimeout()).to(durationProperty(builder::idleTimeout)); + mapper.from(channel.isKeepAliveWithoutCalls()).to(builder::keepAliveWithoutCalls); + Map defaultServiceConfig = new HashMap<>(channel.getServiceConfig()); + if (channel.getHealth().isEnabled()) { + String serviceNameToCheck = (channel.getHealth().getServiceName() != null) + ? channel.getHealth().getServiceName() : ""; + defaultServiceConfig.put("healthCheckConfig", Map.of("serviceName", serviceNameToCheck)); + } + if (!defaultServiceConfig.isEmpty()) { + builder.defaultServiceConfig(defaultServiceConfig); + } + if (channel.getDefaultDeadline() != null && channel.getDefaultDeadline().toMillis() > 0L) { + builder.intercept(new DefaultDeadlineSetupClientInterceptor(channel.getDefaultDeadline())); + } + } + + Consumer durationProperty(BiConsumer setter) { + return (duration) -> setter.accept(duration.toNanos(), TimeUnit.NANOSECONDS); + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/CompositeChannelFactoryAutoConfiguration.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/CompositeChannelFactoryAutoConfiguration.java new file mode 100644 index 000000000000..563c388e7d4f --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/CompositeChannelFactoryAutoConfiguration.java @@ -0,0 +1,67 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.client.autoconfigure; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +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.GrpcChannelFactory; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for a + * {@link CompositeGrpcChannelFactory}. + * + * @author Chris Bono + * @since 4.0.0 + */ +@AutoConfiguration +@ConditionalOnGrpcClientEnabled +@Conditional(CompositeChannelFactoryAutoConfiguration.MultipleNonPrimaryChannelFactoriesCondition.class) +public final class CompositeChannelFactoryAutoConfiguration { + + @Bean + @Primary + CompositeGrpcChannelFactory compositeChannelFactory(ObjectProvider channelFactoriesProvider) { + return new CompositeGrpcChannelFactory(channelFactoriesProvider.orderedStream().toList()); + } + + static class MultipleNonPrimaryChannelFactoriesCondition extends NoneNestedConditions { + + MultipleNonPrimaryChannelFactoriesCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnMissingBean(GrpcChannelFactory.class) + static class NoChannelFactoryCondition { + + } + + @ConditionalOnSingleCandidate(GrpcChannelFactory.class) + static class SingleInjectableChannelFactoryCondition { + + } + + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ConditionalOnGrpcClientEnabled.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ConditionalOnGrpcClientEnabled.java new file mode 100644 index 000000000000..40658fff4c9a --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ConditionalOnGrpcClientEnabled.java @@ -0,0 +1,45 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.grpc.stub.AbstractStub; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Conditional; + +/** + * {@link Conditional @Conditional} that only matches when the {@code io.grpc:grpc-stub} + * module is in the classpath and the {@code spring.grpc.client.enabled} property is not + * explicitly set to {@code false}. + * + * @author Freeman Freeman + * @author Chris Bono + * @since 4.0.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +@ConditionalOnClass(AbstractStub.class) +@ConditionalOnProperty(prefix = "spring.grpc.client", name = "enabled", matchIfMissing = true) +public @interface ConditionalOnGrpcClientEnabled { + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/DefaultGrpcClientRegistrations.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/DefaultGrpcClientRegistrations.java new file mode 100644 index 000000000000..26e4ef51b8e2 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/DefaultGrpcClientRegistrations.java @@ -0,0 +1,68 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.client.autoconfigure; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.beans.factory.BeanFactory; +import org.springframework.boot.autoconfigure.AutoConfigurationPackages; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.ChannelConfig; +import org.springframework.core.env.Environment; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.grpc.client.AbstractGrpcClientRegistrar; +import org.springframework.grpc.client.GrpcClientFactory.GrpcClientRegistrationSpec; + +/** + * Default implementation of {@link AbstractGrpcClientRegistrar} that registers client + * bean definitions for the default configured gRPC channel if the + * {@code spring.grpc.client.default-channel} property is set. + * + * @author Dave Syer + * @author Chris Bono + */ +class DefaultGrpcClientRegistrations extends AbstractGrpcClientRegistrar { + + private final Environment environment; + + private final BeanFactory beanFactory; + + DefaultGrpcClientRegistrations(Environment environment, BeanFactory beanFactory) { + this.environment = environment; + this.beanFactory = beanFactory; + } + + @Override + protected GrpcClientRegistrationSpec[] collect(AnnotationMetadata meta) { + Binder binder = Binder.get(this.environment); + boolean hasDefaultChannel = binder.bind("spring.grpc.client.default-channel", ChannelConfig.class).isBound(); + if (hasDefaultChannel) { + List packages = new ArrayList<>(); + if (AutoConfigurationPackages.has(this.beanFactory)) { + packages.addAll(AutoConfigurationPackages.get(this.beanFactory)); + } + GrpcClientProperties clientProperties = binder.bind("spring.grpc.client", GrpcClientProperties.class) + .orElseGet(GrpcClientProperties::new); + return new GrpcClientRegistrationSpec[] { GrpcClientRegistrationSpec.of("default") + .factory(clientProperties.getDefaultStubFactory()) + .packages(packages.toArray(new String[0])) }; + } + return new GrpcClientRegistrationSpec[0]; + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelFactoryConfigurations.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelFactoryConfigurations.java new file mode 100644 index 000000000000..2aeadd57a830 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelFactoryConfigurations.java @@ -0,0 +1,125 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.Channel; +import io.grpc.inprocess.InProcessChannelBuilder; +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.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.client.ChannelCredentialsProvider; +import org.springframework.grpc.client.ClientInterceptorFilter; +import org.springframework.grpc.client.ClientInterceptorsConfigurer; +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; + +/** + * Configurations for {@link GrpcChannelFactory gRPC channel factories}. + * + * @author Chris Bono + */ +class GrpcChannelFactoryConfigurations { + + @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) + @ConditionalOnProperty(prefix = "spring.grpc.client.inprocess.", name = "exclusive", havingValue = "false", + matchIfMissing = true) + @EnableConfigurationProperties(GrpcClientProperties.class) + static class ShadedNettyChannelFactoryConfiguration { + + @Bean + ShadedNettyGrpcChannelFactory shadedNettyGrpcChannelFactory(GrpcClientProperties properties, + ChannelBuilderCustomizers channelBuilderCustomizers, + ClientInterceptorsConfigurer interceptorsConfigurer, + ObjectProvider channelFactoryCustomizers, + ChannelCredentialsProvider credentials) { + List> builderCustomizers = List + .of(channelBuilderCustomizers::customize); + ShadedNettyGrpcChannelFactory factory = new ShadedNettyGrpcChannelFactory(builderCustomizers, + interceptorsConfigurer); + factory.setCredentialsProvider(credentials); + factory.setVirtualTargets(properties); + channelFactoryCustomizers.orderedStream().forEach((customizer) -> customizer.customize(factory)); + return factory; + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass({ Channel.class, NettyChannelBuilder.class }) + @ConditionalOnMissingBean(value = GrpcChannelFactory.class, ignored = InProcessGrpcChannelFactory.class) + @ConditionalOnProperty(prefix = "spring.grpc.client.inprocess.", name = "exclusive", havingValue = "false", + matchIfMissing = true) + @EnableConfigurationProperties(GrpcClientProperties.class) + static class NettyChannelFactoryConfiguration { + + @Bean + NettyGrpcChannelFactory nettyGrpcChannelFactory(GrpcClientProperties properties, + ChannelBuilderCustomizers channelBuilderCustomizers, + ClientInterceptorsConfigurer interceptorsConfigurer, + ObjectProvider channelFactoryCustomizers, + ChannelCredentialsProvider credentials) { + List> builderCustomizers = List + .of(channelBuilderCustomizers::customize); + NettyGrpcChannelFactory factory = new NettyGrpcChannelFactory(builderCustomizers, interceptorsConfigurer); + factory.setCredentialsProvider(credentials); + factory.setVirtualTargets(properties); + channelFactoryCustomizers.orderedStream().forEach((customizer) -> customizer.customize(factory)); + return factory; + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(InProcessChannelBuilder.class) + @ConditionalOnMissingBean(InProcessGrpcChannelFactory.class) + @ConditionalOnProperty(prefix = "spring.grpc.client.inprocess", name = "enabled", havingValue = "true", + matchIfMissing = true) + static class InProcessChannelFactoryConfiguration { + + @Bean + InProcessGrpcChannelFactory inProcessGrpcChannelFactory(ChannelBuilderCustomizers channelBuilderCustomizers, + ClientInterceptorsConfigurer interceptorsConfigurer, + ObjectProvider interceptorFilter, + ObjectProvider channelFactoryCustomizers) { + List> inProcessBuilderCustomizers = List + .of(channelBuilderCustomizers::customize); + InProcessGrpcChannelFactory factory = new InProcessGrpcChannelFactory(inProcessBuilderCustomizers, + interceptorsConfigurer); + if (interceptorFilter != null) { + factory.setInterceptorFilter(interceptorFilter.getIfAvailable(() -> null)); + } + channelFactoryCustomizers.orderedStream().forEach((customizer) -> customizer.customize(factory)); + return factory; + } + + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelFactoryCustomizer.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelFactoryCustomizer.java new file mode 100644 index 000000000000..7e640a3e7b83 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelFactoryCustomizer.java @@ -0,0 +1,37 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.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.0.0 + */ +public interface GrpcChannelFactoryCustomizer { + + /** + * Customize the given {@link GrpcChannelFactory}. + * @param factory the factory to customize + */ + void customize(GrpcChannelFactory factory); + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfiguration.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfiguration.java new file mode 100644 index 000000000000..95895fef06aa --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfiguration.java @@ -0,0 +1,106 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.client.autoconfigure; + +import io.grpc.CompressorRegistry; +import io.grpc.DecompressorRegistry; +import io.grpc.ManagedChannelBuilder; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientAutoConfiguration.ClientScanConfiguration; +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; +import org.springframework.grpc.client.GrpcClientFactory; + +@AutoConfiguration(before = CompositeChannelFactoryAutoConfiguration.class) +@ConditionalOnGrpcClientEnabled +@EnableConfigurationProperties(GrpcClientProperties.class) +@Import({ GrpcCodecConfiguration.class, GrpcChannelFactoryConfigurations.ShadedNettyChannelFactoryConfiguration.class, + GrpcChannelFactoryConfigurations.NettyChannelFactoryConfiguration.class, + GrpcChannelFactoryConfigurations.InProcessChannelFactoryConfiguration.class, ClientScanConfiguration.class }) +public final class GrpcClientAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + ClientInterceptorsConfigurer clientInterceptorsConfigurer(ApplicationContext applicationContext) { + return new ClientInterceptorsConfigurer(applicationContext); + } + + @Bean + @ConditionalOnMissingBean(ChannelCredentialsProvider.class) + NamedChannelCredentialsProvider channelCredentialsProvider(SslBundles bundles, GrpcClientProperties properties) { + return new NamedChannelCredentialsProvider(bundles, properties); + } + + @Bean + > GrpcChannelBuilderCustomizer clientPropertiesChannelCustomizer( + GrpcClientProperties properties) { + return new ClientPropertiesChannelBuilderCustomizer<>(properties); + } + + @ConditionalOnBean(CompressorRegistry.class) + @Bean + > GrpcChannelBuilderCustomizer compressionClientCustomizer( + CompressorRegistry registry) { + return (name, builder) -> builder.compressorRegistry(registry); + } + + @ConditionalOnBean(DecompressorRegistry.class) + @Bean + > GrpcChannelBuilderCustomizer decompressionClientCustomizer( + DecompressorRegistry registry) { + return (name, builder) -> builder.decompressorRegistry(registry); + } + + @ConditionalOnMissingBean + @Bean + ChannelBuilderCustomizers channelBuilderCustomizers(ObjectProvider> customizers) { + return new ChannelBuilderCustomizers(customizers.orderedStream().toList()); + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingBean(GrpcClientFactory.class) + @Import(DefaultGrpcClientRegistrations.class) + static class ClientScanConfiguration { + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(name = "io.grpc.kotlin.AbstractCoroutineStub") + static class GrpcClientCoroutineStubConfiguration { + + @Bean + @ConditionalOnMissingBean + CoroutineStubFactory coroutineStubFactory() { + return new CoroutineStubFactory(); + } + + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientObservationAutoConfiguration.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientObservationAutoConfiguration.java new file mode 100644 index 000000000000..a98a9e9af435 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientObservationAutoConfiguration.java @@ -0,0 +1,46 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.client.autoconfigure; + +import io.micrometer.core.instrument.binder.grpc.ObservationGrpcClientInterceptor; +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.grpc.client.GlobalClientInterceptor; + +@AutoConfiguration( + afterName = "org.springframework.boot.micrometer.observation.autoconfigure.ObservationAutoConfiguration") +@ConditionalOnGrpcClientEnabled +@ConditionalOnClass({ ObservationRegistry.class, ObservationGrpcClientInterceptor.class }) +@ConditionalOnBean(ObservationRegistry.class) +@ConditionalOnProperty(name = "spring.grpc.client.observation.enabled", havingValue = "true", matchIfMissing = true) + +public final class GrpcClientObservationAutoConfiguration { + + @Bean + @GlobalClientInterceptor + @ConditionalOnMissingBean + ObservationGrpcClientInterceptor observationGrpcClientInterceptor(ObservationRegistry observationRegistry) { + return new ObservationGrpcClientInterceptor(observationRegistry); + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientProperties.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientProperties.java new file mode 100644 index 000000000000..48bfc399266e --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientProperties.java @@ -0,0 +1,454 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.HashMap; +import java.util.Map; +import java.util.function.Consumer; + +import io.grpc.ManagedChannel; +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.convert.DurationUnit; +import org.springframework.context.EnvironmentAware; +import org.springframework.core.env.Environment; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.grpc.client.BlockingStubFactory; +import org.springframework.grpc.client.NegotiationType; +import org.springframework.grpc.client.StubFactory; +import org.springframework.grpc.client.VirtualTargets; +import org.springframework.util.unit.DataSize; + +@ConfigurationProperties(prefix = "spring.grpc.client") +public class GrpcClientProperties implements EnvironmentAware, VirtualTargets { + + /** + * Map of channels configured by name. + */ + private final Map channels = new HashMap<>(); + + /** + * The default channel configuration to use for new channels. + */ + private final ChannelConfig defaultChannel = new ChannelConfig(); + + /** + * Default stub factory to use for all channels. + */ + private Class> defaultStubFactory = BlockingStubFactory.class; + + private Environment environment; + + GrpcClientProperties() { + this.defaultChannel.setAddress("static://localhost:9090"); + this.environment = new StandardEnvironment(); + } + + public Map getChannels() { + return this.channels; + } + + public ChannelConfig getDefaultChannel() { + return this.defaultChannel; + } + + public Class> getDefaultStubFactory() { + return this.defaultStubFactory; + } + + public void setDefaultStubFactory(Class> defaultStubFactory) { + this.defaultStubFactory = defaultStubFactory; + } + + @Override + public void setEnvironment(Environment environment) { + this.environment = environment; + } + + /** + * Gets the configured channel with the given name. If no channel is configured for + * the specified name then one is created using the default channel as a template. + * @param name the name of the channel + * @return the configured channel if found, or a newly created channel using the + * default channel as a template + */ + public ChannelConfig getChannel(String name) { + if ("default".equals(name)) { + return this.defaultChannel; + } + ChannelConfig channel = this.channels.get(name); + if (channel != null) { + return channel; + } + channel = this.defaultChannel.copy(); + String address = name; + if (!name.contains(":/") && !name.startsWith("unix:")) { + if (name.contains(":")) { + address = "static://" + name; + } + else { + address = this.defaultChannel.getAddress(); + if (!address.contains(":/")) { + address = "static://" + address; + } + } + } + channel.setAddress(address); + return channel; + } + + @Override + public String getTarget(String authority) { + ChannelConfig channel = this.getChannel(authority); + String address = channel.getAddress(); + if (address.startsWith("static:") || address.startsWith("tcp:")) { + address = address.substring(address.indexOf(":") + 1).replaceFirst("/*", ""); + } + return this.environment.resolvePlaceholders(address); + } + + /** + * Represents the configuration for a {@link ManagedChannel gRPC channel}. + */ + public static class ChannelConfig { + + /** + * The target address uri to connect to. + */ + private String address = "static://localhost:9090"; + + /** + * The default deadline for RPCs performed on this channel. + */ + private @Nullable Duration defaultDeadline; + + /** + * The load balancing policy the channel should use. + */ + private String defaultLoadBalancingPolicy = "round_robin"; + + /** + * Whether keep alive is enabled on the channel. + */ + private boolean enableKeepAlive; + + private final Health health = new Health(); + + /** + * The duration without ongoing RPCs before going to idle mode. + */ + @DurationUnit(ChronoUnit.SECONDS) + private Duration idleTimeout = Duration.ofSeconds(20); + + /** + * 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 keepAliveTime = Duration.ofMinutes(5); + + /** + * The default timeout for a keepAlives ping request. + */ + @DurationUnit(ChronoUnit.SECONDS) + private Duration keepAliveTimeout = Duration.ofSeconds(20); + + /** + * Whether a keepAlive will be performed when there are no outstanding RPC on a + * connection. + */ + private boolean keepAliveWithoutCalls; + + /** + * Maximum message size allowed to be received by the channel (default 4MiB). Set + * to '-1' to use the highest possible limit (not recommended). + */ + private DataSize maxInboundMessageSize = DataSize.ofBytes(4194304); + + /** + * Maximum metadata size allowed to be received by the channel (default 8KiB). Set + * to '-1' to use the highest possible limit (not recommended). + */ + private DataSize maxInboundMetadataSize = DataSize.ofBytes(8192); + + /** + * The negotiation type for the channel. + */ + private NegotiationType negotiationType = NegotiationType.PLAINTEXT; + + /** + * Flag to say that strict SSL checks are not enabled (so the remote certificate + * could be anonymous). + */ + private boolean secure = true; + + /** + * Map representation of the service config to use for the channel. + */ + private final Map serviceConfig = new HashMap<>(); + + private final Ssl ssl = new Ssl(); + + /** + * The custom User-Agent for the channel. + */ + private @Nullable String userAgent; + + public String getAddress() { + return this.address; + } + + public void setAddress(final String address) { + this.address = address; + } + + public @Nullable Duration getDefaultDeadline() { + return this.defaultDeadline; + } + + public void setDefaultDeadline(@Nullable Duration defaultDeadline) { + this.defaultDeadline = defaultDeadline; + } + + public String getDefaultLoadBalancingPolicy() { + return this.defaultLoadBalancingPolicy; + } + + public void setDefaultLoadBalancingPolicy(final String defaultLoadBalancingPolicy) { + this.defaultLoadBalancingPolicy = defaultLoadBalancingPolicy; + } + + public boolean isEnableKeepAlive() { + return this.enableKeepAlive; + } + + public void setEnableKeepAlive(boolean enableKeepAlive) { + this.enableKeepAlive = enableKeepAlive; + } + + public Health getHealth() { + return this.health; + } + + public Duration getIdleTimeout() { + return this.idleTimeout; + } + + public void setIdleTimeout(Duration idleTimeout) { + this.idleTimeout = idleTimeout; + } + + public Duration getKeepAliveTime() { + return this.keepAliveTime; + } + + public void setKeepAliveTime(Duration keepAliveTime) { + this.keepAliveTime = keepAliveTime; + } + + public Duration getKeepAliveTimeout() { + return this.keepAliveTimeout; + } + + public void setKeepAliveTimeout(Duration keepAliveTimeout) { + this.keepAliveTimeout = keepAliveTimeout; + } + + public boolean isKeepAliveWithoutCalls() { + return this.keepAliveWithoutCalls; + } + + public void setKeepAliveWithoutCalls(boolean keepAliveWithoutCalls) { + this.keepAliveWithoutCalls = keepAliveWithoutCalls; + } + + public DataSize getMaxInboundMessageSize() { + return this.maxInboundMessageSize; + } + + public void setMaxInboundMessageSize(final DataSize maxInboundMessageSize) { + this.setMaxInboundSize(maxInboundMessageSize, (s) -> this.maxInboundMessageSize = s, + "maxInboundMessageSize"); + } + + public DataSize getMaxInboundMetadataSize() { + return this.maxInboundMetadataSize; + } + + public void setMaxInboundMetadataSize(DataSize maxInboundMetadataSize) { + this.setMaxInboundSize(maxInboundMetadataSize, (s) -> this.maxInboundMetadataSize = s, + "maxInboundMetadataSize"); + } + + private void setMaxInboundSize(DataSize maxSize, Consumer setter, String propertyName) { + if (maxSize != null && maxSize.toBytes() >= 0) { + setter.accept(maxSize); + } + else if (maxSize != null && maxSize.toBytes() == -1) { + setter.accept(DataSize.ofBytes(Integer.MAX_VALUE)); + } + else { + throw new IllegalArgumentException("Unsupported %s: %s".formatted(propertyName, maxSize)); + } + } + + public NegotiationType getNegotiationType() { + return this.negotiationType; + } + + public void setNegotiationType(NegotiationType negotiationType) { + this.negotiationType = negotiationType; + } + + public boolean isSecure() { + return this.secure; + } + + public void setSecure(boolean secure) { + this.secure = secure; + } + + public Map getServiceConfig() { + return this.serviceConfig; + } + + public Ssl getSsl() { + return this.ssl; + } + + public @Nullable String getUserAgent() { + return this.userAgent; + } + + public void setUserAgent(@Nullable String userAgent) { + this.userAgent = userAgent; + } + + /** + * Provide a copy of the channel instance. + * @return a copy of the channel instance. + */ + ChannelConfig copy() { + ChannelConfig copy = new ChannelConfig(); + copy.address = this.address; + copy.defaultLoadBalancingPolicy = this.defaultLoadBalancingPolicy; + copy.negotiationType = this.negotiationType; + copy.enableKeepAlive = this.enableKeepAlive; + copy.idleTimeout = this.idleTimeout; + copy.keepAliveTime = this.keepAliveTime; + copy.keepAliveTimeout = this.keepAliveTimeout; + copy.keepAliveWithoutCalls = this.keepAliveWithoutCalls; + copy.maxInboundMessageSize = this.maxInboundMessageSize; + copy.maxInboundMetadataSize = this.maxInboundMetadataSize; + copy.userAgent = this.userAgent; + copy.defaultDeadline = this.defaultDeadline; + copy.health.copyValuesFrom(this.getHealth()); + copy.ssl.copyValuesFrom(this.getSsl()); + copy.serviceConfig.putAll(this.serviceConfig); + return copy; + } + + 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; + } + + /** + * Copies the values from another instance. + * @param other instance to copy values from + */ + void copyValuesFrom(Health other) { + this.enabled = other.enabled; + this.serviceName = other.serviceName; + } + + } + + 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 isEnabled() { + return this.enabled; + } + + public void setEnabled(@Nullable Boolean enabled) { + this.enabled = enabled; + } + + public boolean determineEnabled() { + return (this.enabled != null) ? this.enabled : this.bundle != null; + } + + public @Nullable String getBundle() { + return this.bundle; + } + + public void setBundle(@Nullable String bundle) { + this.bundle = bundle; + } + + /** + * Copies the values from another instance. + * @param other instance to copy values from + */ + void copyValuesFrom(Ssl other) { + this.enabled = other.enabled; + this.bundle = other.bundle; + } + + } + + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcCodecConfiguration.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcCodecConfiguration.java new file mode 100644 index 000000000000..77ded0c30100 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcCodecConfiguration.java @@ -0,0 +1,84 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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 io.grpc.ManagedChannelBuilder; + +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; + +/** + * The configuration that contains all codec related beans for clients. + * + * @author Andrei Lisa + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(Codec.class) +class GrpcCodecConfiguration { + + /** + * The compressor registry that is set on the + * {@link ManagedChannelBuilder#compressorRegistry(CompressorRegistry) channel + * 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 compressorRegistry(ObjectProvider compressors) { + if (compressors.stream().count() == 0) { + return CompressorRegistry.getDefaultInstance(); + } + CompressorRegistry registry = CompressorRegistry.newEmptyInstance(); + compressors.orderedStream().forEachOrdered(registry::register); + return registry; + } + + /** + * The decompressor registry that is set on the + * {@link ManagedChannelBuilder#decompressorRegistry(DecompressorRegistry) channel + * 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 decompressorRegistry(ObjectProvider decompressors) { + if (decompressors.stream().count() == 0) { + return DecompressorRegistry.getDefaultInstance(); + } + DecompressorRegistry registry = DecompressorRegistry.emptyInstance(); + for (Decompressor decompressor : decompressors.orderedStream().toList()) { + registry = registry.with(decompressor, false); + } + return registry; + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/NamedChannelCredentialsProvider.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/NamedChannelCredentialsProvider.java new file mode 100644 index 000000000000..0e8cc2b3ca73 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/NamedChannelCredentialsProvider.java @@ -0,0 +1,79 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.client.autoconfigure; + +import javax.net.ssl.TrustManagerFactory; + +import io.grpc.ChannelCredentials; +import io.grpc.InsecureChannelCredentials; +import io.grpc.TlsChannelCredentials; + +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.ChannelConfig; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.grpc.client.ChannelCredentialsProvider; +import org.springframework.grpc.client.NegotiationType; +import org.springframework.grpc.internal.InsecureTrustManagerFactory; +import org.springframework.util.Assert; + +/** + * Provides channel credentials using channel configuration and {@link SslBundles}. + * + * @author David Syer + */ +class NamedChannelCredentialsProvider implements ChannelCredentialsProvider { + + private final SslBundles bundles; + + private final GrpcClientProperties properties; + + NamedChannelCredentialsProvider(SslBundles bundles, GrpcClientProperties properties) { + this.bundles = bundles; + this.properties = properties; + } + + @Override + public ChannelCredentials getChannelCredentials(String path) { + ChannelConfig channel = this.properties.getChannel(path); + boolean sslEnabled = channel.getSsl().determineEnabled(); + if (!sslEnabled && channel.getNegotiationType() == NegotiationType.PLAINTEXT) { + return InsecureChannelCredentials.create(); + } + if (sslEnabled) { + String bundleName = channel.getSsl().getBundle(); + Assert.notNull(bundleName, "Bundle name must not be null when SSL is enabled"); + SslBundle bundle = this.bundles.getBundle(bundleName); + TrustManagerFactory trustManagers = channel.isSecure() ? bundle.getManagers().getTrustManagerFactory() + : InsecureTrustManagerFactory.INSTANCE; + return TlsChannelCredentials.newBuilder() + .keyManager(bundle.getManagers().getKeyManagerFactory().getKeyManagers()) + .trustManager(trustManagers.getTrustManagers()) + .build(); + } + else { + if (channel.isSecure()) { + return TlsChannelCredentials.create(); + } + else { + return TlsChannelCredentials.newBuilder() + .trustManager(InsecureTrustManagerFactory.INSTANCE.getTrustManagers()) + .build(); + } + } + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/package-info.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/package-info.java new file mode 100644 index 000000000000..efa3388c6505 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Auto-configuration for gRPC client. + */ +@NullMarked +package org.springframework.boot.grpc.client.autoconfigure; + +import org.jspecify.annotations.NullMarked; diff --git a/module/spring-boot-grpc-client/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/module/spring-boot-grpc-client/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 000000000000..b2914ae3fdc3 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,29 @@ +{ + "groups": [], + "properties": [ + { + "name": "spring.grpc.client.enabled", + "type": "java.lang.Boolean", + "description": "Whether to enable client autoconfiguration.", + "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.inprocess.exclusive", + "type": "java.lang.Boolean", + "description": "Whether the inprocess channel factory should be the only channel factory available. When the value is true, no other channel factory will be configured.", + "defaultValue": true + }, + { + "name": "spring.grpc.client.observation.enabled", + "type": "java.lang.Boolean", + "description": "Whether to enable Observations on the client.", + "defaultValue": true + } + ] +} diff --git a/module/spring-boot-grpc-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/module/spring-boot-grpc-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000000..7d3249ab92fc --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,3 @@ +org.springframework.boot.grpc.client.autoconfigure.CompositeChannelFactoryAutoConfiguration +org.springframework.boot.grpc.client.autoconfigure.GrpcClientAutoConfiguration +org.springframework.boot.grpc.client.autoconfigure.GrpcClientObservationAutoConfiguration diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/ChannelBuilderCustomizersTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/ChannelBuilderCustomizersTests.java new file mode 100644 index 000000000000..dd089aefff49 --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/ChannelBuilderCustomizersTests.java @@ -0,0 +1,124 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.List; + +import io.grpc.ManagedChannelBuilder; +import io.grpc.netty.NettyChannelBuilder; +import org.junit.jupiter.api.Test; + +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link ChannelBuilderCustomizers}. + * + * @author Chris Bono + */ +class ChannelBuilderCustomizersTests { + + private static final String DEFAULT_TARGET = "localhost"; + + @Test + void customizeWithNullCustomizersShouldDoNothing() { + ManagedChannelBuilder channelBuilder = mock(ManagedChannelBuilder.class); + new ChannelBuilderCustomizers(null).customize(DEFAULT_TARGET, channelBuilder); + then(channelBuilder).shouldHaveNoInteractions(); + } + + @Test + void customizeSimpleChannelBuilder() { + ChannelBuilderCustomizers customizers = new ChannelBuilderCustomizers( + List.of(new SimpleChannelBuilderCustomizer())); + NettyChannelBuilder channelBuilder = mock(NettyChannelBuilder.class); + customizers.customize(DEFAULT_TARGET, channelBuilder); + then(channelBuilder).should().flowControlWindow(100); + } + + @Test + void customizeShouldCheckGeneric() { + List> list = new ArrayList<>(); + list.add(new TestCustomizer<>()); + list.add(new TestNettyChannelBuilderCustomizer()); + list.add(new TestShadedNettyChannelBuilderCustomizer()); + ChannelBuilderCustomizers customizers = new ChannelBuilderCustomizers(list); + + customizers.customize(DEFAULT_TARGET, mock(ManagedChannelBuilder.class)); + assertThat(list.get(0).getCount()).isOne(); + assertThat(list.get(1).getCount()).isZero(); + assertThat(list.get(2).getCount()).isZero(); + + customizers.customize(DEFAULT_TARGET, mock(NettyChannelBuilder.class)); + assertThat(list.get(0).getCount()).isEqualTo(2); + assertThat(list.get(1).getCount()).isOne(); + assertThat(list.get(2).getCount()).isZero(); + + customizers.customize(DEFAULT_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(); + } + + static class SimpleChannelBuilderCustomizer implements GrpcChannelBuilderCustomizer { + + @Override + public void customize(String target, NettyChannelBuilder channelBuilder) { + channelBuilder.flowControlWindow(100); + } + + } + + /** + * Test customizer that will match any {@link GrpcChannelBuilderCustomizer}. + */ + static class TestCustomizer> implements GrpcChannelBuilderCustomizer { + + private int count; + + @Override + public void customize(String target, T channelBuilder) { + this.count++; + } + + int getCount() { + return this.count; + } + + } + + /** + * Test customizer that will match only {@link NettyChannelBuilder}. + */ + static class TestNettyChannelBuilderCustomizer extends TestCustomizer { + + } + + /** + * Test customizer that will match only + * {@link io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder}. + */ + static class TestShadedNettyChannelBuilderCustomizer + extends TestCustomizer { + + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/CompositeChannelFactoryAutoConfigurationTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/CompositeChannelFactoryAutoConfigurationTests.java new file mode 100644 index 000000000000..5540405d15bc --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/CompositeChannelFactoryAutoConfigurationTests.java @@ -0,0 +1,141 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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 + */ +@SuppressWarnings({ "unchecked", "rawtypes" }) +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_ZAA, + 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_ZAA = mock(); + + @Bean + @Order(3) + GrpcChannelFactory channelFactoryFoo() { + return CHANNEL_FACTORY_FOO; + } + + @Bean + @Order(1) + GrpcChannelFactory channelFactoryBar() { + return CHANNEL_FACTORY_BAR; + } + + @Bean + @Order(2) + GrpcChannelFactory channelFactoryZaa() { + return CHANNEL_FACTORY_ZAA; + } + + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/DefaultGrpcClientRegistrationsTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/DefaultGrpcClientRegistrationsTests.java new file mode 100644 index 000000000000..365ba6189811 --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/DefaultGrpcClientRegistrationsTests.java @@ -0,0 +1,84 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurationPackages; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.core.env.MapPropertySource; +import org.springframework.grpc.client.BlockingStubFactory; +import org.springframework.grpc.client.CoroutineStubFactory; +import org.springframework.grpc.client.GrpcClientFactory.GrpcClientRegistrationSpec; +import org.springframework.grpc.client.ReactorStubFactory; +import org.springframework.grpc.client.StubFactory; +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for the {@link DefaultGrpcClientRegistrations}. + * + * @author CheolHwan Ihn + * @author Chris Bono + */ +class DefaultGrpcClientRegistrationsTests { + + private static void assertThatPropertiesResultInExpectedStubFactory(Map properties, + Class> expectedStubFactoryClass) { + MockEnvironment environment = new MockEnvironment(); + environment.getPropertySources() + .addFirst(new MapPropertySource("defaultGrpcClientRegistrationsTests", properties)); + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + AutoConfigurationPackages.register(context, "org.springframework.boot.grpc.client.autoconfigure"); + DefaultGrpcClientRegistrations registrations = new DefaultGrpcClientRegistrations(environment, + context.getBeanFactory()); + + GrpcClientRegistrationSpec[] specs = registrations.collect(null); + + assertThat(specs).hasSize(1); + assertThat(specs[0].factory()).isEqualTo(expectedStubFactoryClass); + } + } + + @Test + void withReactorStubFactory() { + Map properties = new HashMap<>(); + properties.put("spring.grpc.client.default-stub-factory", ReactorStubFactory.class.getName()); + properties.put("spring.grpc.client.default-channel.address", "static://localhost:9090"); + assertThatPropertiesResultInExpectedStubFactory(properties, ReactorStubFactory.class); + } + + @Test + void withDefaultStubFactory() { + Map properties = new HashMap<>(); + properties.put("spring.grpc.client.default-channel.address", "static://localhost:9090"); + assertThatPropertiesResultInExpectedStubFactory(properties, BlockingStubFactory.class); + } + + @Test + void withCoroutineStubFactory() { + Map properties = new HashMap<>(); + properties.put("spring.grpc.client.default-stub-factory", CoroutineStubFactory.class.getName()); + properties.put("spring.grpc.client.default-channel.address", "static://localhost:9090"); + assertThatPropertiesResultInExpectedStubFactory(properties, CoroutineStubFactory.class); + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfigurationTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfigurationTests.java new file mode 100644 index 000000000000..f109af556774 --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfigurationTests.java @@ -0,0 +1,450 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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 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.AutoConfigurations; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientAutoConfiguration.ClientScanConfiguration; +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.GrpcClientFactory; +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.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 + */ +@SuppressWarnings({ "unchecked", "rawtypes" }) +class GrpcClientAutoConfigurationTests { + + private ApplicationContextRunner contextRunner() { + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcClientAutoConfiguration.class, SslAutoConfiguration.class)); + } + + private ApplicationContextRunner contextRunnerWithoutInProcessChannelFactory() { + return 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(GrpcClientAutoConfiguration.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(NamedChannelCredentialsProvider.class) + .hasFieldOrPropertyWithValue("properties", context.getBean(GrpcClientProperties.class)) + .extracting("bundles") + .isInstanceOf(SslBundles.class)); + } + + @Test + void clientPropertiesAutoConfiguredResolvesPlaceholders() { + this.contextRunner() + .withPropertyValues("spring.grpc.client.channels.c1.address=my-server-${channelName}:8888", + "channelName=foo") + .run((context) -> assertThat(context).getBean(GrpcClientProperties.class) + .satisfies((properties) -> assertThat(properties.getTarget("c1")).isEqualTo("my-server-foo:8888"))); + } + + @Test + void clientPropertiesChannelCustomizerAutoConfiguredWithHealthAsExpected() { + this.contextRunner() + .withPropertyValues("spring.grpc.client.channels.test.health.enabled=true", + "spring.grpc.client.channels.test.health.service-name=my-service") + .run((context) -> { + assertThat(context).getBean("clientPropertiesChannelCustomizer", GrpcChannelBuilderCustomizer.class) + .isNotNull(); + var customizer = context.getBean("clientPropertiesChannelCustomizer", + GrpcChannelBuilderCustomizer.class); + ManagedChannelBuilder builder = Mockito.mock(); + customizer.customize("test", builder); + Map healthCheckConfig = Map.of("healthCheckConfig", Map.of("serviceName", "my-service")); + then(builder).should().defaultServiceConfig(healthCheckConfig); + }); + } + + @Test + void clientPropertiesChannelCustomizerAutoConfiguredWithoutHealthAsExpected() { + this.contextRunner().run((context) -> { + assertThat(context).getBean("clientPropertiesChannelCustomizer", GrpcChannelBuilderCustomizer.class) + .isNotNull(); + var customizer = context.getBean("clientPropertiesChannelCustomizer", GrpcChannelBuilderCustomizer.class); + ManagedChannelBuilder builder = Mockito.mock(); + customizer.customize("test", builder); + then(builder).should(never()).defaultServiceConfig(anyMap()); + }); + } + + @Test + void compressionCustomizerAutoConfiguredAsExpected() { + this.contextRunner().run((context) -> { + assertThat(context).getBean("compressionClientCustomizer", GrpcChannelBuilderCustomizer.class).isNotNull(); + var customizer = context.getBean("compressionClientCustomizer", GrpcChannelBuilderCustomizer.class); + var compressorRegistry = context.getBean(CompressorRegistry.class); + ManagedChannelBuilder builder = Mockito.mock(); + customizer.customize("testChannel", builder); + then(builder).should().compressorRegistry(compressorRegistry); + }); + } + + @Test + void whenNoCompressorRegistryThenCompressionCustomizerIsNotConfigured() { + // Codec class guards the imported GrpcCodecConfiguration which provides the + // registry + this.contextRunner() + .withClassLoader(new FilteredClassLoader(Codec.class)) + .run((context) -> assertThat(context) + .getBean("compressionClientCustomizer", GrpcChannelBuilderCustomizer.class) + .isNull()); + } + + @Test + void decompressionCustomizerAutoConfiguredAsExpected() { + this.contextRunner().run((context) -> { + assertThat(context).getBean("decompressionClientCustomizer", GrpcChannelBuilderCustomizer.class) + .isNotNull(); + var customizer = context.getBean("decompressionClientCustomizer", GrpcChannelBuilderCustomizer.class); + var decompressorRegistry = context.getBean(DecompressorRegistry.class); + ManagedChannelBuilder builder = Mockito.mock(); + customizer.customize("testChannel", builder); + then(builder).should().decompressorRegistry(decompressorRegistry); + }); + } + + @Test + void whenNoDecompressorRegistryThenDecompressionCustomizerIsNotConfigured() { + // Codec class guards the imported GrpcCodecConfiguration which provides the + // registry + this.contextRunner() + .withClassLoader(new FilteredClassLoader(Codec.class)) + .run((context) -> assertThat(context) + .getBean("decompressionClientCustomizer", GrpcChannelBuilderCustomizer.class) + .isNull()); + } + + @Test + void whenHasUserDefinedChannelBuilderCustomizersDoesNotAutoConfigureBean() { + ChannelBuilderCustomizers customCustomizers = mock(); + this.contextRunner() + .withBean("customCustomizers", ChannelBuilderCustomizers.class, () -> customCustomizers) + .run((context) -> assertThat(context).getBean(ChannelBuilderCustomizers.class).isSameAs(customCustomizers)); + } + + @Test + void channelBuilderCustomizersAutoConfiguredAsExpected() { + this.contextRunner() + .withUserConfiguration(ChannelBuilderCustomizersConfig.class) + .run((context) -> assertThat(context).getBean(ChannelBuilderCustomizers.class) + .extracting("customizers", InstanceOfAssertFactories.list(GrpcChannelBuilderCustomizer.class)) + .contains(ChannelBuilderCustomizersConfig.CUSTOMIZER_BAR, + ChannelBuilderCustomizersConfig.CUSTOMIZER_FOO)); + } + + @Test + void clientScanConfigurationAutoConfiguredAsExpected() { + this.contextRunner().run((context) -> assertThat(context).hasSingleBean(ClientScanConfiguration.class)); + } + + @Test + void whenHasUserDefinedClientFactoryDoesNotAutoConfigureClientScanConfiguration() { + GrpcClientFactory clientFactory = mock(); + this.contextRunner() + .withBean("customClientFactory", GrpcClientFactory.class, () -> clientFactory) + .run((context) -> assertThat(context).doesNotHaveBean(ClientScanConfiguration.class)); + } + + @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() + .withPropertyValues("spring.grpc.server.port=0") + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class) + .isInstanceOf(ShadedNettyGrpcChannelFactory.class) + .hasFieldOrPropertyWithValue("credentials", context.getBean(NamedChannelCredentialsProvider.class)) + .extracting("targets") + .isInstanceOf(GrpcClientProperties.class)); + } + + @Test + void nettyChannelFactoryAutoConfiguredAsExpected() { + this.contextRunnerWithoutInProcessChannelFactory() + .withClassLoader(new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder.class)) + .withPropertyValues("spring.grpc.server.port=0") + .run((context) -> assertThat(context).getBean(GrpcChannelFactory.class) + .isInstanceOf(NettyGrpcChannelFactory.class) + .hasFieldOrPropertyWithValue("credentials", context.getBean(NamedChannelCredentialsProvider.class)) + .extracting("targets") + .isInstanceOf(GrpcClientProperties.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); + } + + @SuppressWarnings("unchecked") + private > void channelFactoryAutoConfiguredWithCustomizers( + ApplicationContextRunner contextRunner, ManagedChannelBuilder mockChannelBuilder, + Class expectedChannelFactoryType) { + GrpcChannelBuilderCustomizer customizer1 = (__, b) -> b.keepAliveTime(40L, TimeUnit.SECONDS); + GrpcChannelBuilderCustomizer customizer2 = (__, b) -> b.keepAliveTime(50L, TimeUnit.SECONDS); + ChannelBuilderCustomizers customizers = new ChannelBuilderCustomizers(List.of(customizer1, customizer2)); + contextRunner.withPropertyValues("spring.grpc.server.port=0") + .withBean("channelBuilderCustomizers", ChannelBuilderCustomizers.class, () -> customizers) + .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) + static class ChannelBuilderCustomizersConfig { + + static GrpcChannelBuilderCustomizer CUSTOMIZER_FOO = mock(); + + static GrpcChannelBuilderCustomizer CUSTOMIZER_BAR = mock(); + + @Bean + @Order(200) + GrpcChannelBuilderCustomizer customizerFoo() { + return CUSTOMIZER_FOO; + } + + @Bean + @Order(100) + GrpcChannelBuilderCustomizer customizerBar() { + return CUSTOMIZER_BAR; + } + + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientObservationAutoConfigurationTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientObservationAutoConfigurationTests.java new file mode 100644 index 000000000000..47158ea5fda0 --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientObservationAutoConfigurationTests.java @@ -0,0 +1,118 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.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}. + */ +class GrpcClientObservationAutoConfigurationTests { + + private final ApplicationContextRunner baseContextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcClientObservationAutoConfiguration.class)); + + private ApplicationContextRunner validContextRunner() { + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcClientObservationAutoConfiguration.class)) + .withBean("observationRegistry", ObservationRegistry.class, Mockito::mock); + } + + @Test + void whenObservationRegistryNotOnClasspathAutoConfigSkipped() { + this.validContextRunner() + .withClassLoader(new FilteredClassLoader(ObservationRegistry.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcClientObservationAutoConfiguration.class)); + } + + @Test + void whenObservationGrpcClientInterceptorNotOnClasspathAutoConfigSkipped() { + this.validContextRunner() + .withClassLoader(new FilteredClassLoader(ObservationGrpcClientInterceptor.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcClientObservationAutoConfiguration.class)); + } + + @Test + void whenObservationRegistryNotProvidedThenAutoConfigSkipped() { + this.baseContextRunner + .run((context) -> assertThat(context).doesNotHaveBean(GrpcClientObservationAutoConfiguration.class)); + } + + @Test + void whenObservationPropertyEnabledThenAutoConfigNotSkipped() { + this.validContextRunner() + .withPropertyValues("spring.grpc.client.observation.enabled=true") + .run((context) -> assertThat(context).hasSingleBean(GrpcClientObservationAutoConfiguration.class)); + } + + @Test + void whenObservationPropertyDisabledThenAutoConfigIsSkipped() { + this.validContextRunner() + .withPropertyValues("spring.grpc.client.observation.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcClientObservationAutoConfiguration.class)); + } + + @Test + void whenClientEnabledPropertyNotSetThenAutoConfigNotSkipped() { + this.validContextRunner() + .run((context) -> assertThat(context).hasSingleBean(GrpcClientObservationAutoConfiguration.class)); + } + + @Test + void whenClientEnabledPropertySetTrueThenAutoConfigIsNotSkipped() { + this.validContextRunner() + .withPropertyValues("spring.grpc.client.enabled=true") + .run((context) -> assertThat(context).hasSingleBean(GrpcClientObservationAutoConfiguration.class)); + } + + @Test + void whenClientEnabledPropertySetFalseThenAutoConfigIsSkipped() { + this.validContextRunner() + .withPropertyValues("spring.grpc.client.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcClientObservationAutoConfiguration.class)); + } + + @Test + void whenGrpcStubNotOnClasspathThenAutoConfigIsSkipped() { + this.validContextRunner() + .withClassLoader(new FilteredClassLoader(AbstractStub.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcClientObservationAutoConfiguration.class)); + } + + @Test + void whenAllConditionsAreMetThenInterceptorConfiguredAsExpected() { + this.validContextRunner().run((context) -> { + assertThat(context).hasSingleBean(ObservationGrpcClientInterceptor.class); + assertThat(context.getBeansWithAnnotation(GlobalClientInterceptor.class)).hasEntrySatisfying( + "observationGrpcClientInterceptor", + (bean) -> assertThat(bean.getClass().isAssignableFrom(ObservationGrpcClientInterceptor.class)) + .isTrue()); + }); + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientPropertiesTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientPropertiesTests.java new file mode 100644 index 000000000000..71eddc8e8ddb --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientPropertiesTests.java @@ -0,0 +1,311 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.HashMap; +import java.util.Map; +import java.util.function.Function; + +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.ChannelConfig; +import org.springframework.grpc.client.NegotiationType; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.util.unit.DataSize; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; + +/** + * Tests for {@link GrpcClientProperties}. + * + * @author Chris Bono + */ +class GrpcClientPropertiesTests { + + private GrpcClientProperties bindProperties(Map map) { + return new Binder(new MapConfigurationPropertySource(map)) + .bind("spring.grpc.client", GrpcClientProperties.class) + .get(); + } + + private GrpcClientProperties newProperties(ChannelConfig defaultChannel, Map channels) { + var properties = new GrpcClientProperties(); + ReflectionTestUtils.setField(properties, "defaultChannel", defaultChannel); + ReflectionTestUtils.setField(properties, "channels", channels); + return properties; + } + + @Nested + class BindPropertiesAPI { + + @Test + void defaultChannelWithDefaultValues() { + this.withDefaultValues("default-channel", GrpcClientProperties::getDefaultChannel); + } + + @Test + void specificChannelWithDefaultValues() { + this.withDefaultValues("channels.c1", (p) -> p.getChannel("c1")); + } + + private void withDefaultValues(String channelName, + Function channelFromProperties) { + Map map = new HashMap<>(); + // we have to at least bind one property or bind() fails + map.put("spring.grpc.client.%s.enable-keep-alive".formatted(channelName), "false"); + GrpcClientProperties properties = bindProperties(map); + var channel = channelFromProperties.apply(properties); + assertThat(channel.getAddress()).isEqualTo("static://localhost:9090"); + assertThat(channel.getDefaultLoadBalancingPolicy()).isEqualTo("round_robin"); + assertThat(channel.getHealth().isEnabled()).isFalse(); + assertThat(channel.getHealth().getServiceName()).isNull(); + assertThat(channel.getNegotiationType()).isEqualTo(NegotiationType.PLAINTEXT); + assertThat(channel.isEnableKeepAlive()).isFalse(); + assertThat(channel.getIdleTimeout()).isEqualTo(Duration.ofSeconds(20)); + assertThat(channel.getKeepAliveTime()).isEqualTo(Duration.ofMinutes(5)); + assertThat(channel.getKeepAliveTimeout()).isEqualTo(Duration.ofSeconds(20)); + assertThat(channel.isEnableKeepAlive()).isFalse(); + assertThat(channel.isKeepAliveWithoutCalls()).isFalse(); + assertThat(channel.getMaxInboundMessageSize()).isEqualTo(DataSize.ofBytes(4194304)); + assertThat(channel.getMaxInboundMetadataSize()).isEqualTo(DataSize.ofBytes(8192)); + assertThat(channel.getUserAgent()).isNull(); + assertThat(channel.isSecure()).isTrue(); + assertThat(channel.getSsl().isEnabled()).isNull(); + assertThat(channel.getSsl().determineEnabled()).isFalse(); + assertThat(channel.getSsl().getBundle()).isNull(); + } + + @Test + void defaultChannelWithSpecifiedValues() { + this.withSpecifiedValues("default-channel", GrpcClientProperties::getDefaultChannel); + } + + @Test + void specificChannelWithSpecifiedValues() { + this.withSpecifiedValues("channels.c1", (p) -> p.getChannel("c1")); + } + + private void withSpecifiedValues(String channelName, + Function channelFromProperties) { + Map map = new HashMap<>(); + var propPrefix = "spring.grpc.client.%s.".formatted(channelName); + map.put("%s.address".formatted(propPrefix), "static://my-server:8888"); + map.put("%s.default-load-balancing-policy".formatted(propPrefix), "pick_first"); + map.put("%s.health.enabled".formatted(propPrefix), "true"); + map.put("%s.health.service-name".formatted(propPrefix), "my-service"); + map.put("%s.negotiation-type".formatted(propPrefix), "plaintext_upgrade"); + map.put("%s.enable-keep-alive".formatted(propPrefix), "true"); + map.put("%s.idle-timeout".formatted(propPrefix), "1m"); + map.put("%s.keep-alive-time".formatted(propPrefix), "200s"); + map.put("%s.keep-alive-timeout".formatted(propPrefix), "60000ms"); + map.put("%s.keep-alive-without-calls".formatted(propPrefix), "true"); + map.put("%s.max-inbound-message-size".formatted(propPrefix), "200MB"); + map.put("%s.max-inbound-metadata-size".formatted(propPrefix), "1GB"); + map.put("%s.user-agent".formatted(propPrefix), "me"); + map.put("%s.secure".formatted(propPrefix), "false"); + map.put("%s.ssl.enabled".formatted(propPrefix), "true"); + map.put("%s.ssl.bundle".formatted(propPrefix), "my-bundle"); + GrpcClientProperties properties = bindProperties(map); + var channel = channelFromProperties.apply(properties); + assertThat(channel.getAddress()).isEqualTo("static://my-server:8888"); + assertThat(channel.getDefaultLoadBalancingPolicy()).isEqualTo("pick_first"); + assertThat(channel.getHealth().isEnabled()).isTrue(); + assertThat(channel.getHealth().getServiceName()).isEqualTo("my-service"); + assertThat(channel.getNegotiationType()).isEqualTo(NegotiationType.PLAINTEXT_UPGRADE); + assertThat(channel.isEnableKeepAlive()).isTrue(); + assertThat(channel.getIdleTimeout()).isEqualTo(Duration.ofMinutes(1)); + assertThat(channel.getKeepAliveTime()).isEqualTo(Duration.ofSeconds(200)); + assertThat(channel.getKeepAliveTimeout()).isEqualTo(Duration.ofMillis(60000)); + assertThat(channel.isEnableKeepAlive()).isTrue(); + assertThat(channel.isKeepAliveWithoutCalls()).isTrue(); + assertThat(channel.getMaxInboundMessageSize()).isEqualTo(DataSize.ofMegabytes(200)); + assertThat(channel.getMaxInboundMetadataSize()).isEqualTo(DataSize.ofGigabytes(1)); + assertThat(channel.getUserAgent()).isEqualTo("me"); + assertThat(channel.isSecure()).isFalse(); + assertThat(channel.getSsl().isEnabled()).isTrue(); + assertThat(channel.getSsl().determineEnabled()).isTrue(); + assertThat(channel.getSsl().getBundle()).isEqualTo("my-bundle"); + } + + @Test + void withoutKeepAliveUnitsSpecified() { + Map map = new HashMap<>(); + map.put("spring.grpc.client.default-channel.idle-timeout", "1"); + map.put("spring.grpc.client.default-channel.keep-alive-time", "60"); + map.put("spring.grpc.client.default-channel.keep-alive-timeout", "5"); + GrpcClientProperties properties = bindProperties(map); + var defaultChannel = properties.getDefaultChannel(); + assertThat(defaultChannel.getIdleTimeout()).isEqualTo(Duration.ofSeconds(1)); + assertThat(defaultChannel.getKeepAliveTime()).isEqualTo(Duration.ofSeconds(60)); + assertThat(defaultChannel.getKeepAliveTimeout()).isEqualTo(Duration.ofSeconds(5)); + } + + @Test + void withoutInboundSizeUnitsSpecified() { + Map map = new HashMap<>(); + map.put("spring.grpc.client.default-channel.max-inbound-message-size", "1000"); + map.put("spring.grpc.client.default-channel.max-inbound-metadata-size", "256"); + GrpcClientProperties properties = bindProperties(map); + var defaultChannel = properties.getDefaultChannel(); + assertThat(defaultChannel.getMaxInboundMessageSize()).isEqualTo(DataSize.ofBytes(1000)); + assertThat(defaultChannel.getMaxInboundMetadataSize()).isEqualTo(DataSize.ofBytes(256)); + } + + @Test + void withServiceConfig() { + Map map = new HashMap<>(); + // we have to at least bind one property or bind() fails + map.put("spring.grpc.client.%s.service-config.something.key".formatted("default-channel"), "value"); + GrpcClientProperties properties = bindProperties(map); + var channel = properties.getDefaultChannel(); + assertThat(channel.getServiceConfig()).hasSize(1); + assertThat(channel.getServiceConfig().get("something")).isInstanceOf(Map.class); + } + + @Test + void whenBundleNameSetThenDetermineEnabledReturnsTrue() { + Map map = new HashMap<>(); + map.put("spring.grpc.client.default-channel.ssl.bundle", "my-bundle"); + GrpcClientProperties properties = bindProperties(map); + var channel = properties.getDefaultChannel(); + assertThat(channel.getSsl().isEnabled()).isNull(); + assertThat(channel.getSsl().determineEnabled()).isTrue(); + } + + } + + @Nested + class GetChannelAPI { + + @Test + void withDefaultNameReturnsDefaultChannel() { + var properties = new GrpcClientProperties(); + var defaultChannel = properties.getChannel("default"); + assertThat(properties).extracting("defaultChannel").isSameAs(defaultChannel); + assertThat(properties).extracting("channels", InstanceOfAssertFactories.MAP).isEmpty(); + } + + @Test + void withKnownNameReturnsKnownChannel() { + Map map = new HashMap<>(); + // we have to at least bind one property or bind() fails + map.put("spring.grpc.client.channels.c1.enable-keep-alive", "false"); + GrpcClientProperties properties = bindProperties(map); + var channel = properties.getChannel("c1"); + assertThat(properties).extracting("channels", InstanceOfAssertFactories.MAP) + .containsExactly(entry("c1", channel)); + } + + @Test + void withUnknownNameReturnsNewChannelWithCopiedDefaults() { + var defaultChannel = new ChannelConfig(); + defaultChannel.setAddress("static://my-server:9999"); + defaultChannel.setDefaultLoadBalancingPolicy("custom"); + defaultChannel.getHealth().setEnabled(true); + defaultChannel.getHealth().setServiceName("custom-service"); + defaultChannel.setEnableKeepAlive(true); + defaultChannel.setIdleTimeout(Duration.ofMinutes(1)); + defaultChannel.setKeepAliveTime(Duration.ofMinutes(4)); + defaultChannel.setKeepAliveTimeout(Duration.ofMinutes(6)); + defaultChannel.setKeepAliveWithoutCalls(true); + defaultChannel.setMaxInboundMessageSize(DataSize.ofMegabytes(100)); + defaultChannel.setMaxInboundMetadataSize(DataSize.ofMegabytes(200)); + defaultChannel.setUserAgent("me"); + defaultChannel.setDefaultDeadline(Duration.ofMinutes(1)); + defaultChannel.getSsl().setEnabled(true); + defaultChannel.getSsl().setBundle("custom-bundle"); + var properties = newProperties(defaultChannel, Collections.emptyMap()); + var newChannel = properties.getChannel("new-channel"); + assertThat(newChannel).usingRecursiveComparison().isEqualTo(defaultChannel); + assertThat(properties).extracting("channels", InstanceOfAssertFactories.MAP).isEmpty(); + } + + @Test + void withUnknownNameReturnsNewChannelWithOwnAddress() { + var defaultChannel = new ChannelConfig(); + defaultChannel.setAddress("static://my-server:9999"); + var properties = newProperties(defaultChannel, Collections.emptyMap()); + var newChannel = properties.getChannel("other-server:8888"); + assertThat(newChannel).usingRecursiveComparison().ignoringFields("address").isEqualTo(defaultChannel); + assertThat(newChannel).hasFieldOrPropertyWithValue("address", "static://other-server:8888"); + assertThat(properties).extracting("channels", InstanceOfAssertFactories.MAP).isEmpty(); + } + + } + + @Nested + class GetTargetAPI { + + @Test + void channelWithStaticAddressReturnsStrippedAddress() { + var defaultChannel = new ChannelConfig(); + var channel1 = new ChannelConfig(); + channel1.setAddress("static://my-server:8888"); + var properties = newProperties(defaultChannel, Map.of("c1", channel1)); + assertThat(properties.getTarget("c1")).isEqualTo("my-server:8888"); + assertThat(properties).extracting("channels", InstanceOfAssertFactories.MAP) + .containsExactly(entry("c1", channel1)); + } + + @Test + void channelWithTcpAddressReturnsStrippedAddress() { + var defaultChannel = new ChannelConfig(); + var channel1 = new ChannelConfig(); + channel1.setAddress("tcp://my-server:8888"); + var properties = newProperties(defaultChannel, Map.of("c1", channel1)); + assertThat(properties.getTarget("c1")).isEqualTo("my-server:8888"); + assertThat(properties).extracting("channels", InstanceOfAssertFactories.MAP) + .containsExactly(entry("c1", channel1)); + } + + @Test + void channelWithAddressPropertyPlaceholdersPopulatesFromEnvironment() { + var defaultChannel = new ChannelConfig(); + var channel1 = new ChannelConfig(); + channel1.setAddress("my-server-${channelName}:8888"); + var properties = newProperties(defaultChannel, Map.of("c1", channel1)); + var env = new MockEnvironment(); + env.setProperty("channelName", "foo"); + properties.setEnvironment(env); + assertThat(properties.getTarget("c1")).isEqualTo("my-server-foo:8888"); + } + + } + + @Nested + class CopyDefaultsAPI { + + @Test + void copyFromDefaultChannel() { + var properties = new GrpcClientProperties(); + var defaultChannel = properties.getDefaultChannel(); + var newChannel = defaultChannel.copy(); + assertThat(newChannel).usingRecursiveComparison().isEqualTo(defaultChannel); + assertThat(newChannel.getServiceConfig()).isEqualTo(defaultChannel.getServiceConfig()); + } + + } + +} diff --git a/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcCodecConfigurationTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcCodecConfigurationTests.java new file mode 100644 index 000000000000..a36f0b79cb86 --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcCodecConfigurationTests.java @@ -0,0 +1,100 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.client.autoconfigure; + +import io.grpc.Codec; +import io.grpc.Compressor; +import io.grpc.CompressorRegistry; +import io.grpc.Decompressor; +import io.grpc.DecompressorRegistry; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GrpcCodecConfiguration}. + * + * @author Andrei Lisa + */ +class GrpcCodecConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcCodecConfiguration.class)); + + @Test + void whenCodecNotOnClasspathThenAutoconfigurationSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(Codec.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcCodecConfiguration.class)); + } + + @Test + void whenHasCustomCompressorRegistryDoesNotAutoConfigureBean() { + CompressorRegistry customRegistry = mock(); + this.contextRunner.withBean("customCompressorRegistry", CompressorRegistry.class, () -> customRegistry) + .run((context) -> assertThat(context).getBean(CompressorRegistry.class).isSameAs(customRegistry)); + } + + @Test + void compressorRegistryAutoConfiguredAsExpected() { + this.contextRunner.run((context) -> assertThat(context).getBean(CompressorRegistry.class) + .isSameAs(CompressorRegistry.getDefaultInstance())); + } + + @Test + void whenCustomCompressorsThenCompressorRegistryIsNewInstance() { + Compressor compressor = mock(); + given(compressor.getMessageEncoding()).willReturn("foo"); + this.contextRunner.withBean(Compressor.class, () -> compressor).run((context) -> { + assertThat(context).hasSingleBean(CompressorRegistry.class); + CompressorRegistry registry = context.getBean(CompressorRegistry.class); + assertThat(registry).isNotSameAs(CompressorRegistry.getDefaultInstance()); + assertThat(registry.lookupCompressor("foo")).isSameAs(compressor); + }); + } + + @Test + void whenHasCustomDecompressorRegistryDoesNotAutoConfigureBean() { + DecompressorRegistry customRegistry = mock(); + this.contextRunner.withBean("customDecompressorRegistry", DecompressorRegistry.class, () -> customRegistry) + .run((context) -> assertThat(context).getBean(DecompressorRegistry.class).isSameAs(customRegistry)); + } + + @Test + void decompressorRegistryAutoConfiguredAsExpected() { + this.contextRunner.run((context) -> assertThat(context).getBean(DecompressorRegistry.class) + .isSameAs(DecompressorRegistry.getDefaultInstance())); + } + + @Test + void whenCustomDecompressorsThenDecompressorRegistryIsNewInstance() { + Decompressor decompressor = mock(); + given(decompressor.getMessageEncoding()).willReturn("foo"); + this.contextRunner.withBean(Decompressor.class, () -> decompressor).run((context) -> { + assertThat(context).hasSingleBean(DecompressorRegistry.class); + DecompressorRegistry registry = context.getBean(DecompressorRegistry.class); + assertThat(registry).isNotSameAs(DecompressorRegistry.getDefaultInstance()); + assertThat(registry.lookupDecompressor("foo")).isSameAs(decompressor); + }); + } + +} diff --git a/module/spring-boot-grpc-client/src/test/resources/logback-test.xml b/module/spring-boot-grpc-client/src/test/resources/logback-test.xml new file mode 100644 index 000000000000..b8a41480d7d6 --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/resources/logback-test.xml @@ -0,0 +1,4 @@ + + + + diff --git a/module/spring-boot-grpc-server/build.gradle b/module/spring-boot-grpc-server/build.gradle new file mode 100644 index 000000000000..88dce1015131 --- /dev/null +++ b/module/spring-boot-grpc-server/build.gradle @@ -0,0 +1,67 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id "java-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") + + compileOnly("com.fasterxml.jackson.core:jackson-annotations") + + optional(project(":core:spring-boot-autoconfigure")) + optional(project(":module:spring-boot-actuator")) + optional(project(":module:spring-boot-actuator-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-client")) + optional(project(":module:spring-boot-security-oauth2-resource-server")) + 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.netty:netty-transport-native-epoll") + optional("io.projectreactor:reactor-core") + optional("jakarta.servlet:jakarta.servlet-api") + optional("org.springframework:spring-web") + optional("org.springframework.security:spring-security-config") + optional("org.springframework.security:spring-security-oauth2-client") + optional("org.springframework.security:spring-security-oauth2-resource-server") + optional("org.springframework.security:spring-security-oauth2-jose") + optional("org.springframework.security:spring-security-web") + + testCompileOnly("com.fasterxml.jackson.core:jackson-annotations") + + testImplementation(project(":core:spring-boot-test")) + testImplementation(project(":test-support:spring-boot-test-support")) + + testRuntimeOnly("ch.qos.logback:logback-classic") +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ConditionalOnGrpcNativeServer.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ConditionalOnGrpcNativeServer.java new file mode 100644 index 000000000000..51be07d1fc4c --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ConditionalOnGrpcNativeServer.java @@ -0,0 +1,41 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.context.annotation.Conditional; + +/** + * {@link Conditional @Conditional} that determines if the gRPC server implementation + * should be one of the native varieties (e.g. Netty, Shaded Netty) - i.e. not the servlet + * container. + * + * @author Chris Bono + * @author Dave Syer + * @since 4.0.0 + * @see OnGrpcNativeServerCondition + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Conditional(OnGrpcNativeServerCondition.class) +public @interface ConditionalOnGrpcNativeServer { + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ConditionalOnGrpcServerEnabled.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ConditionalOnGrpcServerEnabled.java new file mode 100644 index 000000000000..9d47c9f0bdb7 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ConditionalOnGrpcServerEnabled.java @@ -0,0 +1,49 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.context.annotation.Conditional; + +/** + * {@link Conditional @Conditional} that checks whether the gRPC server and optionally a + * specific gRPC service is enabled. It matches if the value of the + * {@code spring.grpc.server.enabled} property is not explicitly set to {@code false} and + * if the {@link #value() gRPC service name} is set, that the + * {@code spring.grpc.server..enabled} property is not explicitly set to + * {@code false}. + * + * @author Freeman Freeman + * @author Chris Bono + * @since 4.0.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Conditional(OnEnabledGrpcServerCondition.class) +public @interface ConditionalOnGrpcServerEnabled { + + /** + * Name of the gRPC service. + * @return the name of the gRPC service + */ + String value() default ""; + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ConditionalOnGrpcServletServer.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ConditionalOnGrpcServletServer.java new file mode 100644 index 000000000000..02ac7ec16ebe --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ConditionalOnGrpcServletServer.java @@ -0,0 +1,50 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.grpc.servlet.jakarta.GrpcServlet; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Conditional; + +/** + * {@link Conditional @Conditional} that determines if the Servlet container should be + * used to run the gRPC server. The condition matches only when the app is a servlet web + * application and the {@code io.grpc.servlet.jakarta.GrpcServlet} class is on the + * classpath and the {@code spring.grpc.server.servlet.enabled} property is not explicitly + * set to {@code false}. + * + * @author Chris Bono + * @author Dave Syer + * @since 4.0.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@ConditionalOnClass(GrpcServlet.class) +@ConditionalOnProperty(prefix = "spring.grpc.server", name = "servlet.enabled", havingValue = "true", + matchIfMissing = true) +public @interface ConditionalOnGrpcServletServer { + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ConditionalOnSpringGrpc.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ConditionalOnSpringGrpc.java new file mode 100644 index 000000000000..0e48757af410 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ConditionalOnSpringGrpc.java @@ -0,0 +1,43 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.grpc.BindableService; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.context.annotation.Conditional; +import org.springframework.grpc.server.GrpcServerFactory; + +/** + * {@link Conditional @Conditional} that only matches when Spring gRPC is on the classpath + * (i.e. {@link BindableService} and {@link GrpcServerFactory} are on the classpath). + * + * @author Freeman Freeman + * @author Chris Bono + * @since 4.0.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.METHOD }) +@ConditionalOnClass({ BindableService.class, GrpcServerFactory.class }) +public @interface ConditionalOnSpringGrpc { + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/DefaultServerFactoryPropertyMapper.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/DefaultServerFactoryPropertyMapper.java new file mode 100644 index 000000000000..c395653cc805 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/DefaultServerFactoryPropertyMapper.java @@ -0,0 +1,89 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import io.grpc.ServerBuilder; + +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.grpc.server.DefaultGrpcServerFactory; +import org.springframework.util.unit.DataSize; + +/** + * Helper class used to map {@link GrpcServerProperties} to + * {@link DefaultGrpcServerFactory}. + * + * @param the type of server builder + * @author Chris Bono + */ +class DefaultServerFactoryPropertyMapper> { + + private final GrpcServerProperties properties; + + DefaultServerFactoryPropertyMapper(GrpcServerProperties properties) { + this.properties = properties; + } + + /** + * Map the properties to the server factory's server builder. + * @param serverBuilder the builder + */ + void customizeServerBuilder(T serverBuilder) { + PropertyMapper map = PropertyMapper.get(); + customizeKeepAlive(serverBuilder, map); + customizeInboundLimits(serverBuilder, map); + } + + /** + * Map the keep-alive properties to the server factory's server builder. + * @param serverBuilder the builder + * @param map the property mapper + */ + void customizeKeepAlive(T serverBuilder, PropertyMapper map) { + GrpcServerProperties.KeepAlive keepAliveProps = this.properties.getKeepAlive(); + map.from(keepAliveProps.getTime()).to(durationProperty(serverBuilder::keepAliveTime)); + map.from(keepAliveProps.getTimeout()).to(durationProperty(serverBuilder::keepAliveTimeout)); + map.from(keepAliveProps.getMaxIdle()).to(durationProperty(serverBuilder::maxConnectionIdle)); + map.from(keepAliveProps.getMaxAge()).to(durationProperty(serverBuilder::maxConnectionAge)); + map.from(keepAliveProps.getMaxAgeGrace()).to(durationProperty(serverBuilder::maxConnectionAgeGrace)); + map.from(keepAliveProps.getPermitTime()).to(durationProperty(serverBuilder::permitKeepAliveTime)); + map.from(keepAliveProps.isPermitWithoutCalls()).to(serverBuilder::permitKeepAliveWithoutCalls); + } + + /** + * Map the inbound limits properties to the server factory's server builder. + * @param serverBuilder the builder + * @param map the property mapper + */ + void customizeInboundLimits(T serverBuilder, PropertyMapper map) { + map.from(this.properties.getMaxInboundMessageSize()) + .asInt(DataSize::toBytes) + .to(serverBuilder::maxInboundMessageSize); + map.from(this.properties.getMaxInboundMetadataSize()) + .asInt(DataSize::toBytes) + .to(serverBuilder::maxInboundMetadataSize); + } + + Consumer durationProperty(BiConsumer setter) { + return (duration) -> setter.accept(duration.toNanos(), TimeUnit.NANOSECONDS); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcCodecConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcCodecConfiguration.java new file mode 100644 index 000000000000..77ef45b044b3 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcCodecConfiguration.java @@ -0,0 +1,82 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.Codec; +import io.grpc.Compressor; +import io.grpc.CompressorRegistry; +import io.grpc.Decompressor; +import io.grpc.DecompressorRegistry; +import io.grpc.ServerBuilder; + +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; + +/** + * The configuration that contains all codec related beans for gRPC servers. + * + * @author Andrei Lisa + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(Codec.class) +class GrpcCodecConfiguration { + + /** + * 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 compressorRegistry(ObjectProvider compressors) { + if (compressors.stream().count() == 0) { + return CompressorRegistry.getDefaultInstance(); + } + CompressorRegistry registry = CompressorRegistry.newEmptyInstance(); + compressors.orderedStream().forEachOrdered(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 decompressorRegistry(ObjectProvider decompressors) { + if (decompressors.stream().count() == 0) { + return DecompressorRegistry.getDefaultInstance(); + } + DecompressorRegistry registry = DecompressorRegistry.emptyInstance(); + for (Decompressor decompressor : decompressors.orderedStream().toList()) { + registry = registry.with(decompressor, false); + } + return registry; + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerAutoConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerAutoConfiguration.java new file mode 100644 index 000000000000..697f54af5c98 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerAutoConfiguration.java @@ -0,0 +1,128 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.CompressorRegistry; +import io.grpc.DecompressorRegistry; +import io.grpc.ServerBuilder; + +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.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.core.Ordered; +import org.springframework.grpc.server.ServerBuilderCustomizer; +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. + *

+ * Spring gRPC must be on the classpath and at least one {@link BindableService} bean + * registered in the context in order for the auto-configuration to execute. + * + * @author David Syer + * @author Chris Bono + * @since 4.0.0 + */ +@AutoConfiguration(after = GrpcServerFactoryAutoConfiguration.class) +@ConditionalOnSpringGrpc +@ConditionalOnGrpcServerEnabled +@ConditionalOnBean(BindableService.class) +@EnableConfigurationProperties(GrpcServerProperties.class) +@Import({ GrpcCodecConfiguration.class }) +public final class GrpcServerAutoConfiguration { + + @ConditionalOnMissingBean + @Bean + ServerBuilderCustomizers serverBuilderCustomizers(ObjectProvider> customizers) { + return new ServerBuilderCustomizers(customizers.orderedStream().toList()); + } + + @ConditionalOnMissingBean(GrpcServiceConfigurer.class) + @Bean + DefaultGrpcServiceConfigurer grpcServiceConfigurer(ApplicationContext applicationContext) { + return new DefaultGrpcServiceConfigurer(applicationContext); + } + + @ConditionalOnMissingBean(GrpcServiceDiscoverer.class) + @Bean + DefaultGrpcServiceDiscoverer grpcServiceDiscoverer(ApplicationContext applicationContext) { + return new DefaultGrpcServiceDiscoverer(applicationContext); + } + + @ConditionalOnBean(CompressorRegistry.class) + @Bean + > ServerBuilderCustomizer compressionServerConfigurer(CompressorRegistry registry) { + return (builder) -> builder.compressorRegistry(registry); + } + + @ConditionalOnBean(DecompressorRegistry.class) + @Bean + > ServerBuilderCustomizer decompressionServerConfigurer( + DecompressorRegistry registry) { + return (builder) -> builder.decompressorRegistry(registry); + } + + @ConditionalOnBean(GrpcServerExecutorProvider.class) + @Bean + > ServerBuilderCustomizer executorServerConfigurer( + GrpcServerExecutorProvider provider) { + return new ServerBuilderCustomizerImplementation<>(provider); + } + + private final class ServerBuilderCustomizerImplementation> + implements ServerBuilderCustomizer, Ordered { + + private final GrpcServerExecutorProvider provider; + + private ServerBuilderCustomizerImplementation(GrpcServerExecutorProvider provider) { + this.provider = provider; + } + + @Override + public int getOrder() { + return 0; + } + + @Override + public void customize(T builder) { + builder.executor(this.provider.getExecutor()); + } + + } + + @ConditionalOnClass(name = "com.salesforce.reactivegrpc.common.Function") + @Configuration + @Import(ReactiveStubBeanDefinitionRegistrar.class) + static class ReactiveStubConfiguration { + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerExecutorProvider.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerExecutorProvider.java new file mode 100644 index 000000000000..ac5f1d8c849e --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerExecutorProvider.java @@ -0,0 +1,36 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure; + +import java.util.concurrent.Executor; + +/** + * Strategy interface to determine the {@link Executor} to use for the gRPC server. + * + * @author Chris Bono + * @since 4.0.0 + */ +@FunctionalInterface +public interface GrpcServerExecutorProvider { + + /** + * Returns a {@link Executor} for the gRPC server, if it needs to be customized. + * @return the executor to use for the gRPC server + */ + Executor getExecutor(); + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerFactoryAutoConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerFactoryAutoConfiguration.java new file mode 100644 index 000000000000..76cafd55d012 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerFactoryAutoConfiguration.java @@ -0,0 +1,112 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure; + +import java.util.List; + +import io.grpc.BindableService; +import io.grpc.servlet.jakarta.GrpcServlet; +import io.grpc.servlet.jakarta.ServletServerBuilder; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureOrder; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.Ordered; +import org.springframework.grpc.server.service.GrpcServiceConfigurer; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; +import org.springframework.util.unit.DataSize; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for gRPC server factories. + *

+ * gRPC must be on the classpath and at least one {@link BindableService} bean registered + * in the context in order for the auto-configuration to execute. + * + * @author David Syer + * @author Chris Bono + * @author Toshiaki Maki + * @since 4.0.0 + */ +@AutoConfiguration +@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE) +@ConditionalOnSpringGrpc +@ConditionalOnGrpcServerEnabled +@ConditionalOnBean(BindableService.class) +public final class GrpcServerFactoryAutoConfiguration { + + @Configuration(proxyBeanMethods = false) + @ConditionalOnGrpcNativeServer + static class GrpcServerFactoryConfiguration { + + @Configuration(proxyBeanMethods = false) + @Import({ GrpcServerFactoryConfigurations.ShadedNettyServerFactoryConfiguration.class, + GrpcServerFactoryConfigurations.NettyServerFactoryConfiguration.class, + GrpcServerFactoryConfigurations.InProcessServerFactoryConfiguration.class }) + static class NettyServerFactoryConfiguration { + + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnGrpcServletServer + public static class GrpcServletConfiguration { + + private static Log logger = LogFactory.getLog(GrpcServletConfiguration.class); + + @Bean + ServletRegistrationBean grpcServlet(GrpcServerProperties properties, + GrpcServiceDiscoverer serviceDiscoverer, GrpcServiceConfigurer serviceConfigurer, + ServerBuilderCustomizers serverBuilderCustomizers) { + List serviceNames = serviceDiscoverer.listServiceNames(); + if (logger.isInfoEnabled()) { + serviceNames.forEach((service) -> logger.info("Registering gRPC service: " + service)); + } + List paths = serviceNames.stream().map((service) -> "/" + service + "/*").toList(); + ServletServerBuilder servletServerBuilder = new ServletServerBuilder(); + serviceDiscoverer.findServices() + .stream() + .map((serviceSpec) -> serviceConfigurer.configure(serviceSpec, null)) + .forEach(servletServerBuilder::addService); + PropertyMapper mapper = PropertyMapper.get(); + mapper.from(properties.getMaxInboundMessageSize()) + .asInt(DataSize::toBytes) + .to(servletServerBuilder::maxInboundMessageSize); + serverBuilderCustomizers.customize(servletServerBuilder); + ServletRegistrationBean servlet = new ServletRegistrationBean<>( + servletServerBuilder.buildServlet()); + servlet.setUrlMappings(paths); + return servlet; + } + + @Configuration(proxyBeanMethods = false) + @Import(GrpcServerFactoryConfigurations.InProcessServerFactoryConfiguration.class) + static class InProcessConfiguration { + + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerFactoryConfigurations.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerFactoryConfigurations.java new file mode 100644 index 000000000000..3065640510ca --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerFactoryConfigurations.java @@ -0,0 +1,194 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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 javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.TrustManagerFactory; + +import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.netty.NettyServerBuilder; +import io.netty.handler.ssl.util.InsecureTrustManagerFactory; + +import org.springframework.beans.factory.ObjectProvider; +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.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.ssl.SslBundle; +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.GrpcServerFactory; +import org.springframework.grpc.server.InProcessGrpcServerFactory; +import org.springframework.grpc.server.NettyGrpcServerFactory; +import org.springframework.grpc.server.ServerBuilderCustomizer; +import org.springframework.grpc.server.ServerServiceDefinitionFilter; +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; +import org.springframework.grpc.server.service.ServerInterceptorFilter; +import org.springframework.util.Assert; + +/** + * Configurations for {@link GrpcServerFactory gRPC server factories}. + * + * @author Chris Bono + */ +class GrpcServerFactoryConfigurations { + + private static void applyServerFactoryCustomizers(ObjectProvider customizers, + GrpcServerFactory factory) { + customizers.orderedStream().forEach((customizer) -> customizer.customize(factory)); + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class) + @ConditionalOnMissingBean(value = GrpcServerFactory.class, ignored = InProcessGrpcServerFactory.class) + @ConditionalOnProperty(prefix = "spring.grpc.server.inprocess.", name = "exclusive", havingValue = "false", + matchIfMissing = true) + @EnableConfigurationProperties(GrpcServerProperties.class) + static class ShadedNettyServerFactoryConfiguration { + + @Bean + ShadedNettyGrpcServerFactory shadedNettyGrpcServerFactory(GrpcServerProperties properties, + GrpcServiceDiscoverer serviceDiscoverer, GrpcServiceConfigurer serviceConfigurer, + ServerBuilderCustomizers serverBuilderCustomizers, SslBundles bundles, + ObjectProvider customizers) { + ShadedNettyServerFactoryPropertyMapper mapper = new ShadedNettyServerFactoryPropertyMapper(properties); + List> builderCustomizers = List + .of(mapper::customizeServerBuilder, serverBuilderCustomizers::customize); + KeyManagerFactory keyManager = null; + TrustManagerFactory trustManager = null; + if (properties.getSsl().determineEnabled()) { + String bundleName = properties.getSsl().getBundle(); + Assert.notNull(bundleName, () -> "SSL bundleName must not be null"); + SslBundle bundle = bundles.getBundle(bundleName); + keyManager = bundle.getManagers().getKeyManagerFactory(); + trustManager = properties.getSsl().isSecure() ? bundle.getManagers().getTrustManagerFactory() + : io.grpc.netty.shaded.io.netty.handler.ssl.util.InsecureTrustManagerFactory.INSTANCE; + } + ShadedNettyGrpcServerFactory factory = new ShadedNettyGrpcServerFactory(properties.determineAddress(), + builderCustomizers, keyManager, trustManager, properties.getSsl().getClientAuth()); + applyServerFactoryCustomizers(customizers, factory); + serviceDiscoverer.findServices() + .stream() + .map((serviceSpec) -> serviceConfigurer.configure(serviceSpec, factory)) + .forEach(factory::addService); + return factory; + } + + @ConditionalOnBean(ShadedNettyGrpcServerFactory.class) + @ConditionalOnMissingBean(name = "shadedNettyGrpcServerLifecycle") + @Bean + GrpcServerLifecycle shadedNettyGrpcServerLifecycle(ShadedNettyGrpcServerFactory factory, + GrpcServerProperties properties, ApplicationEventPublisher eventPublisher) { + return new GrpcServerLifecycle(factory, properties.getShutdownGracePeriod(), eventPublisher); + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(NettyServerBuilder.class) + @ConditionalOnMissingBean(value = GrpcServerFactory.class, ignored = InProcessGrpcServerFactory.class) + @ConditionalOnProperty(prefix = "spring.grpc.server.inprocess.", name = "exclusive", havingValue = "false", + matchIfMissing = true) + @EnableConfigurationProperties(GrpcServerProperties.class) + static class NettyServerFactoryConfiguration { + + @Bean + NettyGrpcServerFactory nettyGrpcServerFactory(GrpcServerProperties properties, + GrpcServiceDiscoverer serviceDiscoverer, GrpcServiceConfigurer serviceConfigurer, + ServerBuilderCustomizers serverBuilderCustomizers, SslBundles bundles, + ObjectProvider customizers) { + NettyServerFactoryPropertyMapper mapper = new NettyServerFactoryPropertyMapper(properties); + List> builderCustomizers = List + .of(mapper::customizeServerBuilder, serverBuilderCustomizers::customize); + KeyManagerFactory keyManager = null; + TrustManagerFactory trustManager = null; + if (properties.getSsl().determineEnabled()) { + String bundleName = properties.getSsl().getBundle(); + Assert.notNull(bundleName, () -> "SSL bundleName must not be null"); + SslBundle bundle = bundles.getBundle(bundleName); + keyManager = bundle.getManagers().getKeyManagerFactory(); + trustManager = properties.getSsl().isSecure() ? bundle.getManagers().getTrustManagerFactory() + : InsecureTrustManagerFactory.INSTANCE; + } + NettyGrpcServerFactory factory = new NettyGrpcServerFactory(properties.determineAddress(), + builderCustomizers, keyManager, trustManager, properties.getSsl().getClientAuth()); + applyServerFactoryCustomizers(customizers, factory); + serviceDiscoverer.findServices() + .stream() + .map((serviceSpec) -> serviceConfigurer.configure(serviceSpec, factory)) + .forEach(factory::addService); + return factory; + } + + @ConditionalOnBean(NettyGrpcServerFactory.class) + @ConditionalOnMissingBean(name = "nettyGrpcServerLifecycle") + @Bean + GrpcServerLifecycle nettyGrpcServerLifecycle(NettyGrpcServerFactory factory, GrpcServerProperties properties, + ApplicationEventPublisher eventPublisher) { + return new GrpcServerLifecycle(factory, properties.getShutdownGracePeriod(), eventPublisher); + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(InProcessGrpcServerFactory.class) + @ConditionalOnMissingBean(InProcessGrpcServerFactory.class) + @ConditionalOnProperty(prefix = "spring.grpc.server.inprocess", name = "name") + @EnableConfigurationProperties(GrpcServerProperties.class) + static class InProcessServerFactoryConfiguration { + + @Bean + InProcessGrpcServerFactory inProcessGrpcServerFactory(GrpcServerProperties properties, + GrpcServiceDiscoverer serviceDiscoverer, GrpcServiceConfigurer serviceConfigurer, + ServerBuilderCustomizers serverBuilderCustomizers, + ObjectProvider interceptorFilter, + ObjectProvider serviceFilter, + ObjectProvider customizers) { + var mapper = new InProcessServerFactoryPropertyMapper(properties); + List> builderCustomizers = List + .of(mapper::customizeServerBuilder, serverBuilderCustomizers::customize); + InProcessGrpcServerFactory factory = new InProcessGrpcServerFactory(properties.getInprocess().getName(), + builderCustomizers); + factory.setInterceptorFilter(interceptorFilter.getIfAvailable()); + factory.setServiceFilter(serviceFilter.getIfAvailable()); + applyServerFactoryCustomizers(customizers, factory); + serviceDiscoverer.findServices() + .stream() + .map((serviceSpec) -> serviceConfigurer.configure(serviceSpec, factory)) + .forEach(factory::addService); + return factory; + } + + @ConditionalOnBean(InProcessGrpcServerFactory.class) + @ConditionalOnMissingBean(name = "inProcessGrpcServerLifecycle") + @Bean + GrpcServerLifecycle inProcessGrpcServerLifecycle(InProcessGrpcServerFactory factory, + GrpcServerProperties properties, ApplicationEventPublisher eventPublisher) { + return new GrpcServerLifecycle(factory, properties.getShutdownGracePeriod(), eventPublisher); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerFactoryCustomizer.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerFactoryCustomizer.java new file mode 100644 index 000000000000..1965c1cc10e9 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerFactoryCustomizer.java @@ -0,0 +1,37 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure; + +import org.springframework.grpc.server.GrpcServerFactory; + +/** + * Callback interface that can be implemented by beans wishing to customize the + * {@link GrpcServerFactory server factory} before it is fully initialized. + * + * @author Chris Bono + * @since 4.0.0 + */ +@FunctionalInterface +public interface GrpcServerFactoryCustomizer { + + /** + * Customize the given {@link GrpcServerFactory}. + * @param serverFactory the server factory to customize + */ + void customize(GrpcServerFactory serverFactory); + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerObservationAutoConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerObservationAutoConfiguration.java new file mode 100644 index 000000000000..b0739440f393 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerObservationAutoConfiguration.java @@ -0,0 +1,69 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure; + +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.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; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for gRPC server-side observations. + * + * @author Sunny Tang + * @author Chris Bono + * @author Dave Syer + * @since 4.0.0 + */ +@AutoConfiguration( + afterName = "org.springframework.boot.micrometer.observation.autoconfigure.ObservationAutoConfiguration") +@ConditionalOnSpringGrpc +@ConditionalOnClass({ ObservationRegistry.class, ObservationGrpcServerInterceptor.class }) +@ConditionalOnGrpcServerEnabled("observation") +@ConditionalOnBean(ObservationRegistry.class) +public final class GrpcServerObservationAutoConfiguration { + + @Bean + @Order(0) + @GlobalServerInterceptor + ObservationGrpcServerInterceptor observationGrpcServerInterceptor(ObservationRegistry observationRegistry) { + return new ObservationGrpcServerInterceptor(observationRegistry); + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(name = "io.grpc.kotlin.AbstractCoroutineStub") + static class GrpcServerCoroutineStubConfiguration { + + @Bean + @Order(10) + @GlobalServerInterceptor + ObservationCoroutineContextServerInterceptor observationCoroutineGrpcServerInterceptor( + ObservationRegistry observationRegistry) { + return new ObservationCoroutineContextServerInterceptor(observationRegistry); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerProperties.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerProperties.java new file mode 100644 index 000000000000..bfa5573fc485 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerProperties.java @@ -0,0 +1,443 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; + +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.grpc.internal.GrpcUtils; +import org.springframework.util.unit.DataSize; +import org.springframework.util.unit.DataUnit; + +@ConfigurationProperties(prefix = "spring.grpc.server") +public class GrpcServerProperties { + + /** + * Server should listen to any IPv4 and IPv6 address. + */ + public static final String ANY_IP_ADDRESS = "*"; + + /** + * The address to bind to in the form 'host:port' or a pseudo URL like + * 'static://host:port'. When the address is set it takes precedence over any + * configured host/port values. + */ + private @Nullable String address; + + /** + * Server host to bind to. The default is any IP address ('*'). + */ + private String host = ANY_IP_ADDRESS; + + /** + * Server port to listen on. When the value is 0, a random available port is selected. + */ + private int port = GrpcUtils.DEFAULT_PORT; + + /** + * Maximum message size allowed to be received by the server (default 4MiB). + */ + @DataSizeUnit(DataUnit.BYTES) + private DataSize maxInboundMessageSize = DataSize.ofBytes(4194304); + + /** + * Maximum metadata size allowed to be received by the server (default 8KiB). + */ + @DataSizeUnit(DataUnit.BYTES) + private DataSize maxInboundMetadataSize = DataSize.ofBytes(8192); + + /** + * 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 shutdownGracePeriod = Duration.ofSeconds(30); + + private final Health health = new Health(); + + private final Inprocess inprocess = new Inprocess(); + + private final KeepAlive keepAlive = new KeepAlive(); + + private final Ssl ssl = new Ssl(); + + public @Nullable String getAddress() { + return this.address; + } + + public void setAddress(@Nullable String address) { + this.address = address; + } + + /** + * Returns the configured address or an address created from the configured host and + * port if no address has been set. + * @return the address to bind to + */ + public String determineAddress() { + return (this.address != null) ? this.address : this.host + ":" + this.port; + } + + public String getHost() { + return this.host; + } + + public void setHost(String host) { + this.host = host; + } + + public int getPort() { + return this.port; + } + + public void setPort(int port) { + this.port = port; + } + + public DataSize getMaxInboundMessageSize() { + return this.maxInboundMessageSize; + } + + public void setMaxInboundMessageSize(DataSize maxInboundMessageSize) { + this.maxInboundMessageSize = maxInboundMessageSize; + } + + public DataSize getMaxInboundMetadataSize() { + return this.maxInboundMetadataSize; + } + + public void setMaxInboundMetadataSize(DataSize maxInboundMetadataSize) { + this.maxInboundMetadataSize = maxInboundMetadataSize; + } + + public Duration getShutdownGracePeriod() { + return this.shutdownGracePeriod; + } + + public void setShutdownGracePeriod(Duration shutdownGracePeriod) { + this.shutdownGracePeriod = shutdownGracePeriod; + } + + public Health getHealth() { + return this.health; + } + + public Inprocess getInprocess() { + return this.inprocess; + } + + public KeepAlive getKeepAlive() { + return this.keepAlive; + } + + public Ssl getSsl() { + return this.ssl; + } + + public static class Health { + + /** + * Whether to auto-configure Health feature on the gRPC server. + */ + private boolean enabled = true; + + private final Actuator actuator = new Actuator(); + + public boolean getEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public Actuator getActuator() { + return this.actuator; + } + + } + + public static class Actuator { + + /** + * Whether to adapt Actuator health indicators into gRPC health checks. + */ + private boolean enabled = true; + + /** + * Whether to update the overall gRPC server health (the '' service) with the + * aggregate status of the configured health indicators. + */ + private boolean updateOverallHealth = true; + + /** + * How often to update the health status. + */ + private Duration updateRate = Duration.ofSeconds(5); + + /** + * The initial delay before updating the health status the very first time. + */ + private Duration updateInitialDelay = Duration.ofSeconds(5); + + /** + * List of Actuator health indicator paths to adapt into gRPC health checks. + */ + private List healthIndicatorPaths = new ArrayList<>(); + + public boolean getEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public boolean getUpdateOverallHealth() { + return this.updateOverallHealth; + } + + public void setUpdateOverallHealth(boolean updateOverallHealth) { + this.updateOverallHealth = updateOverallHealth; + } + + public Duration getUpdateRate() { + return this.updateRate; + } + + public void setUpdateRate(Duration updateRate) { + this.updateRate = updateRate; + } + + public Duration getUpdateInitialDelay() { + return this.updateInitialDelay; + } + + public void setUpdateInitialDelay(Duration updateInitialDelay) { + this.updateInitialDelay = updateInitialDelay; + } + + public List getHealthIndicatorPaths() { + return this.healthIndicatorPaths; + } + + public void setHealthIndicatorPaths(List healthIndicatorPaths) { + this.healthIndicatorPaths = healthIndicatorPaths; + } + + } + + 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; + } + + } + + 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); + + /** + * Maximum time a connection can remain idle before being gracefully terminated + * (default infinite). + */ + @DurationUnit(ChronoUnit.SECONDS) + private @Nullable Duration maxIdle; + + /** + * 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 maxAgeGrace; + + /** + * Maximum keep-alive time clients are permitted to configure (default 5m). + */ + @DurationUnit(ChronoUnit.SECONDS) + private @Nullable Duration permitTime = 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 permitWithoutCalls; + + 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 @Nullable Duration getMaxIdle() { + return this.maxIdle; + } + + public void setMaxIdle(@Nullable Duration maxIdle) { + this.maxIdle = maxIdle; + } + + public @Nullable Duration getMaxAge() { + return this.maxAge; + } + + public void setMaxAge(@Nullable Duration maxAge) { + this.maxAge = maxAge; + } + + public @Nullable Duration getMaxAgeGrace() { + return this.maxAgeGrace; + } + + public void setMaxAgeGrace(@Nullable Duration maxAgeGrace) { + this.maxAgeGrace = maxAgeGrace; + } + + public @Nullable Duration getPermitTime() { + return this.permitTime; + } + + public void setPermitTime(@Nullable Duration permitTime) { + this.permitTime = permitTime; + } + + public boolean isPermitWithoutCalls() { + return this.permitWithoutCalls; + } + + public void setPermitWithoutCalls(boolean permitWithoutCalls) { + this.permitWithoutCalls = permitWithoutCalls; + } + + } + + 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; + } + + /** + * Determine whether to enable SSL support. When the {@code enabled} property is + * specified it determines enablement. Otherwise, the support is enabled if the + * {@code bundle} is provided. + * @return whether to enable SSL support + */ + public boolean determineEnabled() { + return (this.enabled != null) ? this.enabled : this.bundle != null; + } + + 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; + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerReflectionAutoConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerReflectionAutoConfiguration.java new file mode 100644 index 000000000000..fffd3e690638 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerReflectionAutoConfiguration.java @@ -0,0 +1,52 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +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.ConditionalOnClass; +import org.springframework.context.annotation.Bean; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for gRPC Reflection service + *

+ * This auto-configuration is enabled by default. To disable it, set the configuration + * flag {spring.grpc.server.reflection.enabled=false} in your application properties. + * + * @author Haris Zujo + * @author Dave Syer + * @author Chris Bono + * @author Andrey Litvitski + * @since 4.0.0 + */ +@AutoConfiguration(before = GrpcServerFactoryAutoConfiguration.class) +@ConditionalOnSpringGrpc +@ConditionalOnClass({ ProtoReflectionServiceV1.class }) +@ConditionalOnGrpcServerEnabled("reflection") +@ConditionalOnBean(BindableService.class) +public final class GrpcServerReflectionAutoConfiguration { + + @Bean + BindableService serverReflection() { + return ProtoReflectionServiceV1.newInstance(); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/InProcessServerFactoryPropertyMapper.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/InProcessServerFactoryPropertyMapper.java new file mode 100644 index 000000000000..00736791cf24 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/InProcessServerFactoryPropertyMapper.java @@ -0,0 +1,41 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure; + +import io.grpc.inprocess.InProcessServerBuilder; + +import org.springframework.boot.context.properties.PropertyMapper; + +/** + * Helper class used to map {@link GrpcServerProperties} to + * {@link InProcessServerBuilder}. + * + * @author Chris Bono + */ +class InProcessServerFactoryPropertyMapper extends DefaultServerFactoryPropertyMapper { + + InProcessServerFactoryPropertyMapper(GrpcServerProperties properties) { + super(properties); + } + + @Override + void customizeServerBuilder(InProcessServerBuilder serverBuilder) { + PropertyMapper mapper = PropertyMapper.get(); + customizeInboundLimits(serverBuilder, mapper); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/NettyServerFactoryPropertyMapper.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/NettyServerFactoryPropertyMapper.java new file mode 100644 index 000000000000..53124a910ade --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/NettyServerFactoryPropertyMapper.java @@ -0,0 +1,40 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure; + +import io.grpc.netty.NettyServerBuilder; + +import org.springframework.grpc.server.NettyGrpcServerFactory; + +/** + * Helper class used to map {@link GrpcServerProperties} to + * {@link NettyGrpcServerFactory}. + * + * @author Chris Bono + */ +class NettyServerFactoryPropertyMapper extends DefaultServerFactoryPropertyMapper { + + NettyServerFactoryPropertyMapper(GrpcServerProperties properties) { + super(properties); + } + + @Override + void customizeServerBuilder(NettyServerBuilder nettyServerBuilder) { + super.customizeServerBuilder(nettyServerBuilder); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/OnEnabledGrpcServerCondition.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/OnEnabledGrpcServerCondition.java new file mode 100644 index 000000000000..913ed012ba5d --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/OnEnabledGrpcServerCondition.java @@ -0,0 +1,73 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure; + +import java.util.Map; + +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.autoconfigure.condition.ConditionMessage; +import org.springframework.boot.autoconfigure.condition.ConditionOutcome; +import org.springframework.boot.autoconfigure.condition.SpringBootCondition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.type.AnnotatedTypeMetadata; +import org.springframework.util.StringUtils; + +/** + * {@link SpringBootCondition} to check whether gRPC server/service is enabled. + * + * @author Chris Bono + * @see ConditionalOnGrpcServerEnabled + */ +class OnEnabledGrpcServerCondition extends SpringBootCondition { + + private static final String SERVER_PROPERTY = "spring.grpc.server.enabled"; + + private static final String SERVICE_PROPERTY = "spring.grpc.server.%s.enabled"; + + @Override + public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) { + Boolean serverEnabled = context.getEnvironment().getProperty(SERVER_PROPERTY, Boolean.class); + if (serverEnabled != null && !serverEnabled) { + return new ConditionOutcome(serverEnabled, + ConditionMessage.forCondition(ConditionalOnGrpcServerEnabled.class) + .because(SERVER_PROPERTY + " is " + serverEnabled)); + } + String serviceName = getServiceName(metadata); + if (StringUtils.hasLength(serviceName)) { + Boolean serviceEnabled = context.getEnvironment() + .getProperty(SERVICE_PROPERTY.formatted(serviceName), Boolean.class); + if (serviceEnabled != null) { + return new ConditionOutcome(serviceEnabled, + ConditionMessage.forCondition(ConditionalOnGrpcServerEnabled.class) + .because(SERVICE_PROPERTY.formatted(serviceName) + " is " + serviceEnabled)); + } + } + return ConditionOutcome.match(ConditionMessage.forCondition(ConditionalOnGrpcServerEnabled.class) + .because("server and service are enabled by default")); + } + + private static @Nullable String getServiceName(AnnotatedTypeMetadata metadata) { + Map attributes = metadata + .getAnnotationAttributes(ConditionalOnGrpcServerEnabled.class.getName()); + if (attributes == null) { + return null; + } + return (String) attributes.get("value"); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/OnGrpcNativeServerCondition.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/OnGrpcNativeServerCondition.java new file mode 100644 index 000000000000..7de51852c75e --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/OnGrpcNativeServerCondition.java @@ -0,0 +1,69 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure; + +import io.grpc.servlet.jakarta.GrpcServlet; + +import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnNotWebApplication; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Conditional; + +/** + * {@link Conditional @Conditional} that determines if the gRPC server implementation + * should be one of the native varieties (e.g. Netty, Shaded Netty) - i.e. not the servlet + * container. The condition matches when the app is not a Reactive web application OR the + * {@code io.grpc.servlet.jakarta.GrpcServlet} class is not on the classpath OR the app is + * a servlet web application and the {@code io.grpc.servlet.jakarta.GrpcServlet} is on the + * classpath BUT the {@code spring.grpc.server.servlet.enabled} property is explicitly set + * to {@code false}. + * + * @author Dave Syer + * @author Chris Bono + */ +class OnGrpcNativeServerCondition extends AnyNestedCondition { + + OnGrpcNativeServerCondition() { + super(ConfigurationPhase.PARSE_CONFIGURATION); + } + + @ConditionalOnNotWebApplication + static class OnNonWebApplication { + + } + + @ConditionalOnMissingClass("io.grpc.servlet.jakarta.GrpcServlet") + static class OnGrpcServletClass { + + } + + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) + @ConditionalOnClass(GrpcServlet.class) + @ConditionalOnProperty(prefix = "spring.grpc.server", name = "servlet.enabled", havingValue = "false") + static class OnExplicitlyDisabledServlet { + + } + + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) + static class OnExplicitlyDisabledWebflux { + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ServerBuilderCustomizers.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ServerBuilderCustomizers.java new file mode 100644 index 000000000000..7af249e3fae7 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ServerBuilderCustomizers.java @@ -0,0 +1,58 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import io.grpc.ServerBuilder; + +import org.springframework.boot.util.LambdaSafe; +import org.springframework.grpc.server.ServerBuilderCustomizer; + +/** + * Invokes the available {@link ServerBuilderCustomizer} instances in the context for a + * given {@link ServerBuilder}. + * + * @author Chris Bono + */ +class ServerBuilderCustomizers { + + private final List> customizers; + + ServerBuilderCustomizers(List> customizers) { + this.customizers = (customizers != null) ? new ArrayList<>(customizers) : Collections.emptyList(); + } + + /** + * Customize the specified {@link ServerBuilder}. Locates all + * {@link ServerBuilderCustomizer} beans able to handle the specified instance and + * invoke {@link ServerBuilderCustomizer#customize} on them. + * @param the type of server builder + * @param serverBuilder the builder to customize + * @return the customized builder + */ + @SuppressWarnings("unchecked") + > T customize(T serverBuilder) { + LambdaSafe.callbacks(ServerBuilderCustomizer.class, this.customizers, serverBuilder) + .withLogger(ServerBuilderCustomizers.class) + .invoke((customizer) -> customizer.customize(serverBuilder)); + return serverBuilder; + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ServletEnvironmentPostProcessor.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ServletEnvironmentPostProcessor.java new file mode 100644 index 000000000000..c3a345780c92 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ServletEnvironmentPostProcessor.java @@ -0,0 +1,45 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.Map; + +import org.springframework.boot.EnvironmentPostProcessor; +import org.springframework.boot.SpringApplication; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; +import org.springframework.util.ClassUtils; + +/** + * An {@link EnvironmentPostProcessor} that sets the {@code server.http2.enabled} property + * to {@code true} when {@code io.grpc.servlet.jakarta.GrpcServlet} is on the classpath. + * + * @author Dave Syer + */ +class ServletEnvironmentPostProcessor implements EnvironmentPostProcessor { + + private static final boolean SERVLET_AVAILABLE = ClassUtils.isPresent("io.grpc.servlet.jakarta.GrpcServlet", null); + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + if (SERVLET_AVAILABLE) { + environment.getPropertySources() + .addFirst(new MapPropertySource("grpc-servlet", Map.of("server.http2.enabled", "true"))); + } + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ShadedNettyServerFactoryPropertyMapper.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ShadedNettyServerFactoryPropertyMapper.java new file mode 100644 index 000000000000..3b9c0fbab3f5 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ShadedNettyServerFactoryPropertyMapper.java @@ -0,0 +1,40 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure; + +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; + +import org.springframework.grpc.server.ShadedNettyGrpcServerFactory; + +/** + * Helper class used to map {@link GrpcServerProperties} to + * {@link ShadedNettyGrpcServerFactory}. + * + * @author Chris Bono + */ +class ShadedNettyServerFactoryPropertyMapper extends DefaultServerFactoryPropertyMapper { + + ShadedNettyServerFactoryPropertyMapper(GrpcServerProperties properties) { + super(properties); + } + + @Override + void customizeServerBuilder(NettyServerBuilder nettyServerBuilder) { + super.customizeServerBuilder(nettyServerBuilder); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/exception/GrpcExceptionHandlerAutoConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/exception/GrpcExceptionHandlerAutoConfiguration.java new file mode 100644 index 000000000000..8cb0ed97c774 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/exception/GrpcExceptionHandlerAutoConfiguration.java @@ -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. + */ + +package org.springframework.boot.grpc.server.autoconfigure.exception; + +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.ConditionalOnMissingBean; +import org.springframework.boot.grpc.server.autoconfigure.ConditionalOnGrpcServerEnabled; +import org.springframework.boot.grpc.server.autoconfigure.ConditionalOnSpringGrpc; +import org.springframework.context.annotation.Bean; +import org.springframework.grpc.server.GlobalServerInterceptor; +import org.springframework.grpc.server.exception.CompositeGrpcExceptionHandler; +import org.springframework.grpc.server.exception.GrpcExceptionHandler; +import org.springframework.grpc.server.exception.GrpcExceptionHandlerInterceptor; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for gRPC server-side exception + * handling. + * + * @author Dave Syer + * @author Chris Bono + * @since 4.0.0 + */ +@AutoConfiguration +@ConditionalOnSpringGrpc +@ConditionalOnGrpcServerEnabled("exception-handler") +@ConditionalOnBean(GrpcExceptionHandler.class) +@ConditionalOnMissingBean(GrpcExceptionHandlerInterceptor.class) +public final class GrpcExceptionHandlerAutoConfiguration { + + @GlobalServerInterceptor + @Bean + GrpcExceptionHandlerInterceptor globalExceptionHandlerInterceptor( + ObjectProvider exceptionHandler) { + return new GrpcExceptionHandlerInterceptor(new CompositeGrpcExceptionHandler( + exceptionHandler.orderedStream().toArray(GrpcExceptionHandler[]::new))); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/exception/package-info.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/exception/package-info.java new file mode 100644 index 000000000000..9e2fb4a769ac --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/exception/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Auto-configuration for gRPC server exception handling. + */ +@NullMarked +package org.springframework.boot.grpc.server.autoconfigure.exception; + +import org.jspecify.annotations.NullMarked; diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/ActuatorHealthAdapter.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/ActuatorHealthAdapter.java new file mode 100644 index 000000000000..ae0df28c28e1 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/ActuatorHealthAdapter.java @@ -0,0 +1,113 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure.health; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import io.grpc.health.v1.HealthCheckResponse.ServingStatus; +import io.grpc.protobuf.services.HealthStatusManager; + +import org.springframework.boot.actuate.health.HealthEndpoint; +import org.springframework.boot.actuate.health.StatusAggregator; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.boot.health.contributor.Status; +import org.springframework.core.log.LogAccessor; +import org.springframework.util.Assert; + +/** + * Adapts {@link HealthIndicator Actuator health indicators} into gRPC health checks by + * periodically invoking {@link HealthEndpoint health endpoints} and updating the health + * status in gRPC {@link HealthStatusManager}. + * + * @author Chris Bono + * @since 4.0.0 + */ +public class ActuatorHealthAdapter { + + private static final String INVALID_INDICATOR_MSG = "Unable to determine health for '%s' - check that your configured health-indicator-paths point to available indicators"; + + private final LogAccessor logger = new LogAccessor(getClass()); + + private final HealthStatusManager healthStatusManager; + + private final HealthEndpoint healthEndpoint; + + private final StatusAggregator statusAggregator; + + private final boolean updateOverallHealth; + + private final List healthIndicatorPaths; + + protected ActuatorHealthAdapter(HealthStatusManager healthStatusManager, HealthEndpoint healthEndpoint, + StatusAggregator statusAggregator, boolean updateOverallHealth, List healthIndicatorPaths) { + this.healthStatusManager = healthStatusManager; + this.healthEndpoint = healthEndpoint; + this.statusAggregator = statusAggregator; + this.updateOverallHealth = updateOverallHealth; + Assert.notEmpty(healthIndicatorPaths, () -> "at least one health indicator path is required"); + this.healthIndicatorPaths = healthIndicatorPaths; + } + + protected void updateHealthStatus() { + var individualStatuses = this.updateIndicatorsHealthStatus(); + if (this.updateOverallHealth) { + this.updateOverallHealthStatus(individualStatuses); + } + } + + protected Set updateIndicatorsHealthStatus() { + Set statuses = new HashSet<>(); + this.healthIndicatorPaths.forEach((healthIndicatorPath) -> { + var healthComponent = this.healthEndpoint.healthForPath(healthIndicatorPath.split("/")); + if (healthComponent == null) { + this.logger.warn(() -> INVALID_INDICATOR_MSG.formatted(healthIndicatorPath)); + } + else { + this.logger.trace(() -> "Actuator returned '%s' for indicator '%s'".formatted(healthComponent, + healthIndicatorPath)); + var actuatorStatus = healthComponent.getStatus(); + var grpcStatus = toServingStatus(actuatorStatus.getCode()); + this.healthStatusManager.setStatus(healthIndicatorPath, grpcStatus); + this.logger.trace(() -> "Updated gRPC health status to '%s' for service '%s'".formatted(grpcStatus, + healthIndicatorPath)); + statuses.add(actuatorStatus); + } + }); + return statuses; + } + + protected void updateOverallHealthStatus(Set individualStatuses) { + var overallActuatorStatus = this.statusAggregator.getAggregateStatus(individualStatuses); + var overallGrpcStatus = toServingStatus(overallActuatorStatus.getCode()); + this.logger.trace(() -> "Actuator aggregate status '%s' for overall health".formatted(overallActuatorStatus)); + this.healthStatusManager.setStatus("", overallGrpcStatus); + this.logger.trace(() -> "Updated overall gRPC health status to '%s'".formatted(overallGrpcStatus)); + } + + protected ServingStatus toServingStatus(String actuatorHealthStatusCode) { + return switch (actuatorHealthStatusCode) { + case "UP" -> ServingStatus.SERVING; + case "DOWN" -> ServingStatus.NOT_SERVING; + case "OUT_OF_SERVICE" -> ServingStatus.NOT_SERVING; + case "UNKNOWN" -> ServingStatus.UNKNOWN; + default -> ServingStatus.UNKNOWN; + }; + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/ActuatorHealthAdapterInvoker.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/ActuatorHealthAdapterInvoker.java new file mode 100644 index 000000000000..b1780a911c1e --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/ActuatorHealthAdapterInvoker.java @@ -0,0 +1,65 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure.health; + +import java.time.Duration; +import java.time.Instant; + +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.task.SimpleAsyncTaskSchedulerBuilder; +import org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler; + +/** + * Periodically invokes the {@link ActuatorHealthAdapter} in the background. + * + * @author Chris Bono + */ +class ActuatorHealthAdapterInvoker implements InitializingBean, DisposableBean { + + private final ActuatorHealthAdapter healthAdapter; + + private final SimpleAsyncTaskScheduler taskScheduler; + + private final Duration updateInitialDelay; + + private final Duration updateFixedRate; + + ActuatorHealthAdapterInvoker(ActuatorHealthAdapter healthAdapter, SimpleAsyncTaskSchedulerBuilder schedulerBuilder, + Duration updateInitialDelay, Duration updateFixedRate) { + this.healthAdapter = healthAdapter; + this.taskScheduler = schedulerBuilder.threadNamePrefix("healthAdapter-").build(); + this.updateInitialDelay = updateInitialDelay; + this.updateFixedRate = updateFixedRate; + } + + @Override + public void afterPropertiesSet() { + this.taskScheduler.scheduleAtFixedRate(this::updateHealthStatus, Instant.now().plus(this.updateInitialDelay), + this.updateFixedRate); + } + + @Override + public void destroy() { + this.taskScheduler.close(); + } + + void updateHealthStatus() { + this.healthAdapter.updateHealthStatus(); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfiguration.java new file mode 100644 index 000000000000..c94454e6aed0 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfiguration.java @@ -0,0 +1,132 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.List; + +import io.grpc.BindableService; +import io.grpc.protobuf.services.HealthStatusManager; + +import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint; +import org.springframework.boot.actuate.health.HealthEndpoint; +import org.springframework.boot.actuate.health.StatusAggregator; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionMessage; +import org.springframework.boot.autoconfigure.condition.ConditionOutcome; +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.SpringBootCondition; +import org.springframework.boot.autoconfigure.task.TaskSchedulingAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.context.properties.bind.BindResult; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.grpc.server.autoconfigure.ConditionalOnGrpcServerEnabled; +import org.springframework.boot.grpc.server.autoconfigure.ConditionalOnSpringGrpc; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerFactoryAutoConfiguration; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerProperties; +import org.springframework.boot.task.SimpleAsyncTaskSchedulerBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.type.AnnotatedTypeMetadata; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for gRPC server-side health service. + * + * @author Daniel Theuke (daniel.theuke@heuboe.de) + * @author Chris Bono + * @since 4.0.0 + */ +@AutoConfiguration(before = GrpcServerFactoryAutoConfiguration.class) +@ConditionalOnSpringGrpc +@ConditionalOnClass(HealthStatusManager.class) +@ConditionalOnGrpcServerEnabled("health") +@ConditionalOnBean(BindableService.class) +public final class GrpcServerHealthAutoConfiguration { + + @Bean(destroyMethod = "enterTerminalState") + @ConditionalOnMissingBean + HealthStatusManager healthStatusManager() { + return new HealthStatusManager(); + } + + @Bean + BindableService grpcHealthService(HealthStatusManager healthStatusManager) { + return healthStatusManager.getHealthService(); + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(HealthEndpoint.class) + @ConditionalOnAvailableEndpoint(endpoint = HealthEndpoint.class) + @AutoConfigureAfter(value = TaskSchedulingAutoConfiguration.class, + name = "org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration") + @ConditionalOnGrpcServerEnabled("health.actuator") + @Conditional(OnHealthIndicatorPathsCondition.class) + @EnableConfigurationProperties(GrpcServerProperties.class) + @EnableScheduling + static class ActuatorHealthAdapterConfiguration { + + @Bean + @ConditionalOnMissingBean + ActuatorHealthAdapter healthAdapter(HealthStatusManager healthStatusManager, HealthEndpoint healthEndpoint, + StatusAggregator statusAggregator, GrpcServerProperties serverProperties) { + return new ActuatorHealthAdapter(healthStatusManager, healthEndpoint, statusAggregator, + serverProperties.getHealth().getActuator().getUpdateOverallHealth(), + serverProperties.getHealth().getActuator().getHealthIndicatorPaths()); + } + + @Bean + ActuatorHealthAdapterInvoker healthAdapterInvoker(ActuatorHealthAdapter healthAdapter, + SimpleAsyncTaskSchedulerBuilder schedulerBuilder, GrpcServerProperties serverProperties) { + return new ActuatorHealthAdapterInvoker(healthAdapter, schedulerBuilder, + serverProperties.getHealth().getActuator().getUpdateInitialDelay(), + serverProperties.getHealth().getActuator().getUpdateRate()); + } + + } + + /** + * Condition to determine if + * {@code spring.grpc.server.health.actuator.health-indicator-paths} is specified with + * at least one entry. + */ + static class OnHealthIndicatorPathsCondition extends SpringBootCondition { + + @Override + public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) { + String propertyName = "spring.grpc.server.health.actuator.health-indicator-paths"; + BindResult> property = Binder.get(context.getEnvironment()) + .bind(propertyName, Bindable.listOf(String.class)); + ConditionMessage.Builder messageBuilder = ConditionMessage + .forCondition("Health indicator paths (at least one)"); + if (property.isBound() && !property.get().isEmpty()) { + return ConditionOutcome + .match(messageBuilder.because("property %s found with at least one entry".formatted(propertyName))); + } + return ConditionOutcome.noMatch( + messageBuilder.because("property %s not found with at least one entry".formatted(propertyName))); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/package-info.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/package-info.java new file mode 100644 index 000000000000..d7ef5d4d129f --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Auto-configuration for gRPC server health adapter. + */ +@NullMarked +package org.springframework.boot.grpc.server.autoconfigure.health; + +import org.jspecify.annotations.NullMarked; diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/package-info.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/package-info.java new file mode 100644 index 000000000000..5cd06d1019bb --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/package-info.java @@ -0,0 +1,24 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Auto-configuration for gRPC server. + */ + +@NullMarked +package org.springframework.boot.grpc.server.autoconfigure; + +import org.jspecify.annotations.NullMarked; diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcDisableCsrfHttpConfigurer.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcDisableCsrfHttpConfigurer.java new file mode 100644 index 000000000000..13358ea45623 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcDisableCsrfHttpConfigurer.java @@ -0,0 +1,70 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure.security; + +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.AndRequestMatcher; +import org.springframework.security.web.util.matcher.NegatedRequestMatcher; + +/** + * A custom {@link AbstractHttpConfigurer} that disables CSRF protection for gRPC + * requests. + *

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

+ * + * @author Dave Syer + * @since 4.0.0 + * @see AbstractHttpConfigurer + * @see HttpSecurity + */ +public class GrpcDisableCsrfHttpConfigurer extends AbstractHttpConfigurer { + + @Override + public void init(HttpSecurity http) throws Exception { + ApplicationContext context = http.getSharedObject(ApplicationContext.class); + if (context != null && context.getBeanNamesForType(GrpcServiceDiscoverer.class).length == 1 + && isServletEnabledAndCsrfDisabled(context) && isCsrfConfigurerPresent(http)) { + http.csrf(this::disable); + } + } + + @SuppressWarnings("unchecked") + private boolean isCsrfConfigurerPresent(HttpSecurity http) { + return http.getConfigurer(CsrfConfigurer.class) != null; + } + + private void disable(CsrfConfigurer csrf) { + csrf.requireCsrfProtectionMatcher(new AndRequestMatcher(CsrfFilter.DEFAULT_CSRF_MATCHER, + new NegatedRequestMatcher(GrpcServletRequest.all()))); + } + + private boolean isServletEnabledAndCsrfDisabled(ApplicationContext context) { + return context.getEnvironment().getProperty("spring.grpc.server.servlet.enabled", Boolean.class, true) + && !context.getEnvironment() + .getProperty("spring.grpc.server.security.csrf.enabled", Boolean.class, false); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcReactiveRequest.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcReactiveRequest.java new file mode 100644 index 000000000000..83172c6e3904 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcReactiveRequest.java @@ -0,0 +1,131 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure.security; + +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import reactor.core.publisher.Mono; + +import org.springframework.boot.grpc.server.autoconfigure.security.GrpcServletRequest.GrpcServletRequestMatcher; +import org.springframework.boot.security.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.web.server.ServerWebExchange; + +/** + * Factory for a request matcher used to match against resource locations for gRPC + * services. + * + * @author Dave Syer + * @since 4.0.0 + */ +public final class GrpcReactiveRequest { + + private GrpcReactiveRequest() { + } + + /** + * Returns a matcher that includes all gRPC services from the application context. The + * {@link GrpcReactiveRequestMatcher#excluding(String...) excluding} method can be + * used to remove specific services by name if required. For example: + * + *
+	 * GrpcReactiveRequest.all().excluding("my-service")
+	 * 
+ * @return the configured {@link ServerWebExchangeMatcher} + */ + public static GrpcReactiveRequestMatcher all() { + return new GrpcReactiveRequestMatcher(); + } + + /** + * The request matcher used to match against resource locations. + */ + public static final class GrpcReactiveRequestMatcher + extends ApplicationContextServerWebExchangeMatcher { + + private final Set exclusions; + + private volatile ServerWebExchangeMatcher delegate; + + private GrpcReactiveRequestMatcher() { + this(new HashSet<>()); + } + + private GrpcReactiveRequestMatcher(Set exclusions) { + super(GrpcServiceDiscoverer.class); + this.exclusions = exclusions; + this.delegate = (request) -> MatchResult.notMatch(); + } + + /** + * Return a new {@link GrpcServletRequestMatcher} based on this one but excluding + * the specified services. + * @param rest additional services to exclude + * @return a new {@link GrpcServletRequestMatcher} + */ + public GrpcReactiveRequestMatcher excluding(String... rest) { + return excluding(Set.of(rest)); + } + + /** + * Return a new {@link GrpcServletRequestMatcher} based on this one but excluding + * the specified services. + * @param exclusions additional service names to exclude + * @return a new {@link GrpcServletRequestMatcher} + */ + public GrpcReactiveRequestMatcher excluding(Set exclusions) { + Assert.notNull(exclusions, "Exclusions must not be null"); + Set subset = new LinkedHashSet<>(this.exclusions); + subset.addAll(exclusions); + return new GrpcReactiveRequestMatcher(subset); + } + + @Override + protected void initialized(Supplier context) { + List matchers = getDelegateMatchers(context.get()).toList(); + this.delegate = matchers.isEmpty() ? (request) -> MatchResult.notMatch() + : new OrServerWebExchangeMatcher(matchers); + } + + private Stream getDelegateMatchers(GrpcServiceDiscoverer context) { + return getPatterns(context).map(PathPatternParserServerWebExchangeMatcher::new); + } + + private Stream getPatterns(GrpcServiceDiscoverer context) { + return context.listServiceNames() + .stream() + .filter((service) -> !this.exclusions.stream().anyMatch((type) -> type.equals(service))) + .map((service) -> "/" + service + "/**"); + } + + @Override + protected Mono matches(ServerWebExchange exchange, Supplier context) { + return this.delegate.matches(exchange); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcSecurityAutoConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcSecurityAutoConfiguration.java new file mode 100644 index 000000000000..c74ee8a4b92c --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcSecurityAutoConfiguration.java @@ -0,0 +1,114 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure.security; + +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.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.grpc.server.autoconfigure.ConditionalOnGrpcNativeServer; +import org.springframework.boot.grpc.server.autoconfigure.ConditionalOnGrpcServerEnabled; +import org.springframework.boot.grpc.server.autoconfigure.ConditionalOnGrpcServletServer; +import org.springframework.boot.grpc.server.autoconfigure.ConditionalOnSpringGrpc; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerExecutorProvider; +import org.springframework.boot.grpc.server.autoconfigure.exception.GrpcExceptionHandlerAutoConfiguration; +import org.springframework.boot.grpc.server.autoconfigure.security.GrpcSecurityAutoConfiguration.ExceptionHandlerConfiguration; +import org.springframework.boot.grpc.server.autoconfigure.security.GrpcSecurityAutoConfiguration.GrpcNativeSecurityConfigurerConfiguration; +import org.springframework.boot.grpc.server.autoconfigure.security.GrpcSecurityAutoConfiguration.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.exception.GrpcExceptionHandler; +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.web.SecurityFilterChain; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for gRPC server-side security. + * + * @author Dave Syer + * @author Chris Bono + * @author Andrey Litvitski + * @since 4.0.0 + */ +@AutoConfiguration(before = GrpcExceptionHandlerAutoConfiguration.class, + afterName = "org.springframework.boot.security.autoconfigure.servlet.SecurityAutoConfiguration") +@ConditionalOnSpringGrpc +@ConditionalOnClass(ObjectPostProcessor.class) +@ConditionalOnGrpcServerEnabled +@Import({ ExceptionHandlerConfiguration.class, GrpcNativeSecurityConfigurerConfiguration.class, + GrpcServletSecurityConfigurerConfiguration.class }) +public final class GrpcSecurityAutoConfiguration { + + @Configuration(proxyBeanMethods = false) + @Import(AuthenticationConfiguration.class) + static class ExceptionHandlerConfiguration { + + @Bean + GrpcExceptionHandler accessExceptionHandler() { + return new SecurityGrpcExceptionHandler(); + } + + } + + @ConditionalOnBean(ObjectPostProcessor.class) + @ConditionalOnGrpcNativeServer + @Configuration(proxyBeanMethods = false) + static class GrpcNativeSecurityConfigurerConfiguration { + + @Bean + GrpcSecurity grpcSecurity(ObjectPostProcessor objectPostProcessor, + AuthenticationConfiguration authenticationConfiguration, ApplicationContext context) throws Exception { + AuthenticationManagerBuilder authenticationManagerBuilder = authenticationConfiguration + .authenticationManagerBuilder(objectPostProcessor, context); + authenticationManagerBuilder + .parentAuthenticationManager(authenticationConfiguration.getAuthenticationManager()); + return new GrpcSecurity(objectPostProcessor, authenticationManagerBuilder, context); + } + + } + + @ConditionalOnBean(SecurityFilterChain.class) + @ConditionalOnGrpcServletServer + @Configuration(proxyBeanMethods = false) + static class GrpcServletSecurityConfigurerConfiguration { + + @Bean + @GlobalServerInterceptor + SecurityContextServerInterceptor securityContextInterceptor() { + return new SecurityContextServerInterceptor(); + } + + @Bean + @ConditionalOnMissingBean(GrpcServerExecutorProvider.class) + GrpcServerExecutorProvider grpcServerExecutorProvider() { + return () -> new DelegatingSecurityContextExecutor(GrpcUtil.SHARED_CHANNEL_EXECUTOR.create()); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServletRequest.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServletRequest.java new file mode 100644 index 000000000000..1436ddac3779 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServletRequest.java @@ -0,0 +1,137 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure.security; + +import java.util.HashSet; +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.springframework.boot.security.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.web.context.WebApplicationContext; + +/** + * Factory for a request matcher used to match against resource locations for gRPC + * services. + * + * @author Dave Syer + * @since 4.0.0 + */ +public final class GrpcServletRequest { + + private GrpcServletRequest() { + } + + /** + * Returns a matcher that includes all gRPC services from the application context. The + * {@link GrpcServletRequestMatcher#excluding(String...) excluding} method can be used + * to remove specific services by name if required. For example: + * + *
+	 * GrpcServletRequest.all().excluding("my-service")
+	 * 
+ * @return the configured {@link RequestMatcher} + */ + public static GrpcServletRequestMatcher all() { + return new GrpcServletRequestMatcher(); + } + + /** + * The request matcher used to match against resource locations. + */ + public static final class GrpcServletRequestMatcher + extends ApplicationContextRequestMatcher { + + private final Set exclusions; + + private volatile RequestMatcher delegate; + + private GrpcServletRequestMatcher() { + this(new HashSet<>()); + } + + private GrpcServletRequestMatcher(Set exclusions) { + super(GrpcServiceDiscoverer.class); + this.exclusions = exclusions; + this.delegate = (request) -> false; + } + + /** + * Return a new {@link GrpcServletRequestMatcher} based on this one but excluding + * the specified services. + * @param rest additional services to exclude + * @return a new {@link GrpcServletRequestMatcher} + */ + public GrpcServletRequestMatcher excluding(String... rest) { + return excluding(Set.of(rest)); + } + + /** + * Return a new {@link GrpcServletRequestMatcher} based on this one but excluding + * the specified services. + * @param exclusions additional service names to exclude + * @return a new {@link GrpcServletRequestMatcher} + */ + public GrpcServletRequestMatcher excluding(Set exclusions) { + Assert.notNull(exclusions, "Exclusions must not be null"); + Set subset = new LinkedHashSet<>(this.exclusions); + subset.addAll(exclusions); + return new GrpcServletRequestMatcher(subset); + } + + @Override + protected void initialized(Supplier context) { + List matchers = getDelegateMatchers(context.get()).toList(); + this.delegate = matchers.isEmpty() ? (request) -> false : new OrRequestMatcher(matchers); + } + + @Override + protected boolean ignoreApplicationContext(WebApplicationContext context) { + return context.getBeanNamesForType(GrpcServiceDiscoverer.class).length != 1; + } + + private Stream getDelegateMatchers(GrpcServiceDiscoverer context) { + return getPatterns(context).map((path) -> { + Assert.hasText(path, "Path must not be empty"); + return PathPatternRequestMatcher.withDefaults().matcher(path); + }); + } + + private Stream getPatterns(GrpcServiceDiscoverer context) { + return context.listServiceNames() + .stream() + .filter((service) -> !this.exclusions.stream().anyMatch((type) -> type.equals(service))) + .map((service) -> "/" + service + "/**"); + } + + @Override + protected boolean matches(HttpServletRequest request, Supplier context) { + return this.delegate.matches(request); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/OAuth2ClientAutoConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/OAuth2ClientAutoConfiguration.java new file mode 100644 index 000000000000..d4c64c088cb0 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/OAuth2ClientAutoConfiguration.java @@ -0,0 +1,60 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure.security; + +import java.util.ArrayList; +import java.util.List; + +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.context.properties.EnableConfigurationProperties; +import org.springframework.boot.grpc.server.autoconfigure.ConditionalOnSpringGrpc; +import org.springframework.boot.security.oauth2.client.autoconfigure.ConditionalOnOAuth2ClientRegistrationProperties; +import org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientProperties; +import org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientPropertiesMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for gRPC OAuth2 security. + * + * @author Dave Syer + * @since 4.0.0 + */ +// Copied from Spring Boot (https://github.com/spring-projects/spring-boot/issues/40997, ] +// https://github.com/spring-projects/spring-boot/issues/15877) +@AutoConfiguration( + afterName = "org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientAutoConfiguration") +@ConditionalOnSpringGrpc +@ConditionalOnClass(InMemoryClientRegistrationRepository.class) +@ConditionalOnOAuth2ClientRegistrationProperties +@EnableConfigurationProperties(OAuth2ClientProperties.class) +public final class OAuth2ClientAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(ClientRegistrationRepository.class) + InMemoryClientRegistrationRepository clientRegistrationRepository(OAuth2ClientProperties properties) { + List registrations = new ArrayList<>( + new OAuth2ClientPropertiesMapper(properties).asClientRegistrations().values()); + return new InMemoryClientRegistrationRepository(registrations); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/OAuth2ResourceServerAutoConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/OAuth2ResourceServerAutoConfiguration.java new file mode 100644 index 000000000000..ceee64aab1f6 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/OAuth2ResourceServerAutoConfiguration.java @@ -0,0 +1,332 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure.security; + +import java.security.KeyFactory; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.X509EncodedKeySpec; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import io.grpc.BindableService; + +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.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.context.properties.PropertyMapper; +import org.springframework.boot.grpc.server.autoconfigure.ConditionalOnSpringGrpc; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerFactoryAutoConfiguration; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerFactoryAutoConfiguration.GrpcServletConfiguration; +import org.springframework.boot.grpc.server.autoconfigure.security.OAuth2ResourceServerAutoConfiguration.Oauth2ResourceServerConfiguration; +import org.springframework.boot.security.oauth2.server.resource.autoconfigure.ConditionalOnIssuerLocationJwtDecoder; +import org.springframework.boot.security.oauth2.server.resource.autoconfigure.ConditionalOnPublicKeyJwtDecoder; +import org.springframework.boot.security.oauth2.server.resource.autoconfigure.OAuth2ResourceServerProperties; +import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.JwkSetUriJwtDecoderBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.grpc.server.GlobalServerInterceptor; +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.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.oauth2.jwt.JwtClaimValidator; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder.JwkSetUriJwtDecoderBuilder; +import org.springframework.security.oauth2.jwt.SupplierJwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; +import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; +import org.springframework.security.oauth2.server.resource.introspection.SpringOpaqueTokenIntrospector; +import org.springframework.util.CollectionUtils; + +import static org.springframework.security.config.Customizer.withDefaults; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for gRPC OAuth2 resource server. + * + * @author Dave Syer + * @since 4.0.0 + */ +// All copied from Spring Boot +// (https://github.com/spring-projects/spring-boot/issues/43978), except the +// 2 @Beans of type AuthenticationProcessInterceptor +@AutoConfiguration( + beforeName = "org.springframework.boot.security.autoconfigure.servlet.UserDetailsServiceAutoConfiguration", + afterName = { "org.springframework.boot.security.autoconfigure.servlet.SecurityAutoConfiguration", + "org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration" }, + after = { GrpcSecurityAutoConfiguration.class, GrpcServerFactoryAutoConfiguration.class }) +@EnableConfigurationProperties(OAuth2ResourceServerProperties.class) +@ConditionalOnSpringGrpc +@ConditionalOnClass({ InMemoryClientRegistrationRepository.class, BearerTokenAuthenticationToken.class, + ObjectPostProcessor.class }) +@ConditionalOnMissingBean(GrpcServletConfiguration.class) +@ConditionalOnBean({ BindableService.class, GrpcSecurityAutoConfiguration.class }) +@Import({ Oauth2ResourceServerConfiguration.JwtConfiguration.class, + Oauth2ResourceServerConfiguration.OpaqueTokenConfiguration.class }) +public final class OAuth2ResourceServerAutoConfiguration { + + @Configuration(proxyBeanMethods = false) + static class Oauth2ResourceServerConfiguration { + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(JwtDecoder.class) + @Import({ OAuth2ResourceServerJwtConfiguration.JwtConverterConfiguration.class, + OAuth2ResourceServerJwtConfiguration.JwtDecoderConfiguration.class, + OAuth2ResourceServerJwtConfiguration.OAuth2SecurityFilterChainConfiguration.class }) + static class JwtConfiguration { + + } + + @Configuration(proxyBeanMethods = false) + @Import({ OAuth2ResourceServerOpaqueTokenConfiguration.OpaqueTokenIntrospectionClientConfiguration.class, + OAuth2ResourceServerOpaqueTokenConfiguration.OAuth2SecurityFilterChainConfiguration.class }) + static class OpaqueTokenConfiguration { + + } + + } + + @Configuration(proxyBeanMethods = false) + static class OAuth2ResourceServerOpaqueTokenConfiguration { + + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingBean(OpaqueTokenIntrospector.class) + static class OpaqueTokenIntrospectionClientConfiguration { + + @Bean + @ConditionalOnProperty(name = "spring.security.oauth2.resourceserver.opaquetoken.introspection-uri") + SpringOpaqueTokenIntrospector blockingOpaqueTokenIntrospector(OAuth2ResourceServerProperties properties) { + OAuth2ResourceServerProperties.Opaquetoken opaqueToken = properties.getOpaquetoken(); + return SpringOpaqueTokenIntrospector.withIntrospectionUri(opaqueToken.getIntrospectionUri()) + .clientId(opaqueToken.getClientId()) + .clientSecret(opaqueToken.getClientSecret()) + .build(); + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingBean(AuthenticationProcessInterceptor.class) + static class OAuth2SecurityFilterChainConfiguration { + + @Bean + @ConditionalOnBean(OpaqueTokenIntrospector.class) + @GlobalServerInterceptor + AuthenticationProcessInterceptor opaqueTokenAuthenticationProcessInterceptor(GrpcSecurity http) + throws Exception { + http.authorizeRequests((requests) -> requests.allRequests().authenticated()); + http.oauth2ResourceServer((resourceServer) -> resourceServer.opaqueToken(withDefaults())); + return http.build(); + } + + } + + } + + @Configuration(proxyBeanMethods = false) + static class OAuth2ResourceServerJwtConfiguration { + + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingBean(JwtDecoder.class) + static class JwtDecoderConfiguration { + + private final OAuth2ResourceServerProperties.Jwt properties; + + private final List> additionalValidators; + + JwtDecoderConfiguration(OAuth2ResourceServerProperties properties, + ObjectProvider> additionalValidators) { + this.properties = properties.getJwt(); + this.additionalValidators = additionalValidators.orderedStream().toList(); + } + + @Bean + @ConditionalOnProperty(name = "spring.security.oauth2.resourceserver.jwt.jwk-set-uri") + JwtDecoder blockingJwtDecoderByJwkKeySetUri( + ObjectProvider customizers) { + JwkSetUriJwtDecoderBuilder builder = NimbusJwtDecoder.withJwkSetUri(this.properties.getJwkSetUri()) + .jwsAlgorithms(this::jwsAlgorithms); + customizers.orderedStream().forEach((customizer) -> customizer.customize(builder)); + NimbusJwtDecoder nimbusJwtDecoder = builder.build(); + String issuerUri = this.properties.getIssuerUri(); + OAuth2TokenValidator defaultValidator = (issuerUri != null) + ? JwtValidators.createDefaultWithIssuer(issuerUri) : JwtValidators.createDefault(); + nimbusJwtDecoder.setJwtValidator(getValidators(defaultValidator)); + return nimbusJwtDecoder; + } + + private void jwsAlgorithms(Set signatureAlgorithms) { + for (String algorithm : this.properties.getJwsAlgorithms()) { + signatureAlgorithms.add(SignatureAlgorithm.from(algorithm)); + } + } + + private OAuth2TokenValidator getValidators(OAuth2TokenValidator defaultValidator) { + List audiences = this.properties.getAudiences(); + if (CollectionUtils.isEmpty(audiences) && this.additionalValidators.isEmpty()) { + return defaultValidator; + } + List> validators = new ArrayList<>(); + validators.add(defaultValidator); + if (!CollectionUtils.isEmpty(audiences)) { + validators.add(audValidator(audiences)); + } + validators.addAll(this.additionalValidators); + return new DelegatingOAuth2TokenValidator<>(validators); + } + + private JwtClaimValidator> audValidator(List audiences) { + return new JwtClaimValidator<>(JwtClaimNames.AUD, (aud) -> nullSafeDisjoint(aud, audiences)); + } + + private boolean nullSafeDisjoint(List c1, List c2) { + return c1 != null && !Collections.disjoint(c1, c2); + } + + @Bean + @ConditionalOnPublicKeyJwtDecoder + JwtDecoder blockingJwtDecoderByPublicKeyValue() throws Exception { + RSAPublicKey publicKey = (RSAPublicKey) KeyFactory.getInstance("RSA") + .generatePublic(new X509EncodedKeySpec(getKeySpec(this.properties.readPublicKey()))); + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(publicKey) + .signatureAlgorithm(SignatureAlgorithm.from(exactlyOneAlgorithm())) + .build(); + jwtDecoder.setJwtValidator(getValidators(JwtValidators.createDefault())); + return jwtDecoder; + } + + private byte[] getKeySpec(String keyValue) { + keyValue = keyValue.replace("-----BEGIN PUBLIC KEY-----", "").replace("-----END PUBLIC KEY-----", ""); + return Base64.getMimeDecoder().decode(keyValue); + } + + private String exactlyOneAlgorithm() { + List algorithms = this.properties.getJwsAlgorithms(); + int count = (algorithms != null) ? algorithms.size() : 0; + if (count != 1) { + throw new IllegalStateException( + "Creating a JWT decoder using a public key requires exactly one JWS algorithm but " + count + + " were configured"); + } + return algorithms.get(0); + } + + @Bean + @ConditionalOnIssuerLocationJwtDecoder + SupplierJwtDecoder blockingJwtDecoderByIssuerUri( + ObjectProvider customizers) { + return new SupplierJwtDecoder(() -> { + String issuerUri = this.properties.getIssuerUri(); + JwkSetUriJwtDecoderBuilder builder = NimbusJwtDecoder.withIssuerLocation(issuerUri); + customizers.orderedStream().forEach((customizer) -> customizer.customize(builder)); + NimbusJwtDecoder jwtDecoder = builder.build(); + jwtDecoder.setJwtValidator(getValidators(JwtValidators.createDefaultWithIssuer(issuerUri))); + return jwtDecoder; + }); + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingBean(AuthenticationProcessInterceptor.class) + static class OAuth2SecurityFilterChainConfiguration { + + @Bean + @ConditionalOnBean(JwtDecoder.class) + @GlobalServerInterceptor + AuthenticationProcessInterceptor jwtAuthenticationProcessInterceptor(GrpcSecurity http) throws Exception { + http.authorizeRequests((requests) -> requests.allRequests().authenticated()); + http.oauth2ResourceServer((resourceServer) -> resourceServer.jwt(withDefaults())); + return http.build(); + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingBean(JwtAuthenticationConverter.class) + @Conditional(JwtConverterPropertiesCondition.class) + static class JwtConverterConfiguration { + + private final OAuth2ResourceServerProperties.Jwt properties; + + JwtConverterConfiguration(OAuth2ResourceServerProperties properties) { + this.properties = properties.getJwt(); + } + + @Bean + JwtAuthenticationConverter getJwtAuthenticationConverter() { + JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); + PropertyMapper map = PropertyMapper.get(); + map.from(this.properties.getAuthorityPrefix()).to(grantedAuthoritiesConverter::setAuthorityPrefix); + map.from(this.properties.getAuthoritiesClaimDelimiter()) + .to(grantedAuthoritiesConverter::setAuthoritiesClaimDelimiter); + map.from(this.properties.getAuthoritiesClaimName()) + .to(grantedAuthoritiesConverter::setAuthoritiesClaimName); + JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); + map.from(this.properties.getPrincipalClaimName()).to(jwtAuthenticationConverter::setPrincipalClaimName); + jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter); + return jwtAuthenticationConverter; + } + + } + + private static class JwtConverterPropertiesCondition extends AnyNestedCondition { + + JwtConverterPropertiesCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnProperty(prefix = "spring.security.oauth2.resourceserver.jwt", name = "authority-prefix") + static class OnAuthorityPrefix { + + } + + @ConditionalOnProperty(prefix = "spring.security.oauth2.resourceserver.jwt", name = "principal-claim-name") + static class OnPrincipalClaimName { + + } + + @ConditionalOnProperty(prefix = "spring.security.oauth2.resourceserver.jwt", + name = "authorities-claim-name") + static class OnAuthoritiesClaimName { + + } + + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/package-info.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/package-info.java new file mode 100644 index 000000000000..264c92d6e9a6 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Auto-configuration for gRPC server security. + */ +@NullMarked +package org.springframework.boot.grpc.server.autoconfigure.security; + +import org.jspecify.annotations.NullMarked; diff --git a/module/spring-boot-grpc-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/module/spring-boot-grpc-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 000000000000..cfafdb62f4e3 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,51 @@ +{ + "groups": [], + "properties": [ + { + "name": "spring.grpc.server.enabled", + "type": "java.lang.Boolean", + "description": "Whether to enable server autoconfiguration.", + "defaultValue": true + }, + { + "name": "spring.grpc.server.exception-handling.enabled", + "type": "java.lang.Boolean", + "description": "Whether to enable user-defined global exception handling on the gRPC server.", + "defaultValue": true + }, + { + "name": "spring.grpc.server.inprocess.exclusive", + "type": "java.lang.Boolean", + "description": "Whether the inprocess server factory should be the only server factory available. When the value is true, no other server factory will be configured.", + "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.port", + "defaultValue": "9090" + }, + { + "name": "spring.grpc.server.reflection.enabled", + "type": "java.lang.Boolean", + "description": "Whether to enable Reflection on the gRPC server.", + "defaultValue": true + }, + { + "name": "spring.grpc.server.security.csrf.enabled", + "type": "java.lang.Boolean", + "description": "Whether to enable CSRF protection on gRPC requests.", + "defaultValue": false + }, + { + "name": "spring.grpc.server.servlet.enabled", + "type": "java.lang.Boolean", + "description": "Whether to use a servlet server in a servlet-based web application. When the value is false, a native gRPC server will be created as long as one is available, and it will listen on its own port. Should only be needed if the GrpcServlet is on the classpath", + "defaultValue": true + } + ] +} diff --git a/module/spring-boot-grpc-server/src/main/resources/META-INF/spring.factories b/module/spring-boot-grpc-server/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000000..fc5b89e9e454 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/resources/META-INF/spring.factories @@ -0,0 +1,5 @@ +org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer=\ +org.springframework.boot.grpc.server.autoconfigure.security.GrpcDisableCsrfHttpConfigurer + +org.springframework.boot.EnvironmentPostProcessor=\ +org.springframework.boot.grpc.server.autoconfigure.ServletEnvironmentPostProcessor diff --git a/module/spring-boot-grpc-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/module/spring-boot-grpc-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000000..bfb64d0cd23f --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,9 @@ +org.springframework.boot.grpc.server.autoconfigure.GrpcServerAutoConfiguration +org.springframework.boot.grpc.server.autoconfigure.GrpcServerFactoryAutoConfiguration +org.springframework.boot.grpc.server.autoconfigure.GrpcServerObservationAutoConfiguration +org.springframework.boot.grpc.server.autoconfigure.GrpcServerReflectionAutoConfiguration +org.springframework.boot.grpc.server.autoconfigure.exception.GrpcExceptionHandlerAutoConfiguration +org.springframework.boot.grpc.server.autoconfigure.health.GrpcServerHealthAutoConfiguration +org.springframework.boot.grpc.server.autoconfigure.security.GrpcSecurityAutoConfiguration +org.springframework.boot.grpc.server.autoconfigure.security.OAuth2ClientAutoConfiguration +org.springframework.boot.grpc.server.autoconfigure.security.OAuth2ResourceServerAutoConfiguration diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcCodecConfigurationTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcCodecConfigurationTests.java new file mode 100644 index 000000000000..479319e29d6c --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcCodecConfigurationTests.java @@ -0,0 +1,100 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.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 GrpcCodecConfiguration}. + * + * @author Andrei Lisa + */ +class GrpcCodecConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcCodecConfiguration.class)); + + @Test + void whenCodecNotOnClasspathThenAutoconfigurationSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(Codec.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcCodecConfiguration.class)); + } + + @Test + void whenHasCustomCompressorRegistryDoesNotAutoConfigureBean() { + CompressorRegistry customRegistry = mock(); + this.contextRunner.withBean("customCompressorRegistry", CompressorRegistry.class, () -> customRegistry) + .run((context) -> assertThat(context).getBean(CompressorRegistry.class).isSameAs(customRegistry)); + } + + @Test + void compressorRegistryAutoConfiguredAsExpected() { + this.contextRunner.run((context) -> assertThat(context).getBean(CompressorRegistry.class) + .isSameAs(CompressorRegistry.getDefaultInstance())); + } + + @Test + void whenCustomCompressorsThenCompressorRegistryIsNewInstance() { + Compressor compressor = mock(); + given(compressor.getMessageEncoding()).willReturn("foo"); + this.contextRunner.withBean(Compressor.class, () -> compressor).run((context) -> { + assertThat(context).hasSingleBean(CompressorRegistry.class); + CompressorRegistry registry = context.getBean(CompressorRegistry.class); + assertThat(registry).isNotSameAs(CompressorRegistry.getDefaultInstance()); + assertThat(registry.lookupCompressor("foo")).isSameAs(compressor); + }); + } + + @Test + void whenHasCustomDecompressorRegistryDoesNotAutoConfigureBean() { + DecompressorRegistry customRegistry = mock(); + this.contextRunner.withBean("customDecompressorRegistry", DecompressorRegistry.class, () -> customRegistry) + .run((context) -> assertThat(context).getBean(DecompressorRegistry.class).isSameAs(customRegistry)); + } + + @Test + void decompressorRegistryAutoConfiguredAsExpected() { + this.contextRunner.run((context) -> assertThat(context).getBean(DecompressorRegistry.class) + .isSameAs(DecompressorRegistry.getDefaultInstance())); + } + + @Test + void whenCustomDecompressorsThenDecompressorRegistryIsNewInstance() { + Decompressor decompressor = mock(); + given(decompressor.getMessageEncoding()).willReturn("foo"); + this.contextRunner.withBean(Decompressor.class, () -> decompressor).run((context) -> { + assertThat(context).hasSingleBean(DecompressorRegistry.class); + DecompressorRegistry registry = context.getBean(DecompressorRegistry.class); + assertThat(registry).isNotSameAs(DecompressorRegistry.getDefaultInstance()); + assertThat(registry.lookupDecompressor("foo")).isSameAs(decompressor); + }); + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerAutoConfigurationTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerAutoConfigurationTests.java new file mode 100644 index 000000000000..552c3777b3d3 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerAutoConfigurationTests.java @@ -0,0 +1,593 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.List; +import java.util.concurrent.TimeUnit; + +import io.grpc.BindableService; +import io.grpc.Codec; +import io.grpc.CompressorRegistry; +import io.grpc.DecompressorRegistry; +import io.grpc.Grpc; +import io.grpc.ServerBuilder; +import io.grpc.ServerServiceDefinition; +import io.grpc.ServiceDescriptor; +import io.grpc.netty.NettyServerBuilder; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.stubbing.Answer; + +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.AbstractApplicationContextRunner; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.grpc.server.GrpcServerFactory; +import org.springframework.grpc.server.InProcessGrpcServerFactory; +import org.springframework.grpc.server.NettyGrpcServerFactory; +import org.springframework.grpc.server.ServerBuilderCustomizer; +import org.springframework.grpc.server.ServerServiceDefinitionFilter; +import org.springframework.grpc.server.ShadedNettyGrpcServerFactory; +import org.springframework.grpc.server.lifecycle.GrpcServerLifecycle; +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; +import org.springframework.grpc.server.service.ServerInterceptorFilter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GrpcServerAutoConfiguration}. + * + * @author Chris Bono + * @author Andrey Litvitski + */ +@SuppressWarnings("rawtypes") +class GrpcServerAutoConfigurationTests { + + private final BindableService service = mock(); + + private final ServerServiceDefinition serviceDefinition = ServerServiceDefinition.builder("my-service").build(); + + @BeforeEach + void prepareForTest() { + given(this.service.bindService()).willReturn(this.serviceDefinition); + } + + private ApplicationContextRunner contextRunner() { + // NOTE: we use noop server lifecycle to avoid startup + ApplicationContextRunner runner = new ApplicationContextRunner(); + return contextRunner(runner); + } + + private ApplicationContextRunner contextRunner(ApplicationContextRunner runner) { + return runner + .withConfiguration(AutoConfigurations.of(GrpcServerAutoConfiguration.class, + GrpcServerFactoryAutoConfiguration.class, SslAutoConfiguration.class)) + .withBean("shadedNettyGrpcServerLifecycle", GrpcServerLifecycle.class, Mockito::mock) + .withBean("nettyGrpcServerLifecycle", GrpcServerLifecycle.class, Mockito::mock) + .withBean("inProcessGrpcServerLifecycle", GrpcServerLifecycle.class, Mockito::mock) + .withBean(BindableService.class, () -> this.service); + } + + private WebApplicationContextRunner webContextRunner(WebApplicationContextRunner runner) { + return runner + .withConfiguration(AutoConfigurations.of(GrpcServerAutoConfiguration.class, + GrpcServerFactoryAutoConfiguration.class, SslAutoConfiguration.class)) + .withBean("shadedNettyGrpcServerLifecycle", GrpcServerLifecycle.class, Mockito::mock) + .withBean("nettyGrpcServerLifecycle", GrpcServerLifecycle.class, Mockito::mock) + .withBean("inProcessGrpcServerLifecycle", GrpcServerLifecycle.class, Mockito::mock) + .withBean(BindableService.class, () -> this.service); + } + + private ApplicationContextRunner contextRunnerWithLifecyle() { + // NOTE: we use noop server lifecycle to avoid startup + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcServerAutoConfiguration.class, + GrpcServerFactoryAutoConfiguration.class, SslAutoConfiguration.class)) + .withBean(BindableService.class, () -> this.service); + } + + @Test + void whenGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner() + .withClassLoader(new FilteredClassLoader(BindableService.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerAutoConfiguration.class)); + } + + @Test + void whenSpringGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner() + .withClassLoader(new FilteredClassLoader(GrpcServerFactory.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerAutoConfiguration.class)); + } + + @Test + void whenNoBindableServicesRegisteredAutoConfigurationIsSkipped() { + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(GrpcServerAutoConfiguration.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertySetFalseThenAutoConfigurationIsSkipped() { + this.contextRunner() + .withPropertyValues("spring.grpc.server.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertyNotSetThenAutoConfigurationIsNotSkipped() { + this.contextRunner().run((context) -> assertThat(context).hasSingleBean(GrpcServerAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertySetTrueThenAutoConfigurationIsNotSkipped() { + this.contextRunner() + .withPropertyValues("spring.grpc.server.enabled=true") + .run((context) -> assertThat(context).hasSingleBean(GrpcServerAutoConfiguration.class)); + } + + @Test + void whenHasUserDefinedGrpcServiceDiscovererDoesNotAutoConfigureBean() { + GrpcServiceDiscoverer customGrpcServiceDiscoverer = mock(GrpcServiceDiscoverer.class); + this.contextRunnerWithLifecyle() + .withBean("customGrpcServiceDiscoverer", GrpcServiceDiscoverer.class, () -> customGrpcServiceDiscoverer) + .withPropertyValues("spring.grpc.server.port=0") + .run((context) -> assertThat(context).getBean(GrpcServiceDiscoverer.class) + .isSameAs(customGrpcServiceDiscoverer)); + } + + @Test + void grpcServiceDiscovererAutoConfiguredAsExpected() { + this.contextRunnerWithLifecyle() + .withPropertyValues("spring.grpc.server.port=0") + .run((context) -> assertThat(context).getBean(GrpcServiceDiscoverer.class) + .isInstanceOf(DefaultGrpcServiceDiscoverer.class)); + } + + @Test + void whenHasUserDefinedServerBuilderCustomizersDoesNotAutoConfigureBean() { + ServerBuilderCustomizers customCustomizers = mock(ServerBuilderCustomizers.class); + this.contextRunner() + .withBean("customCustomizers", ServerBuilderCustomizers.class, () -> customCustomizers) + .run((context) -> assertThat(context).getBean(ServerBuilderCustomizers.class).isSameAs(customCustomizers)); + } + + @Test + void serverBuilderCustomizersAutoConfiguredAsExpected() { + this.contextRunner() + .withUserConfiguration(ServerBuilderCustomizersConfig.class) + .run((context) -> assertThat(context).getBean(ServerBuilderCustomizers.class) + .extracting("customizers", InstanceOfAssertFactories.list(ServerBuilderCustomizer.class)) + .contains(ServerBuilderCustomizersConfig.CUSTOMIZER_BAR, + ServerBuilderCustomizersConfig.CUSTOMIZER_FOO)); + } + + @Test + void whenHasUserDefinedServerFactoryDoesNotAutoConfigureBean() { + GrpcServerFactory customServerFactory = mock(GrpcServerFactory.class); + this.contextRunner() + .withBean("customServerFactory", GrpcServerFactory.class, () -> customServerFactory) + .run((context) -> assertThat(context).getBean(GrpcServerFactory.class).isSameAs(customServerFactory)); + } + + @Test + void userDefinedServerFactoryWithInProcessServerFactory() { + GrpcServerFactory customServerFactory = mock(GrpcServerFactory.class); + this.contextRunner() + .withPropertyValues("spring.grpc.server.inprocess.name=foo") + .withBean("customServerFactory", GrpcServerFactory.class, () -> customServerFactory) + .run((context) -> assertThat(context).getBeans(GrpcServerFactory.class) + .containsOnlyKeys("customServerFactory", "inProcessGrpcServerFactory")); + } + + @Test + void whenShadedAndNonShadedNettyOnClasspathShadedNettyFactoryIsAutoConfigured() { + this.contextRunner() + .run((context) -> assertThat(context).getBean(GrpcServerFactory.class) + .isInstanceOf(ShadedNettyGrpcServerFactory.class)); + } + + @Test + void shadedNettyFactoryWithInProcessServerFactory() { + this.contextRunner() + .withPropertyValues("spring.grpc.server.inprocess.name=foo") + .run((context) -> assertThat(context).getBeans(GrpcServerFactory.class) + .containsOnlyKeys("shadedNettyGrpcServerFactory", "inProcessGrpcServerFactory")); + } + + @Test + void whenOnlyNonShadedNettyOnClasspathNonShadedNettyFactoryIsAutoConfigured() { + this.contextRunner() + .withClassLoader(new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)) + .run((context) -> assertThat(context).getBean(GrpcServerFactory.class) + .isInstanceOf(NettyGrpcServerFactory.class)); + } + + @Test + void nonShadedNettyFactoryWithInProcessServerFactory() { + this.contextRunner() + .withClassLoader(new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)) + .withPropertyValues("spring.grpc.server.inprocess.name=foo") + .run((context) -> assertThat(context).getBeans(GrpcServerFactory.class) + .containsOnlyKeys("nettyGrpcServerFactory", "inProcessGrpcServerFactory")); + } + + @Test + void whenShadedNettyAndNettyNotOnClasspathNoServerFactoryIsAutoConfigured() { + this.contextRunner() + .withClassLoader(new FilteredClassLoader(NettyServerBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerFactory.class)); + } + + @Test + void noServerFactoryWithInProcessServerFactory() { + this.contextRunner() + .withClassLoader(new FilteredClassLoader(NettyServerBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)) + .withPropertyValues("spring.grpc.server.inprocess.name=foo") + .run((context) -> assertThat(context).getBean(GrpcServerFactory.class) + .isInstanceOf(InProcessGrpcServerFactory.class)); + } + + @Test + void shadedNettyServerFactoryAutoConfiguredWithCustomLifecycle() { + GrpcServerLifecycle customServerLifecycle = mock(GrpcServerLifecycle.class); + this.contextRunnerWithLifecyle() + .withBean("shadedNettyGrpcServerLifecycle", GrpcServerLifecycle.class, () -> customServerLifecycle) + .run((context) -> { + assertThat(context).getBean(GrpcServerFactory.class).isInstanceOf(ShadedNettyGrpcServerFactory.class); + assertThat(context).getBean("shadedNettyGrpcServerLifecycle", GrpcServerLifecycle.class) + .isSameAs(customServerLifecycle); + }); + } + + @Test + void nettyServerFactoryAutoConfiguredWithCustomLifecycle() { + GrpcServerLifecycle customServerLifecycle = mock(GrpcServerLifecycle.class); + this.contextRunnerWithLifecyle() + .withClassLoader(new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)) + .withBean("nettyGrpcServerLifecycle", GrpcServerLifecycle.class, () -> customServerLifecycle) + .run((context) -> { + assertThat(context).getBean(GrpcServerFactory.class).isInstanceOf(NettyGrpcServerFactory.class); + assertThat(context).getBean("nettyGrpcServerLifecycle", GrpcServerLifecycle.class) + .isSameAs(customServerLifecycle); + }); + } + + @Test + void inProcessServerFactoryAutoConfiguredWithCustomLifecycle() { + GrpcServerLifecycle customServerLifecycle = mock(GrpcServerLifecycle.class); + this.contextRunnerWithLifecyle() + .withPropertyValues("spring.grpc.server.inprocess.name=foo") + .withClassLoader(new FilteredClassLoader(NettyServerBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)) + .withBean("inProcessGrpcServerLifecycle", GrpcServerLifecycle.class, () -> customServerLifecycle) + .run((context) -> { + assertThat(context).getBean(GrpcServerFactory.class).isInstanceOf(InProcessGrpcServerFactory.class); + assertThat(context).getBean("inProcessGrpcServerLifecycle", GrpcServerLifecycle.class) + .isSameAs(customServerLifecycle); + }); + } + + @Test + void shadedNettyServerFactoryAutoConfiguredAsExpected() { + serverFactoryAutoConfiguredAsExpected( + this.contextRunner() + .withPropertyValues("spring.grpc.server.host=myhost", "spring.grpc.server.port=6160"), + ShadedNettyGrpcServerFactory.class, "myhost:6160", "shadedNettyGrpcServerLifecycle"); + } + + @Test + void serverFactoryAutoConfiguredInWebAppWhenServletDisabled() { + serverFactoryAutoConfiguredAsExpected( + this.webContextRunner(new WebApplicationContextRunner()) + .withPropertyValues("spring.grpc.server.host=myhost", "spring.grpc.server.port=6160") + .withPropertyValues("spring.grpc.server.servlet.enabled=false"), + GrpcServerFactory.class, "myhost:6160", "shadedNettyGrpcServerLifecycle"); + } + + @Test + void nettyServerFactoryAutoConfiguredAsExpected() { + serverFactoryAutoConfiguredAsExpected(this.contextRunner() + .withPropertyValues("spring.grpc.server.host=myhost", "spring.grpc.server.port=6160") + .withClassLoader(new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)), + NettyGrpcServerFactory.class, "myhost:6160", "nettyGrpcServerLifecycle"); + } + + @Test + void inProcessServerFactoryAutoConfiguredAsExpected() { + serverFactoryAutoConfiguredAsExpected( + this.contextRunner() + .withPropertyValues("spring.grpc.server.inprocess.name=foo") + .withClassLoader(new FilteredClassLoader(NettyServerBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)), + InProcessGrpcServerFactory.class, "foo", "inProcessGrpcServerLifecycle"); + } + + private void serverFactoryAutoConfiguredAsExpected(AbstractApplicationContextRunner contextRunner, + Class expectedServerFactoryType, String expectedAddress, String expectedLifecycleBeanName) { + contextRunner.run((context) -> { + assertThat(context).getBean(GrpcServerFactory.class) + .isInstanceOf(expectedServerFactoryType) + .hasFieldOrPropertyWithValue("address", expectedAddress) + .extracting("serviceList", InstanceOfAssertFactories.list(ServerServiceDefinition.class)) + .singleElement() + .extracting(ServerServiceDefinition::getServiceDescriptor) + .extracting(ServiceDescriptor::getName) + .isEqualTo("my-service"); + assertThat(context).getBean(expectedLifecycleBeanName, GrpcServerLifecycle.class).isNotNull(); + }); + } + + @Test + void shadedNettyServerFactoryAutoConfiguredWithCustomizers() { + io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder builder = mock(); + serverFactoryAutoConfiguredWithCustomizers(this.contextRunnerWithLifecyle(), builder, + ShadedNettyGrpcServerFactory.class); + } + + @Test + void nettyServerFactoryAutoConfiguredWithCustomizers() { + // FilteredClassLoader hides the class from the auto-configuration but not from + // the Java SPI used by ServerBuilder.forPort(int) which by default returns + // shaded Netty. This results in class cast exception when + // NettyGrpcServerFactory is expecting a non-shaded server builder. We static + // mock the builder to return non-shaded Netty - which would happen in + // real world. + try (MockedStatic serverBuilderForPort = Mockito.mockStatic(Grpc.class)) { + serverBuilderForPort.when(() -> Grpc.newServerBuilderForPort(anyInt(), any())) + .thenAnswer((Answer) (invocation) -> NettyServerBuilder + .forPort(invocation.getArgument(0))); + NettyServerBuilder builder = mock(); + serverFactoryAutoConfiguredWithCustomizers(this.contextRunnerWithLifecyle() + .withClassLoader(new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)), + builder, NettyGrpcServerFactory.class); + } + } + + @SuppressWarnings("unchecked") + private > void serverFactoryAutoConfiguredWithCustomizers( + ApplicationContextRunner contextRunner, ServerBuilder mockServerBuilder, + Class expectedServerFactoryType) { + ServerBuilderCustomizer customizer1 = (serverBuilder) -> serverBuilder.keepAliveTime(40L, TimeUnit.SECONDS); + ServerBuilderCustomizer customizer2 = (serverBuilder) -> serverBuilder.keepAliveTime(50L, TimeUnit.SECONDS); + ServerBuilderCustomizers customizers = new ServerBuilderCustomizers(List.of(customizer1, customizer2)); + contextRunner.withPropertyValues("spring.grpc.server.port=0", "spring.grpc.server.keep-alive.time=30s") + .withBean("serverBuilderCustomizers", ServerBuilderCustomizers.class, () -> customizers) + .run((context) -> assertThat(context).getBean(GrpcServerFactory.class) + .isInstanceOf(expectedServerFactoryType) + .extracting("serverBuilderCustomizers", InstanceOfAssertFactories.list(ServerBuilderCustomizer.class)) + .satisfies((allCustomizers) -> { + allCustomizers.forEach((c) -> c.customize(mockServerBuilder)); + InOrder ordered = inOrder(mockServerBuilder); + ordered.verify(mockServerBuilder) + .keepAliveTime(Duration.ofSeconds(30L).toNanos(), TimeUnit.NANOSECONDS); + ordered.verify(mockServerBuilder).keepAliveTime(40L, TimeUnit.SECONDS); + ordered.verify(mockServerBuilder).keepAliveTime(50L, TimeUnit.SECONDS); + })); + } + + @Test + void nettyServerFactoryAutoConfiguredWithSsl() { + serverFactoryAutoConfiguredAsExpected(this.contextRunner() + .withPropertyValues("spring.grpc.server.ssl.bundle=ssltest", + "spring.ssl.bundle.jks.ssltest.keystore.location=classpath:org/springframework/boot/grpc/server/autoconfigure/test.jks", + "spring.ssl.bundle.jks.ssltest.keystore.password=secret", + "spring.ssl.bundle.jks.ssltest.key.password=password", "spring.grpc.server.host=myhost", + "spring.grpc.server.port=6160") + .withClassLoader(new FilteredClassLoader(io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)), + NettyGrpcServerFactory.class, "myhost:6160", "nettyGrpcServerLifecycle"); + } + + @Nested + class WithCodecConfiguration { + + @SuppressWarnings("unchecked") + @Test + void compressionCustomizerAutoConfiguredAsExpected() { + GrpcServerAutoConfigurationTests.this.contextRunner().run((context) -> { + assertThat(context).getBean("compressionServerConfigurer", ServerBuilderCustomizer.class).isNotNull(); + var customizer = context.getBean("compressionServerConfigurer", ServerBuilderCustomizer.class); + var compressorRegistry = context.getBean(CompressorRegistry.class); + ServerBuilder builder = mock(); + customizer.customize(builder); + then(builder).should().compressorRegistry(compressorRegistry); + }); + } + + @Test + void whenNoCompressorRegistryThenCompressionCustomizerIsNotConfigured() { + // Codec class guards the imported GrpcCodecConfiguration which provides the + // registry + GrpcServerAutoConfigurationTests.this.contextRunner() + .withClassLoader(new FilteredClassLoader(Codec.class)) + .run((context) -> assertThat(context) + .getBean("compressionServerConfigurer", ServerBuilderCustomizer.class) + .isNull()); + } + + @SuppressWarnings("unchecked") + @Test + void decompressionCustomizerAutoConfiguredAsExpected() { + GrpcServerAutoConfigurationTests.this.contextRunner().run((context) -> { + assertThat(context).getBean("decompressionServerConfigurer", ServerBuilderCustomizer.class).isNotNull(); + var customizer = context.getBean("decompressionServerConfigurer", ServerBuilderCustomizer.class); + var decompressorRegistry = context.getBean(DecompressorRegistry.class); + ServerBuilder builder = mock(); + customizer.customize(builder); + then(builder).should().decompressorRegistry(decompressorRegistry); + }); + } + + @Test + void whenNoDecompressorRegistryThenDecompressionCustomizerIsNotConfigured() { + // Codec class guards the imported GrpcCodecConfiguration which provides the + // registry + GrpcServerAutoConfigurationTests.this.contextRunner() + .withClassLoader(new FilteredClassLoader(Codec.class)) + .run((context) -> assertThat(context) + .getBean("decompressionClientCustomizer", ServerBuilderCustomizer.class) + .isNull()); + } + + } + + @Nested + class WithAllFactoriesServiceFilterAutoConfig { + + @Test + void whenNoServiceFilterThenFactoryUsesNoFilter() { + GrpcServerAutoConfigurationTests.this.contextRunner() + .withPropertyValues("spring.grpc.server.inprocess.name=foo") + .withClassLoader(new FilteredClassLoader(NettyServerBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)) + .run((context) -> assertThat(context).getBean(GrpcServerFactory.class) + .isInstanceOf(InProcessGrpcServerFactory.class) + .extracting("serviceFilter") + .isNull()); + } + + @Test + void whenUniqueServiceFilterThenFactoryUsesFilter() { + ServerServiceDefinitionFilter serviceFilter = mock(); + GrpcServerAutoConfigurationTests.this.contextRunner() + .withPropertyValues("spring.grpc.server.inprocess.name=foo") + .withClassLoader(new FilteredClassLoader(NettyServerBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)) + .withBean(ServerServiceDefinitionFilter.class, () -> serviceFilter) + .run((context) -> assertThat(context).getBean(GrpcServerFactory.class) + .isInstanceOf(InProcessGrpcServerFactory.class) + .extracting("serviceFilter") + .isSameAs(serviceFilter)); + } + + @Test + void whenMultipleServiceFiltersThenThrowsException() { + GrpcServerAutoConfigurationTests.this.contextRunnerWithLifecyle() + .withPropertyValues("spring.grpc.server.inprocess.name=foo") + .withClassLoader(new FilteredClassLoader(NettyServerBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)) + .withBean("filter1", ServerServiceDefinitionFilter.class, Mockito::mock) + .withBean("filter2", ServerServiceDefinitionFilter.class, Mockito::mock) + .run((context) -> assertThat(context).hasFailed() + .getFailure() + .hasMessageContaining("expected single matching bean but found 2: filter1,filter2")); + } + + } + + @Nested + class WithGrpcServiceConfigurerAutoConfig { + + @Test + void whenHasUserDefinedBeanDoesNotAutoConfigureBean() { + GrpcServiceConfigurer customGrpcServiceConfigurer = mock(GrpcServiceConfigurer.class); + GrpcServerAutoConfigurationTests.this.contextRunner() + .withBean("customGrpcServiceConfigurer", GrpcServiceConfigurer.class, () -> customGrpcServiceConfigurer) + .run((context) -> assertThat(context).getBean(GrpcServiceConfigurer.class) + .isSameAs(customGrpcServiceConfigurer)); + } + + @Test + void configurerAutoConfiguredAsExpected() { + GrpcServerAutoConfigurationTests.this.contextRunnerWithLifecyle() + .withPropertyValues("spring.grpc.server.port=0") + .run((context) -> assertThat(context).getBean(GrpcServiceConfigurer.class) + .isInstanceOf(DefaultGrpcServiceConfigurer.class)); + } + + @Test + void whenNoServerInterceptorFilterThenConfigurerUsesNoFilter() { + GrpcServerAutoConfigurationTests.this.contextRunnerWithLifecyle() + .withPropertyValues("spring.grpc.server.inprocess.name=foo") + .withClassLoader(new FilteredClassLoader(NettyServerBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)) + .run((context) -> assertThat(context).getBean(InProcessGrpcServerFactory.class) + .extracting("interceptorFilter") + .isNull()); + } + + @Test + void whenUniqueServerInterceptorFilterThenConfigurerUsesFilter() { + ServerInterceptorFilter interceptorFilter = mock(); + GrpcServerAutoConfigurationTests.this.contextRunnerWithLifecyle() + .withPropertyValues("spring.grpc.server.inprocess.name=foo") + .withClassLoader(new FilteredClassLoader(NettyServerBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)) + .withBean(ServerInterceptorFilter.class, () -> interceptorFilter) + .run((context) -> assertThat(context).getBean(InProcessGrpcServerFactory.class) + .extracting("interceptorFilter") + .isSameAs(interceptorFilter)); + } + + @Test + void whenMultipleServerInterceptorFiltersThenThrowsException() { + GrpcServerAutoConfigurationTests.this.contextRunnerWithLifecyle() + .withPropertyValues("spring.grpc.server.inprocess.name=foo") + .withClassLoader(new FilteredClassLoader(NettyServerBuilder.class, + io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)) + .withBean("filter1", ServerInterceptorFilter.class, Mockito::mock) + .withBean("filter2", ServerInterceptorFilter.class, Mockito::mock) + .run((context) -> assertThat(context).hasFailed() + .getFailure() + .hasMessageContaining("expected single matching bean but found 2: filter1,filter2")); + } + + } + + @Configuration(proxyBeanMethods = false) + static class ServerBuilderCustomizersConfig { + + static ServerBuilderCustomizer CUSTOMIZER_FOO = mock(); + + static ServerBuilderCustomizer CUSTOMIZER_BAR = mock(); + + @Bean + @Order(200) + ServerBuilderCustomizer customizerFoo() { + return CUSTOMIZER_FOO; + } + + @Bean + @Order(100) + ServerBuilderCustomizer customizerBar() { + return CUSTOMIZER_BAR; + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerObservationAutoConfigurationTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerObservationAutoConfigurationTests.java new file mode 100644 index 000000000000..336724257976 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerObservationAutoConfigurationTests.java @@ -0,0 +1,133 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure; + +import java.util.List; +import java.util.Map; + +import io.grpc.BindableService; +import io.grpc.ServerInterceptor; +import io.micrometer.core.instrument.binder.grpc.ObservationGrpcServerInterceptor; +import io.micrometer.observation.ObservationRegistry; +import org.assertj.core.api.Condition; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.grpc.server.GlobalServerInterceptor; +import org.springframework.grpc.server.GrpcServerFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for the {@link GrpcServerObservationAutoConfiguration}. + */ +class GrpcServerObservationAutoConfigurationTests { + + private final ApplicationContextRunner baseContextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcServerObservationAutoConfiguration.class)); + + private ApplicationContextRunner validContextRunner() { + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcServerObservationAutoConfiguration.class)) + .withBean("observationRegistry", ObservationRegistry.class, Mockito::mock); + } + + @Test + void whenGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.validContextRunner() + .withClassLoader(new FilteredClassLoader(BindableService.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerObservationAutoConfiguration.class)); + } + + @Test + void whenSpringGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.validContextRunner() + .withClassLoader(new FilteredClassLoader(GrpcServerFactory.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerObservationAutoConfiguration.class)); + } + + @Test + void whenObservationRegistryNotOnClasspathAutoConfigSkipped() { + this.validContextRunner() + .withClassLoader(new FilteredClassLoader(ObservationRegistry.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerObservationAutoConfiguration.class)); + } + + @Test + void whenObservationGrpcServerInterceptorNotOnClasspathAutoConfigSkipped() { + this.validContextRunner() + .withClassLoader(new FilteredClassLoader(ObservationGrpcServerInterceptor.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerObservationAutoConfiguration.class)); + } + + @Test + void whenObservationRegistryNotProvidedThenAutoConfigSkipped() { + this.baseContextRunner + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerObservationAutoConfiguration.class)); + } + + @Test + void whenObservationPropertyEnabledThenAutoConfigNotSkipped() { + this.validContextRunner() + .withPropertyValues("spring.grpc.server.observation.enabled=true") + .run((context) -> assertThat(context).hasSingleBean(GrpcServerObservationAutoConfiguration.class)); + } + + @Test + void whenObservationPropertyDisabledThenAutoConfigIsSkipped() { + this.validContextRunner() + .withPropertyValues("spring.grpc.server.observation.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerObservationAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertySetFalseThenAutoConfigurationIsSkipped() { + this.validContextRunner() + .withPropertyValues("spring.grpc.server.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerObservationAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertyNotSetThenAutoConfigurationIsNotSkipped() { + this.validContextRunner() + .run((context) -> assertThat(context).hasSingleBean(GrpcServerObservationAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertySetTrueThenAutoConfigurationIsNotSkipped() { + this.validContextRunner() + .withPropertyValues("spring.grpc.server.enabled=true") + .run((context) -> assertThat(context).hasSingleBean(GrpcServerObservationAutoConfiguration.class)); + } + + @Test + void whenAllConditionsAreMetThenInterceptorConfiguredAsExpected() { + this.validContextRunner() + .run((context) -> assertThat(context).hasSingleBean(ObservationGrpcServerInterceptor.class) + .has(new Condition<>((beans) -> { + Map annotated = beans.getBeansWithAnnotation(GlobalServerInterceptor.class); + List interceptors = beans.getBeanProvider(ServerInterceptor.class) + .orderedStream() + .toList(); + return annotated.size() == 2 && interceptors.get(0) instanceof ObservationGrpcServerInterceptor; + }, "Two global interceptors expected"))); + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerPropertiesTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerPropertiesTests.java new file mode 100644 index 000000000000..22423827e8e8 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerPropertiesTests.java @@ -0,0 +1,192 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; +import org.springframework.util.unit.DataSize; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link GrpcServerProperties}. + * + * @author Chris Bono + */ +class GrpcServerPropertiesTests { + + private GrpcServerProperties bindProperties(Map map) { + return new Binder(new MapConfigurationPropertySource(map)) + .bind("spring.grpc.server", GrpcServerProperties.class) + .get(); + } + + @Nested + class BaseProperties { + + @Test + void bind() { + Map map = new HashMap<>(); + map.put("spring.grpc.server.host", "my-server-ip"); + map.put("spring.grpc.server.port", "3130"); + map.put("spring.grpc.server.shutdown-grace-period", "15"); + GrpcServerProperties properties = bindProperties(map); + assertThat(properties.getHost()).isEqualTo("my-server-ip"); + assertThat(properties.getPort()).isEqualTo(3130); + assertThat(properties.getAddress()).isNull(); + assertThat(properties.determineAddress()).isEqualTo("my-server-ip:3130"); + assertThat(properties.getShutdownGracePeriod()).isEqualTo(Duration.ofSeconds(15)); + } + + } + + @Nested + class HealthProperties { + + @Test + void bindWithNoSettings() { + Map map = new HashMap<>(); + map.put("spring.grpc.server.host", "my-server-ip"); + GrpcServerProperties.Health properties = bindProperties(map).getHealth(); + assertThat(properties.getEnabled()).isTrue(); + assertThat(properties.getActuator().getEnabled()).isTrue(); + assertThat(properties.getActuator().getHealthIndicatorPaths()).isEmpty(); + assertThat(properties.getActuator().getUpdateOverallHealth()).isTrue(); + assertThat(properties.getActuator().getUpdateRate()).isEqualTo(Duration.ofSeconds(5)); + assertThat(properties.getActuator().getUpdateInitialDelay()).isEqualTo(Duration.ofSeconds(5)); + } + + @Test + void bindWithoutUnitsSpecified() { + Map map = new HashMap<>(); + map.put("spring.grpc.server.health.enabled", "false"); + map.put("spring.grpc.server.health.actuator.enabled", "false"); + map.put("spring.grpc.server.health.actuator.health-indicator-paths", "a,b,c"); + map.put("spring.grpc.server.health.actuator.update-overall-health", "false"); + map.put("spring.grpc.server.health.actuator.update-rate", "2s"); + map.put("spring.grpc.server.health.actuator.update-initial-delay", "1m"); + GrpcServerProperties.Health properties = bindProperties(map).getHealth(); + assertThat(properties.getEnabled()).isFalse(); + assertThat(properties.getActuator().getEnabled()).isFalse(); + assertThat(properties.getActuator().getHealthIndicatorPaths()).containsExactly("a", "b", "c"); + assertThat(properties.getActuator().getUpdateOverallHealth()).isFalse(); + assertThat(properties.getActuator().getUpdateRate()).isEqualTo(Duration.ofSeconds(2)); + assertThat(properties.getActuator().getUpdateInitialDelay()).isEqualTo(Duration.ofMinutes(1)); + } + + } + + @Nested + class KeepAliveProperties { + + @Test + void bind() { + Map map = new HashMap<>(); + map.put("spring.grpc.server.keep-alive.time", "45m"); + map.put("spring.grpc.server.keep-alive.timeout", "40s"); + map.put("spring.grpc.server.keep-alive.max-idle", "1h"); + map.put("spring.grpc.server.keep-alive.max-age", "3h"); + map.put("spring.grpc.server.keep-alive.max-age-grace", "21s"); + map.put("spring.grpc.server.keep-alive.permit-time", "33s"); + map.put("spring.grpc.server.keep-alive.permit-without-calls", "true"); + GrpcServerProperties.KeepAlive properties = bindProperties(map).getKeepAlive(); + assertThatPropertiesSetAsExpected(properties); + } + + @Test + void bindWithoutUnitsSpecified() { + Map map = new HashMap<>(); + map.put("spring.grpc.server.keep-alive.time", "2700"); + map.put("spring.grpc.server.keep-alive.timeout", "40"); + map.put("spring.grpc.server.keep-alive.max-idle", "3600"); + map.put("spring.grpc.server.keep-alive.max-age", "10800"); + map.put("spring.grpc.server.keep-alive.max-age-grace", "21"); + map.put("spring.grpc.server.keep-alive.permit-time", "33"); + map.put("spring.grpc.server.keep-alive.permit-without-calls", "true"); + GrpcServerProperties.KeepAlive properties = bindProperties(map).getKeepAlive(); + assertThatPropertiesSetAsExpected(properties); + } + + private void assertThatPropertiesSetAsExpected(GrpcServerProperties.KeepAlive properties) { + assertThat(properties.getTime()).isEqualTo(Duration.ofMinutes(45)); + assertThat(properties.getTimeout()).isEqualTo(Duration.ofSeconds(40)); + assertThat(properties.getMaxIdle()).isEqualTo(Duration.ofHours(1)); + assertThat(properties.getMaxAge()).isEqualTo(Duration.ofHours(3)); + assertThat(properties.getMaxAgeGrace()).isEqualTo(Duration.ofSeconds(21)); + assertThat(properties.getPermitTime()).isEqualTo(Duration.ofSeconds(33)); + assertThat(properties.isPermitWithoutCalls()).isTrue(); + } + + } + + @Nested + class InboundLimitsProperties { + + @Test + void bind() { + Map map = new HashMap<>(); + map.put("spring.grpc.server.max-inbound-message-size", "20MB"); + map.put("spring.grpc.server.max-inbound-metadata-size", "1MB"); + GrpcServerProperties properties = bindProperties(map); + assertThat(properties.getMaxInboundMessageSize()).isEqualTo(DataSize.ofMegabytes(20)); + assertThat(properties.getMaxInboundMetadataSize()).isEqualTo(DataSize.ofMegabytes(1)); + } + + @Test + void bindWithoutUnits() { + Map map = new HashMap<>(); + map.put("spring.grpc.server.max-inbound-message-size", "1048576"); + map.put("spring.grpc.server.max-inbound-metadata-size", "1024"); + GrpcServerProperties properties = bindProperties(map); + assertThat(properties.getMaxInboundMessageSize()).isEqualTo(DataSize.ofMegabytes(1)); + assertThat(properties.getMaxInboundMetadataSize()).isEqualTo(DataSize.ofKilobytes(1)); + } + + } + + @Nested + class AddressProperties { + + @Test + void bind() { + Map map = new HashMap<>(); + map.put("spring.grpc.server.address", "my-server-ip:3130"); + GrpcServerProperties properties = bindProperties(map); + assertThat(properties.getAddress()).isEqualTo("my-server-ip:3130"); + assertThat(properties.determineAddress()).isEqualTo("my-server-ip:3130"); + } + + @Test + void addressTakesPrecedenceOverHostAndPort() { + Map map = new HashMap<>(); + map.put("spring.grpc.server.address", "my-server-ip:3130"); + map.put("spring.grpc.server.host", "foo"); + map.put("spring.grpc.server.port", "10000"); + GrpcServerProperties properties = bindProperties(map); + assertThat(properties.getAddress()).isEqualTo("my-server-ip:3130"); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerReflectionAutoConfigurationTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerReflectionAutoConfigurationTests.java new file mode 100644 index 000000000000..c416e70586a9 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerReflectionAutoConfigurationTests.java @@ -0,0 +1,113 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure; + +import io.grpc.BindableService; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.grpc.server.GrpcServerFactory; +import org.springframework.grpc.server.lifecycle.GrpcServerLifecycle; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link GrpcServerReflectionAutoConfiguration}. + * + * @author Haris Zujo + * @author Chris Bono + * @author Andrey Litvitski + */ +class GrpcServerReflectionAutoConfigurationTests { + + private ApplicationContextRunner contextRunner() { + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcServerReflectionAutoConfiguration.class)) + .withBean("noopServerLifecycle", GrpcServerLifecycle.class, Mockito::mock) + .withBean(BindableService.class, Mockito::mock); + } + + @Test + void whenAutoConfigurationIsNotSkippedThenCreatesReflectionServiceBean() { + this.contextRunner().run((context) -> assertThat(context).hasBean("serverReflection")); + } + + @Test + void whenGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner() + .withClassLoader(new FilteredClassLoader(BindableService.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerReflectionAutoConfiguration.class)); + } + + @Test + void whenSpringGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner() + .withClassLoader(new FilteredClassLoader(GrpcServerFactory.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerReflectionAutoConfiguration.class)); + } + + @Test + void whenNoBindableServiceDefinedThenAutoConfigurationIsSkipped() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcServerReflectionAutoConfiguration.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerReflectionAutoConfiguration.class)); + } + + @Test + void whenReflectionEnabledPropertyNotSetThenAutoConfigurationIsNotSkipped() { + this.contextRunner() + .run((context) -> assertThat(context).hasSingleBean(GrpcServerReflectionAutoConfiguration.class)); + } + + @Test + void whenReflectionEnabledPropertySetTrueThenAutoConfigurationIsNotSkipped() { + this.contextRunner() + .withPropertyValues("spring.grpc.server.reflection.enabled=true") + .run((context) -> assertThat(context).hasSingleBean(GrpcServerReflectionAutoConfiguration.class)); + } + + @Test + void whenReflectionEnabledPropertySetFalseThenAutoConfigurationIsSkipped() { + this.contextRunner() + .withPropertyValues("spring.grpc.server.reflection.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerReflectionAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertyNotSetThenAutoConfigurationIsNotSkipped() { + this.contextRunner() + .run((context) -> assertThat(context).hasSingleBean(GrpcServerReflectionAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertySetTrueThenAutoConfigurationIsNotSkipped() { + this.contextRunner() + .withPropertyValues("spring.grpc.server.enabled=true") + .run((context) -> assertThat(context).hasSingleBean(GrpcServerReflectionAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertySetFalseThenAutoConfigurationIsSkipped() { + this.contextRunner() + .withPropertyValues("spring.grpc.server.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerReflectionAutoConfiguration.class)); + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServletAutoConfigurationTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServletAutoConfigurationTests.java new file mode 100644 index 000000000000..ffbd424a4eb1 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServletAutoConfigurationTests.java @@ -0,0 +1,122 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure; + +import io.grpc.BindableService; +import io.grpc.ServerServiceDefinition; +import io.grpc.internal.GrpcUtil; +import io.grpc.servlet.jakarta.GrpcServlet; +import io.grpc.servlet.jakarta.ServletServerBuilder; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerFactoryAutoConfiguration.GrpcServletConfiguration; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.grpc.server.GrpcServerFactory; +import org.springframework.grpc.server.ServerBuilderCustomizer; +import org.springframework.util.unit.DataSize; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GrpcServerAutoConfiguration}. + * + * @author Chris Bono + * @author Toshiaki Maki + */ +class GrpcServletAutoConfigurationTests { + + private WebApplicationContextRunner contextRunner() { + BindableService service = mock(); + ServerServiceDefinition serviceDefinition = ServerServiceDefinition.builder("my-service").build(); + given(service.bindService()).willReturn(serviceDefinition); + // NOTE: we use noop server lifecycle to avoid startup + return new WebApplicationContextRunner().withConfiguration(AutoConfigurations.of(SslAutoConfiguration.class, + GrpcServerAutoConfiguration.class, GrpcServerFactoryAutoConfiguration.class)) + .withBean(BindableService.class, () -> service); + } + + @Test + void whenGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner() + .withClassLoader(new FilteredClassLoader(BindableService.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServletConfiguration.class) + .doesNotHaveBean(ServletRegistrationBean.class)); + } + + @Test + void whenSpringGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner() + .withClassLoader(new FilteredClassLoader(GrpcServerFactory.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServletConfiguration.class)); + } + + @Test + void whenNoBindableServicesRegisteredAutoConfigurationIsSkipped() { + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(GrpcServerAutoConfiguration.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServletConfiguration.class) + .doesNotHaveBean(ServletRegistrationBean.class)); + } + + @Test + void whenGrpcServletNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner() + .withClassLoader(new FilteredClassLoader(GrpcServlet.class)) + .withPropertyValues("spring.grpc.server.port=0") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServletConfiguration.class) + .doesNotHaveBean(ServletRegistrationBean.class)); + } + + @Test + void whenWebApplicationServletIsAutoConfigured() { + this.contextRunner().run((context) -> assertThat(context).getBean(ServletRegistrationBean.class).isNotNull()); + } + + @Test + void whenCustomizerIsRegistered() { + ServerBuilderCustomizer customizer = mock(); + this.contextRunner() + .withBean(ServerBuilderCustomizer.class, () -> customizer) + .run((context) -> then(customizer).should().customize(any(ServletServerBuilder.class))); + } + + @Test + void whenMaxInboundMessageSizeIsSetThenItIsUsed() { + this.contextRunner() + .withPropertyValues("spring.grpc.server.max-inbound-message-size=10KB") + .run((context) -> assertThat(context).getBean(ServletRegistrationBean.class) + .hasFieldOrPropertyWithValue("servlet.servletAdapter.maxInboundMessageSize", + Math.toIntExact(DataSize.ofKilobytes(10).toBytes()))); + } + + @Test + void whenMaxInboundMessageSizeIsNotSetThenDefaultIsUsed() { + this.contextRunner() + .run((context) -> assertThat(context).getBean(ServletRegistrationBean.class) + .hasFieldOrPropertyWithValue("servlet.servletAdapter.maxInboundMessageSize", + GrpcUtil.DEFAULT_MAX_MESSAGE_SIZE)); + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/OnEnabledGrpcServerConditionTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/OnEnabledGrpcServerConditionTests.java new file mode 100644 index 000000000000..531be45103ff --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/OnEnabledGrpcServerConditionTests.java @@ -0,0 +1,146 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.Collections; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.condition.ConditionOutcome; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.type.AnnotatedTypeMetadata; +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link OnEnabledGrpcServerCondition}. + * + * @author Chris Bono + */ +class OnEnabledGrpcServerConditionTests { + + @Test + void shouldMatchIfNoPropertyIsSet() { + OnEnabledGrpcServerCondition condition = new OnEnabledGrpcServerCondition(); + ConditionOutcome outcome = condition.getMatchOutcome(mockConditionContext(), mockMetadata("")); + assertThat(outcome.isMatch()).isTrue(); + assertThat(outcome.getMessage()) + .isEqualTo("@ConditionalOnGrpcServerEnabled server and service are enabled by default"); + } + + @Test + void shouldMatchIfOnlyGlobalPropertyIsSetAndIsTrue() { + OnEnabledGrpcServerCondition condition = new OnEnabledGrpcServerCondition(); + ConditionOutcome outcome = condition + .getMatchOutcome(mockConditionContext(Map.of("spring.grpc.server.enabled", "true")), mockMetadata("")); + assertThat(outcome.isMatch()).isTrue(); + assertThat(outcome.getMessage()) + .isEqualTo("@ConditionalOnGrpcServerEnabled server and service are enabled by default"); + } + + @Test + void shouldNotMatchIfOnlyGlobalPropertyIsSetAndIsFalse() { + OnEnabledGrpcServerCondition condition = new OnEnabledGrpcServerCondition(); + ConditionOutcome outcome = condition + .getMatchOutcome(mockConditionContext(Map.of("spring.grpc.server.enabled", "false")), mockMetadata("")); + assertThat(outcome.isMatch()).isFalse(); + assertThat(outcome.getMessage()) + .isEqualTo("@ConditionalOnGrpcServerEnabled spring.grpc.server.enabled is false"); + } + + @Test + void shouldMatchIfOnlyServicePropertyIsSetAndIsTrue() { + OnEnabledGrpcServerCondition condition = new OnEnabledGrpcServerCondition(); + ConditionOutcome outcome = condition.getMatchOutcome( + mockConditionContext(Map.of("spring.grpc.server.myservice.enabled", "true")), + mockMetadata("myservice")); + assertThat(outcome.isMatch()).isTrue(); + assertThat(outcome.getMessage()) + .isEqualTo("@ConditionalOnGrpcServerEnabled spring.grpc.server.myservice.enabled is true"); + } + + @Test + void shouldNotMatchIfOnlyServicePropertyIsSetAndIsFalse() { + OnEnabledGrpcServerCondition condition = new OnEnabledGrpcServerCondition(); + ConditionOutcome outcome = condition.getMatchOutcome( + mockConditionContext(Map.of("spring.grpc.server.myservice.enabled", "false")), + mockMetadata("myservice")); + assertThat(outcome.isMatch()).isFalse(); + assertThat(outcome.getMessage()) + .isEqualTo("@ConditionalOnGrpcServerEnabled spring.grpc.server.myservice.enabled is false"); + } + + @Test + void shouldMatchIfGlobalPropertyIsTrueAndServicePropertyIsTrue() { + OnEnabledGrpcServerCondition condition = new OnEnabledGrpcServerCondition(); + ConditionOutcome outcome = condition.getMatchOutcome( + mockConditionContext( + Map.of("spring.grpc.server.enabled", "true", "spring.grpc.server.myservice.enabled", "true")), + mockMetadata("myservice")); + assertThat(outcome.isMatch()).isTrue(); + assertThat(outcome.getMessage()) + .isEqualTo("@ConditionalOnGrpcServerEnabled spring.grpc.server.myservice.enabled is true"); + } + + @Test + void shouldNotMatchIfGlobalPropertyIsTrueAndServicePropertyIsFalse() { + OnEnabledGrpcServerCondition condition = new OnEnabledGrpcServerCondition(); + ConditionOutcome outcome = condition.getMatchOutcome( + mockConditionContext( + Map.of("spring.grpc.server.enabled", "true", "spring.grpc.server.myservice.enabled", "false")), + mockMetadata("myservice")); + assertThat(outcome.isMatch()).isFalse(); + assertThat(outcome.getMessage()) + .isEqualTo("@ConditionalOnGrpcServerEnabled spring.grpc.server.myservice.enabled is false"); + } + + @Test + void shouldNotMatchIfGlobalPropertyIsFalseAndServicePropertyIsTrue() { + OnEnabledGrpcServerCondition condition = new OnEnabledGrpcServerCondition(); + ConditionOutcome outcome = condition.getMatchOutcome( + mockConditionContext( + Map.of("spring.grpc.server.enabled", "false", "spring.grpc.server.myservice.enabled", "true")), + mockMetadata("myservice")); + assertThat(outcome.isMatch()).isFalse(); + assertThat(outcome.getMessage()) + .isEqualTo("@ConditionalOnGrpcServerEnabled spring.grpc.server.enabled is false"); + } + + private ConditionContext mockConditionContext() { + return mockConditionContext(Collections.emptyMap()); + } + + private ConditionContext mockConditionContext(Map properties) { + ConditionContext context = mock(ConditionContext.class); + MockEnvironment environment = new MockEnvironment(); + properties.forEach(environment::setProperty); + given(context.getEnvironment()).willReturn(environment); + return context; + } + + private AnnotatedTypeMetadata mockMetadata(String serviceName) { + AnnotatedTypeMetadata metadata = mock(AnnotatedTypeMetadata.class); + given(metadata.getAnnotationAttributes(ConditionalOnGrpcServerEnabled.class.getName())) + .willReturn(Map.of("value", serviceName)); + return metadata; + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/ServerBuilderCustomizersTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/ServerBuilderCustomizersTests.java new file mode 100644 index 000000000000..43e762376180 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/ServerBuilderCustomizersTests.java @@ -0,0 +1,123 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.concurrent.TimeUnit; + +import io.grpc.ServerBuilder; +import io.grpc.netty.NettyServerBuilder; +import org.junit.jupiter.api.Test; + +import org.springframework.grpc.server.ServerBuilderCustomizer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link ServerBuilderCustomizers}. + * + * @author Chris Bono + */ +class ServerBuilderCustomizersTests { + + @Test + void customizeWithNullCustomizersShouldDoNothing() { + ServerBuilder serverBuilder = mock(ServerBuilder.class); + new ServerBuilderCustomizers(null).customize(serverBuilder); + then(serverBuilder).shouldHaveNoInteractions(); + } + + @Test + void customizeSimpleServerBuilder() { + ServerBuilderCustomizers customizers = new ServerBuilderCustomizers( + List.of(new SimpleServerBuilderCustomizer())); + NettyServerBuilder serverBuilder = mock(NettyServerBuilder.class); + customizers.customize(serverBuilder); + then(serverBuilder).should().maxConnectionAge(100L, TimeUnit.SECONDS); + } + + @Test + void customizeShouldCheckGeneric() { + List> list = new ArrayList<>(); + list.add(new TestCustomizer<>()); + list.add(new TestNettyServerBuilderCustomizer()); + list.add(new TestShadedNettyServerBuilderCustomizer()); + ServerBuilderCustomizers customizers = new ServerBuilderCustomizers(list); + + customizers.customize(mock(ServerBuilder.class)); + assertThat(list.get(0).getCount()).isOne(); + assertThat(list.get(1).getCount()).isZero(); + assertThat(list.get(2).getCount()).isZero(); + + customizers.customize(mock(NettyServerBuilder.class)); + assertThat(list.get(0).getCount()).isEqualTo(2); + assertThat(list.get(1).getCount()).isOne(); + assertThat(list.get(2).getCount()).isZero(); + + customizers.customize(mock(io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder.class)); + assertThat(list.get(0).getCount()).isEqualTo(3); + assertThat(list.get(1).getCount()).isOne(); + assertThat(list.get(2).getCount()).isOne(); + } + + static class SimpleServerBuilderCustomizer implements ServerBuilderCustomizer { + + @Override + public void customize(NettyServerBuilder serverBuilder) { + serverBuilder.maxConnectionAge(100, TimeUnit.SECONDS); + } + + } + + /** + * Test customizer that will match all {@link ServerBuilderCustomizer}. + */ + static class TestCustomizer> implements ServerBuilderCustomizer { + + private int count; + + @Override + public void customize(T serverBuilder) { + this.count++; + } + + int getCount() { + return this.count; + } + + } + + /** + * Test customizer that will match only {@link NettyServerBuilder}. + */ + static class TestNettyServerBuilderCustomizer extends TestCustomizer { + + } + + /** + * Test customizer that will match only + * {@link io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder}. + */ + static class TestShadedNettyServerBuilderCustomizer + extends TestCustomizer { + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/ServerFactoryPropertyMappersTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/ServerFactoryPropertyMappersTests.java new file mode 100644 index 000000000000..0cf7940cc6f4 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/ServerFactoryPropertyMappersTests.java @@ -0,0 +1,85 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.function.Supplier; + +import io.grpc.ServerBuilder; +import org.junit.jupiter.api.Test; + +import org.springframework.util.unit.DataSize; + +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link DefaultServerFactoryPropertyMapper}, + * {@link NettyServerFactoryPropertyMapper}, and + * {@link ShadedNettyServerFactoryPropertyMapper}. + * + * @author Chris Bono + */ +class ServerFactoryPropertyMappersTests { + + @Test + void customizeShadedNettyServerBuilder() { + io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder builder = mock(); + customizeServerBuilder(ShadedNettyServerFactoryPropertyMapper::new, () -> builder); + } + + @Test + void customizeNettyServerBuilder() { + io.grpc.netty.NettyServerBuilder builder = mock(); + customizeServerBuilder(NettyServerFactoryPropertyMapper::new, () -> builder); + } + + @Test + > void customizeBaseServerBuilder() { + T builder = mock(); + customizeServerBuilder(DefaultServerFactoryPropertyMapper::new, () -> builder); + } + + private , X extends DefaultServerFactoryPropertyMapper> void customizeServerBuilder( + Function mapperFactory, Supplier mockBuilderToCustomize) { + GrpcServerProperties properties = new GrpcServerProperties(); + properties.getKeepAlive().setTime(Duration.ofHours(1)); + properties.getKeepAlive().setTimeout(Duration.ofSeconds(10)); + properties.getKeepAlive().setMaxIdle(Duration.ofHours(2)); + properties.getKeepAlive().setMaxAge(Duration.ofHours(3)); + properties.getKeepAlive().setMaxAgeGrace(Duration.ofSeconds(45)); + properties.getKeepAlive().setPermitTime(Duration.ofMinutes(7)); + properties.getKeepAlive().setPermitWithoutCalls(true); + properties.setMaxInboundMessageSize(DataSize.ofMegabytes(333)); + properties.setMaxInboundMetadataSize(DataSize.ofKilobytes(111)); + X mapper = mapperFactory.apply(properties); + T builder = mockBuilderToCustomize.get(); + mapper.customizeServerBuilder(builder); + then(builder).should().keepAliveTime(Duration.ofHours(1).toNanos(), TimeUnit.NANOSECONDS); + then(builder).should().keepAliveTimeout(Duration.ofSeconds(10).toNanos(), TimeUnit.NANOSECONDS); + then(builder).should().maxConnectionIdle(Duration.ofHours(2).toNanos(), TimeUnit.NANOSECONDS); + then(builder).should().maxConnectionAge(Duration.ofHours(3).toNanos(), TimeUnit.NANOSECONDS); + then(builder).should().maxConnectionAgeGrace(Duration.ofSeconds(45).toNanos(), TimeUnit.NANOSECONDS); + then(builder).should().permitKeepAliveTime(Duration.ofMinutes(7).toNanos(), TimeUnit.NANOSECONDS); + then(builder).should().permitKeepAliveWithoutCalls(true); + then(builder).should().maxInboundMessageSize(Math.toIntExact(DataSize.ofMegabytes(333).toBytes())); + then(builder).should().maxInboundMetadataSize(Math.toIntExact(DataSize.ofKilobytes(111).toBytes())); + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/exception/GrpcExceptionHandlerAutoConfigurationTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/exception/GrpcExceptionHandlerAutoConfigurationTests.java new file mode 100644 index 000000000000..f28eed584181 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/exception/GrpcExceptionHandlerAutoConfigurationTests.java @@ -0,0 +1,128 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.exception; + +import io.grpc.BindableService; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.grpc.server.GrpcServerFactory; +import org.springframework.grpc.server.exception.GrpcExceptionHandler; +import org.springframework.grpc.server.exception.GrpcExceptionHandlerInterceptor; +import org.springframework.grpc.server.lifecycle.GrpcServerLifecycle; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link GrpcExceptionHandlerAutoConfiguration}. + * + * @author Chris Bono + */ +class GrpcExceptionHandlerAutoConfigurationTests { + + private ApplicationContextRunner contextRunner() { + // NOTE: we use noop server lifecycle to avoid startup + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcExceptionHandlerAutoConfiguration.class)) + .withBean("noopServerLifecycle", GrpcServerLifecycle.class, Mockito::mock) + .withBean("mockGrpcExceptionHandler", GrpcExceptionHandler.class, Mockito::mock); + } + + @Test + void whenGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner() + .withClassLoader(new FilteredClassLoader(BindableService.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcExceptionHandlerAutoConfiguration.class)); + } + + @Test + void whenSpringGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner() + .withClassLoader(new FilteredClassLoader(GrpcServerFactory.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcExceptionHandlerAutoConfiguration.class)); + } + + @Test + void whenNoGrpcExceptionHandlerRegisteredAutoConfigurationIsSkipped() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcExceptionHandlerAutoConfiguration.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcExceptionHandlerAutoConfiguration.class)); + } + + @Test + void whenExceptionHandlerPropertyNotSetExceptionHandlerIsAutoConfigured() { + this.contextRunner() + .run((context) -> assertThat(context).hasSingleBean(GrpcExceptionHandlerAutoConfiguration.class)); + } + + @Test + void whenExceptionHandlerPropertyIsTrueExceptionHandlerIsAutoConfigured() { + this.contextRunner() + .withPropertyValues("spring.grpc.server.exception-handler.enabled=true") + .run((context) -> assertThat(context).hasSingleBean(GrpcExceptionHandlerAutoConfiguration.class)); + } + + @Test + void whenExceptionHandlerPropertyIsFalseAutoConfigurationIsSkipped() { + this.contextRunner() + .withPropertyValues("spring.grpc.server.exception-handler.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcExceptionHandlerAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertySetFalseThenAutoConfigurationIsSkipped() { + this.contextRunner() + .withPropertyValues("spring.grpc.server.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcExceptionHandlerAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertyNotSetThenAutoConfigurationIsNotSkipped() { + this.contextRunner() + .run((context) -> assertThat(context).hasSingleBean(GrpcExceptionHandlerAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertySetTrueThenAutoConfigurationIsNotSkipped() { + this.contextRunner() + .withPropertyValues("spring.grpc.server.enabled=true") + .run((context) -> assertThat(context).hasSingleBean(GrpcExceptionHandlerAutoConfiguration.class)); + } + + @Test + void whenHasUserDefinedGrpcExceptionHandlerInterceptorDoesNotAutoConfigureBean() { + GrpcExceptionHandlerInterceptor customInterceptor = Mockito.mock(); + this.contextRunner() + .withBean("customInterceptor", GrpcExceptionHandlerInterceptor.class, () -> customInterceptor) + .run((context) -> assertThat(context).getBean(GrpcExceptionHandlerInterceptor.class) + .isSameAs(customInterceptor)); + } + + @Test + void exceptionHandlerInterceptorAutoConfiguredAsExpected() { + this.contextRunner() + .run((context) -> assertThat(context).getBean(GrpcExceptionHandlerInterceptor.class) + .extracting("exceptionHandler.exceptionHandlers", + InstanceOfAssertFactories.array(GrpcExceptionHandler[].class)) + .containsExactly(context.getBean(GrpcExceptionHandler.class))); + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/ActuatorHealthAdapterInvokerTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/ActuatorHealthAdapterInvokerTests.java new file mode 100644 index 000000000000..c905f9613f76 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/ActuatorHealthAdapterInvokerTests.java @@ -0,0 +1,52 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure.health; + +import java.time.Duration; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.task.SimpleAsyncTaskSchedulerBuilder; + +import static org.mockito.BDDMockito.atLeast; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link ActuatorHealthAdapterInvoker}. + */ +class ActuatorHealthAdapterInvokerTests { + + @Test + void healthAdapterInvokedOnSchedule() { + ActuatorHealthAdapter healthAdapter = mock(); + ActuatorHealthAdapterInvoker invoker = new ActuatorHealthAdapterInvoker(healthAdapter, + new SimpleAsyncTaskSchedulerBuilder(), Duration.ofSeconds(5), Duration.ofSeconds(3)); + try { + invoker.afterPropertiesSet(); + Awaitility.await() + .between(Duration.ofSeconds(6), Duration.ofSeconds(12)) + .untilAsserted(() -> then(healthAdapter).should(atLeast(2)).updateHealthStatus()); + } + finally { + invoker.destroy(); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/ActuatorHealthAdapterTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/ActuatorHealthAdapterTests.java new file mode 100644 index 000000000000..498688838a73 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/ActuatorHealthAdapterTests.java @@ -0,0 +1,155 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure.health; + +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import io.grpc.health.v1.HealthCheckResponse.ServingStatus; +import io.grpc.protobuf.services.HealthStatusManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.boot.actuate.health.HealthDescriptor; +import org.springframework.boot.actuate.health.HealthEndpoint; +import org.springframework.boot.actuate.health.StatusAggregator; +import org.springframework.boot.health.contributor.Status; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.never; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link ActuatorHealthAdapter}. + */ +class ActuatorHealthAdapterTests { + + private HealthStatusManager mockHealthStatusManager; + + private HealthEndpoint mockHealthEndpoint; + + private StatusAggregator mockStatusAggregator; + + @BeforeEach + void prepareMocks() { + this.mockHealthStatusManager = mock(); + this.mockHealthEndpoint = mock(); + this.mockStatusAggregator = mock(); + } + + @Disabled("TODO figure out how to mock HealthDescriptor") + @Test + void whenIndicatorPathsFoundStatusIsUpdated() { + var service1 = "check1"; + var service2 = "component2/check2"; + var service3 = "component3a/component3b/check3"; + given(this.mockHealthEndpoint.healthForPath("check1")).willReturn(healthOf(Status.UP)); + given(this.mockHealthEndpoint.healthForPath("component2", "check2")).willReturn(healthOf(Status.DOWN)); + given(this.mockHealthEndpoint.healthForPath("component3a", "component3b", "check3")) + .willReturn(healthOf(Status.UNKNOWN)); + given(this.mockStatusAggregator.getAggregateStatus(anySet())).willReturn(Status.UNKNOWN); + var healthAdapter = new ActuatorHealthAdapter(this.mockHealthStatusManager, this.mockHealthEndpoint, + this.mockStatusAggregator, true, List.of(service1, service2, service3)); + healthAdapter.updateHealthStatus(); + then(this.mockHealthStatusManager).should().setStatus(service1, ServingStatus.SERVING); + then(this.mockHealthStatusManager).should().setStatus(service2, ServingStatus.NOT_SERVING); + then(this.mockHealthStatusManager).should().setStatus(service3, ServingStatus.UNKNOWN); + ArgumentCaptor> statusesArgCaptor = ArgumentCaptor.captor(); + then(this.mockStatusAggregator).should().getAggregateStatus(statusesArgCaptor.capture()); + assertThat(statusesArgCaptor.getValue()) + .containsExactlyInAnyOrderElementsOf(Set.of(Status.UP, Status.DOWN, Status.UNKNOWN)); + then(this.mockHealthStatusManager).should().setStatus("", ServingStatus.UNKNOWN); + } + + @Disabled("TODO figure out how to mock HealthDescriptor") + @Test + void whenOverallHealthIsFalseOverallStatusIsNotUpdated() { + var service1 = "check1"; + given(this.mockHealthEndpoint.healthForPath("check1")).willReturn(healthOf(Status.UP)); + var healthAdapter = new ActuatorHealthAdapter(this.mockHealthStatusManager, this.mockHealthEndpoint, + this.mockStatusAggregator, false, List.of(service1)); + healthAdapter.updateHealthStatus(); + then(this.mockStatusAggregator).shouldHaveNoInteractions(); + then(this.mockHealthStatusManager).should(never()).setStatus(eq(""), any(ServingStatus.class)); + } + + @Disabled("TODO figure out how to mock HealthDescriptor") + @Test + void whenIndicatorPathNotFoundStatusIsNotUpdated() { + var healthAdapter = new ActuatorHealthAdapter(this.mockHealthStatusManager, this.mockHealthEndpoint, + this.mockStatusAggregator, false, List.of("check1")); + healthAdapter.updateHealthStatus(); + then(this.mockHealthStatusManager).shouldHaveNoInteractions(); + } + + @Disabled("TODO figure out how to mock HealthDescriptor") + @Test + void whenNoIndicatorPathsSpecifiedThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new ActuatorHealthAdapter(this.mockHealthStatusManager, this.mockHealthEndpoint, + this.mockStatusAggregator, false, Collections.emptyList())) + .withMessage("at least one health indicator path is required"); + } + + private HealthDescriptor healthOf(Status status) { + HealthDescriptor healthDescriptor = mock(); + given(healthDescriptor.getStatus()).willReturn(status); + return healthDescriptor; + } + + @Nested + class ToServingStatusApi { + + private final ActuatorHealthAdapter healthAdapter = new ActuatorHealthAdapter( + ActuatorHealthAdapterTests.this.mockHealthStatusManager, + ActuatorHealthAdapterTests.this.mockHealthEndpoint, + ActuatorHealthAdapterTests.this.mockStatusAggregator, false, List.of("check1")); + + @Test + void whenActuatorStatusIsUpThenServingStatusIsUp() { + assertThat(this.healthAdapter.toServingStatus(Status.UP.getCode())).isEqualTo(ServingStatus.SERVING); + } + + @Test + void whenActuatorStatusIsUnknownThenServingStatusIsUnknown() { + assertThat(this.healthAdapter.toServingStatus(Status.UNKNOWN.getCode())).isEqualTo(ServingStatus.UNKNOWN); + } + + @Test + void whenActuatorStatusIsDownThenServingStatusIsNotServing() { + assertThat(this.healthAdapter.toServingStatus(Status.DOWN.getCode())).isEqualTo(ServingStatus.NOT_SERVING); + } + + @Test + void whenActuatorStatusIsOutOfServiceThenServingStatusIsNotServing() { + assertThat(this.healthAdapter.toServingStatus(Status.OUT_OF_SERVICE.getCode())) + .isEqualTo(ServingStatus.NOT_SERVING); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfigurationTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfigurationTests.java new file mode 100644 index 000000000000..001fb30ad264 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfigurationTests.java @@ -0,0 +1,272 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.Arrays; + +import io.grpc.BindableService; +import io.grpc.protobuf.services.HealthStatusManager; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration; +import org.springframework.boot.actuate.health.HealthEndpoint; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.task.TaskSchedulingAutoConfiguration; +import org.springframework.boot.grpc.server.autoconfigure.health.GrpcServerHealthAutoConfiguration.ActuatorHealthAdapterConfiguration; +import org.springframework.boot.health.autoconfigure.contributor.HealthContributorAutoConfiguration; +import org.springframework.boot.health.autoconfigure.registry.HealthContributorRegistryAutoConfiguration; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.grpc.server.GrpcServerFactory; +import org.springframework.util.StringUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GrpcServerHealthAutoConfiguration}. + * + * @author Chris Bono + * @author Andrey Litvitski + */ +class GrpcServerHealthAutoConfigurationTests { + + private ApplicationContextRunner contextRunner() { + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcServerHealthAutoConfiguration.class)) + .withBean(BindableService.class, Mockito::mock); + } + + @Test + void whenAutoConfigurationIsNotSkippedThenCreatesDefaultBeans() { + this.contextRunner() + .run((context) -> assertThat(context).hasSingleBean(HealthStatusManager.class) + .hasBean("grpcHealthService")); + } + + @Test + void whenNoBindableServiceDefinedDoesNotAutoConfigureBean() { + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(GrpcServerHealthAutoConfiguration.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerHealthAutoConfiguration.class)); + } + + @Test + void whenGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner() + .withClassLoader(new FilteredClassLoader(BindableService.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerHealthAutoConfiguration.class)); + } + + @Test + void whenSpringGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner() + .withClassLoader(new FilteredClassLoader(GrpcServerFactory.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerHealthAutoConfiguration.class)); + } + + @Test + void whenHealthStatusManagerNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner() + .withClassLoader(new FilteredClassLoader(HealthStatusManager.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerHealthAutoConfiguration.class)); + } + + @Test + void whenHealthPropertyNotSetHealthIsAutoConfigured() { + this.contextRunner() + .run((context) -> assertThat(context).hasSingleBean(GrpcServerHealthAutoConfiguration.class)); + } + + @Test + void whenHealthPropertyIsTrueHealthIsAutoConfigured() { + this.contextRunner() + .withPropertyValues("spring.grpc.server.health.enabled=true") + .run((context) -> assertThat(context).hasSingleBean(GrpcServerHealthAutoConfiguration.class)); + } + + @Test + void whenHealthPropertyIsFalseAutoConfigurationIsSkipped() { + this.contextRunner() + .withPropertyValues("spring.grpc.server.health.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerHealthAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertySetFalseThenAutoConfigurationIsSkipped() { + this.contextRunner() + .withPropertyValues("spring.grpc.server.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerHealthAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertyNotSetThenAutoConfigurationIsNotSkipped() { + this.contextRunner() + .run((context) -> assertThat(context).hasSingleBean(GrpcServerHealthAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertySetTrueThenAutoConfigurationIsNotSkipped() { + this.contextRunner() + .withPropertyValues("spring.grpc.server.enabled=true") + .run((context) -> assertThat(context).hasSingleBean(GrpcServerHealthAutoConfiguration.class)); + } + + @Disabled("Will be tested in an integration test once the Actuator adapter is implemented") + @Test + void enterTerminalStateIsCalledWhenStatusManagerIsStopped() { + } + + @Test + void whenHasUserDefinedHealthStatusManagerDoesNotAutoConfigureBean() { + HealthStatusManager customHealthStatusManager = mock(); + this.contextRunner() + .withBean("customHealthStatusManager", HealthStatusManager.class, () -> customHealthStatusManager) + .withPropertyValues("spring.grpc.server.health.enabled=false") + .run((context) -> assertThat(context).getBean(HealthStatusManager.class) + .isSameAs(customHealthStatusManager)); + } + + @Test + void healthStatusManagerAutoConfiguredAsExpected() { + this.contextRunner().run((context) -> { + assertThat(context).hasSingleBean(GrpcServerHealthAutoConfiguration.class); + assertThat(context).hasSingleBean(HealthStatusManager.class); + assertThat(context).getBean("grpcHealthService", BindableService.class).isNotNull(); + }); + } + + private void assertThatBeanDefinitionsContainInOrder(ConfigurableApplicationContext context, + Class... configClasses) { + var configBeanDefNames = Arrays.stream(configClasses).map(this::beanDefinitionNameForConfigClass).toList(); + var filteredBeanDefNames = Arrays.stream(context.getBeanDefinitionNames()) + .filter(configBeanDefNames::contains) + .toList(); + assertThat(filteredBeanDefNames).containsExactlyElementsOf(configBeanDefNames); + } + + private String beanDefinitionNameForConfigClass(Class configClass) { + var fullName = configClass.getName(); + return StringUtils.uncapitalize(fullName); + } + + @Nested + class ActuatorHealthAdapterConfigurationTests { + + private ApplicationContextRunner validContextRunner() { + return GrpcServerHealthAutoConfigurationTests.this.contextRunner() + .withPropertyValues("spring.grpc.server.health.actuator.health-indicator-paths=my-indicator") + .withConfiguration(AutoConfigurations.of(HealthEndpointAutoConfiguration.class, + HealthContributorRegistryAutoConfiguration.class, HealthContributorAutoConfiguration.class, + TaskSchedulingAutoConfiguration.class)); + } + + @Test + void adapterIsAutoConfiguredAfterHealthAutoConfiguration() { + this.validContextRunner() + .run((context) -> assertThatBeanDefinitionsContainInOrder(context, + HealthEndpointAutoConfiguration.class, ActuatorHealthAdapterConfiguration.class)); + } + + @Test + void adapterIsAutoConfiguredAfterTaskSchedulingAutoConfiguration() { + this.validContextRunner() + .run((context) -> assertThatBeanDefinitionsContainInOrder(context, + TaskSchedulingAutoConfiguration.class, ActuatorHealthAdapterConfiguration.class)); + } + + @Test + void whenHealthEndpointNotOnClasspathAutoConfigurationIsSkipped() { + GrpcServerHealthAutoConfigurationTests.this.contextRunner() + .withConfiguration(AutoConfigurations.of(TaskSchedulingAutoConfiguration.class)) + .withClassLoader(new FilteredClassLoader(HealthEndpoint.class)) + .run((context) -> assertThat(context) + .doesNotHaveBean(GrpcServerHealthAutoConfiguration.ActuatorHealthAdapterConfiguration.class)); + } + + @Test + void whenHealthEndpointNotAvailableAutoConfigurationIsSkipped() { + this.validContextRunner() + .withPropertyValues("management.endpoint.health.enabled=false") + .withConfiguration(AutoConfigurations.of(TaskSchedulingAutoConfiguration.class)) + .run((context) -> assertThat(context) + .doesNotHaveBean(GrpcServerHealthAutoConfiguration.ActuatorHealthAdapterConfiguration.class)); + } + + @Test + void whenActuatorPropertyNotSetAdapterIsAutoConfigured() { + this.validContextRunner() + .run((context) -> assertThat(context) + .hasSingleBean(GrpcServerHealthAutoConfiguration.ActuatorHealthAdapterConfiguration.class)); + } + + @Test + void whenActuatorPropertyIsTrueAdapterIsAutoConfigured() { + this.validContextRunner() + .withPropertyValues("spring.grpc.server.health.actuator.enabled=true") + .run((context) -> assertThat(context) + .hasSingleBean(GrpcServerHealthAutoConfiguration.ActuatorHealthAdapterConfiguration.class)); + } + + @Test + void whenActuatorPropertyIsFalseAdapterIsNotAutoConfigured() { + this.validContextRunner() + .withPropertyValues("spring.grpc.server.health.actuator.enabled=false") + .run((context) -> assertThat(context) + .doesNotHaveBean(GrpcServerHealthAutoConfiguration.ActuatorHealthAdapterConfiguration.class)); + } + + @Test + void whenHealthIndicatorPathsIsNotSpecifiedAdapterIsNotAutoConfigured() { + GrpcServerHealthAutoConfigurationTests.this.contextRunner() + .withConfiguration(AutoConfigurations.of(HealthEndpointAutoConfiguration.class, + TaskSchedulingAutoConfiguration.class)) + .run((context) -> assertThat(context) + .doesNotHaveBean(GrpcServerHealthAutoConfiguration.ActuatorHealthAdapterConfiguration.class)); + } + + @Test + void whenHealthIndicatorPathsIsSpecifiedEmptyAdapterIsNotAutoConfigured() { + GrpcServerHealthAutoConfigurationTests.this.contextRunner() + .withPropertyValues("spring.grpc.server.health.actuator.health-indicator-paths=") + .withConfiguration(AutoConfigurations.of(HealthEndpointAutoConfiguration.class, + TaskSchedulingAutoConfiguration.class)) + .run((context) -> assertThat(context) + .doesNotHaveBean(GrpcServerHealthAutoConfiguration.ActuatorHealthAdapterConfiguration.class)); + } + + @Test + void whenHasUserDefinedAdapterDoesNotAutoConfigureBean() { + ActuatorHealthAdapter customAdapter = mock(); + this.validContextRunner() + .withBean("customAdapter", ActuatorHealthAdapter.class, () -> customAdapter) + .run((context) -> assertThat(context).getBean(ActuatorHealthAdapter.class).isSameAs(customAdapter)); + } + + @Test + void adapterAutoConfiguredAsExpected() { + this.validContextRunner() + .run((context) -> assertThat(context).hasSingleBean(ActuatorHealthAdapter.class) + .hasSingleBean(ActuatorHealthAdapterInvoker.class)); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcReactiveRequestTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcReactiveRequestTests.java new file mode 100644 index 000000000000..cb21b2154e0b --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcReactiveRequestTests.java @@ -0,0 +1,87 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure.security; + +import io.grpc.BindableService; +import io.grpc.ServerServiceDefinition; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.grpc.server.autoconfigure.security.GrpcReactiveRequest.GrpcReactiveRequestMatcher; +import org.springframework.context.ApplicationContext; +import org.springframework.grpc.server.service.DefaultGrpcServiceDiscoverer; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; +import org.springframework.http.codec.ServerCodecConfigurer; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.http.server.reactive.MockServerHttpResponse; +import org.springframework.web.context.support.StaticWebApplicationContext; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.i18n.AcceptHeaderLocaleContextResolver; +import org.springframework.web.server.session.DefaultWebSessionManager; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +class GrpcReactiveRequestTests { + + private StaticWebApplicationContext context = new StaticWebApplicationContext(); + + @BeforeEach + void setup() { + MockService service = mock(); + ServerServiceDefinition serviceDefinition = ServerServiceDefinition.builder("my-service").build(); + given(service.bindService()).willReturn(serviceDefinition); + this.context.registerBean(BindableService.class, () -> service); + this.context.registerBean(GrpcServiceDiscoverer.class, () -> new DefaultGrpcServiceDiscoverer(this.context)); + } + + @Test + void requestMatches() { + GrpcReactiveRequestMatcher matcher = GrpcReactiveRequest.all(); + MockExchange request = mockRequest("/my-service/Method"); + assertThat(matcher.matches(request).block().isMatch()).isTrue(); + } + + private MockExchange mockRequest(String path) { + MockServerHttpRequest servletContext = MockServerHttpRequest.get(path).build(); + MockExchange request = new MockExchange(servletContext, this.context); + return request; + } + + interface MockService extends BindableService { + + } + + static class MockExchange extends DefaultServerWebExchange { + + private ApplicationContext context; + + MockExchange(MockServerHttpRequest request, ApplicationContext context) { + super(request, new MockServerHttpResponse(), new DefaultWebSessionManager(), ServerCodecConfigurer.create(), + new AcceptHeaderLocaleContextResolver()); + this.context = context; + } + + @Override + public ApplicationContext getApplicationContext() { + return this.context; + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcSecurityAutoConfigurationTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcSecurityAutoConfigurationTests.java new file mode 100644 index 000000000000..ea091ff10a51 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcSecurityAutoConfigurationTests.java @@ -0,0 +1,118 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure.security; + +import io.grpc.BindableService; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.logging.ConditionEvaluationReportLoggingListener; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerAutoConfiguration; +import org.springframework.boot.logging.LogLevel; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.server.GrpcServerFactory; +import org.springframework.grpc.server.exception.GrpcExceptionHandler; +import org.springframework.grpc.server.lifecycle.GrpcServerLifecycle; +import org.springframework.grpc.server.security.AuthenticationProcessInterceptor; +import org.springframework.grpc.server.security.GrpcSecurity; +import org.springframework.grpc.server.security.SecurityGrpcExceptionHandler; +import org.springframework.security.config.ObjectPostProcessor; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link GrpcServerAutoConfiguration}. + * + * @author Chris Bono + */ +class GrpcSecurityAutoConfigurationTests { + + private ApplicationContextRunner contextRunner() { + // NOTE: we use noop server lifecycle to avoid startup + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcSecurityAutoConfiguration.class)) + .withBean("noopServerLifecycle", GrpcServerLifecycle.class, Mockito::mock); + } + + @Test + void whenSpringSecurityNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner() + .withClassLoader(new FilteredClassLoader(ObjectPostProcessor.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcSecurityAutoConfiguration.class)); + } + + @Test + void whenGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner() + .withClassLoader(new FilteredClassLoader(BindableService.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcSecurityAutoConfiguration.class)); + } + + @Test + void whenSpringGrpcNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner() + .withClassLoader(new FilteredClassLoader(GrpcServerFactory.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcSecurityAutoConfiguration.class)); + } + + @Test + void whenSpringGrpcAndSpringSecurityPresentGrpcSecurityIsCreated() { + new ApplicationContextRunner() + .withInitializer(ConditionEvaluationReportLoggingListener.forLogLevel(LogLevel.INFO)) + .withConfiguration(AutoConfigurations.of(GrpcSecurityAutoConfiguration.class)) + .withUserConfiguration(ExtraConfiguration.class) + .run((context) -> assertThat(context).hasSingleBean(GrpcSecurity.class)); + } + + @Test + void whenServerEnabledPropertySetFalseThenAutoConfigurationIsSkipped() { + this.contextRunner() + .withPropertyValues("spring.grpc.server.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcSecurityAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertyNotSetThenAutoConfigurationIsNotSkipped() { + this.contextRunner().run((context) -> assertThat(context).hasSingleBean(GrpcSecurityAutoConfiguration.class)); + } + + @Test + void whenServerEnabledPropertySetTrueThenAutoConfigurationIsNotSkipped() { + this.contextRunner() + .withPropertyValues("spring.grpc.server.enabled=true") + .run((context) -> assertThat(context).hasSingleBean(GrpcSecurityAutoConfiguration.class)); + } + + @Test + void grpcSecurityAutoConfiguredAsExpected() { + this.contextRunner().run((context) -> { + assertThat(context).getBean(GrpcExceptionHandler.class).isInstanceOf(SecurityGrpcExceptionHandler.class); + assertThat(context).getBean(AuthenticationProcessInterceptor.class).isNull(); + }); + } + + @EnableMethodSecurity + @Configuration(proxyBeanMethods = false) + static class ExtraConfiguration { + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServletRequestTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServletRequestTests.java new file mode 100644 index 000000000000..28131ce87baf --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServletRequestTests.java @@ -0,0 +1,98 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure.security; + +import io.grpc.BindableService; +import io.grpc.ServerServiceDefinition; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.grpc.server.autoconfigure.security.GrpcServletRequest.GrpcServletRequestMatcher; +import org.springframework.grpc.server.service.DefaultGrpcServiceDiscoverer; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockServletContext; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.context.support.StaticWebApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +class GrpcServletRequestTests { + + private StaticWebApplicationContext context = new StaticWebApplicationContext(); + + @BeforeEach + void setup() { + MockService service = mock(); + ServerServiceDefinition serviceDefinition = ServerServiceDefinition.builder("my-service").build(); + given(service.bindService()).willReturn(serviceDefinition); + this.context.registerBean(BindableService.class, () -> service); + this.context.registerBean(GrpcServiceDiscoverer.class, () -> new DefaultGrpcServiceDiscoverer(this.context)); + } + + @Test + void requestMatches() { + GrpcServletRequestMatcher matcher = GrpcServletRequest.all(); + MockHttpServletRequest request = mockRequest("/my-service/Method"); + assertThat(matcher.matches(request)).isTrue(); + } + + @Test + void noMatch() { + GrpcServletRequestMatcher matcher = GrpcServletRequest.all(); + MockHttpServletRequest request = mockRequest("/other-service/Method"); + assertThat(matcher.matches(request)).isFalse(); + } + + @Test + void requestMatcherExcludes() { + GrpcServletRequestMatcher matcher = GrpcServletRequest.all().excluding("my-service"); + MockHttpServletRequest request = mockRequest("/my-service/Method"); + assertThat(matcher.matches(request)).isFalse(); + } + + @Test + void noServices() { + GrpcServletRequestMatcher matcher = GrpcServletRequest.all(); + MockHttpServletRequest request = mockRequestNoServices("/my-service/Method"); + assertThat(matcher.matches(request)).isFalse(); + } + + private MockHttpServletRequest mockRequestNoServices(String path) { + MockServletContext servletContext = new MockServletContext(); + servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, + new StaticWebApplicationContext()); + MockHttpServletRequest request = new MockHttpServletRequest(servletContext); + request.setPathInfo(path); + return request; + } + + private MockHttpServletRequest mockRequest(String path) { + MockServletContext servletContext = new MockServletContext(); + servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, this.context); + MockHttpServletRequest request = new MockHttpServletRequest(servletContext); + request.setRequestURI(path); + return request; + } + + interface MockService extends BindableService { + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/OAuth2ResourceServerAutoConfigurationTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/OAuth2ResourceServerAutoConfigurationTests.java new file mode 100644 index 000000000000..dfa0ffd5f1f3 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/OAuth2ResourceServerAutoConfigurationTests.java @@ -0,0 +1,175 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure.security; + +import io.grpc.BindableService; +import io.grpc.ServerServiceDefinition; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.logging.ConditionEvaluationReportLoggingListener; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; +import org.springframework.boot.context.annotation.UserConfigurations; +import org.springframework.boot.context.event.ApplicationFailedEvent; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerAutoConfiguration; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerFactoryAutoConfiguration; +import org.springframework.boot.logging.LogLevel; +import org.springframework.boot.security.autoconfigure.servlet.SecurityAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.boot.web.context.servlet.AnnotationConfigServletWebApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.server.GlobalServerInterceptor; +import org.springframework.grpc.server.lifecycle.GrpcServerLifecycle; +import org.springframework.grpc.server.security.AuthenticationProcessInterceptor; +import org.springframework.grpc.server.security.GrpcSecurity; +import org.springframework.security.config.Customizer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GrpcServerAutoConfiguration}. + * + * @author Chris Bono + */ +class OAuth2ResourceServerAutoConfigurationTests { + + private BindableService service = mock(); + + { + ServerServiceDefinition serviceDefinition = ServerServiceDefinition.builder("my-service").build(); + given(this.service.bindService()).willReturn(serviceDefinition); + + } + + private ApplicationContextRunner contextRunner() { + // NOTE: we use noop server lifecycle to avoid startup + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(OAuth2ResourceServerAutoConfiguration.class, + GrpcSecurityAutoConfiguration.class)) + .withBean(BindableService.class, () -> this.service) + .withBean("noopServerLifecycle", GrpcServerLifecycle.class, Mockito::mock); + } + + @Disabled("TODO fix GrpcSecurity mismatch w/ Spring Security 6/7") + @Test + void jwtConfiguredWhenIssuerIsProvided() { + this.contextRunner() + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:9000") + .run((context) -> assertThat(context).hasSingleBean(AuthenticationProcessInterceptor.class)); + } + + @Disabled("TODO fix GrpcSecurity mismatch w/ Spring Security 6/7") + @Test + void jwtConfiguredWhenJwkSetIsProvided() { + this.contextRunner() + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost:9000") + .run((context) -> assertThat(context).hasSingleBean(AuthenticationProcessInterceptor.class)); + } + + @Disabled("TODO fix GrpcSecurity mismatch w/ Spring Security 6/7") + @Test + void customInterceptorWhenJwkSetIsProvided() { + this.contextRunner() + .withInitializer(ConditionEvaluationReportLoggingListener.forLogLevel(LogLevel.INFO)) + .withConfiguration(UserConfigurations.of(CustomInterceptorConfiguration.class)) + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost:9000") + .run((context) -> assertThat(context).hasSingleBean(AuthenticationProcessInterceptor.class)); + } + + @Test + void notConfiguredWhenIssuerNotProvided() { + this.contextRunner() + .run((context) -> assertThat(context).doesNotHaveBean(AuthenticationProcessInterceptor.class)); + } + + @Test + void notConfiguredInWebApplication() { + new WebApplicationContextRunner().withConfiguration(AutoConfigurations.of( + GrpcServerFactoryAutoConfiguration.class, GrpcServerAutoConfiguration.class, + SecurityAutoConfiguration.class, + org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration.class, + OAuth2ResourceServerAutoConfiguration.class, GrpcSecurityAutoConfiguration.class)) + .withBean(BindableService.class, () -> this.service) + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:9000") + .run((context) -> assertThat(context).doesNotHaveBean(AuthenticationProcessInterceptor.class)); + } + + @Test + void notConfiguredInWebApplicationWithNoBindableService() { + new WebApplicationContextRunner(WebApplicationContextRunner.withMockServletContext(MyContext::new)) + .withInitializer(ConditionEvaluationReportLoggingListener.forLogLevel(LogLevel.INFO)) + .withConfiguration(AutoConfigurations.of(GrpcServerFactoryAutoConfiguration.class, + GrpcServerAutoConfiguration.class, SecurityAutoConfiguration.class, + org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration.class, + OAuth2ResourceServerAutoConfiguration.class, GrpcSecurityAutoConfiguration.class)) + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:9000") + .run((context) -> assertThat(context).doesNotHaveBean(AuthenticationProcessInterceptor.class)); + } + + // @Disabled("TODO fix GrpcSecurity mismatch w/ Spring Security 6/7") + @Test + void configuredInWebApplicationWithGrpcNative() { + new WebApplicationContextRunner(WebApplicationContextRunner.withMockServletContext(MyContext::new)) + .withConfiguration(AutoConfigurations.of(GrpcServerFactoryAutoConfiguration.class, + GrpcServerAutoConfiguration.class, SslAutoConfiguration.class, SecurityAutoConfiguration.class, + org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration.class, + OAuth2ResourceServerAutoConfiguration.class, GrpcSecurityAutoConfiguration.class)) + .withInitializer(ConditionEvaluationReportLoggingListener.forLogLevel(LogLevel.INFO)) + .withBean(BindableService.class, () -> this.service) + .withBean("noopServerLifecycle", GrpcServerLifecycle.class, Mockito::mock) + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:9000", + "spring.grpc.server.servlet.enabled=false", "spring.grpc.server.port=0") + .run((context) -> assertThat(context).hasSingleBean(AuthenticationProcessInterceptor.class)); + } + + // Utility class to ensure ApplicationFailedEvent is published + static class MyContext extends AnnotationConfigServletWebApplicationContext { + + @Override + public void refresh() { + try { + super.refresh(); + } + catch (Throwable ex) { + publishEvent(new ApplicationFailedEvent(new SpringApplication(this), new String[0], this, ex)); + throw ex; + } + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomInterceptorConfiguration { + + @Bean + @GlobalServerInterceptor + AuthenticationProcessInterceptor jwtSecurityFilterChain(GrpcSecurity grpc) throws Exception { + return grpc.authorizeRequests((requests) -> requests.allRequests().authenticated()) + .oauth2ResourceServer((resourceServer) -> resourceServer.jwt(Customizer.withDefaults())) + .build(); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/resources/logback-test.xml b/module/spring-boot-grpc-server/src/test/resources/logback-test.xml new file mode 100644 index 000000000000..b8a41480d7d6 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/resources/logback-test.xml @@ -0,0 +1,4 @@ + + + + diff --git a/module/spring-boot-grpc-server/src/test/resources/org/springframework/boot/grpc/server/autoconfigure/test.jks b/module/spring-boot-grpc-server/src/test/resources/org/springframework/boot/grpc/server/autoconfigure/test.jks new file mode 100644 index 000000000000..0fc3e802f754 Binary files /dev/null and b/module/spring-boot-grpc-server/src/test/resources/org/springframework/boot/grpc/server/autoconfigure/test.jks differ diff --git a/module/spring-boot-grpc-test/build.gradle b/module/spring-boot-grpc-test/build.gradle new file mode 100644 index 000000000000..58b67c806c86 --- /dev/null +++ b/module/spring-boot-grpc-test/build.gradle @@ -0,0 +1,41 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id "java-library" + id "org.springframework.boot.deployed" + id "org.springframework.boot.optional-dependencies" +} + +description = "Spring Boot gRPC Test" + +dependencies { + api(project(":module:spring-boot-grpc-client")) + api(project(":module:spring-boot-grpc-server")) + + implementation(project(":core:spring-boot-test")) + + optional(project(":module:spring-boot-test-autoconfigure")) + optional("io.grpc:grpc-inprocess") + optional("io.grpc:grpc-stub") + optional("org.junit.jupiter:junit-jupiter-api") + + testImplementation(project(":test-support:spring-boot-test-support")) + testImplementation("io.grpc:grpc-netty") + + testRuntimeOnly("ch.qos.logback:logback-classic") + //testRuntimeOnly("tools.jackson.core:jackson-databind") +} diff --git a/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/AutoConfigureInProcessTransport.java b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/AutoConfigureInProcessTransport.java new file mode 100644 index 000000000000..56ba22506aa0 --- /dev/null +++ b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/AutoConfigureInProcessTransport.java @@ -0,0 +1,54 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.test.autoconfigure; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.test.autoconfigure.properties.PropertyMapping; + +/** + * Annotation that can be applied to a test class to start an in-process gRPC server. All + * clients that connect to any server via the autoconfigured {@code GrpcChannelFactory} + * will be able to connect to the in-process gRPC server. + * + * @author Dave Syer + * @author Chris Bono + * @since 4.0.0 + * @see InProcessTestAutoConfiguration + */ +@Target({ ElementType.TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@ImportAutoConfiguration +@PropertyMapping("spring.test.grpc.inprocess") +public @interface AutoConfigureInProcessTransport { + + /** + * Whether to start an in-process test gRPC server. Defaults to {@code true}. + * @return whether to start an in-process gRPC server + */ + @SuppressWarnings("unused") + boolean enabled() default true; + +} diff --git a/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/InProcessTestAutoConfiguration.java b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/InProcessTestAutoConfiguration.java new file mode 100644 index 000000000000..83581ef03fad --- /dev/null +++ b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/InProcessTestAutoConfiguration.java @@ -0,0 +1,162 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.test.autoconfigure; + +import java.time.Duration; +import java.util.Collections; +import java.util.List; + +import io.grpc.BindableService; +import io.grpc.ChannelCredentials; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.stub.AbstractStub; +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientAutoConfiguration; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerFactoryAutoConfiguration; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.grpc.client.ClientInterceptorsConfigurer; +import org.springframework.grpc.client.GrpcChannelFactory; +import org.springframework.grpc.client.InProcessGrpcChannelFactory; +import org.springframework.grpc.server.GrpcServerFactory; +import org.springframework.grpc.server.InProcessGrpcServerFactory; +import org.springframework.grpc.server.ServerBuilderCustomizer; +import org.springframework.grpc.server.ServerServiceDefinitionFilter; +import org.springframework.grpc.server.lifecycle.GrpcServerLifecycle; +import org.springframework.grpc.server.service.GrpcServiceConfigurer; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; + +/** + * Auto-configuration for an in-process test gRPC server. + * + * @author Chris Bono + * @author Dave Syer + * @author Andrey Litvitski + * @since 4.0.0 + * @see AutoConfigureInProcessTransport + */ +@AutoConfiguration(before = { GrpcServerFactoryAutoConfiguration.class, GrpcClientAutoConfiguration.class }) +@ConditionalOnClass({ InProcessServerBuilder.class, InProcessChannelBuilder.class, InProcessGrpcServerFactory.class, + InProcessGrpcChannelFactory.class }) +@ConditionalOnBooleanProperty("spring.test.grpc.inprocess.enabled") +public final class InProcessTestAutoConfiguration { + + private final String address = InProcessServerBuilder.generateName(); + + @Bean + @ConditionalOnMissingBean + ClientInterceptorsConfigurer clientInterceptorsConfigurer(ApplicationContext applicationContext) { + return new ClientInterceptorsConfigurer(applicationContext); + } + + @Bean + @ConditionalOnClass({ BindableService.class, GrpcServerFactory.class }) + @ConditionalOnBean(BindableService.class) + @Order(Ordered.HIGHEST_PRECEDENCE) + TestInProcessGrpcServerFactory testInProcessGrpcServerFactory(GrpcServiceDiscoverer serviceDiscoverer, + GrpcServiceConfigurer serviceConfigurer, List> customizers, + @Nullable ServerServiceDefinitionFilter serviceFilter) { + var factory = new TestInProcessGrpcServerFactory(this.address, customizers, serviceFilter); + serviceDiscoverer.findServices() + .stream() + .map((serviceSpec) -> serviceConfigurer.configure(serviceSpec, factory)) + .forEach(factory::addService); + return factory; + } + + @Bean + @ConditionalOnClass({ AbstractStub.class, GrpcChannelFactory.class }) + @Order(Ordered.HIGHEST_PRECEDENCE) + TestInProcessGrpcChannelFactory testInProcessGrpcChannelFactory( + ClientInterceptorsConfigurer interceptorsConfigurer) { + return new TestInProcessGrpcChannelFactory(this.address, interceptorsConfigurer); + } + + @Bean(name = "inProcessGrpcServerLifecycle") + @ConditionalOnBean(InProcessGrpcServerFactory.class) + @Order(Ordered.HIGHEST_PRECEDENCE) + GrpcServerLifecycle inProcessGrpcServerLifecycle(InProcessGrpcServerFactory factory, + ApplicationEventPublisher eventPublisher) { + return new GrpcServerLifecycle(factory, Duration.ofSeconds(30), eventPublisher); + } + + /** + * Specialization of {@link InProcessGrpcServerFactory}. + */ + public static class TestInProcessGrpcServerFactory extends InProcessGrpcServerFactory { + + public TestInProcessGrpcServerFactory(String address, + List> serverBuilderCustomizers, + @Nullable ServerServiceDefinitionFilter serviceFilter) { + super(address, serverBuilderCustomizers); + setServiceFilter(serviceFilter); + } + + } + + /** + * Specialization of {@link InProcessGrpcChannelFactory} that allows the channel + * factory to support all targets, not just those that start with 'in-process:'. + */ + public static class TestInProcessGrpcChannelFactory extends InProcessGrpcChannelFactory { + + TestInProcessGrpcChannelFactory(String address, ClientInterceptorsConfigurer interceptorsConfigurer) { + super(Collections.emptyList(), interceptorsConfigurer); + setVirtualTargets((path) -> address); + } + + /** + * {@inheritDoc} + * @param target the target string as described in method javadocs + * @return {@code true} so that the test factory can handle all targets not just + * those prefixed with 'in-process:' + */ + @Override + public boolean supports(String target) { + return true; + } + + /** + * {@inheritDoc} + *

+ * Overrides the parent behavior so that the channel factory can handle all + * targets, not just those that prefixed with 'in-process:'. + * @param target the target of the channel + * @param creds the credentials for the channel which are ignored in this case + * @return a new inprocess channel builder instance + */ + @Override + protected InProcessChannelBuilder newChannelBuilder(String target, ChannelCredentials creds) { + if (target.startsWith("in-process:")) { + return super.newChannelBuilder(target, creds); + } + return InProcessChannelBuilder.forName(target); + } + + } + +} diff --git a/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/InProcessTransportContextCustomizerFactory.java b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/InProcessTransportContextCustomizerFactory.java new file mode 100644 index 000000000000..bd1ccdf70d30 --- /dev/null +++ b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/InProcessTransportContextCustomizerFactory.java @@ -0,0 +1,91 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.test.autoconfigure; + +import java.util.List; +import java.util.Objects; + +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.test.context.ContextConfigurationAttributes; +import org.springframework.test.context.ContextCustomizer; +import org.springframework.test.context.ContextCustomizerFactory; +import org.springframework.test.context.MergedContextConfiguration; +import org.springframework.test.context.TestContextAnnotationUtils; + +/** + * {@link ContextCustomizerFactory} that starts an in-process gRPC server and replaces the + * regular server and channel factories (e.g. Netty). The customizer can be disabled via + * the {@link AutoConfigureInProcessTransport} annotation or the + * {@value #ENABLED_PROPERTY} property. + * + * @author Chris Bono + */ +class InProcessTransportContextCustomizerFactory implements ContextCustomizerFactory { + + static final String ENABLED_PROPERTY = "spring.test.grpc.inprocess.enabled"; + + @Override + public ContextCustomizer createContextCustomizer(Class testClass, + List configAttributes) { + AutoConfigureInProcessTransport annotation = TestContextAnnotationUtils.findMergedAnnotation(testClass, + AutoConfigureInProcessTransport.class); + return new InProcessTransportContextCustomizer(annotation); + } + + private static class InProcessTransportContextCustomizer implements ContextCustomizer { + + private final @Nullable AutoConfigureInProcessTransport annotation; + + InProcessTransportContextCustomizer(@Nullable AutoConfigureInProcessTransport annotation) { + this.annotation = annotation; + } + + @Override + public void customizeContext(ConfigurableApplicationContext context, + MergedContextConfiguration mergedContextConfiguration) { + if (this.annotation == null + || !context.getEnvironment().getProperty(ENABLED_PROPERTY, Boolean.class, false)) { + return; + } + TestPropertyValues + .of("spring.grpc.client.inprocess.exclusive=true", "spring.grpc.server.inprocess.exclusive=true") + .applyTo(context); + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + InProcessTransportContextCustomizer that = (InProcessTransportContextCustomizer) o; + return Objects.equals(this.annotation, that.annotation); + } + + @Override + public int hashCode() { + return Objects.hash(this.annotation); + } + + } + +} diff --git a/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/LocalGrpcPort.java b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/LocalGrpcPort.java new file mode 100644 index 000000000000..e8c837b338a6 --- /dev/null +++ b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/LocalGrpcPort.java @@ -0,0 +1,42 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.test.autoconfigure; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.beans.factory.annotation.Value; + +/** + * Annotation at the field or method/constructor parameter level that injects the gRPC + * server port that was allocated at runtime. Provides a convenient alternative for + * @Value("${local.grpc.port}"). + * + * @author Dave Syer + * @author Chris Bono + * @since 4.0.0 + */ +@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Value("${local.grpc.port}") +public @interface LocalGrpcPort { + +} diff --git a/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/ServerPortInfoApplicationContextInitializer.java b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/ServerPortInfoApplicationContextInitializer.java new file mode 100644 index 000000000000..b9f00f609aaa --- /dev/null +++ b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/ServerPortInfoApplicationContextInitializer.java @@ -0,0 +1,87 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.test.autoconfigure; + +import java.util.HashMap; +import java.util.Map; + +import org.jspecify.annotations.Nullable; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ApplicationListener; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.MutablePropertySources; +import org.springframework.core.env.PropertySource; +import org.springframework.grpc.server.InProcessGrpcServerFactory; +import org.springframework.grpc.server.lifecycle.GrpcServerStartedEvent; +import org.springframework.util.Assert; + +/** + * {@link ApplicationContextInitializer} implementation to start the management context on + * a random port if the main server's port is 0 and the management context is expected on + * a different port. + * + * @author Dave Syer + * @author Chris Bono + */ +class ServerPortInfoApplicationContextInitializer implements + ApplicationContextInitializer, ApplicationListener { + + private static final String PROPERTY_SOURCE_NAME = "grpc.server.ports"; + + private @Nullable ConfigurableApplicationContext applicationContext; + + @Override + public void initialize(ConfigurableApplicationContext applicationContext) { + this.applicationContext = applicationContext; + applicationContext.addApplicationListener(this); + } + + @Override + public void onApplicationEvent(GrpcServerStartedEvent event) { + if (event.getSource().getFactory() instanceof InProcessGrpcServerFactory) { + return; + } + String propertyName = "local.grpc.port"; + Assert.notNull(this.applicationContext, "ApplicationContext must not be null"); + setPortProperty(this.applicationContext, propertyName, event.getPort()); + } + + private void setPortProperty(ApplicationContext context, String propertyName, int port) { + if (context instanceof ConfigurableApplicationContext configurableContext) { + setPortProperty(configurableContext.getEnvironment(), propertyName, port); + } + if (context.getParent() != null) { + setPortProperty(context.getParent(), propertyName, port); + } + } + + @SuppressWarnings("unchecked") + private void setPortProperty(ConfigurableEnvironment environment, String propertyName, int port) { + MutablePropertySources sources = environment.getPropertySources(); + PropertySource source = sources.get(PROPERTY_SOURCE_NAME); + if (source == null) { + source = new MapPropertySource(PROPERTY_SOURCE_NAME, new HashMap<>()); + sources.addFirst(source); + } + ((Map) source.getSource()).put(propertyName, port); + } + +} diff --git a/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/package-info.java b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/package-info.java new file mode 100644 index 000000000000..c6785eb5dd75 --- /dev/null +++ b/module/spring-boot-grpc-test/src/main/java/org/springframework/boot/grpc/test/autoconfigure/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Auto-configuration for Spring gRPC tests. + */ +@NullMarked +package org.springframework.boot.grpc.test.autoconfigure; + +import org.jspecify.annotations.NullMarked; diff --git a/module/spring-boot-grpc-test/src/main/resources/META-INF/spring.factories b/module/spring-boot-grpc-test/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000000..329a7197e24e --- /dev/null +++ b/module/spring-boot-grpc-test/src/main/resources/META-INF/spring.factories @@ -0,0 +1,7 @@ +# Spring Test Context Customizer Factories +org.springframework.test.context.ContextCustomizerFactory=\ +org.springframework.boot.grpc.test.autoconfigure.InProcessTransportContextCustomizerFactory + +# Application Context Initializers +org.springframework.context.ApplicationContextInitializer=\ +org.springframework.boot.grpc.test.autoconfigure.ServerPortInfoApplicationContextInitializer diff --git a/module/spring-boot-grpc-test/src/main/resources/META-INF/spring/org.springframework.boot.grpc.test.autoconfigure.AutoConfigureInProcessTransport.imports b/module/spring-boot-grpc-test/src/main/resources/META-INF/spring/org.springframework.boot.grpc.test.autoconfigure.AutoConfigureInProcessTransport.imports new file mode 100644 index 000000000000..7abb17f2a061 --- /dev/null +++ b/module/spring-boot-grpc-test/src/main/resources/META-INF/spring/org.springframework.boot.grpc.test.autoconfigure.AutoConfigureInProcessTransport.imports @@ -0,0 +1 @@ +org.springframework.boot.grpc.test.autoconfigure.InProcessTestAutoConfiguration diff --git a/module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/InProcessTestAutoConfigurationTests.java b/module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/InProcessTestAutoConfigurationTests.java new file mode 100644 index 000000000000..bc44b72659c1 --- /dev/null +++ b/module/spring-boot-grpc-test/src/test/java/org/springframework/boot/grpc/test/autoconfigure/InProcessTestAutoConfigurationTests.java @@ -0,0 +1,99 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.test.autoconfigure; + +import io.grpc.BindableService; +import io.grpc.ServerServiceDefinition; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientAutoConfiguration; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerAutoConfiguration; +import org.springframework.boot.grpc.server.autoconfigure.GrpcServerFactoryAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.grpc.client.GrpcChannelFactory; +import org.springframework.grpc.server.GrpcServerFactory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link InProcessTestAutoConfiguration}. + * + * @author Chris Bono + */ +class InProcessTestAutoConfigurationTests { + + private final BindableService service = mock(); + + private final ServerServiceDefinition serviceDefinition = ServerServiceDefinition.builder("my-service").build(); + + @BeforeEach + void prepareForTest() { + given(this.service.bindService()).willReturn(this.serviceDefinition); + } + + private ApplicationContextRunner contextRunner() { + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(InProcessTestAutoConfiguration.class, + GrpcServerAutoConfiguration.class, GrpcServerFactoryAutoConfiguration.class, + SslAutoConfiguration.class, GrpcClientAutoConfiguration.class)) + .withBean(BindableService.class, () -> this.service); + } + + @Test + void whenTestInProcessEnabledPropIsSetToTrueDoesAutoConfigureBeans() { + this.contextRunner() + .withPropertyValues("spring.test.grpc.inprocess.enabled=true", "spring.grpc.server.inprocess.name=foo", + "spring.grpc.server.port=0") + .run((context) -> { + assertThat(context).getBeans(GrpcServerFactory.class) + .containsOnlyKeys("testInProcessGrpcServerFactory", "nettyGrpcServerFactory"); + assertThat(context).getBeans(GrpcChannelFactory.class) + .containsOnlyKeys("testInProcessGrpcChannelFactory", "nettyGrpcChannelFactory"); + }); + } + + @Test + void whenTestInProcessEnabledPropIsNotSetDoesNotAutoConfigureBeans() { + this.contextRunner() + .withPropertyValues("spring.grpc.server.inprocess.name=foo", "spring.grpc.server.port=0") + .run((context) -> { + assertThat(context).getBeans(GrpcServerFactory.class) + .containsOnlyKeys("inProcessGrpcServerFactory", "nettyGrpcServerFactory"); + assertThat(context).getBeans(GrpcChannelFactory.class) + .containsOnlyKeys("inProcessGrpcChannelFactory", "nettyGrpcChannelFactory"); + }); + } + + @Test + void whenTestInProcessEnabledPropIsSetToFalseDoesNotAutoConfigureBeans() { + this.contextRunner() + .withPropertyValues("spring.test.grpc.inprocess.enabled=false", "spring.grpc.server.inprocess.name=foo", + "spring.grpc.server.port=0") + .run((context) -> { + assertThat(context).getBeans(GrpcServerFactory.class) + .containsOnlyKeys("inProcessGrpcServerFactory", "nettyGrpcServerFactory"); + assertThat(context).getBeans(GrpcChannelFactory.class) + .containsOnlyKeys("inProcessGrpcChannelFactory", "nettyGrpcChannelFactory"); + }); + } + +} diff --git a/platform/spring-boot-dependencies/build.gradle b/platform/spring-boot-dependencies/build.gradle index ebbe475fd31d..d8b239dbac56 100644 --- a/platform/spring-boot-dependencies/build.gradle +++ b/platform/spring-boot-dependencies/build.gradle @@ -492,6 +492,30 @@ bom { site("https://groovy-lang.org") } } + library("Grpc Java", "1.75.0") { + group("io.grpc") { + bom("grpc-bom") + } + links { + github("https://github.com/grpc/grpc-java") + docs("https://grpc.io/docs/languages/java/") + releaseNotes("https://github.com/grpc/grpc-java/releases/tag/v{version}") + } + } + library("Grpc Kotlin", "1.5.0") { + group("io.grpc") { + modules = [ + "grpc-kotlin-stub" { + exclude group: "javax.annotation", module: "javax.annotation-api" + } + ] + } + links { + github("https://github.com/grpc/grpc-kotlin") + docs("https://grpc.io/docs/languages/kotlin/") + releaseNotes("https://github.com/grpc/grpc-kotlin/releases/tag/v{version}") + } + } library("Gson", "2.13.2") { group("com.google.code.gson") { modules = [ @@ -1762,6 +1786,27 @@ bom { releaseNotes("https://github.com/prometheus/client_java/releases/tag/parent-{version}") } } + library("Protobuf Common Protos", "2.61.2") { + group("com.google.api.grpc") { + modules = [ + "proto-google-common-protos" + ] + } + links { + github("https://github.com/googleapis/sdk-platform-java") + releaseNotes("https://github.com/googleapis/sdk-platform-java/releases/tag/v-{version}") + } + } + library("Protobuf Java", "4.32.1") { + group("com.google.protobuf") { + bom("protobuf-bom") + } + links { + site("https://protobuf.dev") + github("https://github.com/protocolbuffers/protobuf") + releaseNotes("https://github.com/protocolbuffers/protobuf/releases/tag/v{version}") + } + } library("Pulsar", "4.1.0") { group("org.apache.pulsar") { bom("pulsar-bom") { @@ -2006,6 +2051,7 @@ bom { "spring-boot-elasticsearch", "spring-boot-flyway", "spring-boot-freemarker", + "spring-boot-grpc-server", "spring-boot-graphql", "spring-boot-graphql-test", "spring-boot-groovy-templates", @@ -2332,6 +2378,23 @@ bom { releaseNotes("https://github.com/spring-projects/spring-graphql/releases/tag/v{version}") } } + library("Spring gRPC", "1.0.0-SNAPSHOT") { + considerSnapshots() + group("org.springframework.grpc") { + modules = [ + "spring-grpc-core" + ] + } + links { + site("https://spring.io/projects/spring-grpc") + github("https://github.com/spring-projects/spring-grpc") + javadoc(version -> "https://docs.spring.io/spring-grpc/docs/%s/api" + .formatted(version.forMajorMinorGeneration()), "org.springframework.grpc") + docs(version -> "https://docs.spring.io/spring-grpc/docs/%s/reference" + .formatted(version.forMajorMinorGeneration())) + releaseNotes("https://github.com/spring-projects/spring-grpc/releases/tag/v{version}") + } + } library("Spring HATEOAS", "3.0.0-M5") { considerSnapshots() group("org.springframework.hateoas") { diff --git a/platform/spring-boot-internal-dependencies/build.gradle b/platform/spring-boot-internal-dependencies/build.gradle index 54b72acde413..dcceb5379950 100644 --- a/platform/spring-boot-internal-dependencies/build.gradle +++ b/platform/spring-boot-internal-dependencies/build.gradle @@ -84,11 +84,6 @@ bom { ] } } - library("gRPC", "1.73.0") { - group("io.grpc") { - bom("grpc-bom") - } - } library("Janino", "3.1.12") { group("org.codehaus.janino") { bom("janino") { diff --git a/settings.gradle b/settings.gradle index e34f9c1805ca..cd644e6964ca 100644 --- a/settings.gradle +++ b/settings.gradle @@ -113,6 +113,9 @@ include "module:spring-boot-data-redis" include "module:spring-boot-data-redis-test" include "module:spring-boot-data-rest" include "module:spring-boot-devtools" +include "module:spring-boot-grpc-client" +include "module:spring-boot-grpc-server" +include "module:spring-boot-grpc-test" include "module:spring-boot-elasticsearch" include "module:spring-boot-flyway" include "module:spring-boot-freemarker" @@ -229,6 +232,10 @@ include "starter:spring-boot-starter-flyway" include "starter:spring-boot-starter-freemarker" include "starter:spring-boot-starter-graphql" include "starter:spring-boot-starter-groovy-templates" +include "starter:spring-boot-starter-grpc" +include "starter:spring-boot-starter-grpc-client" +include "starter:spring-boot-starter-grpc-server" +include "starter:spring-boot-starter-grpc-server-web" include "starter:spring-boot-starter-gson" include "starter:spring-boot-starter-hateoas" include "starter:spring-boot-starter-hazelcast" @@ -335,6 +342,7 @@ include ":smoke-test:spring-boot-smoke-test-data-rest" include ":smoke-test:spring-boot-smoke-test-devtools" include ":smoke-test:spring-boot-smoke-test-flyway" include ":smoke-test:spring-boot-smoke-test-graphql" +include ":smoke-test:spring-boot-smoke-test-grpc" include ":smoke-test:spring-boot-smoke-test-hateoas" include ":smoke-test:spring-boot-smoke-test-hibernate" include ":smoke-test:spring-boot-smoke-test-integration" diff --git a/smoke-test/spring-boot-smoke-test-grpc/build.gradle b/smoke-test/spring-boot-smoke-test-grpc/build.gradle new file mode 100644 index 000000000000..dabe58e4386a --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc/build.gradle @@ -0,0 +1,68 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id "java" + id 'com.google.protobuf' version '0.9.5' +} + +description = "Spring Boot gRPC smoke test" + +dependencies { + implementation(platform(project(":platform:spring-boot-dependencies"))) + implementation(project(":starter:spring-boot-starter-actuator")) + implementation(project(":starter:spring-boot-starter-grpc")) + + compileOnly("com.fasterxml.jackson.core:jackson-annotations") + + testCompileOnly("com.fasterxml.jackson.core:jackson-annotations") + + testImplementation(project(":module:spring-boot-grpc-test")) + testImplementation(project(":starter:spring-boot-starter-test")) + testImplementation("io.grpc:grpc-inprocess") +} + + +architectureCheck { + nullMarked = false +} + +tasks.withType(io.spring.javaformat.gradle.tasks.CheckFormat) { + exclude "smoketest/grpc/proto" +} + +// FIXME get from 'protobuf-java-version' from dep mgmt +def protobufJavaVersion = "4.32.1" +// FIXME get from 'grpc-java-version' from dep mgmt +def grpcVersion = "1.75.0" + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:${protobufJavaVersion}" + } + plugins { + grpc { + artifact = "io.grpc:protoc-gen-grpc-java:${grpcVersion}" + } + } + generateProtoTasks { + all()*.plugins { + grpc { + option '@generated=omit' + } + } + } +} diff --git a/smoke-test/spring-boot-smoke-test-grpc/src/main/java/smoketest/grpc/GrpcServerApplication.java b/smoke-test/spring-boot-smoke-test-grpc/src/main/java/smoketest/grpc/GrpcServerApplication.java new file mode 100644 index 000000000000..18873e62fd4e --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc/src/main/java/smoketest/grpc/GrpcServerApplication.java @@ -0,0 +1,29 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smoketest.grpc; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class GrpcServerApplication { + + public static void main(String[] args) { + SpringApplication.run(GrpcServerApplication.class, args); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc/src/main/java/smoketest/grpc/GrpcServerService.java b/smoke-test/spring-boot-smoke-test-grpc/src/main/java/smoketest/grpc/GrpcServerService.java new file mode 100644 index 000000000000..7bab517adc37 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc/src/main/java/smoketest/grpc/GrpcServerService.java @@ -0,0 +1,67 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smoketest.grpc; + +import io.grpc.stub.StreamObserver; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import smoketest.grpc.proto.HelloReply; +import smoketest.grpc.proto.HelloRequest; +import smoketest.grpc.proto.SimpleGrpc; + +import org.springframework.stereotype.Service; + +@Service +public class GrpcServerService extends SimpleGrpc.SimpleImplBase { + + private static Log log = LogFactory.getLog(GrpcServerService.class); + + @Override + public void sayHello(HelloRequest req, StreamObserver responseObserver) { + log.info("Hello " + req.getName()); + if (req.getName().startsWith("error")) { + throw new IllegalArgumentException("Bad name: " + req.getName()); + } + if (req.getName().startsWith("internal")) { + throw new RuntimeException(); + } + HelloReply reply = HelloReply.newBuilder().setMessage("Hello ==> " + req.getName()).build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } + + @Override + public void streamHello(HelloRequest req, StreamObserver responseObserver) { + log.info("Hello " + req.getName()); + int count = 0; + while (count < 10) { + HelloReply reply = HelloReply.newBuilder().setMessage("Hello(" + count + ") ==> " + req.getName()).build(); + responseObserver.onNext(reply); + count++; + try { + Thread.sleep(1000L); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + responseObserver.onError(ex); + return; + } + } + responseObserver.onCompleted(); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc/src/main/java/smoketest/grpc/package-info.java b/smoke-test/spring-boot-smoke-test-grpc/src/main/java/smoketest/grpc/package-info.java new file mode 100644 index 000000000000..dd0921aa14ca --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc/src/main/java/smoketest/grpc/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@NullMarked +package smoketest.grpc; + +import org.jspecify.annotations.NullMarked; diff --git a/smoke-test/spring-boot-smoke-test-grpc/src/main/proto/hello.proto b/smoke-test/spring-boot-smoke-test-grpc/src/main/proto/hello.proto new file mode 100644 index 000000000000..825d235abf02 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc/src/main/proto/hello.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +option java_multiple_files = true; +option java_package = "smoketest.grpc.proto"; +option java_outer_classname = "HelloWorldProto"; + +// The greeting service definition. +service Simple { + // Sends a greeting + rpc SayHello (HelloRequest) returns (HelloReply) { + } + rpc StreamHello(HelloRequest) returns (stream HelloReply) {} +} + +// The request message containing the user's name. +message HelloRequest { + string name = 1; +} + +// The response message containing the greetings +message HelloReply { + string message = 1; +} diff --git a/smoke-test/spring-boot-smoke-test-grpc/src/main/resources/application.properties b/smoke-test/spring-boot-smoke-test-grpc/src/main/resources/application.properties new file mode 100644 index 000000000000..566605a793b1 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.application.name=grpc-server diff --git a/smoke-test/spring-boot-smoke-test-grpc/src/test/java/smoketest/grpc/GrpcServerApplicationHealthTests.java b/smoke-test/spring-boot-smoke-test-grpc/src/test/java/smoketest/grpc/GrpcServerApplicationHealthTests.java new file mode 100644 index 000000000000..651098bf9510 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc/src/test/java/smoketest/grpc/GrpcServerApplicationHealthTests.java @@ -0,0 +1,174 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smoketest.grpc; + +import java.time.Duration; + +import io.grpc.ManagedChannel; +import io.grpc.StatusRuntimeException; +import io.grpc.health.v1.HealthCheckRequest; +import io.grpc.health.v1.HealthCheckResponse.ServingStatus; +import io.grpc.health.v1.HealthGrpc; +import io.grpc.health.v1.HealthGrpc.HealthBlockingStub; +import io.grpc.protobuf.services.HealthStatusManager; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import smoketest.grpc.proto.HelloReply; +import smoketest.grpc.proto.HelloRequest; +import smoketest.grpc.proto.SimpleGrpc; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.grpc.test.autoconfigure.AutoConfigureInProcessTransport; +import org.springframework.boot.health.autoconfigure.contributor.ConditionalOnEnabledHealthIndicator; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.grpc.client.GrpcChannelFactory; +import org.springframework.test.annotation.DirtiesContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Integration tests for gRPC server health feature. + */ +class GrpcServerApplicationHealthTests { + + @Nested + @SpringBootTest(properties = { "spring.grpc.server.port=0", + "spring.grpc.client.channels.health-test.address=static://0.0.0.0:${local.grpc.port}", + "spring.grpc.client.channels.health-test.health.enabled=true", + "spring.grpc.client.channels.health-test.health.service-name=my-service" }) + @DirtiesContext + class WithClientHealthEnabled { + + @Test + void loadBalancerRespectsServerHealth(@Autowired GrpcChannelFactory channels, + @Autowired HealthStatusManager healthStatusManager) { + ManagedChannel channel = channels.createChannel("health-test"); + SimpleGrpc.SimpleBlockingStub client = SimpleGrpc.newBlockingStub(channel); + + // put the service up (SERVING) and give load balancer time to update + updateHealthStatusAndWait("my-service", ServingStatus.SERVING, healthStatusManager); + + // initially the status should be SERVING + assertThatResponseIsServedToChannel(client); + + // put the service down (NOT_SERVING) and give load balancer time to update + updateHealthStatusAndWait("my-service", ServingStatus.NOT_SERVING, healthStatusManager); + + // now the request should fail + assertThatResponseIsNotServedToChannel(client); + + // put the service up (SERVING) and give load balancer time to update + updateHealthStatusAndWait("my-service", ServingStatus.SERVING, healthStatusManager); + + // now the request should pass + assertThatResponseIsServedToChannel(client); + } + + private void updateHealthStatusAndWait(String serviceName, ServingStatus healthStatus, + HealthStatusManager healthStatusManager) { + healthStatusManager.setStatus(serviceName, healthStatus); + try { + Thread.sleep(2000L); + } + catch (InterruptedException ex) { + throw new RuntimeException(ex); + } + } + + private void assertThatResponseIsServedToChannel(SimpleGrpc.SimpleBlockingStub client) { + HelloReply response = client.sayHello(HelloRequest.newBuilder().setName("Alien").build()); + assertThat(response.getMessage()).isEqualTo("Hello ==> Alien"); + } + + private void assertThatResponseIsNotServedToChannel(SimpleGrpc.SimpleBlockingStub client) { + assertThatExceptionOfType(StatusRuntimeException.class) + .isThrownBy(() -> client.sayHello(HelloRequest.newBuilder().setName("Alien").build())) + .withMessageContaining("UNAVAILABLE: Health-check service responded NOT_SERVING for 'my-service'"); + } + + } + + @Nested + @SpringBootTest(properties = { "spring.grpc.server.health.actuator.health-indicator-paths=custom", + "spring.grpc.server.health.actuator.update-initial-delay=3s", + "spring.grpc.server.health.actuator.update-rate=3s", "management.health.defaults.enabled=true" }) + @AutoConfigureInProcessTransport + @DirtiesContext + class WithActuatorHealthAdapter { + + @Test + void healthIndicatorsAdaptedToGrpcHealthStatus(@Autowired GrpcChannelFactory channels) { + var channel = channels.createChannel("0.0.0.0:0"); + var healthStub = HealthGrpc.newBlockingStub(channel); + var serviceName = "custom"; + + // initially the status should be SERVING + assertThatGrpcHealthStatusIs(healthStub, serviceName, ServingStatus.SERVING, Duration.ofSeconds(4)); + + // put the service down and the status should then be NOT_SERVING + CustomHealthIndicator.SERVICE_IS_UP = false; + assertThatGrpcHealthStatusIs(healthStub, serviceName, ServingStatus.NOT_SERVING, Duration.ofSeconds(4)); + + // put the service up and the status should be SERVING + CustomHealthIndicator.SERVICE_IS_UP = true; + assertThatGrpcHealthStatusIs(healthStub, serviceName, ServingStatus.SERVING, Duration.ofSeconds(4)); + } + + private void assertThatGrpcHealthStatusIs(HealthBlockingStub healthBlockingStub, String service, + ServingStatus expectedStatus, Duration maxWaitTime) { + Awaitility.await().atMost(maxWaitTime).ignoreException(StatusRuntimeException.class).untilAsserted(() -> { + var healthRequest = HealthCheckRequest.newBuilder().setService(service).build(); + var healthResponse = healthBlockingStub.check(healthRequest); + assertThat(healthResponse.getStatus()).isEqualTo(expectedStatus); + // verify the overall status as well + var overallHealthRequest = HealthCheckRequest.newBuilder().setService("").build(); + var overallHealthResponse = healthBlockingStub.check(overallHealthRequest); + assertThat(overallHealthResponse.getStatus()).isEqualTo(expectedStatus); + }); + } + + @TestConfiguration + static class MyHealthIndicatorsConfig { + + @ConditionalOnEnabledHealthIndicator("custom") + @Bean + CustomHealthIndicator customHealthIndicator() { + return new CustomHealthIndicator(); + } + + } + + static class CustomHealthIndicator implements HealthIndicator { + + static boolean SERVICE_IS_UP = true; + + @Override + public Health health() { + return SERVICE_IS_UP ? Health.up().build() : Health.down().build(); + } + + } + + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc/src/test/java/smoketest/grpc/GrpcServerApplicationTests.java b/smoke-test/spring-boot-smoke-test-grpc/src/test/java/smoketest/grpc/GrpcServerApplicationTests.java new file mode 100644 index 000000000000..022f7205545f --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc/src/test/java/smoketest/grpc/GrpcServerApplicationTests.java @@ -0,0 +1,99 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smoketest.grpc; + +import io.grpc.ManagedChannel; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import smoketest.grpc.proto.HelloReply; +import smoketest.grpc.proto.HelloRequest; +import smoketest.grpc.proto.SimpleGrpc; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.UseMainMethod; +import org.springframework.grpc.client.GrpcChannelFactory; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(useMainMethod = UseMainMethod.ALWAYS) +@DirtiesContext +class GrpcServerApplicationTests { + + static void main(String[] args) { + new SpringApplicationBuilder(GrpcServerApplication.class).run(); + } + + private void assertThatResponseIsServedToChannel(ManagedChannel clientChannel) { + SimpleGrpc.SimpleBlockingStub client = SimpleGrpc.newBlockingStub(clientChannel); + HelloReply response = client.sayHello(HelloRequest.newBuilder().setName("Alien").build()); + assertThat(response.getMessage()).isEqualTo("Hello ==> Alien"); + } + + @Nested + @SpringBootTest( + properties = { "spring.grpc.server.port=0", + "spring.grpc.client.default-channel.address=0.0.0.0:${local.grpc.port}" }, + useMainMethod = UseMainMethod.ALWAYS) + @DirtiesContext + class ServerUnsecured { + + @Test + void clientChannelWithoutSsl(@Autowired GrpcChannelFactory channels) { + assertThatResponseIsServedToChannel(channels.createChannel("test-channel")); + } + + } + + @Nested + @SpringBootTest(properties = { "spring.grpc.server.port=0", + "spring.grpc.client.channels.test-channel.address=static://0.0.0.0:${local.grpc.port}", + "spring.grpc.client.channels.test-channel.negotiation-type=TLS", + "spring.grpc.client.channels.test-channel.secure=false" }) + @ActiveProfiles("ssl") + @DirtiesContext + class ServerWithSsl { + + @Test + void clientChannelWithSsl(@Autowired GrpcChannelFactory channels) { + assertThatResponseIsServedToChannel(channels.createChannel("test-channel")); + } + + } + + @Nested + @SpringBootTest(properties = { "spring.grpc.server.port=0", "spring.grpc.server.ssl.client-auth=REQUIRE", + "spring.grpc.server.ssl.secure=false", + "spring.grpc.client.channels.test-channel.address=static://0.0.0.0:${local.grpc.port}", + "spring.grpc.client.channels.test-channel.ssl.bundle=ssltest", + "spring.grpc.client.channels.test-channel.negotiation-type=TLS", + "spring.grpc.client.channels.test-channel.secure=false" }) + @ActiveProfiles("ssl") + @DirtiesContext + class ServerWithClientAuth { + + @Test + void clientChannelWithSsl(@Autowired GrpcChannelFactory channels) { + assertThatResponseIsServedToChannel(channels.createChannel("test-channel")); + } + + } + +} diff --git a/smoke-test/spring-boot-smoke-test-grpc/src/test/resources/application-ssl.properties b/smoke-test/spring-boot-smoke-test-grpc/src/test/resources/application-ssl.properties new file mode 100644 index 000000000000..50ad5c4cbe6f --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc/src/test/resources/application-ssl.properties @@ -0,0 +1,5 @@ +spring.grpc.server.ssl.bundle=ssltest +spring.ssl.bundle.jks.ssltest.keystore.location=classpath:test.jks +spring.ssl.bundle.jks.ssltest.keystore.password=secret +spring.ssl.bundle.jks.ssltest.keystore.type=JKS +spring.ssl.bundle.jks.ssltest.key.password=password \ No newline at end of file diff --git a/smoke-test/spring-boot-smoke-test-grpc/src/test/resources/test.jks b/smoke-test/spring-boot-smoke-test-grpc/src/test/resources/test.jks new file mode 100644 index 000000000000..6aa9a28053a5 Binary files /dev/null and b/smoke-test/spring-boot-smoke-test-grpc/src/test/resources/test.jks differ diff --git a/starter/spring-boot-starter-grpc-client/build.gradle b/starter/spring-boot-starter-grpc-client/build.gradle new file mode 100644 index 000000000000..0f7b675db33b --- /dev/null +++ b/starter/spring-boot-starter-grpc-client/build.gradle @@ -0,0 +1,29 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id "org.springframework.boot.starter" +} + +description = "Starter using Spring gRPC client" + + +dependencies { + api(project(":starter:spring-boot-starter")) + api(project(":module:spring-boot-grpc-client")) + api("io.grpc:grpc-netty") + api("io.grpc:grpc-stub") +} diff --git a/starter/spring-boot-starter-grpc-server-web/build.gradle b/starter/spring-boot-starter-grpc-server-web/build.gradle new file mode 100644 index 000000000000..97f6d5d42a03 --- /dev/null +++ b/starter/spring-boot-starter-grpc-server-web/build.gradle @@ -0,0 +1,27 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id "org.springframework.boot.starter" +} +description = "Starter using Spring gRPC Servlet server" + + +dependencies { + api(project(":starter:spring-boot-starter-web")) + api(project(":module:spring-boot-grpc-server")) + api("io.grpc:grpc-servlet-jakarta") +} diff --git a/starter/spring-boot-starter-grpc-server/build.gradle b/starter/spring-boot-starter-grpc-server/build.gradle new file mode 100644 index 000000000000..45e52fc2a92b --- /dev/null +++ b/starter/spring-boot-starter-grpc-server/build.gradle @@ -0,0 +1,29 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id "org.springframework.boot.starter" +} + +description = "Starter using Spring gRPC server" + + +dependencies { + api(project(":starter:spring-boot-starter")) + api(project(":module:spring-boot-grpc-server")) + api("io.grpc:grpc-netty") + api("io.grpc:grpc-services") +} diff --git a/starter/spring-boot-starter-grpc/build.gradle b/starter/spring-boot-starter-grpc/build.gradle new file mode 100644 index 000000000000..0dbb6e144f35 --- /dev/null +++ b/starter/spring-boot-starter-grpc/build.gradle @@ -0,0 +1,28 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id "org.springframework.boot.starter" +} + +description = "Starter using Spring gRPC server and client" + + +dependencies { + api(project(":starter:spring-boot-starter")) + api(project(":starter:spring-boot-starter-grpc-client")) + api(project(":starter:spring-boot-starter-grpc-server")) +}