diff --git a/grpc-common-spring-boot/src/main/java/net/devh/boot/grpc/common/util/InterceptorOrder.java b/grpc-common-spring-boot/src/main/java/net/devh/boot/grpc/common/util/InterceptorOrder.java index 938366a44..100b83db8 100644 --- a/grpc-common-spring-boot/src/main/java/net/devh/boot/grpc/common/util/InterceptorOrder.java +++ b/grpc-common-spring-boot/src/main/java/net/devh/boot/grpc/common/util/InterceptorOrder.java @@ -62,6 +62,10 @@ public final class InterceptorOrder { * The order value for security interceptors related to authorization checks. */ public static final int ORDER_SECURITY_AUTHORISATION = 5200; + /** + * The order value for request validation interceptor. + */ + public static final int REQUEST_VALIDATION = 6000; /** * The order value for interceptors that should be executed last. This is equivalent to * {@link Ordered#LOWEST_PRECEDENCE}. This is the default for interceptors without specified priority. diff --git a/grpc-server-spring-boot-starter/build.gradle b/grpc-server-spring-boot-starter/build.gradle index c3f39af1e..8567ec7f3 100644 --- a/grpc-server-spring-boot-starter/build.gradle +++ b/grpc-server-spring-boot-starter/build.gradle @@ -31,6 +31,8 @@ dependencies { api 'io.grpc:grpc-services' api 'io.grpc:grpc-api' + implementation 'build.buf.protoc-gen-validate:pgv-java-stub:0.9.1' + testImplementation 'io.grpc:grpc-testing' testImplementation('org.springframework.boot:spring-boot-starter-test') } diff --git a/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/validator/BaseValidator.java b/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/validator/BaseValidator.java new file mode 100644 index 000000000..bdc53a336 --- /dev/null +++ b/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/validator/BaseValidator.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2016-2025 The gRPC-Spring Authors + * + * Licensed under the Apache License, Version 2.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 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.devh.boot.grpc.server.validator; + +import io.envoyproxy.pgv.ReflectiveValidatorIndex; +import io.envoyproxy.pgv.ValidationException; +import io.envoyproxy.pgv.ValidatorIndex; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; + +/** + * The {@code BaseValidator} interface provides a default method for validating gRPC request objects + * using the Envoy Proxy's Protoc-Gen-Validate library. + * + * @author Pritesh (priteshdpawar53@gmail.com) + * @since 02/10/25 + * + *

+ * Example usage: + *

+ * {@code
+ * public class MyRequestValidator implements BaseValidator {
+ *     public void validateMyRequest(MyRequest request) {
+ *         // Business validation
+ *     }
+ * }
+ * }
+ * 
+ *

+ */ +public interface BaseValidator { + + /** + * Validates the provided request object using the Envoy Proxy Protoc-Gen-Validate library. + *

+ * This method uses the {@link ValidatorIndex} (specifically the {@link ReflectiveValidatorIndex}) to look up + * the appropriate validator for the request's class and performs validation. If the request is invalid, a + * {@link StatusRuntimeException} with a {@link Status} status is thrown, including details + * of the validation failure. + *

+ * + * @param request the request object to validate + * @param the type of the request object + * @throws StatusRuntimeException if the validation fails, this exception is thrown with a description of the failure + * and a cause explaining the validation issue. + */ + default void validate(T request) { + ValidatorIndex validatorIndex = new ReflectiveValidatorIndex(); + try { + validatorIndex.validatorFor(request.getClass()).assertValid(request); + } catch (ValidationException e) { + throw new StatusRuntimeException(Status.FAILED_PRECONDITION.withCause(e) + .withDescription(e.getField() + " : " + e.getReason() + ". Received value: " + e.getMessage())); + } + } +} diff --git a/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/validator/GrpcValidator.java b/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/validator/GrpcValidator.java new file mode 100644 index 000000000..ea8c29b5a --- /dev/null +++ b/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/validator/GrpcValidator.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2016-2023 The gRPC-Spring Authors + * + * Licensed under the Apache License, Version 2.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 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.devh.boot.grpc.server.validator; + +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.stereotype.Component; + +/** + * Annotation to mark methods that require gRPC validation. This annotation is typically used for methods in a service + * class where a gRPC request needs to be validated using a specific validator class and method. + * + * @author Pritesh (priteshdpawar53@gmail.com) + * @since 02/10/25 + * + *

+ * Usage Example: + *

+ * + *
+ * {@code @GrpcValidator(requestClass = MyRequest.class, validatorClass = MyRequestValidator.class, validatorMethod = "validateRequest")
+ * public void myGrpcMethod(MyRequest request) {
+ *     // Your gRPC method implementation
+ * }
+ * }
+ * 
+ * + *

+ * In this example, the annotation tells the system to use the {@code MyRequestValidator} class and call its + * {@code validateRequest} method to validate the incoming {@code MyRequest}. + *

+ */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Component +public @interface GrpcValidator { + + /** + * The request class that will be validated. + * + *

+ * This represents the type of the gRPC request that needs validation. The validator will perform checks based on + * this class. + *

+ * + * @return the class type of the gRPC request + */ + Class requestClass(); + + /** + * The validator class that contains the validation logic. + * + *

+ * This class is responsible for validating the request class. It should provide the validation method specified by + * {@link #validatorMethod()}. + *

+ * + * @return the class type of the validator + */ + Class validatorClass(); + + /** + * The name of the method in the validator class that performs the validation. + * + *

+ * This method is invoked at runtime to perform the validation logic on the request class. It should take the + * request class type as an argument and return a validation result, typically a boolean or a validation exception. + *

+ * + * @return the name of the validation method + */ + String validatorMethod(); +} diff --git a/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/validator/GrpcValidatorDiscoverer.java b/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/validator/GrpcValidatorDiscoverer.java new file mode 100644 index 000000000..52a7a748e --- /dev/null +++ b/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/validator/GrpcValidatorDiscoverer.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2016-2025 The gRPC-Spring Authors + * + * Licensed under the Apache License, Version 2.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 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.devh.boot.grpc.server.validator; + +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; + +import com.netflix.discovery.shared.Pair; + +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * The {@code GrpcValidatorDiscoverer} class is responsible for discovering and registering gRPC validators + * in the Spring application context based on custom annotations. + * + * @author Pritesh (priteshdpawar53@gmail.com) + * @since 02/10/25 + * + *

+ * Example usage: If a service contains a method annotated with {@link GrpcValidator} for a specific request class, + * the {@code GrpcValidatorDiscoverer} will discover this method and register it in the map so that it can later be + * used for validation during gRPC request processing. + *

+ */ +@Slf4j +@Component +public class GrpcValidatorDiscoverer implements ApplicationContextAware { + + private ApplicationContext applicationContext; + + /** + * A map of request classes to their corresponding validator bean and method. + * The map is populated during the initialization phase using the {@link GrpcValidator} annotation. + * The key is the request class, and the value is a {@link Pair} containing the validator bean and method name. + */ + @Getter + private Map, Pair> requestValidatorMethodMap; + + /** + * Sets the application context. This method is called by the Spring container. + * It allows access to the Spring {@link ApplicationContext} to retrieve beans and methods. + * + * @param applicationContext the Spring {@link ApplicationContext} to be set + */ + @Override + public void setApplicationContext(final ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + /** + * Initializes the {@code GrpcValidatorDiscoverer} by scanning all beans in the Spring context that are + * annotated with {@link Service}. For each of these beans, the method scans its declared methods for the + * {@link GrpcValidator} annotation. + *

+ * It collects the mapping between request classes and their corresponding validator beans and methods, + * storing it in the {@link #requestValidatorMethodMap} field. + *

+ *

+ * This method is executed after the bean has been initialized (as indicated by the {@link PostConstruct} annotation), + * and it logs the initialization status and the resulting map of validators. + *

+ */ + @PostConstruct + public void init() { + log.info("Initializing registration of GrpcValidator annotation"); + + // Discover beans annotated with @Service and their methods annotated with @GrpcValidator + requestValidatorMethodMap = applicationContext.getBeansWithAnnotation(Service.class).values().stream() + .flatMap(bean -> Arrays.stream(bean.getClass().getDeclaredMethods())) // Flatten the methods in the bean + .filter(method -> method.isAnnotationPresent(GrpcValidator.class)) // Filter methods annotated with @GrpcValidator + .map(method -> { + GrpcValidator validator = method.getAnnotation(GrpcValidator.class); + Object validatorBean = applicationContext.getBean(validator.validatorClass()); // Retrieve the validator bean from the context + return new Pair<>(validator.requestClass(), new Pair<>(validatorBean, validator.validatorMethod())); // Return a pair of the request class and the validator info + }) + .collect(Collectors.toMap(Pair::first, Pair::second)); // Collect into a map: request class -> (validator bean, method name) + + log.info("Request to validator map instantiated: {}", requestValidatorMethodMap); + } +} diff --git a/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/validator/RequestValidatingInterceptor.java b/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/validator/RequestValidatingInterceptor.java new file mode 100644 index 000000000..2af944e16 --- /dev/null +++ b/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/validator/RequestValidatingInterceptor.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2016-2025 The gRPC-Spring Authors + * + * Licensed under the Apache License, Version 2.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 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.devh.boot.grpc.server.validator; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Map; + +import com.alibaba.nacos.common.utils.MapUtil; +import com.netflix.discovery.shared.Pair; + +import io.grpc.*; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.common.util.InterceptorOrder; +import net.devh.boot.grpc.server.interceptor.GrpcGlobalServerInterceptor; +import org.springframework.core.annotation.Order; + +/** + * {@code RequestValidatingInterceptor} is a gRPC server interceptor that validates incoming requests using the + * {@link BaseValidator} interface and performs custom validations as defined by {@link GrpcValidator}. + *

+ * This interceptor is registered globally using the {@link GrpcGlobalServerInterceptor} annotation and is invoked + * for every gRPC call on the server. It first validates the request using the base validation logic provided + * by the {@link BaseValidator} interface, and then it performs additional custom validation based on the + * {@link GrpcValidator} annotations discovered by the {@link GrpcValidatorDiscoverer}. + *

+ * + * @author Pritesh (priteshdpawar53@gmail.com) + * @since 02/10/25 + */ +@GrpcGlobalServerInterceptor +@Order(InterceptorOrder.REQUEST_VALIDATION) +@Slf4j +public class RequestValidatingInterceptor implements ServerInterceptor, BaseValidator { + + private final GrpcValidatorDiscoverer validatorDiscoverer; + + /** + * Constructs a new {@code RequestValidatingInterceptor} with the given {@link GrpcValidatorDiscoverer}. + * The {@link GrpcValidatorDiscoverer} is used to discover and retrieve custom validators for request classes. + * + * @param validatorDiscoverer the {@link GrpcValidatorDiscoverer} used for discovering validators + */ + public RequestValidatingInterceptor(GrpcValidatorDiscoverer validatorDiscoverer) { + this.validatorDiscoverer = validatorDiscoverer; + } + + /** + * Intercepts an incoming gRPC request and performs validation on the message. + *

+ * This method is called when a request is received. It first validates the request using the base validation + * provided by {@link BaseValidator}, and then it performs custom validation as defined by the + * {@link GrpcValidator} annotations on methods in service beans. + *

+ * + * @param the type of the request message + * @param the type of the response message + * @param serverCall the gRPC server call + * @param metadata the metadata associated with the call + * @param serverCallHandler the handler to which the call is delegated + * @return a listener for the request + */ + @Override + public ServerCall.Listener interceptCall(ServerCall serverCall, + Metadata metadata, ServerCallHandler serverCallHandler) { + final ServerCall.Listener delegate = serverCallHandler.startCall(serverCall, metadata); + + // Wrapping the listener to perform custom validation on incoming message + return new ForwardingServerCallListener.SimpleForwardingServerCallListener(delegate) { + @Override + public void onMessage(ReqT message) { + log.info("Validating incoming protobuf message: {}", message); + validate(message); // Perform base validation + handleCustomValidations(message); // Perform custom validation + super.onMessage(message); // Proceed with the request + } + }; + } + + /** + * Handles custom validations for the incoming request message based on the {@link GrpcValidator} annotations + * and the discovered validator methods in the {@link GrpcValidatorDiscoverer}. + *

+ * If a custom validation method is found for the given message's class, it invokes the validator method on the + * corresponding validator bean. + *

+ * + * @param message the request message to validate + * @param the type of the request message + * @throws StatusRuntimeException if validation fails, throws an exception with appropriate status and details + */ + private void handleCustomValidations(ReqT message) { + // Retrieve the map of request validators from the discoverer + Map, Pair> requestValidatorMethodMap = + validatorDiscoverer.getRequestValidatorMethodMap(); + + // If there are no validators for the message class, skip custom validation + if (MapUtil.isEmpty(requestValidatorMethodMap) || !requestValidatorMethodMap.containsKey(message.getClass())) { + return; + } + + // Retrieve the validator bean and method name for the message class + Pair validatorBeanPair = requestValidatorMethodMap.get(message.getClass()); + Method validatorMethod; + try { + // Retrieve the validator method + validatorMethod = validatorBeanPair.first().getClass().getDeclaredMethod(validatorBeanPair.second(), message.getClass()); + // Invoke the validator method + validatorMethod.invoke(validatorBeanPair.first(), message); + } catch (NoSuchMethodException | IllegalAccessException e) { + // Handle errors during method invocation + throw new StatusRuntimeException(Status.INTERNAL.withCause(e).withDescription(e.getMessage())); + } catch (InvocationTargetException e) { + // Handle errors thrown by the validator method itself + throw new StatusRuntimeException(Status.fromThrowable(e).withDescription(e.getMessage())); + } + } +} diff --git a/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/validator/package-info.java b/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/validator/package-info.java new file mode 100644 index 000000000..4e3c211a6 --- /dev/null +++ b/grpc-server-spring-boot-starter/src/main/java/net/devh/boot/grpc/server/validator/package-info.java @@ -0,0 +1,5 @@ +/** + * Classes related to the gRPC request validators and their discovery. + */ + +package net.devh.boot.grpc.server.validator;