diff --git a/spring-grpc-docs/src/main/antora/modules/ROOT/pages/server.adoc b/spring-grpc-docs/src/main/antora/modules/ROOT/pages/server.adoc index 42e22f2..c3852a4 100644 --- a/spring-grpc-docs/src/main/antora/modules/ROOT/pages/server.adoc +++ b/spring-grpc-docs/src/main/antora/modules/ROOT/pages/server.adoc @@ -108,10 +108,28 @@ ServerInterceptor myGlobalLoggingInterceptor() { ---- === Per-Service -This is a **WIP** and will be available soon. +To add a server interceptor to be applied to a single service you can simply register a server interceptor bean and then annotate your `BindableService` bean with `@GrpcService`, specifying the interceptor using either the `interceptors` or `interceptorNames` attribute. + +The interceptors are ordered according to their position in the attribute list. +When using both `interceptors` and `interceptorNames`, the former entries precede the latter. + +In the following example, the `myServerInterceptor` will be applied to the `myService` service. + +[source,java] +---- +@Bean +MyServerInterceptor myServerInterceptor() { + return new MyServerInterceptor(); +} + +@GrpcService(interceptors = MyServerInterceptor.class) +BindableService myService() { + ... +} +---- == Observability Spring gRPC provides an autoconfigured interceptor that can be used to provide observability to your gRPC services. All you need to do is add Spring Boot actuators to your project, and optionally a bridge to your observability platform of choice (just like https://docs.spring.io/spring-boot/reference/actuator/observability.html[any other Spring Boot application]). -The `grpc-tomcat` sample in the Spring gRPC repository shows how to do it, and you should see trace logging and metrics when you connect to the server. \ No newline at end of file +The `grpc-tomcat` sample in the Spring gRPC repository shows how to do it, and you should see trace logging and metrics when you connect to the server. diff --git a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/ApplicationContextBeanLookupUtils.java b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/ApplicationContextBeanLookupUtils.java new file mode 100644 index 0000000..ac325e1 --- /dev/null +++ b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/ApplicationContextBeanLookupUtils.java @@ -0,0 +1,96 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.grpc.autoconfigure.server; + +import java.lang.annotation.Annotation; +import java.util.LinkedHashMap; +import java.util.List; + +import org.springframework.context.ApplicationContext; +import org.springframework.core.annotation.Order; +import org.springframework.util.Assert; + +/** + * Convenience methods performing bean lookups that are not currently provided by the + * standard Spring facilities. + * + * @author Chris Bono + */ +final class ApplicationContextBeanLookupUtils { + + private ApplicationContextBeanLookupUtils() { + } + + /** + * Find all beans of the given type which may be annotated with the supplied + * {@link Annotation} type and returns a map of matching bean instances with the + * annotation instance (or null if the bean is not annotated with the specified + * annotation type). + *

+ * Note that the map is ordered according to the matching beans {@link Order} + * annotation. + *

+ * Note that this method considers objects created by FactoryBeans, which means that + * FactoryBeans will get initialized in order to determine their object type. + * @param applicationContext the application context + * @param beanType the type of bean + * @param annotationType the type of annotation + * @param type of bean + * @param type of annotation + * @return a map of matching bean instances with the annotation instance (or null) + * ordered according to their {@link Order} with annotation + */ + static LinkedHashMap getOrderedBeansWithAnnotation( + ApplicationContext applicationContext, Class beanType, Class annotationType) { + Assert.notNull(applicationContext, () -> "applicationContext must not be null"); + var annotatedBeanNamesToBeans = applicationContext.getBeansWithAnnotation(annotationType); + var annotatedBeansToBeanNames = new LinkedHashMap(); + annotatedBeanNamesToBeans.forEach((name, bean) -> annotatedBeansToBeanNames.put(bean, name)); + var orderedBeans = new LinkedHashMap(); + applicationContext.getBeanProvider(beanType).orderedStream().forEachOrdered((b) -> { + var beanName = annotatedBeansToBeanNames.get(b); + A beanAnnotation = null; + if (beanName != null) { + beanAnnotation = applicationContext.findAnnotationOnBean(beanName, annotationType); + } + orderedBeans.put(b, beanAnnotation); + }); + return orderedBeans; + } + + /** + * Find all beans which are annotated with the supplied {@link Annotation} type. + *

+ * Note that this method considers objects created by FactoryBeans, which means that + * FactoryBeans will get initialized in order to determine their object type. + * @param applicationContext the application context + * @param beanType the type of bean + * @param annotationType the type of annotation + * @param type of bean + * @param type of annotation + * @return a list of the matching beans ordered according to their {@link Order} + * annotation + */ + static List getBeansWithAnnotation(ApplicationContext applicationContext, + Class beanType, Class annotationType) { + Assert.notNull(applicationContext, () -> "applicationContext must not be null"); + var nameToBeanMap = applicationContext.getBeansWithAnnotation(annotationType); + var beanToNameMap = new LinkedHashMap(); + nameToBeanMap.forEach((name, bean) -> beanToNameMap.put(bean, name)); + return applicationContext.getBeanProvider(beanType).orderedStream().filter(beanToNameMap::containsKey).toList(); + } + +} diff --git a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/DefaultGrpcServiceConfigurer.java b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/DefaultGrpcServiceConfigurer.java new file mode 100644 index 0000000..5653037 --- /dev/null +++ b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/DefaultGrpcServiceConfigurer.java @@ -0,0 +1,82 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.grpc.autoconfigure.server; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.springframework.context.ApplicationContext; +import org.springframework.lang.Nullable; + +import io.grpc.BindableService; +import io.grpc.ServerInterceptor; +import io.grpc.ServerInterceptors; +import io.grpc.ServerServiceDefinition; +import jakarta.annotation.PostConstruct; + +/** + * Default {@link GrpcServiceConfigurer} that binds and configures services with + * interceptors. + * + * @author Chris Bono + */ +public class DefaultGrpcServiceConfigurer implements GrpcServiceConfigurer { + + private final ApplicationContext applicationContext; + + private List globalInterceptors; + + public DefaultGrpcServiceConfigurer(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + @PostConstruct + private void initializeGlobalInterceptors() { + this.globalInterceptors = findGlobalInterceptors(); + } + + @Override + public ServerServiceDefinition configure(BindableService bindableService, @Nullable GrpcServiceInfo serviceInfo) { + return bindInterceptors(bindableService, serviceInfo); + } + + private List findGlobalInterceptors() { + return ApplicationContextBeanLookupUtils.getBeansWithAnnotation(this.applicationContext, + ServerInterceptor.class, GlobalServerInterceptor.class); + } + + private ServerServiceDefinition bindInterceptors(BindableService bindableService, + @Nullable GrpcServiceInfo serviceInfo) { + var serviceDef = bindableService.bindService(); + if (serviceInfo == null) { + return ServerInterceptors.interceptForward(serviceDef, this.globalInterceptors); + } + // Add global interceptors first + List allInterceptors = new ArrayList<>(this.globalInterceptors); + // Add interceptors by type + Arrays.stream(serviceInfo.interceptors()) + .forEachOrdered( + (interceptorClass) -> allInterceptors.add(this.applicationContext.getBean(interceptorClass))); + // Add interceptors by name + Arrays.stream(serviceInfo.interceptorNames()) + .forEachOrdered((interceptorBeanName) -> allInterceptors + .add(this.applicationContext.getBean(interceptorBeanName, ServerInterceptor.class))); + // TODO handle blend + return ServerInterceptors.interceptForward(serviceDef, allInterceptors); + } + +} diff --git a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/DefaultGrpcServiceDiscoverer.java b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/DefaultGrpcServiceDiscoverer.java index daea2b9..42bdbc3 100644 --- a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/DefaultGrpcServiceDiscoverer.java +++ b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/DefaultGrpcServiceDiscoverer.java @@ -1,11 +1,11 @@ /* - * Copyright (c) 2016-2023 The gRPC-Spring Authors + * Copyright 2023-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -16,17 +16,14 @@ package org.springframework.grpc.autoconfigure.server; -import java.util.HashMap; import java.util.List; -import io.grpc.BindableService; -import io.grpc.ServerInterceptor; -import io.grpc.ServerInterceptors; -import io.grpc.ServerServiceDefinition; - -import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.ApplicationContext; import org.springframework.grpc.server.GrpcServiceDiscoverer; +import org.springframework.lang.Nullable; + +import io.grpc.BindableService; +import io.grpc.ServerServiceDefinition; /** * The default {@link GrpcServiceDiscoverer} that finds all {@link BindableService} beans @@ -34,40 +31,31 @@ import org.springframework.grpc.server.GrpcServiceDiscoverer; * * @author Chris Bono */ -class DefaultGrpcServiceDiscoverer implements GrpcServiceDiscoverer { +public class DefaultGrpcServiceDiscoverer implements GrpcServiceDiscoverer { - private final ObjectProvider grpcServicesProvider; - - private final ObjectProvider serverInterceptorsProvider; + private final GrpcServiceConfigurer serviceConfigurer; private final ApplicationContext applicationContext; - public DefaultGrpcServiceDiscoverer(ObjectProvider grpcServicesProvider, - ObjectProvider serverInterceptorsProvider, ApplicationContext applicationContext) { - this.grpcServicesProvider = grpcServicesProvider; - this.serverInterceptorsProvider = serverInterceptorsProvider; + public DefaultGrpcServiceDiscoverer(GrpcServiceConfigurer serviceConfigurer, + ApplicationContext applicationContext) { + this.serviceConfigurer = serviceConfigurer; this.applicationContext = applicationContext; } @Override public List findServices() { - List globalInterceptors = findGlobalInterceptors(); - return grpcServicesProvider.orderedStream() - .map(BindableService::bindService) - .map((svc) -> ServerInterceptors.interceptForward(svc, globalInterceptors)) + return ApplicationContextBeanLookupUtils + .getOrderedBeansWithAnnotation(this.applicationContext, BindableService.class, GrpcService.class) + .entrySet() + .stream() + .map((e) -> this.serviceConfigurer.configure(e.getKey(), this.serviceInfo(e.getValue()))) .toList(); } - // VisibleForTesting - List findGlobalInterceptors() { - // We find unordered map of beans (keyed by name) with the annotation and then - // reverse the map for easy lookup by bean. - // We then get an ordered stream of all server interceptors and filter - // out those that are not present in map of annotated interceptor beans. - var nameToBeanMap = applicationContext.getBeansWithAnnotation(GlobalServerInterceptor.class); - var beanToNameMap = new HashMap(); - nameToBeanMap.forEach((name, bean) -> beanToNameMap.put(bean, name)); - return this.serverInterceptorsProvider.orderedStream().filter(beanToNameMap::containsKey).toList(); + @Nullable + private GrpcServiceInfo serviceInfo(@Nullable GrpcService grpcService) { + return grpcService != null ? GrpcServiceInfo.from(grpcService) : null; } } diff --git a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GrpcServerAutoConfiguration.java b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GrpcServerAutoConfiguration.java index 08d759b..2f28dfc 100644 --- a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GrpcServerAutoConfiguration.java +++ b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GrpcServerAutoConfiguration.java @@ -37,7 +37,6 @@ import io.grpc.BindableService; import io.grpc.CompressorRegistry; import io.grpc.DecompressorRegistry; import io.grpc.ServerBuilder; -import io.grpc.ServerInterceptor; /** * {@link EnableAutoConfiguration Auto-configuration} for gRPC server-side components. @@ -77,10 +76,15 @@ public class GrpcServerAutoConfiguration { @ConditionalOnMissingBean @Bean - GrpcServiceDiscoverer grpcServiceDiscoverer(ObjectProvider bindableServicesProvider, - ObjectProvider serverInterceptorsProvider, ApplicationContext applicationContext) { - return new DefaultGrpcServiceDiscoverer(bindableServicesProvider, serverInterceptorsProvider, - applicationContext); + GrpcServiceConfigurer grpcServiceConfigurer(ApplicationContext applicationContext) { + return new DefaultGrpcServiceConfigurer(applicationContext); + } + + @ConditionalOnMissingBean + @Bean + GrpcServiceDiscoverer grpcServiceDiscoverer(GrpcServiceConfigurer grpcServiceConfigurer, + ApplicationContext applicationContext) { + return new DefaultGrpcServiceDiscoverer(grpcServiceConfigurer, applicationContext); } @ConditionalOnBean(CompressorRegistry.class) diff --git a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GrpcService.java b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GrpcService.java new file mode 100644 index 0000000..5655323 --- /dev/null +++ b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GrpcService.java @@ -0,0 +1,78 @@ +/* + * Copyright 2016-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Copy from net.devh:grpc-spring-boot-starter. + */ + +package org.springframework.grpc.autoconfigure.server; + +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.context.annotation.Bean; +import org.springframework.stereotype.Service; + +import io.grpc.BindableService; +import io.grpc.ServerInterceptor; + +/** + * Annotation that marks gRPC services that should be registered with a gRPC server. + *

+ * NOTE: This annotation is optional as all {@link BindableService} beans will be + * registered with a gRPC server. However, this annotation allows specifying additional + * information about the service (e.g. interceptors). + *

+ * NOTE: This annotation should only be added to {@link BindableService} beans. + * + * @author Michael (yidongnan@gmail.com) + * @author Chris Bono + */ +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Service +@Bean +public @interface GrpcService { + + /** + * The {@link ServerInterceptor} bean types to be applied to the service. + * @return the interceptor bean types to be applied to the service + */ + Class[] interceptors() default {}; + + /** + * The {@link ServerInterceptor} bean names to be applied to the service. + * @return the interceptor bean names to be applied to the service + */ + String[] interceptorNames() default {}; + + /** + * Whether the service specific interceptors should be blended with the global + * interceptors. + *

+ * When false, the global interceptors are applied first, followed by the service + * specific interceptors. + *

+ * When true, the global interceptors are merged and sorted (blended) with the service + * specific interceptors. + * @return whether the service specific interceptors should be blended with the global + * interceptors + */ + boolean blendWithGlobalInterceptors() default false; + +} diff --git a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GrpcServiceConfigurer.java b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GrpcServiceConfigurer.java new file mode 100644 index 0000000..6a4bfac --- /dev/null +++ b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GrpcServiceConfigurer.java @@ -0,0 +1,40 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.grpc.autoconfigure.server; + +import org.springframework.lang.Nullable; + +import io.grpc.BindableService; +import io.grpc.ServerServiceDefinition; + +/** + * Configures and binds a {@link BindableService gRPC Service}. + * + * @author Chris Bono + */ +@FunctionalInterface +public interface GrpcServiceConfigurer { + + /** + * Configure and bind a gRPC service. + * @param bindableService service to bind and configure + * @param serviceInfo optional additional service information + * @return configured service definition + */ + ServerServiceDefinition configure(BindableService bindableService, @Nullable GrpcServiceInfo serviceInfo); + +} diff --git a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GrpcServiceInfo.java b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GrpcServiceInfo.java new file mode 100644 index 0000000..f3eaef4 --- /dev/null +++ b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GrpcServiceInfo.java @@ -0,0 +1,78 @@ +/* + * Copyright 2024-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.grpc.autoconfigure.server; + +import java.util.List; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +import io.grpc.ServerInterceptor; + +/** + * Additional information about a gRPC service that can be used when configuring the + * service. + * + * @param interceptors list of {@link ServerInterceptor} bean types to be applied to the + * service + * @param interceptorNames list of {@link ServerInterceptor} bean names to be applied to + * the service + * @param blendWithGlobalInterceptors whether the service specific interceptors should be + * merged and sorted (blended) with the global interceptors + * @author Chris Bono + */ +public record GrpcServiceInfo(Class[] interceptors, String[] interceptorNames, + boolean blendWithGlobalInterceptors) { + + public GrpcServiceInfo { + Assert.notNull(interceptors, "interceptors must not be null"); + Assert.notNull(interceptorNames, "interceptorNames must not be null"); + } + + /** + * Construct a service info from a {@link GrpcService} annotation. + * @param grpcService the service annotation + * @return the service info or null if the supplied annotation is null + */ + @Nullable + public static GrpcServiceInfo from(@Nullable GrpcService grpcService) { + return grpcService != null ? new GrpcServiceInfo(grpcService.interceptors(), grpcService.interceptorNames(), + grpcService.blendWithGlobalInterceptors()) : null; + } + + /** + * Construct a service info with the specified interceptors. + * @param interceptors non-null list of interceptor bean types + * @return the service info with the supplied interceptors + */ + @SuppressWarnings("unchecked") + public static GrpcServiceInfo withInterceptors(List> interceptors) { + Assert.notNull(interceptors, "interceptors must not be null"); + return new GrpcServiceInfo(interceptors.toArray(new Class[0]), new String[0], false); + } + + /** + * Construct a service info with the specified interceptors names. + * @param interceptorNames non-null list of interceptor bean names + * @return the service info with the supplied interceptors names + */ + @SuppressWarnings("unchecked") + public static GrpcServiceInfo withInterceptorNames(List interceptorNames) { + Assert.notNull(interceptorNames, "interceptorNames must not be null"); + return new GrpcServiceInfo(new Class[0], interceptorNames.toArray(new String[0]), false); + } + +} diff --git a/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/DefaultGrpcServiceConfigurerTests.java b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/DefaultGrpcServiceConfigurerTests.java new file mode 100644 index 0000000..e07ffe2 --- /dev/null +++ b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/DefaultGrpcServiceConfigurerTests.java @@ -0,0 +1,346 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.grpc.autoconfigure.server; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.function.Function; + +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.stubbing.Answer; + +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.NoUniqueBeanDefinitionException; +import org.springframework.boot.autoconfigure.AutoConfigurations; +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.server.lifecycle.GrpcServerLifecycle; +import org.springframework.lang.Nullable; + +import io.grpc.BindableService; +import io.grpc.ServerInterceptor; +import io.grpc.ServerInterceptors; +import io.grpc.ServerServiceDefinition; + +/** + * Tests for {@link DefaultGrpcServiceConfigurer}. + * + * @author Chris Bono + */ +class DefaultGrpcServiceConfigurerTests { + + private ApplicationContextRunner contextRunner() { + // NOTE: we use noop server lifecycle to avoid startup + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcServerAutoConfiguration.class)) + .withBean("noopServerLifecycle", GrpcServerLifecycle.class, Mockito::mock); + } + + @Test + void globalServerInterceptorsAreFoundInProperOrder() { + this.contextRunner() + .withUserConfiguration(GlobalServerInterceptorsConfig.class) + .run((context) -> assertThat(context).getBean(DefaultGrpcServiceConfigurer.class) + .extracting("globalInterceptors", InstanceOfAssertFactories.LIST) + .containsExactly(GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_BAR, + GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_FOO)); + } + + @Nested + class WithNoServiceInfoSpecified { + + @Test + void whenNoGlobalInterceptorsRegisteredThenServiceGetsNoInterceptors() { + customizeContextAndRunServiceConfigurerWithServiceInfo(Function.identity(), null, List.of()); + } + + @Test + void whenGlobalInterceptorsRegisteredThenServiceGetsGlobalInterceptors() { + customizeContextAndRunServiceConfigurerWithServiceInfo( + (contextRunner) -> contextRunner.withUserConfiguration(GlobalServerInterceptorsConfig.class), null, + List.of(GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_BAR, + GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_FOO)); + } + + } + + @Nested + class WithServiceInfoWithSingleInterceptor { + + @Test + void whenSingleBeanOfInterceptorTypeRegisteredThenItIsUsed() { + GrpcServiceInfo serviceInfo = GrpcServiceInfo.withInterceptors(List.of(TestServerInterceptorA.class)); + List expectedInterceptors = List.of(ServiceSpecificInterceptorsConfig.SVC_INTERCEPTOR_A); + customizeContextAndRunServiceConfigurerWithServiceInfo( + (contextRunner) -> contextRunner.withUserConfiguration(ServiceSpecificInterceptorsConfig.class), + serviceInfo, expectedInterceptors); + } + + @Test + void whenMultipleBeansOfInterceptorTypeRegisteredThenThrowsException() { + GrpcServiceInfo serviceInfo = GrpcServiceInfo.withInterceptors(List.of(ServerInterceptor.class)); + customizeContextAndRunServiceConfigurerWithServiceInfo( + (contextRunner) -> contextRunner.withUserConfiguration(ServiceSpecificInterceptorsConfig.class), + serviceInfo, NoUniqueBeanDefinitionException.class); + } + + @Test + void whenNoBeanOfInterceptorTypeRegisteredThenThrowsException() { + GrpcServiceInfo serviceInfo = GrpcServiceInfo.withInterceptors(List.of(ServerInterceptor.class)); + customizeContextAndRunServiceConfigurerWithServiceInfo(Function.identity(), serviceInfo, + NoSuchBeanDefinitionException.class); + } + + } + + @Nested + class WithServiceInfoWithMultipleInterceptors { + + @Test + void whenSingleBeanOfEachInterceptorTypeRegisteredThenTheyAreUsed() { + GrpcServiceInfo serviceInfo = GrpcServiceInfo + .withInterceptors(List.of(TestServerInterceptorB.class, TestServerInterceptorA.class)); + List expectedInterceptors = List.of(ServiceSpecificInterceptorsConfig.SVC_INTERCEPTOR_B, + ServiceSpecificInterceptorsConfig.SVC_INTERCEPTOR_A); + customizeContextAndRunServiceConfigurerWithServiceInfo( + (contextRunner) -> contextRunner.withUserConfiguration(ServiceSpecificInterceptorsConfig.class), + serviceInfo, expectedInterceptors); + } + + } + + @Nested + class WithServiceInfoWithSingleInterceptorName { + + @Test + void whenSingleBeanWithInterceptorNameRegisteredThenItIsUsed() { + GrpcServiceInfo serviceInfo = GrpcServiceInfo.withInterceptorNames(List.of("interceptorB")); + List expectedInterceptors = List.of(ServiceSpecificInterceptorsConfig.SVC_INTERCEPTOR_B); + customizeContextAndRunServiceConfigurerWithServiceInfo( + (contextRunner) -> contextRunner.withUserConfiguration(ServiceSpecificInterceptorsConfig.class), + serviceInfo, expectedInterceptors); + } + + @Test + void whenNoBeanWithInterceptorNameRegisteredThenThrowsException() { + GrpcServiceInfo serviceInfo = GrpcServiceInfo.withInterceptorNames(List.of("interceptor1")); + customizeContextAndRunServiceConfigurerWithServiceInfo(Function.identity(), serviceInfo, + NoSuchBeanDefinitionException.class); + } + + } + + @Nested + class WithServiceInfoWithMultipleInterceptorNames { + + @Test + void whenSingleBeanWithEachInterceptorNameRegisteredThenTheyAreUsed() { + GrpcServiceInfo serviceInfo = GrpcServiceInfo.withInterceptorNames(List.of("interceptorB", "interceptorA")); + List expectedInterceptors = List.of(ServiceSpecificInterceptorsConfig.SVC_INTERCEPTOR_B, + ServiceSpecificInterceptorsConfig.SVC_INTERCEPTOR_A); + customizeContextAndRunServiceConfigurerWithServiceInfo( + (contextRunner) -> contextRunner.withUserConfiguration(ServiceSpecificInterceptorsConfig.class), + serviceInfo, expectedInterceptors); + } + + } + + @Nested + class WithServiceInfoWithInterceptorAndInterceptorName { + + @SuppressWarnings("unchecked") + @Test + void whenSingleBeanOfEachAvailableThenTheyAreBothUsed() { + GrpcServiceInfo serviceInfo = new GrpcServiceInfo(new Class[] { TestServerInterceptorB.class }, + new String[] { "interceptorA" }, false); + List expectedInterceptors = List.of(ServiceSpecificInterceptorsConfig.SVC_INTERCEPTOR_B, + ServiceSpecificInterceptorsConfig.SVC_INTERCEPTOR_A); + customizeContextAndRunServiceConfigurerWithServiceInfo( + (contextRunner) -> contextRunner.withUserConfiguration(ServiceSpecificInterceptorsConfig.class), + serviceInfo, expectedInterceptors); + } + + } + + @Nested + class WithServiceInfoCombinedWithGlobalInterceptors { + + @Test + void whenBlendInterceptorsFalseThenGlobalInterceptorsAddedFirst() { + GrpcServiceInfo serviceInfo = GrpcServiceInfo + .withInterceptors(List.of(TestServerInterceptorB.class, TestServerInterceptorA.class)); + List expectedInterceptors = List.of( + GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_BAR, + GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_FOO, + ServiceSpecificInterceptorsConfig.SVC_INTERCEPTOR_B, + ServiceSpecificInterceptorsConfig.SVC_INTERCEPTOR_A); + customizeContextAndRunServiceConfigurerWithServiceInfo((contextRunner) -> contextRunner + .withUserConfiguration(GlobalServerInterceptorsConfig.class, ServiceSpecificInterceptorsConfig.class), + serviceInfo, expectedInterceptors); + } + + @SuppressWarnings("unchecked") + @Disabled("Needs 'blend interceptors' to be implemented") + @Test + void whenBlendInterceptorsTrueThenGlobalInterceptorsBlended() { + GrpcServiceInfo serviceInfo = new GrpcServiceInfo( + new Class[] { TestServerInterceptorB.class, TestServerInterceptorA.class }, new String[0], true); + List expectedInterceptors = List.of( + GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_BAR, + ServiceSpecificInterceptorsConfig.SVC_INTERCEPTOR_B, + GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_FOO, + ServiceSpecificInterceptorsConfig.SVC_INTERCEPTOR_A); + customizeContextAndRunServiceConfigurerWithServiceInfo((contextRunner) -> contextRunner + .withUserConfiguration(GlobalServerInterceptorsConfig.class, ServiceSpecificInterceptorsConfig.class), + serviceInfo, expectedInterceptors); + } + + } + + private void customizeContextAndRunServiceConfigurerWithServiceInfo( + Function contextCustomizer, GrpcServiceInfo serviceInfo, + List expectedInterceptors) { + this.customizeContextAndRunServiceConfigurerWithServiceInfo(contextCustomizer, serviceInfo, + expectedInterceptors, null); + } + + private void customizeContextAndRunServiceConfigurerWithServiceInfo( + Function contextCustomizer, GrpcServiceInfo serviceInfo, + Class expectedExceptionType) { + this.customizeContextAndRunServiceConfigurerWithServiceInfo(contextCustomizer, serviceInfo, null, + expectedExceptionType); + } + + private void customizeContextAndRunServiceConfigurerWithServiceInfo( + Function contextCustomizer, GrpcServiceInfo serviceInfo, + @Nullable List expectedInterceptors, + @Nullable Class expectedExceptionType) { + // It gets difficult to verify interceptors are added properly to mocked services. + // To make it easier, we just static mock ServerInterceptors.interceptForward to + // echo back the service def. This way we can verify the interceptors were passed + // in the proper order as we rely on ServerInterceptors.interceptForward being + // well tested in grpc-java. + try (MockedStatic serverInterceptorsMocked = Mockito.mockStatic(ServerInterceptors.class)) { + serverInterceptorsMocked + .when(() -> ServerInterceptors.interceptForward(any(ServerServiceDefinition.class), anyList())) + .thenAnswer((Answer) invocation -> invocation.getArgument(0)); + BindableService service = mock(); + ServerServiceDefinition serviceDef = mock(); + when(service.bindService()).thenReturn(serviceDef); + this.contextRunner() + .withBean("service", BindableService.class, () -> service) + .with(contextCustomizer) + .run((context) -> { + DefaultGrpcServiceConfigurer configurer = context.getBean(DefaultGrpcServiceConfigurer.class); + if (expectedExceptionType != null) { + assertThatThrownBy(() -> configurer.configure(service, serviceInfo)) + .isInstanceOf(expectedExceptionType); + serverInterceptorsMocked.verifyNoInteractions(); + } + else { + configurer.configure(service, serviceInfo); + serverInterceptorsMocked + .verify(() -> ServerInterceptors.interceptForward(serviceDef, expectedInterceptors)); + } + }); + } + } + + interface TestServerInterceptorA extends ServerInterceptor { + + } + + interface TestServerInterceptorB extends ServerInterceptor { + + } + + @Configuration(proxyBeanMethods = false) + static class GlobalServerInterceptorsConfig { + + static BindableService SERVICE_A = mock(); + + static ServerServiceDefinition SERVICE_DEF_A = mock(); + + static ServerInterceptor GLOBAL_INTERCEPTOR_FOO = mock(); + + static ServerInterceptor GLOBAL_INTERCEPTOR_IGNORED = mock(); + + static ServerInterceptor GLOBAL_INTERCEPTOR_BAR = mock(); + + @Bean + BindableService serviceA() { + when(SERVICE_A.bindService()).thenReturn(SERVICE_DEF_A); + return SERVICE_A; + } + + @Bean + @Order(200) + @GlobalServerInterceptor + ServerInterceptor globalInterceptorFoo() { + return GLOBAL_INTERCEPTOR_FOO; + } + + @Bean + @Order(150) + ServerInterceptor globalInterceptorIgnored() { + return GLOBAL_INTERCEPTOR_IGNORED; + } + + @Bean + @Order(100) + @GlobalServerInterceptor + ServerInterceptor globalInterceptorBar() { + return GLOBAL_INTERCEPTOR_BAR; + } + + } + + @Configuration(proxyBeanMethods = false) + static class ServiceSpecificInterceptorsConfig { + + static TestServerInterceptorB SVC_INTERCEPTOR_B = mock(); + + static TestServerInterceptorA SVC_INTERCEPTOR_A = mock(); + + @Bean + @Order(150) + TestServerInterceptorB interceptorB() { + return SVC_INTERCEPTOR_B; + } + + @Bean + @Order(225) + TestServerInterceptorA interceptorA() { + return SVC_INTERCEPTOR_A; + } + + } + +} diff --git a/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/DefaultGrpcServiceDiscovererTests.java b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/DefaultGrpcServiceDiscovererTests.java index 0dc5f88..c093436 100644 --- a/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/DefaultGrpcServiceDiscovererTests.java +++ b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/DefaultGrpcServiceDiscovererTests.java @@ -16,18 +16,18 @@ package org.springframework.grpc.autoconfigure.server; -import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.grpc.autoconfigure.server.DefaultGrpcServiceDiscovererTests.DefaultGrpcServiceDiscovererTestsConfig.SERVICE_A; +import static org.springframework.grpc.autoconfigure.server.DefaultGrpcServiceDiscovererTests.DefaultGrpcServiceDiscovererTestsConfig.SERVICE_B; + +import java.util.LinkedHashMap; +import java.util.Map; -import io.grpc.BindableService; -import io.grpc.ServerInterceptor; -import io.grpc.ServerInterceptors; -import io.grpc.ServerServiceDefinition; import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.MockedStatic; import org.mockito.Mockito; -import org.mockito.stubbing.Answer; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -36,12 +36,8 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.grpc.server.lifecycle.GrpcServerLifecycle; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.when; +import io.grpc.BindableService; +import io.grpc.ServerServiceDefinition; /** * Tests for {@link DefaultGrpcServiceDiscoverer}. @@ -58,50 +54,29 @@ class DefaultGrpcServiceDiscovererTests { } @Test - void globalServerInterceptorsAreFoundInProperOrder() { + void servicesAreFoundInProperOrderWithExpectedGrpcServiceAnnotations() { + TestServiceConfigurer configurer = new TestServiceConfigurer(); this.contextRunner() - .withUserConfiguration(GlobalServerInterceptorsConfig.class) - .run((context) -> assertThat(context).getBean(DefaultGrpcServiceDiscoverer.class) - .extracting(DefaultGrpcServiceDiscoverer::findGlobalInterceptors, InstanceOfAssertFactories.LIST) - .containsExactly(GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_BAR, - GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_FOO)); - } - - @Test - void servicesAreFoundInProperOrderWithGlobalInterceptorsApplied() { - // It gets difficult to verify interceptors are added properly to mocked services. - // To make it easier, we just static mock ServerInterceptors.interceptForward to - // echo back the service def. This way we can verify the interceptors were passed - // in the proper order as we rely/trust that ServerInterceptors.interceptForward - // is - // tested well in grpc-java. - try (MockedStatic serverInterceptorsMocked = Mockito.mockStatic(ServerInterceptors.class)) { - serverInterceptorsMocked - .when(() -> ServerInterceptors.interceptForward(any(ServerServiceDefinition.class), anyList())) - .thenAnswer((Answer) invocation -> invocation.getArgument(0)); - this.contextRunner().withUserConfiguration(GlobalServerInterceptorsConfig.class).run((context) -> { + .withUserConfiguration(DefaultGrpcServiceDiscovererTestsConfig.class) + .withBean("customServiceConfigurer", GrpcServiceConfigurer.class, () -> configurer) + .run((context) -> { assertThat(context).getBean(DefaultGrpcServiceDiscoverer.class) .extracting(DefaultGrpcServiceDiscoverer::findServices, InstanceOfAssertFactories.LIST) - .containsExactly(GlobalServerInterceptorsConfig.SERVICE_DEF_B, - GlobalServerInterceptorsConfig.SERVICE_DEF_A); - ArgumentCaptor serviceDefArg = ArgumentCaptor.captor(); - ArgumentCaptor> interceptorsArg = ArgumentCaptor.captor(); - serverInterceptorsMocked.verify( - () -> ServerInterceptors.interceptForward(serviceDefArg.capture(), interceptorsArg.capture()), - times(2)); - assertThat(serviceDefArg.getAllValues()).containsExactly(GlobalServerInterceptorsConfig.SERVICE_DEF_B, - GlobalServerInterceptorsConfig.SERVICE_DEF_A); - assertThat(interceptorsArg.getAllValues()).containsExactly( - List.of(GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_BAR, - GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_FOO), - List.of(GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_BAR, - GlobalServerInterceptorsConfig.GLOBAL_INTERCEPTOR_FOO)); + .containsExactly(DefaultGrpcServiceDiscovererTestsConfig.SERVICE_DEF_B, + DefaultGrpcServiceDiscovererTestsConfig.SERVICE_DEF_A); + assertThat(configurer.invocations).hasSize(2); + assertThat(configurer.invocations.keySet()).containsExactly(SERVICE_B, SERVICE_A); + assertThat(configurer.invocations).containsEntry(SERVICE_B, null); + assertThat(configurer.invocations).hasEntrySatisfying(SERVICE_A, (serviceInfo) -> { + assertThat(serviceInfo.interceptors()).isEmpty(); + assertThat(serviceInfo.interceptorNames()).isEmpty(); + assertThat(serviceInfo.blendWithGlobalInterceptors()).isFalse(); + }); }); - } } @Configuration(proxyBeanMethods = false) - static class GlobalServerInterceptorsConfig { + static class DefaultGrpcServiceDiscovererTestsConfig { static BindableService SERVICE_A = mock(); @@ -111,12 +86,7 @@ class DefaultGrpcServiceDiscovererTests { static ServerServiceDefinition SERVICE_DEF_B = mock(); - static ServerInterceptor GLOBAL_INTERCEPTOR_FOO = mock(); - - static ServerInterceptor GLOBAL_INTERCEPTOR_IGNORED = mock(); - - static ServerInterceptor GLOBAL_INTERCEPTOR_BAR = mock(); - + @GrpcService @Bean @Order(200) BindableService serviceA() { @@ -131,24 +101,16 @@ class DefaultGrpcServiceDiscovererTests { return SERVICE_B; } - @Bean - @Order(200) - @GlobalServerInterceptor - ServerInterceptor globalInterceptorFoo() { - return GLOBAL_INTERCEPTOR_FOO; - } + } - @Bean - @Order(150) - ServerInterceptor globalInterceptorIgnored() { - return GLOBAL_INTERCEPTOR_IGNORED; - } + static class TestServiceConfigurer implements GrpcServiceConfigurer { - @Bean - @Order(100) - @GlobalServerInterceptor - ServerInterceptor globalInterceptorBar() { - return GLOBAL_INTERCEPTOR_BAR; + Map invocations = new LinkedHashMap<>(); + + @Override + public ServerServiceDefinition configure(BindableService bindableService, GrpcServiceInfo serviceInfo) { + invocations.put(bindableService, serviceInfo); + return bindableService.bindService(); } } diff --git a/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/GrpcServiceInfoTests.java b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/GrpcServiceInfoTests.java new file mode 100644 index 0000000..3461af6 --- /dev/null +++ b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/GrpcServiceInfoTests.java @@ -0,0 +1,118 @@ +/* + * Copyright 2024-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.grpc.autoconfigure.server; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.core.annotation.AnnotationUtils; + +import io.grpc.ServerInterceptor; + +import java.util.List; + +/** + * Tests for {@link GrpcServiceInfo}. + */ +class GrpcServiceInfoTests { + + @Nested + class WithInterceptorsApiInvokedWith { + + @Test + void nullInterceptors() { + assertThatIllegalArgumentException().isThrownBy(() -> GrpcServiceInfo.withInterceptors(null)) + .withMessage("interceptors must not be null"); + } + + @Test + void interceptorTypes() { + assertThat(GrpcServiceInfo.withInterceptors(List.of(ServerInterceptor.class))).satisfies((serviceInfo) -> { + assertThat(serviceInfo.interceptors()).containsExactly(ServerInterceptor.class); + assertThat(serviceInfo.interceptorNames()).isEmpty(); + assertThat(serviceInfo.blendWithGlobalInterceptors()).isFalse(); + }); + } + + } + + @Nested + class WithInterceptorNamesApiInvokedWith { + + @Test + void nullInterceptorNames() { + assertThatIllegalArgumentException().isThrownBy(() -> GrpcServiceInfo.withInterceptorNames(null)) + .withMessage("interceptorNames must not be null"); + } + + @Test + void interceptorNames() { + assertThat(GrpcServiceInfo.withInterceptorNames(List.of("myInterceptor"))).satisfies((serviceInfo) -> { + assertThat(serviceInfo.interceptors()).isEmpty(); + assertThat(serviceInfo.interceptorNames()).containsExactly("myInterceptor"); + assertThat(serviceInfo.blendWithGlobalInterceptors()).isFalse(); + }); + } + + } + + @Nested + class FromApiInvokedWith { + + @Test + void nullGrpcService() { + assertThat(GrpcServiceInfo.from(null)).isNull(); + } + + @Test + void grpcServiceAnnotationWithDefaults() { + var grpcServiceAnnotation = AnnotationUtils.findAnnotation(TestServiceMarkedWithDefaults.class, + GrpcService.class); + assertThat(GrpcServiceInfo.from(grpcServiceAnnotation)).satisfies((serviceInfo) -> { + assertThat(serviceInfo.interceptors()).isEmpty(); + assertThat(serviceInfo.interceptorNames()).isEmpty(); + assertThat(serviceInfo.blendWithGlobalInterceptors()).isFalse(); + }); + } + + @Test + void grpcServiceAnnotationWithAttributes() { + var grpcServiceAnnotation = AnnotationUtils.findAnnotation(TestServiceMarkedWithAttributes.class, + GrpcService.class); + assertThat(GrpcServiceInfo.from(grpcServiceAnnotation)).satisfies((serviceInfo) -> { + assertThat(serviceInfo.interceptors()).containsExactly(ServerInterceptor.class); + assertThat(serviceInfo.interceptorNames()).containsExactly("myInterceptor"); + assertThat(serviceInfo.blendWithGlobalInterceptors()).isTrue(); + }); + } + + } + + @GrpcService + static class TestServiceMarkedWithDefaults { + + } + + @GrpcService(interceptors = ServerInterceptor.class, interceptorNames = "myInterceptor", + blendWithGlobalInterceptors = true) + static class TestServiceMarkedWithAttributes { + + } + +}