From 40e72dfb919f0610a5330c5073894a2b67dcbaea Mon Sep 17 00:00:00 2001 From: onobc Date: Sat, 20 Sep 2025 12:37:26 -0500 Subject: [PATCH 1/7] Add Spring gRPC support This commit introduces support for Spring gRPC by moving the autoconfiguration, test, and starter modules from Spring gRPC to Spring Boot (here). The relavant docs from Spring gRPC are not included in this commit and will be available in a subsequent commit. Signed-off-by: onobc --- .../DocumentConfigurationProperties.java | 5 + config/checkstyle/checkstyle-suppressions.xml | 1 + documentation/spring-boot-docs/build.gradle | 3 + module/spring-boot-grpc-client/build.gradle | 67 +++ .../ChannelBuilderCustomizers.java | 60 ++ .../ClientInterceptorsConfiguration.java | 40 ++ ...entPropertiesChannelBuilderCustomizer.java | 83 +++ .../ClientScanConfiguration.java | 88 +++ ...positeChannelFactoryAutoConfiguration.java | 67 +++ .../ConditionalOnGrpcClientEnabled.java | 45 ++ .../GrpcChannelFactoryConfigurations.java | 124 ++++ .../GrpcChannelFactoryCustomizer.java | 29 + .../GrpcClientAutoConfiguration.java | 91 +++ ...rpcClientObservationAutoConfiguration.java | 46 ++ .../autoconfigure/GrpcClientProperties.java | 478 ++++++++++++++++ .../NamedChannelCredentialsProvider.java | 79 +++ .../codec/GrpcCodecConfiguration.java | 57 ++ .../autoconfigure/codec/package-info.java | 23 + .../client/autoconfigure/package-info.java | 23 + ...itional-spring-configuration-metadata.json | 29 + ...ot.autoconfigure.AutoConfiguration.imports | 3 + .../ChannelBuilderCustomizersTests.java | 124 ++++ .../ClientScanConfigurationTests.java | 179 ++++++ ...eChannelFactoryAutoConfigurationTests.java | 141 +++++ .../GrpcClientAutoConfigurationTests.java | 435 ++++++++++++++ ...ientObservationAutoConfigurationTests.java | 118 ++++ .../GrpcClientPropertiesTests.java | 299 ++++++++++ .../codec/GrpcCodecConfigurationTests.java | 48 ++ .../src/test/resources/logback-test.xml | 4 + module/spring-boot-grpc-server/build.gradle | 67 +++ .../ConditionalOnGrpcNativeServer.java | 41 ++ .../ConditionalOnGrpcServerEnabled.java | 45 ++ .../ConditionalOnGrpcServletServer.java | 50 ++ .../DefaultServerFactoryPropertyMapper.java | 89 +++ .../GrpcServerAutoConfiguration.java | 130 +++++ .../GrpcServerExecutorProvider.java | 29 + .../GrpcServerFactoryAutoConfiguration.java | 114 ++++ .../GrpcServerFactoryConfigurations.java | 194 +++++++ .../GrpcServerFactoryCustomizer.java | 29 + ...rpcServerObservationAutoConfiguration.java | 71 +++ .../autoconfigure/GrpcServerProperties.java | 458 +++++++++++++++ ...GrpcServerReflectionAutoConfiguration.java | 54 ++ .../InProcessServerFactoryPropertyMapper.java | 41 ++ .../NettyServerFactoryPropertyMapper.java | 40 ++ .../OnGrpcNativeServerCondition.java | 69 +++ .../ServerBuilderCustomizers.java | 59 ++ .../ServletEnvironmentPostProcessor.java | 39 ++ ...hadedNettyServerFactoryPropertyMapper.java | 40 ++ .../codec/GrpcCodecConfiguration.java | 57 ++ .../autoconfigure/codec/package-info.java | 23 + ...GrpcExceptionHandlerAutoConfiguration.java | 61 ++ .../autoconfigure/exception/package-info.java | 23 + .../health/ActuatorHealthAdapter.java | 113 ++++ .../health/ActuatorHealthAdapterInvoker.java | 65 +++ .../GrpcServerHealthAutoConfiguration.java | 134 +++++ .../autoconfigure/health/package-info.java | 23 + .../server/autoconfigure/package-info.java | 24 + .../GrpcDisableCsrfHttpConfigurer.java | 70 +++ .../security/GrpcReactiveRequest.java | 131 +++++ .../GrpcSecurityAutoConfiguration.java | 113 ++++ .../security/GrpcServletRequest.java | 137 +++++ .../OAuth2ClientAutoConfiguration.java | 61 ++ ...OAuth2ResourceServerAutoConfiguration.java | 331 +++++++++++ .../autoconfigure/security/package-info.java | 23 + ...itional-spring-configuration-metadata.json | 51 ++ .../main/resources/META-INF/spring.factories | 5 + ...ot.autoconfigure.AutoConfiguration.imports | 9 + .../GrpcServerAutoConfigurationTests.java | 529 ++++++++++++++++++ ...rverObservationAutoConfigurationTests.java | 117 ++++ .../GrpcServerPropertiesTests.java | 190 +++++++ ...erverReflectionAutoConfigurationTests.java | 97 ++++ .../GrpcServletAutoConfigurationTests.java | 114 ++++ .../ServerBuilderCustomizersTests.java | 123 ++++ .../ServerFactoryPropertyMappersTests.java | 85 +++ .../codec/GrpcCodecConfigurationTests.java | 48 ++ ...xceptionHandlerAutoConfigurationTests.java | 128 +++++ .../ActuatorHealthAdapterInvokerTests.java | 52 ++ .../health/ActuatorHealthAdapterTests.java | 155 +++++ ...rpcServerHealthAutoConfigurationTests.java | 282 ++++++++++ .../security/GrpcReactiveRequestTests.java | 87 +++ .../GrpcSecurityAutoConfigurationTests.java | 118 ++++ .../security/GrpcServletRequestTests.java | 98 ++++ ...2ResourceServerAutoConfigurationTests.java | 175 ++++++ .../src/test/resources/logback-test.xml | 4 + .../boot/grpc/server/autoconfigure/test.jks | Bin 0 -> 1276 bytes .../build.gradle | 7 + .../grpc/AutoConfigureInProcessTransport.java | 53 ++ .../grpc/InProcessTestAutoConfiguration.java | 157 ++++++ ...cessTransportContextCustomizerFactory.java | 91 +++ .../autoconfigure/grpc/LocalGrpcPort.java | 42 ++ ...PortInfoApplicationContextInitializer.java | 87 +++ .../test/autoconfigure/grpc/package-info.java | 23 + .../main/resources/META-INF/spring.factories | 5 + ...pc.AutoConfigureInProcessTransport.imports | 2 + .../InProcessTestAutoConfigurationTests.java | 99 ++++ .../spring-boot-dependencies/build.gradle | 63 +++ .../build.gradle | 5 - settings.gradle | 7 + .../spring-boot-smoke-test-grpc/build.gradle | 67 +++ .../smoketest/grpc/GrpcServerApplication.java | 29 + .../smoketest/grpc/GrpcServerService.java | 67 +++ .../java/smoketest/grpc/package-info.java | 20 + .../src/main/proto/hello.proto | 23 + .../src/main/resources/application.properties | 1 + .../GrpcServerApplicationHealthTests.java | 174 ++++++ .../grpc/GrpcServerApplicationTests.java | 99 ++++ .../test/resources/application-ssl.properties | 5 + .../src/test/resources/test.jks | Bin 0 -> 2264 bytes .../build.gradle | 29 + .../build.gradle | 27 + .../build.gradle | 29 + starter/spring-boot-starter-grpc/build.gradle | 28 + 112 files changed, 9538 insertions(+), 5 deletions(-) create mode 100644 module/spring-boot-grpc-client/build.gradle create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ChannelBuilderCustomizers.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ClientInterceptorsConfiguration.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ClientPropertiesChannelBuilderCustomizer.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ClientScanConfiguration.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/CompositeChannelFactoryAutoConfiguration.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ConditionalOnGrpcClientEnabled.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelFactoryConfigurations.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelFactoryCustomizer.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfiguration.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientObservationAutoConfiguration.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientProperties.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/NamedChannelCredentialsProvider.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/codec/GrpcCodecConfiguration.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/codec/package-info.java create mode 100644 module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/package-info.java create mode 100644 module/spring-boot-grpc-client/src/main/resources/META-INF/additional-spring-configuration-metadata.json create mode 100644 module/spring-boot-grpc-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/ChannelBuilderCustomizersTests.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/ClientScanConfigurationTests.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/CompositeChannelFactoryAutoConfigurationTests.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfigurationTests.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientObservationAutoConfigurationTests.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientPropertiesTests.java create mode 100644 module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/codec/GrpcCodecConfigurationTests.java create mode 100644 module/spring-boot-grpc-client/src/test/resources/logback-test.xml create mode 100644 module/spring-boot-grpc-server/build.gradle create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ConditionalOnGrpcNativeServer.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ConditionalOnGrpcServerEnabled.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ConditionalOnGrpcServletServer.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/DefaultServerFactoryPropertyMapper.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerAutoConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerExecutorProvider.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerFactoryAutoConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerFactoryConfigurations.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerFactoryCustomizer.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerObservationAutoConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerProperties.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerReflectionAutoConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/InProcessServerFactoryPropertyMapper.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/NettyServerFactoryPropertyMapper.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/OnGrpcNativeServerCondition.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ServerBuilderCustomizers.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ServletEnvironmentPostProcessor.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ShadedNettyServerFactoryPropertyMapper.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/codec/GrpcCodecConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/codec/package-info.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/exception/GrpcExceptionHandlerAutoConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/exception/package-info.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/ActuatorHealthAdapter.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/ActuatorHealthAdapterInvoker.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/package-info.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/package-info.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcDisableCsrfHttpConfigurer.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcReactiveRequest.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcSecurityAutoConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServletRequest.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/OAuth2ClientAutoConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/OAuth2ResourceServerAutoConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/package-info.java create mode 100644 module/spring-boot-grpc-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json create mode 100644 module/spring-boot-grpc-server/src/main/resources/META-INF/spring.factories create mode 100644 module/spring-boot-grpc-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerAutoConfigurationTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerObservationAutoConfigurationTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerPropertiesTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerReflectionAutoConfigurationTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServletAutoConfigurationTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/ServerBuilderCustomizersTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/ServerFactoryPropertyMappersTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/codec/GrpcCodecConfigurationTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/exception/GrpcExceptionHandlerAutoConfigurationTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/ActuatorHealthAdapterInvokerTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/ActuatorHealthAdapterTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfigurationTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcReactiveRequestTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcSecurityAutoConfigurationTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcServletRequestTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/security/OAuth2ResourceServerAutoConfigurationTests.java create mode 100644 module/spring-boot-grpc-server/src/test/resources/logback-test.xml create mode 100644 module/spring-boot-grpc-server/src/test/resources/org/springframework/boot/grpc/server/autoconfigure/test.jks create mode 100644 module/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/grpc/AutoConfigureInProcessTransport.java create mode 100644 module/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/grpc/InProcessTestAutoConfiguration.java create mode 100644 module/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/grpc/InProcessTransportContextCustomizerFactory.java create mode 100644 module/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/grpc/LocalGrpcPort.java create mode 100644 module/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/grpc/ServerPortInfoApplicationContextInitializer.java create mode 100644 module/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/grpc/package-info.java create mode 100644 module/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.grpc.AutoConfigureInProcessTransport.imports create mode 100644 module/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/grpc/InProcessTestAutoConfigurationTests.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc/build.gradle create mode 100644 smoke-test/spring-boot-smoke-test-grpc/src/main/java/smoketest/grpc/GrpcServerApplication.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc/src/main/java/smoketest/grpc/GrpcServerService.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc/src/main/java/smoketest/grpc/package-info.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc/src/main/proto/hello.proto create mode 100644 smoke-test/spring-boot-smoke-test-grpc/src/main/resources/application.properties create mode 100644 smoke-test/spring-boot-smoke-test-grpc/src/test/java/smoketest/grpc/GrpcServerApplicationHealthTests.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc/src/test/java/smoketest/grpc/GrpcServerApplicationTests.java create mode 100644 smoke-test/spring-boot-smoke-test-grpc/src/test/resources/application-ssl.properties create mode 100644 smoke-test/spring-boot-smoke-test-grpc/src/test/resources/test.jks create mode 100644 starter/spring-boot-starter-grpc-client/build.gradle create mode 100644 starter/spring-boot-starter-grpc-server-web/build.gradle create mode 100644 starter/spring-boot-starter-grpc-server/build.gradle create mode 100644 starter/spring-boot-starter-grpc/build.gradle 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 929c586a5c9c..80b5f29278c0 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 bc93c50f8426..66765cf2d765 100644 --- a/documentation/spring-boot-docs/build.gradle +++ b/documentation/spring-boot-docs/build.gradle @@ -93,6 +93,8 @@ dependencies { implementation(project(path: ":module:spring-boot-data-elasticsearch")) implementation(project(path: ":module:spring-boot-data-neo4j")) implementation(project(path: ":module:spring-boot-devtools")) + 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")) @@ -178,6 +180,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..663b564cf50c --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ChannelBuilderCustomizers.java @@ -0,0 +1,60 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.client.autoconfigure; + +import 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 + * @since 4.0.0 + */ +public 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/ClientInterceptorsConfiguration.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ClientInterceptorsConfiguration.java new file mode 100644 index 000000000000..2716b6e30f36 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ClientInterceptorsConfiguration.java @@ -0,0 +1,40 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.client.autoconfigure; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.client.ClientInterceptorsConfigurer; + +/** + * Configuration for {@link ClientInterceptorsConfigurer}. + * + * @author Chris Bono + * @since 4.0.0 + */ +@Configuration(proxyBeanMethods = false) +public class ClientInterceptorsConfiguration { + + @Bean + @ConditionalOnMissingBean + ClientInterceptorsConfigurer clientInterceptorsConfigurer(ApplicationContext applicationContext) { + return new ClientInterceptorsConfigurer(applicationContext); + } + +} 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/ClientScanConfiguration.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ClientScanConfiguration.java new file mode 100644 index 000000000000..e2aa4a566759 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/ClientScanConfiguration.java @@ -0,0 +1,88 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.jspecify.annotations.Nullable; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.boot.autoconfigure.AutoConfigurationPackages; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.grpc.client.autoconfigure.ClientScanConfiguration.DefaultGrpcClientRegistrations; +import org.springframework.boot.grpc.client.autoconfigure.GrpcClientProperties.ChannelConfig; +import org.springframework.context.EnvironmentAware; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.env.Environment; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.grpc.client.AbstractGrpcClientRegistrar; +import org.springframework.grpc.client.GrpcClientFactory; +import org.springframework.grpc.client.GrpcClientFactory.GrpcClientRegistrationSpec; +import org.springframework.util.Assert; + +@Configuration(proxyBeanMethods = false) +@ConditionalOnMissingBean(GrpcClientFactory.class) +@Import(DefaultGrpcClientRegistrations.class) +public class ClientScanConfiguration { + + static class DefaultGrpcClientRegistrations extends AbstractGrpcClientRegistrar + implements EnvironmentAware, BeanFactoryAware { + + private @Nullable Environment environment; + + private @Nullable BeanFactory beanFactory; + + @Override + public void setEnvironment(Environment environment) { + this.environment = environment; + } + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + this.beanFactory = beanFactory; + } + + @Override + protected GrpcClientRegistrationSpec[] collect(AnnotationMetadata meta) { + Assert.notNull(this.environment, "Environment must not be null"); + Assert.notNull(this.beanFactory, "BeanFactory must not be null"); + 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 props = binder.bind("spring.grpc.client", GrpcClientProperties.class) + .orElseGet(GrpcClientProperties::new); + + return new GrpcClientRegistrationSpec[] { GrpcClientRegistrationSpec.of("default") + .factory(props.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/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/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..57a8af5ae711 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelFactoryConfigurations.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.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); + var 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); + var 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..c3a665ca8b32 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcChannelFactoryCustomizer.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 org.springframework.boot.grpc.client.autoconfigure; + +import org.springframework.grpc.client.GrpcChannelFactory; + +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..a14f0aa716d2 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfiguration.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.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.codec.GrpcCodecConfiguration; +import org.springframework.boot.ssl.SslBundles; +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.CoroutineStubFactory; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; + +@AutoConfiguration(before = CompositeChannelFactoryAutoConfiguration.class) +@ConditionalOnGrpcClientEnabled +@EnableConfigurationProperties(GrpcClientProperties.class) +@Import({ GrpcCodecConfiguration.class, ClientInterceptorsConfiguration.class, + GrpcChannelFactoryConfigurations.ShadedNettyChannelFactoryConfiguration.class, + GrpcChannelFactoryConfigurations.NettyChannelFactoryConfiguration.class, + GrpcChannelFactoryConfigurations.InProcessChannelFactoryConfiguration.class, ClientScanConfiguration.class }) +public final class GrpcClientAutoConfiguration { + + @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) + @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..0da9e0dcf4da --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientProperties.java @@ -0,0 +1,478 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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 { + + /** + * The default channel configuration to use for new channels. + */ + private final ChannelConfig defaultChannel = new ChannelConfig(); + + /** + * Map of channels configured by name. + */ + private final Map channels = new HashMap<>(); + + /** + * 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 ChannelConfig getDefaultChannel() { + return this.defaultChannel; + } + + public Map getChannels() { + return this.channels; + } + + 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"; + + public String getAddress() { + return this.address; + } + + public void setAddress(final String address) { + this.address = address; + } + + // -------------------------------------------------- + // defaultLoadBalancingPolicy + // -------------------------------------------------- + + /** + * The default load balancing policy the channel should use. + */ + private String defaultLoadBalancingPolicy = "round_robin"; + + public String getDefaultLoadBalancingPolicy() { + return this.defaultLoadBalancingPolicy; + } + + public void setDefaultLoadBalancingPolicy(final String defaultLoadBalancingPolicy) { + this.defaultLoadBalancingPolicy = defaultLoadBalancingPolicy; + } + + // -------------------------------------------------- + + private final Health health = new Health(); + + public Health getHealth() { + return this.health; + } + + /** + * Map representation of the service config to use for the channel. + */ + private final Map serviceConfig = new HashMap<>(); + + public Map getServiceConfig() { + return this.serviceConfig; + } + + /** + * The negotiation type for the channel. + */ + private NegotiationType negotiationType = NegotiationType.PLAINTEXT; + + public NegotiationType getNegotiationType() { + return this.negotiationType; + } + + public void setNegotiationType(NegotiationType negotiationType) { + this.negotiationType = negotiationType; + } + + // -------------------------------------------------- + // KeepAlive + // -------------------------------------------------- + + /** + * Whether keep alive is enabled on the channel. + */ + private boolean enableKeepAlive = false; + + public boolean isEnableKeepAlive() { + return this.enableKeepAlive; + } + + public void setEnableKeepAlive(boolean enableKeepAlive) { + this.enableKeepAlive = enableKeepAlive; + } + + // -------------------------------------------------- + + /** + * The duration without ongoing RPCs before going to idle mode. + */ + @DurationUnit(ChronoUnit.SECONDS) + private Duration idleTimeout = Duration.ofSeconds(20); + + public Duration getIdleTimeout() { + return this.idleTimeout; + } + + public void setIdleTimeout(Duration idleTimeout) { + this.idleTimeout = idleTimeout; + } + + // -------------------------------------------------- + + /** + * 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); + + public Duration getKeepAliveTime() { + return this.keepAliveTime; + } + + public void setKeepAliveTime(Duration keepAliveTime) { + this.keepAliveTime = keepAliveTime; + } + + // -------------------------------------------------- + + /** + * The default timeout for a keepAlives ping request. + */ + @DurationUnit(ChronoUnit.SECONDS) + private Duration keepAliveTimeout = Duration.ofSeconds(20); + + public Duration getKeepAliveTimeout() { + return this.keepAliveTimeout; + } + + public void setKeepAliveTimeout(Duration keepAliveTimeout) { + this.keepAliveTimeout = keepAliveTimeout; + } + + // -------------------------------------------------- + + /** + * Whether a keepAlive will be performed when there are no outstanding RPC on a + * connection. + */ + private boolean keepAliveWithoutCalls = false; + + public boolean isKeepAliveWithoutCalls() { + return this.keepAliveWithoutCalls; + } + + public void setKeepAliveWithoutCalls(boolean keepAliveWithoutCalls) { + this.keepAliveWithoutCalls = keepAliveWithoutCalls; + } + + // -------------------------------------------------- + // Message Transfer + // -------------------------------------------------- + + /** + * 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); + + 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)); + } + } + + // -------------------------------------------------- + + /** + * The custom User-Agent for the channel. + */ + private @Nullable String userAgent = null; + + public @Nullable String getUserAgent() { + return this.userAgent; + } + + public void setUserAgent(@Nullable String userAgent) { + this.userAgent = userAgent; + } + + /** + * The default deadline for RPCs performed on this channel. + */ + private @Nullable Duration defaultDeadline = null; + + public @Nullable Duration getDefaultDeadline() { + return this.defaultDeadline; + } + + public void setDefaultDeadline(@Nullable Duration defaultDeadline) { + this.defaultDeadline = defaultDeadline; + } + + /** + * Provide a copy of the channel instance. + * @return a copy of the channel instance. + */ + public 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; + } + + // -------------------------------------------------- + + /** + * Flag to say that strict SSL checks are not enabled (so the remote certificate + * could be anonymous). + */ + private boolean secure = true; + + public boolean isSecure() { + return this.secure; + } + + public void setSecure(boolean secure) { + this.secure = secure; + } + + // -------------------------------------------------- + + private final Ssl ssl = new Ssl(); + + public Ssl getSsl() { + return this.ssl; + } + + 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 boolean isEnabled() { + return (this.enabled != null) ? this.enabled : this.bundle != null; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + 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 + */ + public void copyValuesFrom(Ssl other) { + this.enabled = other.enabled; + this.bundle = other.bundle; + } + + } + + public static class Health { + + /** + * Whether to enable client-side health check for the channel. + */ + private boolean enabled = false; + + /** + * 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 + */ + public void copyValuesFrom(Health other) { + this.enabled = other.enabled; + this.serviceName = other.serviceName; + } + + } + + } + +} 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..45778bcefe15 --- /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 + * @since 4.0.0 + */ +public class NamedChannelCredentialsProvider implements ChannelCredentialsProvider { + + private final SslBundles bundles; + + private final GrpcClientProperties properties; + + public NamedChannelCredentialsProvider(SslBundles bundles, GrpcClientProperties properties) { + this.bundles = bundles; + this.properties = properties; + } + + @Override + public ChannelCredentials getChannelCredentials(String path) { + ChannelConfig channel = this.properties.getChannel(path); + if (!channel.getSsl().isEnabled() && channel.getNegotiationType() == NegotiationType.PLAINTEXT) { + return InsecureChannelCredentials.create(); + } + if (channel.getSsl().isEnabled()) { + 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/codec/GrpcCodecConfiguration.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/codec/GrpcCodecConfiguration.java new file mode 100644 index 000000000000..ea01da35c325 --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/codec/GrpcCodecConfiguration.java @@ -0,0 +1,57 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.client.autoconfigure.codec; + +import io.grpc.Codec; +import io.grpc.Compressor; +import io.grpc.CompressorRegistry; +import io.grpc.Decompressor; +import io.grpc.DecompressorRegistry; + +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 + * @since 4.0.0 + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(Codec.class) +public class GrpcCodecConfiguration { + + @Bean + @ConditionalOnMissingBean + CompressorRegistry compressorRegistry(ObjectProvider compressors) { + CompressorRegistry registry = CompressorRegistry.getDefaultInstance(); + compressors.orderedStream().forEachOrdered(registry::register); + return registry; + } + + @Bean + @ConditionalOnMissingBean + DecompressorRegistry decompressorRegistry(ObjectProvider decompressors) { + DecompressorRegistry registry = DecompressorRegistry.getDefaultInstance(); + decompressors.orderedStream().forEachOrdered((decompressor) -> registry.with(decompressor, false)); + return registry; + } + +} diff --git a/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/codec/package-info.java b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/codec/package-info.java new file mode 100644 index 000000000000..82c09391fd1f --- /dev/null +++ b/module/spring-boot-grpc-client/src/main/java/org/springframework/boot/grpc/client/autoconfigure/codec/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 codecs. + */ +@NullMarked +package org.springframework.boot.grpc.client.autoconfigure.codec; + +import org.jspecify.annotations.NullMarked; 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..52beeba738d9 --- /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.observations.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/ClientScanConfigurationTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/ClientScanConfigurationTests.java new file mode 100644 index 000000000000..16cc33bca683 --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/ClientScanConfigurationTests.java @@ -0,0 +1,179 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.client.autoconfigure; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.AutoConfigurationPackages; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.context.properties.bind.BindException; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +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.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for the {@link ClientScanConfiguration}. + * + * @author CheolHwan Ihn + */ +class ClientScanConfigurationTests { + + @Test + void testReactorStubFactory() { + 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"); + + MockEnvironment environment = new MockEnvironment(); + environment.getPropertySources().addFirst(new MapPropertySource("test", properties)); + + ClientScanConfiguration.DefaultGrpcClientRegistrations registrations = new ClientScanConfiguration.DefaultGrpcClientRegistrations(); + registrations.setEnvironment(environment); + + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.setEnvironment(environment); + AutoConfigurationPackages.register(context, "org.springframework.grpc.autoconfigure.client"); + registrations.setBeanFactory(context.getBeanFactory()); + + GrpcClientRegistrationSpec[] specs = registrations.collect(null); + + assertThat(specs).hasSize(1); + assertThat(specs[0].factory()).isEqualTo(ReactorStubFactory.class); + } + + @Test + void testDefaultStubFactory() { + Map properties = new HashMap<>(); + properties.put("spring.grpc.client.default-channel.address", "static://localhost:9090"); + + MockEnvironment environment = new MockEnvironment(); + environment.getPropertySources().addFirst(new MapPropertySource("test", properties)); + + ClientScanConfiguration.DefaultGrpcClientRegistrations registrations = new ClientScanConfiguration.DefaultGrpcClientRegistrations(); + registrations.setEnvironment(environment); + + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.setEnvironment(environment); + AutoConfigurationPackages.register(context, "org.springframework.grpc.autoconfigure.client"); + registrations.setBeanFactory(context.getBeanFactory()); + + GrpcClientRegistrationSpec[] specs = registrations.collect(null); + + assertThat(specs).hasSize(1); + assertThat(specs[0].factory()).isEqualTo(BlockingStubFactory.class); + } + + @Test + void testCoroutineStubFactory() { + 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"); + + MockEnvironment env = new MockEnvironment(); + env.getPropertySources().addFirst(new MapPropertySource("test", properties)); + + var regs = new ClientScanConfiguration.DefaultGrpcClientRegistrations(); + regs.setEnvironment(env); + + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.setEnvironment(env); + AutoConfigurationPackages.register(context, "org.springframework.grpc.autoconfigure.client"); + regs.setBeanFactory(context.getBeanFactory()); + + GrpcClientRegistrationSpec[] specs = regs.collect(null); + assertThat(specs).hasSize(1); + assertThat(specs[0].factory()).isEqualTo(CoroutineStubFactory.class); + } + } + + @Test + void testInvalidStubFactoryValueThrowsBindException() { + Map properties = new HashMap<>(); + properties.put("spring.grpc.client.default-stub-factory", "com.example.InvalidStubFactory"); + properties.put("spring.grpc.client.default-channel.address", "static://localhost:9090"); + + MockEnvironment env = new MockEnvironment(); + env.getPropertySources().addFirst(new MapPropertySource("test", properties)); + + var regs = new ClientScanConfiguration.DefaultGrpcClientRegistrations(); + regs.setEnvironment(env); + + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.setEnvironment(env); + AutoConfigurationPackages.register(context, "org.springframework.grpc.autoconfigure.client"); + regs.setBeanFactory(context.getBeanFactory()); + + assertThatExceptionOfType(BindException.class).isThrownBy(() -> regs.collect(null)) + .isInstanceOf(BindException.class) + .withMessageContaining("spring.grpc.client.default-stub-factory"); + } + } + + @Nested + @TestConfiguration(proxyBeanMethods = false) + @SpringBootTest(classes = { ClientScanConfiguration.class, ClientScanConfigurationSpringBootTest.TestConfig.class }, + properties = { "spring.grpc.client.default-channel.address=static://localhost:9090", + "spring.grpc.client.default-stub-factory=org.springframework.grpc.client.ReactorStubFactory" }) + class ClientScanConfigurationSpringBootTest { + + @Autowired + private ApplicationContext context; + + @Autowired + private GrpcClientProperties props; + + @Autowired + private Environment env; + + @Test + void propertyIsBoundAsBeanAndUsable() { + assertThat(this.props.getDefaultStubFactory()).isEqualTo(ReactorStubFactory.class); + + GrpcClientProperties rebound = Binder.get(this.env) + .bind("spring.grpc.client", GrpcClientProperties.class) + .get(); + + assertThat(rebound.getDefaultStubFactory()).isEqualTo(ReactorStubFactory.class); + } + + @Configuration + @EnableConfigurationProperties(GrpcClientProperties.class) + static class TestConfig { + + } + + } + +} 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/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..175be92bf2cb --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientAutoConfigurationTests.java @@ -0,0 +1,435 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.ssl.SslBundles; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.grpc.client.ChannelCredentialsProvider; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; +import org.springframework.grpc.client.GrpcChannelFactory; +import org.springframework.grpc.client.InProcessGrpcChannelFactory; +import org.springframework.grpc.client.NettyGrpcChannelFactory; +import org.springframework.grpc.client.ShadedNettyGrpcChannelFactory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.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 whenNoCompressorRegistryAutoConfigurationIsSkipped() { + // 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 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 whenNoDecompressorRegistryAutoConfigurationIsSkipped() { + // 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 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 whenHasUserDefinedChannelBuilderCustomizersDoesNotAutoConfigureBean() { + ChannelBuilderCustomizers customCustomizers = mock(ChannelBuilderCustomizers.class); + 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 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..8f6d9452c20d --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/GrpcClientPropertiesTests.java @@ -0,0 +1,299 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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()).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().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); + } + + } + + @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/codec/GrpcCodecConfigurationTests.java b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/codec/GrpcCodecConfigurationTests.java new file mode 100644 index 000000000000..cefda3c929b5 --- /dev/null +++ b/module/spring-boot-grpc-client/src/test/java/org/springframework/boot/grpc/client/autoconfigure/codec/GrpcCodecConfigurationTests.java @@ -0,0 +1,48 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.codec; + +import io.grpc.CompressorRegistry; +import io.grpc.DecompressorRegistry; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link GrpcCodecConfiguration}. + * + * @author Andrei Lisa + */ +class GrpcCodecConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcCodecConfiguration.class)); + + @Test + void testCompressorRegistryBean() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(CompressorRegistry.class)); + } + + @Test + void testDecompressorRegistryBean() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(DecompressorRegistry.class)); + } + +} 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..de80378dcc5a --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ConditionalOnGrpcServerEnabled.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.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.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Conditional; + +/** + * {@link Conditional @Conditional} that only matches when the + * {@code io.grpc.BindableService} class is on the classpath and 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 }) +@ConditionalOnClass(BindableService.class) +@ConditionalOnProperty(prefix = "spring.grpc.server", name = "enabled", matchIfMissing = true) +public @interface ConditionalOnGrpcServerEnabled { + +} 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/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..9f984ed34850 --- /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> { + + 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 mapper = PropertyMapper.get(); + customizeKeepAlive(serverBuilder, mapper); + customizeInboundLimits(serverBuilder, mapper); + } + + /** + * Map the keep-alive properties to the server factory's server builder. + * @param serverBuilder the builder + * @param mapper the property mapper + */ + void customizeKeepAlive(T serverBuilder, PropertyMapper mapper) { + GrpcServerProperties.KeepAlive keepAliveProps = this.properties.getKeepAlive(); + mapper.from(keepAliveProps.getTime()).to(durationProperty(serverBuilder::keepAliveTime)); + mapper.from(keepAliveProps.getTimeout()).to(durationProperty(serverBuilder::keepAliveTimeout)); + mapper.from(keepAliveProps.getMaxIdle()).to(durationProperty(serverBuilder::maxConnectionIdle)); + mapper.from(keepAliveProps.getMaxAge()).to(durationProperty(serverBuilder::maxConnectionAge)); + mapper.from(keepAliveProps.getMaxAgeGrace()).to(durationProperty(serverBuilder::maxConnectionAgeGrace)); + mapper.from(keepAliveProps.getPermitTime()).to(durationProperty(serverBuilder::permitKeepAliveTime)); + mapper.from(keepAliveProps.isPermitWithoutCalls()).to(serverBuilder::permitKeepAliveWithoutCalls); + } + + /** + * Map the inbound limits properties to the server factory's server builder. + * @param serverBuilder the builder + * @param mapper the property mapper + */ + void customizeInboundLimits(T serverBuilder, PropertyMapper mapper) { + mapper.from(this.properties.getMaxInboundMessageSize()) + .asInt(DataSize::toBytes) + .to(serverBuilder::maxInboundMessageSize); + mapper.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/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..178cb7746d89 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerAutoConfiguration.java @@ -0,0 +1,130 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.boot.grpc.server.autoconfigure.codec.GrpcCodecConfiguration; +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.GrpcServerFactory; +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) +@ConditionalOnGrpcServerEnabled +@ConditionalOnClass({ GrpcServerFactory.class }) +@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..9a1551cf5da0 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerExecutorProvider.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 org.springframework.boot.grpc.server.autoconfigure; + +import java.util.concurrent.Executor; + +public interface GrpcServerExecutorProvider { + + /** + * Returns a {@link Executor} for the gRPC server, if it needs tio 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..6572c52343ee --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerFactoryAutoConfiguration.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; + +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.autoconfigure.condition.ConditionalOnClass; +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.GrpcServerFactory; +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) +@ConditionalOnGrpcServerEnabled +@ConditionalOnClass(GrpcServerFactory.class) +@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..ca31400c26ee --- /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().isEnabled()) { + 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.getAddress(), + 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().isEnabled()) { + 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.getAddress(), 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..95a3f385f0da --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerFactoryCustomizer.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 org.springframework.boot.grpc.server.autoconfigure; + +import org.springframework.grpc.server.GrpcServerFactory; + +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..a8dedd52637d --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerObservationAutoConfiguration.java @@ -0,0 +1,71 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure; + +import 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.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.grpc.server.GlobalServerInterceptor; +import org.springframework.grpc.server.GrpcServerFactory; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for gRPC server-side observations. + * + * @author Sunny Tang + * @author Chris Bono + * @author Dave Syer + * @since 4.0.0 + */ +@AutoConfiguration( + afterName = "org.springframework.boot.micrometer.observation.autoconfigure.ObservationAutoConfiguration") +@ConditionalOnGrpcServerEnabled +@ConditionalOnClass({ GrpcServerFactory.class, ObservationRegistry.class, ObservationGrpcServerInterceptor.class }) +@ConditionalOnBean(ObservationRegistry.class) +@ConditionalOnProperty(name = "spring.grpc.server.observation.enabled", havingValue = "true", matchIfMissing = true) +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..c4140cbacfc6 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerProperties.java @@ -0,0 +1,458 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure; + +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 = "*"; + + /** + * Server address 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. + * The default is 9090. + */ + private int port = GrpcUtils.DEFAULT_PORT; + + /** + * 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); + + /** + * 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); + + private final Health health = new Health(); + + private final KeepAlive keepAlive = new KeepAlive(); + + private final Inprocess inprocess = new Inprocess(); + + /** + * The address to bind to. could be a host:port combination or a pseudo URL like + * static://host:port. Can not be set if host or port are set independently. + */ + @Nullable private String address; + + public String getAddress() { + return (this.address != null) ? this.address : this.host + ":" + this.port; + } + + public void setAddress(String address) { + this.address = address; + } + + private final Ssl ssl = new Ssl(); + + public Ssl getSsl() { + return this.ssl; + } + + public String getHost() { + return this.host; + } + + public void setHost(String host) { + if (this.address != null) { + throw new IllegalStateException("Cannot set host when address is already set"); + } + this.host = host; + } + + public int getPort() { + if (this.address != null) { + return GrpcUtils.getPort(this.address); + } + return this.port; + } + + public void setPort(int port) { + if (this.address != null) { + throw new IllegalStateException("Cannot set port when address is already set"); + } + this.port = port; + } + + public @Nullable Duration getShutdownGracePeriod() { + return this.shutdownGracePeriod; + } + + public void setShutdownGracePeriod(Duration shutdownGracePeriod) { + this.shutdownGracePeriod = shutdownGracePeriod; + } + + 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 Health getHealth() { + return this.health; + } + + public KeepAlive getKeepAlive() { + return this.keepAlive; + } + + public Inprocess getInprocess() { + return this.inprocess; + } + + public static class Health { + + /** + * Whether to auto-configure Health feature on the gRPC server. + */ + private Boolean enabled = true; + + private final ActuatorAdapt actuator = new ActuatorAdapt(); + + public Boolean getEnabled() { + return this.enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + public ActuatorAdapt getActuator() { + return this.actuator; + } + + } + + public static class ActuatorAdapt { + + /** + * 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 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 = null; + + /** + * Maximum time a connection may exist before being gracefully terminated (default + * infinite). + */ + @DurationUnit(ChronoUnit.SECONDS) + private @Nullable Duration maxAge = null; + + /** + * Maximum time for graceful connection termination (default infinite). + */ + @DurationUnit(ChronoUnit.SECONDS) + private @Nullable Duration maxAgeGrace = null; + + /** + * 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 = false; + + public @Nullable Duration getTime() { + return this.time; + } + + public void setTime(Duration time) { + this.time = time; + } + + public @Nullable Duration getTimeout() { + return this.timeout; + } + + public void setTimeout(Duration timeout) { + this.timeout = timeout; + } + + public @Nullable Duration getMaxIdle() { + return this.maxIdle; + } + + public void setMaxIdle(Duration maxIdle) { + this.maxIdle = maxIdle; + } + + public @Nullable Duration getMaxAge() { + return this.maxAge; + } + + public void setMaxAge(Duration maxAge) { + this.maxAge = maxAge; + } + + public @Nullable Duration getMaxAgeGrace() { + return this.maxAgeGrace; + } + + public void setMaxAgeGrace(Duration maxAgeGrace) { + this.maxAgeGrace = maxAgeGrace; + } + + public @Nullable Duration getPermitTime() { + return this.permitTime; + } + + public void setPermitTime(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. Enabled automatically if "bundle" is provided + * unless specified otherwise. + */ + 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 boolean isEnabled() { + return (this.enabled != null) ? this.enabled : this.bundle != null; + } + + public void copyDefaultsFrom(Ssl config) { + if (this.enabled == null) { + this.enabled = config.enabled; + } + if (this.bundle == null) { + this.bundle = config.bundle; + } + + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public @Nullable String getBundle() { + return this.bundle; + } + + public void setBundle(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; + } + + } + + public static class Inprocess { + + /** + * The name of the in-process server or null to not start the in-process server. + */ + private @Nullable String name; + + /** + * Whether the inprocess server factory should be the only server factory + * available. When the value is true no other server factory will be configured. + */ + private @Nullable Boolean exclusive; + + public @Nullable String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public @Nullable Boolean getExclusive() { + return this.exclusive; + } + + public void setExclusive(Boolean exclusive) { + this.exclusive = exclusive; + } + + } + +} 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..c3c1ed0f960c --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerReflectionAutoConfiguration.java @@ -0,0 +1,54 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.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.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.grpc.server.GrpcServerFactory; + +/** + * {@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) +@ConditionalOnGrpcServerEnabled +@ConditionalOnClass({ GrpcServerFactory.class, ProtoReflectionServiceV1.class }) +@ConditionalOnBean(BindableService.class) +@ConditionalOnProperty(name = "spring.grpc.server.reflection.enabled", havingValue = "true", matchIfMissing = true) +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/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..973414a9f109 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ServerBuilderCustomizers.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure; + +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 + * @since 4.0.0 + */ +public 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..923c67d3c66b --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/ServletEnvironmentPostProcessor.java @@ -0,0 +1,39 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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; + +public 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/codec/GrpcCodecConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/codec/GrpcCodecConfiguration.java new file mode 100644 index 000000000000..9bec055f950b --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/codec/GrpcCodecConfiguration.java @@ -0,0 +1,57 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure.codec; + +import io.grpc.Codec; +import io.grpc.Compressor; +import io.grpc.CompressorRegistry; +import io.grpc.Decompressor; +import io.grpc.DecompressorRegistry; + +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/servers. + * + * @author Andrei Lisa + * @since 4.0.0 + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(Codec.class) +public class GrpcCodecConfiguration { + + @Bean + @ConditionalOnMissingBean + CompressorRegistry compressorRegistry(ObjectProvider compressors) { + CompressorRegistry registry = CompressorRegistry.getDefaultInstance(); + compressors.orderedStream().forEachOrdered(registry::register); + return registry; + } + + @Bean + @ConditionalOnMissingBean + DecompressorRegistry decompressorRegistry(ObjectProvider decompressors) { + DecompressorRegistry registry = DecompressorRegistry.getDefaultInstance(); + decompressors.orderedStream().forEachOrdered((decompressor) -> registry.with(decompressor, false)); + return registry; + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/codec/package-info.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/codec/package-info.java new file mode 100644 index 000000000000..918b696933f2 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/codec/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 codecs. + */ +@NullMarked +package org.springframework.boot.grpc.server.autoconfigure.codec; + +import org.jspecify.annotations.NullMarked; 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..c388c6d73e73 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/exception/GrpcExceptionHandlerAutoConfiguration.java @@ -0,0 +1,61 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure.exception; + +import io.grpc.Grpc; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.grpc.server.autoconfigure.ConditionalOnGrpcServerEnabled; +import org.springframework.context.annotation.Bean; +import org.springframework.grpc.server.GlobalServerInterceptor; +import org.springframework.grpc.server.GrpcServerFactory; +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 +@ConditionalOnGrpcServerEnabled +@ConditionalOnClass({ Grpc.class, GrpcServerFactory.class }) +@ConditionalOnBean(GrpcExceptionHandler.class) +@ConditionalOnMissingBean(GrpcExceptionHandlerInterceptor.class) +@ConditionalOnProperty(prefix = "spring.grpc.server.exception-handler", name = "enabled", havingValue = "true", + matchIfMissing = true) +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..abcdd1b62eba --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfiguration.java @@ -0,0 +1,134 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.ConditionalOnProperty; +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.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.grpc.server.GrpcServerFactory; +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) +@ConditionalOnGrpcServerEnabled +@ConditionalOnClass({ GrpcServerFactory.class, HealthStatusManager.class }) +@ConditionalOnBean(BindableService.class) +@ConditionalOnProperty(name = "spring.grpc.server.health.enabled", havingValue = "true", matchIfMissing = true) +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") + @ConditionalOnProperty(name = "spring.grpc.server.health.actuator.enabled", havingValue = "true", + matchIfMissing = true) + @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..6524f6559673 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/GrpcSecurityAutoConfiguration.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.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.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.GrpcServerFactory; +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") +@ConditionalOnGrpcServerEnabled +@ConditionalOnClass({ GrpcServerFactory.class, ObjectPostProcessor.class }) +@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) + @Configuration(proxyBeanMethods = false) + @ConditionalOnGrpcNativeServer + 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..f4b272902a26 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/OAuth2ClientAutoConfiguration.java @@ -0,0 +1,61 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.grpc.server.autoconfigure.security; + +import java.util.ArrayList; +import java.util.List; + +import io.grpc.BindableService; + +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.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.grpc.server.GrpcServerFactory; +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") +@ConditionalOnClass({ BindableService.class, GrpcServerFactory.class, 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..9480aea41611 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/security/OAuth2ResourceServerAutoConfiguration.java @@ -0,0 +1,331 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.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.GrpcServerFactory; +import org.springframework.grpc.server.security.AuthenticationProcessInterceptor; +import org.springframework.grpc.server.security.GrpcSecurity; +import org.springframework.security.config.ObjectPostProcessor; +import org.springframework.security.oauth2.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) +@ConditionalOnClass({ BindableService.class, GrpcServerFactory.class, 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..68dbe4f646cc --- /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.observations.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..49203d9172bf --- /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.env.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/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..a27067e48054 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerAutoConfigurationTests.java @@ -0,0 +1,529 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.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.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 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 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..015efde7fb39 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerObservationAutoConfigurationTests.java @@ -0,0 +1,117 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.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 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 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..262d94efc645 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerPropertiesTests.java @@ -0,0 +1,190 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.BindException; +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; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * 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.getAddress()).isEqualTo("my-server-ip:3130"); + assertThat(properties.getPort()).isEqualTo(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.getPort()).isEqualTo(3130); + } + + @Test + void illegalBecauseAddressAndPortSpecified() { + Map map = new HashMap<>(); + map.put("spring.grpc.server.address", "my-server-ip:3130"); + map.put("spring.grpc.server.port", "10000"); + assertThatExceptionOfType(BindException.class).isThrownBy(() -> bindProperties(map)); + } + + } + +} 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..b8328a81e25e --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerReflectionAutoConfigurationTests.java @@ -0,0 +1,97 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.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.runner.ApplicationContextRunner; +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 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..d66bb67f640c --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServletAutoConfigurationTests.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; + +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.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 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/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/codec/GrpcCodecConfigurationTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/codec/GrpcCodecConfigurationTests.java new file mode 100644 index 000000000000..c2338d5974c7 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/codec/GrpcCodecConfigurationTests.java @@ -0,0 +1,48 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.codec; + +import io.grpc.CompressorRegistry; +import io.grpc.DecompressorRegistry; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link GrpcCodecConfiguration}. + * + * @author Andrei Lisa + */ +class GrpcCodecConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcCodecConfiguration.class)); + + @Test + void testCompressorRegistryBean() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(CompressorRegistry.class)); + } + + @Test + void testDecompressorRegistryBean() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(DecompressorRegistry.class)); + } + +} 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..efcfdf985bd6 --- /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.Grpc; +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(Grpc.class)) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcExceptionHandlerAutoConfiguration.class)); + } + + @Test + void whenSprimgGrpcNotOnClasspathAutoConfigurationIsSkipped() { + 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..31446ca114d0 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfigurationTests.java @@ -0,0 +1,282 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.ServerServiceDefinition; +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.GrpcServerFactoryAutoConfiguration; +import org.springframework.boot.grpc.server.autoconfigure.ServerBuilderCustomizers; +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.ssl.SslBundles; +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.lifecycle.GrpcServerLifecycle; +import org.springframework.grpc.server.service.GrpcServiceConfigurer; +import org.springframework.grpc.server.service.GrpcServiceDiscoverer; +import org.springframework.util.StringUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +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 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)); + } + + @Test + void healthIsAutoConfiguredBeforeGrpcServerFactory() { + BindableService service = mock(); + ServerServiceDefinition serviceDefinition = ServerServiceDefinition.builder("my-service").build(); + given(service.bindService()).willReturn(serviceDefinition); + this.contextRunner() + .withConfiguration(AutoConfigurations.of(GrpcServerFactoryAutoConfiguration.class)) + .withBean("noopServerLifecycle", GrpcServerLifecycle.class, Mockito::mock) + .withBean("serverBuilderCustomizers", ServerBuilderCustomizers.class, Mockito::mock) + .withBean("grpcServicesDiscoverer", GrpcServiceDiscoverer.class, Mockito::mock) + .withBean("grpcServiceConfigurer", GrpcServiceConfigurer.class, Mockito::mock) + .withBean("sslBundles", SslBundles.class, Mockito::mock) + .withPropertyValues("spring.grpc.server.port=0") + .run((context) -> assertThatBeanDefinitionsContainInOrder(context, GrpcServerHealthAutoConfiguration.class, + GrpcServerFactoryAutoConfiguration.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 0000000000000000000000000000000000000000..0fc3e802f75461dd074facb9611d350db4d5960f GIT binary patch literal 1276 zcmezO_TO6u1_mZ5W@O+hNi8nXP0YzmEM{O}OjTL=XFE`?-k{cikBv*4jgf^>i%F1? zk(GfZ`?F{4vBFug6<%MmmXJ+)mEgQoslXV6!5_H^yana6V1?kw1w zbE0Bi&GHlLHzfosgjrwL)qKcc5I;l0LF?W2lpQg%-cHp$l(#o)?Jkath1@gQN{hG8 zig@wKv#0R7vd_QC=JG%%Ffy=4=$RT=0v*d`(8R=M(8RcU0W%XL6BCP-)w&Y~JZv0V zZ64=rS(uqv84M~6g$xAPm_u3EggJBalM{0?@{3DgVjNh+*s+LlVG-lTBF2m)W*{fd zYiMC$VQ64zW@K(?5e4L0B5?=MWswHLZ0z7LVq$~_7BeF|vl9agPmO-znfkD()@R+bGrT$(AXmN24#zv*XRX z#$UNu(Lmln78u;Jd@N!tBKmU@J0!OJc3G%!N>OO@P1n+F-CorAVRmOQaA8six!iWP z)M3lXpnJ*TI=kIlH(Yxia-ls?xvctEx&P5B6()tKm`>%bo~@fX9{j%TtMU1G!|pw& zZ6BRjIqQ^`bIxR@OmMno&8^H%tpq36Esh&T(+MJ_la+#pV>+3scS%> 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-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/grpc/InProcessTransportContextCustomizerFactory.java b/module/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/grpc/InProcessTransportContextCustomizerFactory.java new file mode 100644 index 000000000000..2aaf39c00593 --- /dev/null +++ b/module/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/grpc/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.test.autoconfigure.grpc; + +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-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/grpc/LocalGrpcPort.java b/module/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/grpc/LocalGrpcPort.java new file mode 100644 index 000000000000..778d449d62ed --- /dev/null +++ b/module/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/grpc/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.test.autoconfigure.grpc; + +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-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/grpc/ServerPortInfoApplicationContextInitializer.java b/module/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/grpc/ServerPortInfoApplicationContextInitializer.java new file mode 100644 index 000000000000..a9736be6ce0e --- /dev/null +++ b/module/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/grpc/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.test.autoconfigure.grpc; + +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-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/grpc/package-info.java b/module/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/grpc/package-info.java new file mode 100644 index 000000000000..3acb78aa1eba --- /dev/null +++ b/module/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/grpc/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.test.autoconfigure.grpc; + +import org.jspecify.annotations.NullMarked; diff --git a/module/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring.factories b/module/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring.factories index 4a7ffc6de711..05ae59d9b41b 100644 --- a/module/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring.factories +++ b/module/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring.factories @@ -4,6 +4,7 @@ org.springframework.boot.test.autoconfigure.OnFailureConditionReportContextCusto org.springframework.boot.test.autoconfigure.OverrideAutoConfigurationContextCustomizerFactory,\ org.springframework.boot.test.autoconfigure.actuate.observability.ObservabilityContextCustomizerFactory,\ org.springframework.boot.test.autoconfigure.filter.TypeExcludeFiltersContextCustomizerFactory,\ +org.springframework.boot.test.autoconfigure.grpc.InProcessTransportContextCustomizerFactory,\ org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizerFactory,\ org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory @@ -14,3 +15,7 @@ org.springframework.boot.test.autoconfigure.web.client.MockRestServiceServerRese org.springframework.boot.test.autoconfigure.web.servlet.MockMvcPrintOnlyOnFailureTestExecutionListener,\ org.springframework.boot.test.autoconfigure.web.servlet.WebDriverTestExecutionListener,\ org.springframework.boot.test.autoconfigure.webservices.client.MockWebServiceServerTestExecutionListener + +# Application Context Initializers +org.springframework.context.ApplicationContextInitializer=\ +org.springframework.boot.test.autoconfigure.grpc.ServerPortInfoApplicationContextInitializer diff --git a/module/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.grpc.AutoConfigureInProcessTransport.imports b/module/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.grpc.AutoConfigureInProcessTransport.imports new file mode 100644 index 000000000000..f44e2574b269 --- /dev/null +++ b/module/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.grpc.AutoConfigureInProcessTransport.imports @@ -0,0 +1,2 @@ +# AutoConfigureInProcessTransport auto-configuration imports +org.springframework.boot.test.autoconfigure.grpc.InProcessTestAutoConfiguration diff --git a/module/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/grpc/InProcessTestAutoConfigurationTests.java b/module/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/grpc/InProcessTestAutoConfigurationTests.java new file mode 100644 index 000000000000..90a165b61ae1 --- /dev/null +++ b/module/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/grpc/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.test.autoconfigure.grpc; + +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 7269f5bebe56..ee87c5dce117 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.74.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.4.3") { + 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 = [ @@ -1771,6 +1795,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.31.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") { @@ -2015,6 +2060,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", @@ -2343,6 +2389,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 9b13448c9f81..ee4890e982f6 100644 --- a/settings.gradle +++ b/settings.gradle @@ -102,6 +102,8 @@ include "module:spring-boot-data-r2dbc" include "module:spring-boot-data-redis" 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-elasticsearch" include "module:spring-boot-flyway" include "module:spring-boot-freemarker" @@ -206,6 +208,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" @@ -313,6 +319,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..233e3470e076 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-grpc/build.gradle @@ -0,0 +1,67 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id "java" + id 'com.google.protobuf' version '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")) + + testImplementation(project(":starter:spring-boot-starter-test")) + testImplementation("io.grpc:grpc-inprocess") + testImplementation("org.awaitility:awaitility") + testImplementation("org.testcontainers:junit-jupiter") + + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + + +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.30.2" +// FIXME get from 'grpc-java-version' from dep mgmt +def grpcVersion = "1.74.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..2b46a871dadd --- /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.health.autoconfigure.contributor.ConditionalOnEnabledHealthIndicator; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.boot.test.autoconfigure.grpc.AutoConfigureInProcessTransport; +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 0000000000000000000000000000000000000000..6aa9a28053a591e41453e665e5024e8a8cb78b3d GIT binary patch literal 2264 zcmchYX*3iJ7sqE|hQS!q5Mv)4GM2$i#uAFqC`%7x7baWA*i&dRX>3`uq(XS?3XSYp z%38`&ib7E$8j~$cF^}gt?|I+noW8#w?uYxk=iGD8|K9Vzd#pVc0002(2k@T|2@MMI zqxqr2AhQO*TVi`j@((S;e;g;l$#dAA{>vf0kX$R(Qn4oKgGEYjZ5zti2dw?Z6A zh%LuFCNI?9o+Z1duJL-++e#cjO`zlK?u9s030=k_*wD1#-$FbIDRDnA^vo@fm( zzjt(3VJrGOr0iHXSTM|rYN#>RZ@Dp`PwB2zrDQffLvuoR2~V3ReYa0&vU^dXd8isV zsAf*@!8s%xBvHLseXn6f?1kefe(8uAmAbaF$x{Ykzb6c6jdUwY1$y4tFzsj7 zIghr!T#ODfu@Po!a29@kXQ8kY#(LE<0o7?7PQ|eMeY@Equ?R-6*f@Na3o&stDQ=6( zQzDSQhCnS(9Bu9W_~giknP0vECqUsr4_9y_}nEU`cy z4}dApnAip92wMwgzciAFpc3i}+-#Zlq+iF7d1y}d4Qsp8=%l1N8NIs161I`HmkcpQ zY4*CUCFJJf(2!M{`&qQ}3($KeTQ=)mMrBs`DOb;%Of0tC)9he_p~w&CO#DfCgx(%s z{@|D(brX_Gb}ZDLmGej*JgEl0Et>q~kgTXuJg-PwvRjNx8sBbIShxD=xOySzw{;^X zAvrh5HTg>Xq@<{#^!Kg}B?qz@b<{ebD)yaSf&RChBIJQo-?Ahzw@qopSe^e&>^IuU zydM4Y1_C&>k7u|}=; z63R7$H6zat=hNExxEwXu1fQ*ytuEkP!{w{|#6TIEq1#*ck=6_NM*ILF65tmD-O5&R zMI!-MT<3U~t@}(CN4@RlZ~1I>C=!ywF)dNI{VvH;5Y3(Z4jY^%_c&fsm4Q`<1g|qX z&!h29jXjVE3nJnet*L)XL?-8<>qDbVGP%i^NwOZfwWO7?Mr!X7 zl}sG@9S_5}}td}$xrWIYY=e(VVBiv%A+M-{M z!3_^Tc=pV?niT!{D`!{e@W;MvrZ(OER{x7itVAtwE~spPtPtma|J=5dv&_oE!5H#` zdgXJ;+gJ4hI}*9QX9jpL`Gb)yCe%1}t!&O-^sihyZys%%5uF~WhsR_w(q7;vV5d4P zr%ZUA2}kO+L^2ePTgGT9Ua71w<+)poSyjTdLq&xbUn`<6&SpwFp(HRHUyU6J3WZ_! zfztko79+94Tq%mTYj53(RYcL&1~5`I#+w3`(Q|r+P(aT z%?r(^?IWw~19CB&uvXf(f7&BnEE{zwK4piVU`I4j1j?v5d4N<7VUJ8nM`$7S*mfKR z#9-JzPRZ?{M!@L+0N^V)IyeeP2T|^UK|m0QD+Ibs!wEoml^N!YO#vW~j~jraX(0A3 z6Kux?IRLez`O^X;{!4g%BhcRn>^H*qKZ3*|{_YGuz)KCJcu;)DSES5D2tDE`C02YR0R%Vy1T7k|RQ;3g<0icA$AuP0pOvc~jGl zz+NeKv_FT_;GWK&8XlDUv&hv9kxg?@c!bu?83i=YQ$S!K09Y)Glg3Hz?@|)ZCBlVz zP8i}#XZkMoje3I=h&I!!s_m?Qi@1MR`yv7X*yEs47qOs^t^?&=;*IQ!q&)gq_Sx5* z?fhU8Q*PSe*w7y)FH#P!9R^Xw!lTT+zI39L<&8cViaj$A(Z2Cg7!{V?uuyi#vlNCg z40i}2ivw&y&1-&Nh&WMG`&aIt>)(#tKTJ}^@696Kw1-{IzSOTnFF+0@k$o3%ZHS;Q#;t literal 0 HcmV?d00001 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")) +} From 36e780378f31f4256a300ebcbf42eeb392f06795 Mon Sep 17 00:00:00 2001 From: onobc Date: Mon, 22 Sep 2025 14:00:12 -0500 Subject: [PATCH 2/7] Update gRPC and protobuf versions Updates the following gRPC related library versions: - `grpc-bom` from `1.74.0` to `1.75.0` - `protobuf-bum` from `4.31.1` to `4.32.1` Signed-off-by: onobc --- platform/spring-boot-dependencies/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/platform/spring-boot-dependencies/build.gradle b/platform/spring-boot-dependencies/build.gradle index ee87c5dce117..749b3c06cf37 100644 --- a/platform/spring-boot-dependencies/build.gradle +++ b/platform/spring-boot-dependencies/build.gradle @@ -492,7 +492,7 @@ bom { site("https://groovy-lang.org") } } - library("Grpc Java", "1.74.0") { + library("Grpc Java", "1.75.0") { group("io.grpc") { bom("grpc-bom") } @@ -1806,7 +1806,7 @@ bom { releaseNotes("https://github.com/googleapis/sdk-platform-java/releases/tag/v-{version}") } } - library("Protobuf Java", "4.31.1") { + library("Protobuf Java", "4.32.1") { group("com.google.protobuf") { bom("protobuf-bom") } From e802ea032852ac0f3e813c16875dd9a31b369a1d Mon Sep 17 00:00:00 2001 From: onobc Date: Mon, 22 Sep 2025 14:29:45 -0500 Subject: [PATCH 3/7] Update gRPC Kotlin version to 1.5.0 This updates the gRPC Kotlin version from `1.4.3` to `1.5.0` Signed-off-by: onobc --- platform/spring-boot-dependencies/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platform/spring-boot-dependencies/build.gradle b/platform/spring-boot-dependencies/build.gradle index 749b3c06cf37..af5511f3ccc1 100644 --- a/platform/spring-boot-dependencies/build.gradle +++ b/platform/spring-boot-dependencies/build.gradle @@ -502,7 +502,7 @@ bom { releaseNotes("https://github.com/grpc/grpc-java/releases/tag/v{version}") } } - library("Grpc Kotlin", "1.4.3") { + library("Grpc Kotlin", "1.5.0") { group("io.grpc") { modules = [ "grpc-kotlin-stub" { From a6a9a84fc85e1b5cd905b2c8c3b6c11d75ce6b76 Mon Sep 17 00:00:00 2001 From: onobc Date: Mon, 22 Sep 2025 14:41:35 -0500 Subject: [PATCH 4/7] Update gRPC and protobuf versions (take 2) Updates the gRPC and protobuf versions in the gRPC smoke test: - `grpc-bom` from `1.74.0` to `1.75.0` - `protobuf-bum` from `4.31.1` to `4.32.1` Signed-off-by: onobc --- smoke-test/spring-boot-smoke-test-grpc/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/smoke-test/spring-boot-smoke-test-grpc/build.gradle b/smoke-test/spring-boot-smoke-test-grpc/build.gradle index 233e3470e076..18ff7ed52bee 100644 --- a/smoke-test/spring-boot-smoke-test-grpc/build.gradle +++ b/smoke-test/spring-boot-smoke-test-grpc/build.gradle @@ -44,9 +44,9 @@ tasks.withType(io.spring.javaformat.gradle.tasks.CheckFormat) { } // FIXME get from 'protobuf-java-version' from dep mgmt -def protobufJavaVersion = "4.30.2" +def protobufJavaVersion = "4.32.1" // FIXME get from 'grpc-java-version' from dep mgmt -def grpcVersion = "1.74.0" +def grpcVersion = "1.75.0" protobuf { protoc { From 76ac9fb2b4a2b919a2f5e1bc1ee015c96005c215 Mon Sep 17 00:00:00 2001 From: onobc Date: Tue, 23 Sep 2025 11:50:37 -0500 Subject: [PATCH 5/7] Use the non-deprecated env post processor key Uses `org.springframework.boot.EnvironmentPostProcessor` instead of the deprecated `org.springframework.boot.env.EnvironmentPostProcessor` key in `spring.factories` for the `spring-boot-grpc-server` module. Signed-off-by: onobc --- .../src/main/resources/META-INF/spring.factories | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 49203d9172bf..fc5b89e9e454 100644 --- 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 @@ -1,5 +1,5 @@ org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer=\ org.springframework.boot.grpc.server.autoconfigure.security.GrpcDisableCsrfHttpConfigurer -org.springframework.boot.env.EnvironmentPostProcessor=\ +org.springframework.boot.EnvironmentPostProcessor=\ org.springframework.boot.grpc.server.autoconfigure.ServletEnvironmentPostProcessor From 6b2b95e17c30e17abb401d9f20fff9d288713bfc Mon Sep 17 00:00:00 2001 From: onobc Date: Tue, 23 Sep 2025 12:08:57 -0500 Subject: [PATCH 6/7] Fix config prop name in gRPC client and server The config prop name was mismatched in the both the gRPC client and server modules additional-spring-configuration-metadata.json files. This makes them consistent using the singular form of the property `spring.grpc.(client|server).observation.enabled`. Signed-off-by: onobc --- .../META-INF/additional-spring-configuration-metadata.json | 2 +- .../META-INF/additional-spring-configuration-metadata.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/module/spring-boot-grpc-client/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/module/spring-boot-grpc-client/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 52beeba738d9..b2914ae3fdc3 100644 --- a/module/spring-boot-grpc-client/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/module/spring-boot-grpc-client/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -20,7 +20,7 @@ "defaultValue": true }, { - "name": "spring.grpc.client.observations.enabled", + "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-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/module/spring-boot-grpc-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 68dbe4f646cc..cfafdb62f4e3 100644 --- a/module/spring-boot-grpc-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/module/spring-boot-grpc-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -20,7 +20,7 @@ "defaultValue": true }, { - "name": "spring.grpc.server.observations.enabled", + "name": "spring.grpc.server.observation.enabled", "type": "java.lang.Boolean", "description": "Whether to enable Observations on the server.", "defaultValue": true From 32331a9df7e0b2a43864ae8bb42c5e9b768a589a Mon Sep 17 00:00:00 2001 From: onobc Date: Tue, 23 Sep 2025 22:30:51 -0500 Subject: [PATCH 7/7] Fix checkstyle Signed-off-by: onobc --- .../test/autoconfigure/grpc/InProcessTestAutoConfiguration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/grpc/InProcessTestAutoConfiguration.java b/module/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/grpc/InProcessTestAutoConfiguration.java index 3e1ab07ea360..ad6da08bf21a 100644 --- a/module/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/grpc/InProcessTestAutoConfiguration.java +++ b/module/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/grpc/InProcessTestAutoConfiguration.java @@ -20,11 +20,11 @@ import java.util.Collections; import java.util.List; -import io.grpc.stub.AbstractStub; 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;