diff --git a/docs/en/index.md b/docs/en/index.md index 1f43b7ee0..cc13b7fd7 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -12,6 +12,7 @@ customization you need for your project. - Server - [Getting Started](server/getting-started.md) - [Configuration](server/configuration.md) + - [Exception Handling](server/exception-handling.md) - [Contextual Data / Scoped Beans](server/contextual-data.md) - [Testing the Service](server/testing.md) - [Security](server/security.md) diff --git a/docs/en/server/configuration.md b/docs/en/server/configuration.md index 236790ccb..a5b5013a8 100644 --- a/docs/en/server/configuration.md +++ b/docs/en/server/configuration.md @@ -17,6 +17,7 @@ This section describes how you can configure your grpc-spring-boot-starter appli - [Getting Started](getting-started.md) - *Configuration* +- [Exception Handling](exception-handling.md) - [Contextual Data / Scoped Beans](contextual-data.md) - [Testing the Service](testing.md) - [Security](security.md) @@ -121,6 +122,7 @@ public GrpcServerConfigurer keepAliveServerConfigurer() { - [Getting Started](getting-started.md) - *Configuration* +- [Exception Handling](exception-handling.md) - [Contextual Data / Scoped Beans](contextual-data.md) - [Testing the Service](testing.md) - [Security](security.md) diff --git a/docs/en/server/contextual-data.md b/docs/en/server/contextual-data.md index 6fd2cab94..4a6024cbd 100644 --- a/docs/en/server/contextual-data.md +++ b/docs/en/server/contextual-data.md @@ -13,6 +13,7 @@ This section describes how you can store contextual / per request data. - [Getting Started](getting-started.md) - [Configuration](configuration.md) +- [Exception Handling](exception-handling.md) - *Contextual Data / Scoped Beans* - [Testing the Service](testing.md) - [Security](security.md) @@ -61,6 +62,7 @@ public void grpcMethod(Request request, StreamObserver responseObserve - [Getting Started](getting-started.md) - [Configuration](configuration.md) +- [Exception Handling](exception-handling.md) - *Contextual Data / Scoped Beans* - [Testing the Service](testing.md) - [Security](security.md) diff --git a/docs/en/server/exception-handling.md b/docs/en/server/exception-handling.md new file mode 100644 index 000000000..ec06c1482 --- /dev/null +++ b/docs/en/server/exception-handling.md @@ -0,0 +1,119 @@ +# Exception Handling inside GrpcService + +[<- Back to Index](../index.md) + +This section describes how you can handle exceptions inside GrpcService layer without cluttering up your code. + +## Table of Contents + +- [Proper exception handling](#proper-exception-handling) +- [Detailed explanation](#detailed-explanation) + - [Priority of mapped exceptions](#priority-of-mapped-exceptions) + - [Sending Metadata in response](#sending-metadata-in-response) + - [Overview of returnable types](#overview-of-returnable-types) + +## Additional Topics + +- [Getting Started](getting-started.md) +- [Configuration](configuration.md) +- [Contextual Data](contextual-data.md) +- *Exception Handling* +- [Testing the Service](testing.md) +- [Security](security.md) + +## Proper exception handling + +If you are already familiar with spring's [error handling](https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#boot-features-error-handling), +you should see some similarities with the exception handling for gRPC. + +_An explanation for the following class:_ + +```java +@GrpcAdvice +public class GrpcExceptionAdvice { + + + @GrpcExceptionHandler + public Status handleInvalidArgument(IllegalArgumentException e) { + return Status.INVALID_ARGUMENT.withDescription("Your description").withCause(e); + } + + @GrpcExceptionHandler(ResourceNotFoundException.class) + public StatusException handleResourceNotFoundException(ResourceNotFoundException e) { + Status status = Status.NOT_FOUND.withDescription("Your description").withCause(e); + Metadata metadata = ... + return status.asException(metadata); + } + +} +``` + +- `@GrpcAdvice` marks a class to be checked up for exception handling methods +- `@GrpcExceptionHandler` marks the annotated method to be executed, in case of the _specified_ exception being thrown + - f.e. if your application throws `IllegalArgumentException`, + then the `handleInvalidArgument(IllegalArgumentException e)` method will be executed +- The method must either return a `io.grpc.Status`, `StatusException`, or `StatusRuntimeException` +- If you handle server errors, you might want to log the exception/stacktrace inside the exception handler + +> **Note:** Cause is not transmitted from server to client - as stated in [official docs](https://grpc.github.io/grpc-java/javadoc/io/grpc/Status.html#withCause-java.lang.Throwable-) +> So we recommend adding it to the `Status`/`StatusException` to avoid the loss of information on the server side. + +## Detailed explanation + +### Priority of mapped exceptions + +Given this method with specified exception in the annotation *and* as a method argument + +```java +@GrpcExceptionHandler(ResourceNotFoundException.class) +public StatusException handleResourceNotFoundException(ResourceNotFoundException e) { + // your exception handling +} +``` + +If the `GrpcExceptionHandler` annotation contains at least one exception type, then only those will be +considered for exception handling for that method. The method parameters must be "compatible" with the specified +exception types. If the annotation does not specify any handled exception types, then all method parameters are being +used instead. + +_("Compatible" means that the exception type in annotation is either the same class or a superclass of one of the +listed method parameters)_ + +### Sending Metadata in response + +In case you want to send metadata in your exception response, let's have a look at the following example. + +```java +@GrpcExceptionHandler +public StatusRuntimeException handleResourceNotFoundException(IllegalArgumentException e) { + Status status = Status.INVALID_ARGUMENT.withDescription("Your description"); + Metadata metadata = ... + return status.asRuntimeException(metadata); +} +``` + +If you do not need `Metadata` in your response, just return your specified `Status`. + +### Overview of returnable types + +Here is a small overview of possible mapped return types with `@GrpcExceptionHandler` and if custom `Metadata` can be +returned: + +| Return Type | Supports Custom Metadata | +| ----------- | --------------- | +| `Status` | ✗ | +| `StatusException` | ✔ | +| `StatusRuntimeException` | ✔ | + +## Additional Topics + +- [Getting Started](getting-started.md) +- [Configuration](configuration.md) +- [Contextual Data](contextual-data.md) +- *Exception Handling* +- [Testing the Service](testing.md) +- [Security](security.md) + +---------- + +[<- Back to Index](../index.md) diff --git a/docs/en/server/getting-started.md b/docs/en/server/getting-started.md index ad2d888e7..de6e8bd72 100644 --- a/docs/en/server/getting-started.md +++ b/docs/en/server/getting-started.md @@ -18,6 +18,7 @@ This section describes the steps necessary to convert your application into a gr - *Getting started* - [Configuration](configuration.md) +- [Exception Handling](exception-handling.md) - [Contextual Data / Scoped Beans](contextual-data.md) - [Testing the Service](testing.md) - [Security](security.md) @@ -316,6 +317,7 @@ See [here](testing.md#grpcurl) for `gRPCurl` example command output and addition - *Getting Started* - [Configuration](configuration.md) +- [Exception Handling](exception-handling.md) - [Contextual Data / Scoped Beans](contextual-data.md) - [Testing the Service](testing.md) - [Security](security.md) diff --git a/docs/en/server/security.md b/docs/en/server/security.md index dad5ca1e4..876ee9d8e 100644 --- a/docs/en/server/security.md +++ b/docs/en/server/security.md @@ -19,6 +19,7 @@ We strongly recommend enabling at least transport layer security. - [Getting Started](getting-started.md) - [Configuration](configuration.md) +- [Exception Handling](exception-handling.md) - [Contextual Data / Scoped Beans](contextual-data.md) - [Testing the Service](testing.md) - *Security* @@ -279,6 +280,7 @@ public void methodX(Request request, StreamObserver responseObserver) - [Getting Started](getting-started.md) - [Configuration](configuration.md) +- [Exception Handling](exception-handling.md) - [Contextual Data / Scoped Beans](contextual-data.md) - [Testing the Service](testing.md) - *Security* diff --git a/docs/en/server/testing.md b/docs/en/server/testing.md index 4f2bc85ef..0bbaf6277 100644 --- a/docs/en/server/testing.md +++ b/docs/en/server/testing.md @@ -22,6 +22,7 @@ Please refer to [Tests with Grpc-Stubs](../client/testing.md). - [Getting Started](getting-started.md) - [Configuration](configuration.md) +- [Exception Handling](exception-handling.md) - [Contextual Data / Scoped Beans](contextual-data.md) - *Testing the Service* - [Security](security.md) @@ -368,6 +369,7 @@ For more information regarding `gRPCurl` please refer to their [official documen - [Getting Started](getting-started.md) - [Configuration](configuration.md) +- [Exception Handling](exception-handling.md) - [Contextual Data / Scoped Beans](contextual-data.md) - *Testing the Service* - [Security](security.md) diff --git a/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/advice/GrpcAdvice.java b/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/advice/GrpcAdvice.java new file mode 100644 index 000000000..8d25d83f2 --- /dev/null +++ b/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/advice/GrpcAdvice.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2016-2021 Michael Zhang + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.devh.boot.grpc.server.advice; + +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; + +/** + * Special {@link Component @Component} to declare global gRPC exception handling. + * + * Every class annotated with {@link GrpcAdvice @GrpcAdvice} is marked to be scanned for + * {@link GrpcExceptionHandler @GrpcExceptionHandler} annotations. + *

+ * + * @author Andjelko Perisic (andjelko.perisic@gmail.com) + * @see GrpcExceptionHandler + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Component +public @interface GrpcAdvice { + +} diff --git a/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/advice/GrpcAdviceDiscoverer.java b/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/advice/GrpcAdviceDiscoverer.java new file mode 100644 index 000000000..5c9db512c --- /dev/null +++ b/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/advice/GrpcAdviceDiscoverer.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2016-2021 Michael Zhang + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.devh.boot.grpc.server.advice; + +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.core.MethodIntrospector; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils.MethodFilter; + +import lombok.extern.slf4j.Slf4j; + +/** + * A discovery class to find all Beans annotated with {@link GrpcAdvice @GrpcAdvice} and for all found beans a second + * search is performed looking for methods with {@link GrpcExceptionHandler @GrpcExceptionHandler}. + * + * @author Andjelko Perisic (andjelko.perisic@gmail.com) + * @see GrpcAdvice + * @see GrpcExceptionHandler + */ +@Slf4j +public class GrpcAdviceDiscoverer implements InitializingBean, ApplicationContextAware { + + /** + * A filter for selecting {@code @GrpcExceptionHandler} methods. + */ + public static final MethodFilter EXCEPTION_HANDLER_METHODS = + method -> AnnotatedElementUtils.hasAnnotation(method, GrpcExceptionHandler.class); + + private ApplicationContext applicationContext; + private Map annotatedBeans; + private Set annotatedMethods; + + @Override + public void setApplicationContext(final ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + @Override + public void afterPropertiesSet() { + annotatedBeans = applicationContext.getBeansWithAnnotation(GrpcAdvice.class); + annotatedBeans.forEach( + (key, value) -> log.debug("Found gRPC advice: " + key + ", class: " + value.getClass().getName())); + + annotatedMethods = findAnnotatedMethods(); + } + + private Set findAnnotatedMethods() { + return this.annotatedBeans.values().stream() + .map(Object::getClass) + .map(this::findAnnotatedMethods) + .flatMap(Collection::stream) + .collect(Collectors.toSet()); + } + + private Set findAnnotatedMethods(final Class clazz) { + return MethodIntrospector.selectMethods(clazz, EXCEPTION_HANDLER_METHODS); + } + + public Map getAnnotatedBeans() { + Assert.state(annotatedBeans != null, "@GrpcAdvice annotation scanning failed."); + return annotatedBeans; + } + + public Set getAnnotatedMethods() { + Assert.state(annotatedMethods != null, "@GrpcExceptionHandler annotation scanning failed."); + return annotatedMethods; + } + +} diff --git a/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/advice/GrpcAdviceExceptionHandler.java b/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/advice/GrpcAdviceExceptionHandler.java new file mode 100644 index 000000000..d9c398acf --- /dev/null +++ b/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/advice/GrpcAdviceExceptionHandler.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2016-2021 Michael Zhang + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.devh.boot.grpc.server.advice; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.lang.reflect.Type; +import java.util.Map.Entry; + +import org.springframework.lang.Nullable; + +import lombok.extern.slf4j.Slf4j; + +/** + * As part of {@link GrpcAdvice @GrpcAdvice}, when a thrown exception is caught during gRPC calls (via global + * interceptor {@link GrpcAdviceExceptionInterceptor}, then this thrown exception is being handled. By + * {@link GrpcExceptionHandlerMethodResolver} is a mapping between exception and the in case to be executed method + * provided.
+ * Returned object is declared in {@link GrpcAdvice @GrpcAdvice} classes with annotated methods + * {@link GrpcExceptionHandler @GrpcExceptionHandler}. + *

+ * + * @author Andjelko Perisic (andjelko.perisic@gmail.com) + * @see GrpcExceptionHandlerMethodResolver + * @see GrpcAdviceExceptionInterceptor + */ +@Slf4j +public class GrpcAdviceExceptionHandler { + + private final GrpcExceptionHandlerMethodResolver grpcExceptionHandlerMethodResolver; + + public GrpcAdviceExceptionHandler( + final GrpcExceptionHandlerMethodResolver grpcExceptionHandlerMethodResolver) { + this.grpcExceptionHandlerMethodResolver = grpcExceptionHandlerMethodResolver; + } + + /** + * Given an exception, a lookup is performed to retrieve mapped method.
+ * In case of successful returned method, and matching exception parameter type for given exception, the exception + * is handed over to retrieved method. Retrieved method is then being invoked. + * + * @param exception exception to search for + * @param type of exception + * @return result of invoked mapped method to given exception + * @throws Throwable rethrows exception if no mapping existent or exceptions raised by implementation + */ + @Nullable + public Object handleThrownException(E exception) throws Throwable { + log.debug("Exception caught during gRPC execution: ", exception); + + final Class exceptionClass = exception.getClass(); + boolean exceptionIsMapped = + grpcExceptionHandlerMethodResolver.isMethodMappedForException(exceptionClass); + if (!exceptionIsMapped) { + throw exception; + } + + Entry methodWithInstance = + grpcExceptionHandlerMethodResolver.resolveMethodWithInstance(exceptionClass); + Method mappedMethod = methodWithInstance.getValue(); + Object instanceOfMappedMethod = methodWithInstance.getKey(); + Object[] instancedParams = determineInstancedParameters(mappedMethod, exception); + + return invokeMappedMethodSafely(mappedMethod, instanceOfMappedMethod, instancedParams); + } + + private Object[] determineInstancedParameters(Method mappedMethod, E exception) { + + Parameter[] parameters = mappedMethod.getParameters(); + Object[] instancedParams = new Object[parameters.length]; + + for (int i = 0; i < parameters.length; i++) { + Class parameterClass = convertToClass(parameters[i]); + if (parameterClass.isAssignableFrom(exception.getClass())) { + instancedParams[i] = exception; + break; + } + } + return instancedParams; + } + + private Class convertToClass(Parameter parameter) { + Type paramType = parameter.getParameterizedType(); + if (paramType instanceof Class) { + return (Class) paramType; + } + throw new IllegalStateException("Parameter type of method has to be from Class, it was: " + paramType); + } + + private Object invokeMappedMethodSafely( + Method mappedMethod, + Object instanceOfMappedMethod, + Object[] instancedParams) throws Throwable { + try { + return mappedMethod.invoke(instanceOfMappedMethod, instancedParams); + } catch (InvocationTargetException | IllegalAccessException e) { + throw e.getCause(); // throw the exception thrown by implementation + } + } + +} diff --git a/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/advice/GrpcAdviceExceptionInterceptor.java b/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/advice/GrpcAdviceExceptionInterceptor.java new file mode 100644 index 000000000..fb587393f --- /dev/null +++ b/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/advice/GrpcAdviceExceptionInterceptor.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2016-2021 Michael Zhang + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.devh.boot.grpc.server.advice; + +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.grpc.ServerCall.Listener; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; + +/** + * Interceptor to use for global exception handling. Every raised {@link Throwable} is caught and being processed. + * Actual processing of exception is in {@link GrpcAdviceExceptionListener}. + *

+ * + * @author Andjelko Perisic (andjelko.perisic@gmail.com) + * @see GrpcAdviceExceptionHandler + * @see GrpcAdviceExceptionListener + */ +public class GrpcAdviceExceptionInterceptor implements ServerInterceptor { + + private final GrpcAdviceExceptionHandler grpcAdviceExceptionHandler; + + public GrpcAdviceExceptionInterceptor(final GrpcAdviceExceptionHandler grpcAdviceExceptionHandler) { + this.grpcAdviceExceptionHandler = grpcAdviceExceptionHandler; + } + + @Override + public Listener interceptCall( + ServerCall call, + Metadata headers, + ServerCallHandler next) { + try { + Listener delegate = next.startCall(call, headers); + return new GrpcAdviceExceptionListener<>(delegate, call, grpcAdviceExceptionHandler); + } catch (Throwable throwable) { + return noOpCallListener(); + } + } + + /** + * Creates a new no-op call listener because you can neither return null nor throw an exception in + * {@link #interceptCall(ServerCall, Metadata, ServerCallHandler)}. + * + * @param The type of the request. + * @return The newly created dummy listener. + */ + protected Listener noOpCallListener() { + return new Listener() {}; + } + +} diff --git a/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/advice/GrpcAdviceExceptionListener.java b/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/advice/GrpcAdviceExceptionListener.java new file mode 100644 index 000000000..4568ac1ce --- /dev/null +++ b/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/advice/GrpcAdviceExceptionListener.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2016-2021 Michael Zhang + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.devh.boot.grpc.server.advice; + +import io.grpc.ForwardingServerCallListener.SimpleForwardingServerCallListener; +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.grpc.ServerCall.Listener; +import io.grpc.Status; +import io.grpc.StatusException; +import io.grpc.StatusRuntimeException; +import lombok.extern.slf4j.Slf4j; + +/** + * In case an exception is thrown inside {@link #onHalfClose()}, it is being handled by invoking annotated methods with + * {@link GrpcExceptionHandler @GrpcExceptionHandler}. On successful invocation proper exception handling is done. + *

+ * Note: In case of raised exceptions by implementation a {@link Status#INTERNAL} is returned in + * {@link #handleThrownExceptionByImplementation(Throwable)}. + * + * @param gRPC request type + * @param gRPC response type + * @author Andjelko Perisic (andjelko.perisic@gmail.com) + * @see GrpcAdviceExceptionHandler + */ +@Slf4j +public class GrpcAdviceExceptionListener extends SimpleForwardingServerCallListener { + + private final GrpcAdviceExceptionHandler exceptionHandler; + private final ServerCall serverCall; + + protected GrpcAdviceExceptionListener( + Listener delegate, + ServerCall serverCall, + GrpcAdviceExceptionHandler grpcAdviceExceptionHandler) { + super(delegate); + this.serverCall = serverCall; + this.exceptionHandler = grpcAdviceExceptionHandler; + } + + @Override + public void onHalfClose() { + try { + super.onHalfClose(); + + } catch (Throwable throwable) { + handleCaughtException(throwable); + } + } + + private void handleCaughtException(Throwable throwable) { + try { + Object mappedReturnType = exceptionHandler.handleThrownException(throwable); + Status status = resolveStatus(mappedReturnType).withCause(throwable); + Metadata metadata = resolveMetadata(mappedReturnType); + + serverCall.close(status, metadata); + } catch (Throwable throwableWhileResolving) { + handleThrownExceptionByImplementation(throwableWhileResolving); + } + } + + private Status resolveStatus(Object mappedReturnType) { + if (mappedReturnType instanceof Status) { + return (Status) mappedReturnType; + } else if (mappedReturnType instanceof Throwable) { + return Status.fromThrowable((Throwable) mappedReturnType); + } + throw new IllegalStateException(String.format( + "Error for mapped return type [%s] inside @GrpcAdvice, it has to be of type: " + + "[Status, StatusException, StatusRuntimeException, Throwable] ", + mappedReturnType)); + } + + private Metadata resolveMetadata(Object mappedReturnType) { + Metadata result = null; + if (mappedReturnType instanceof StatusException) { + StatusException statusException = (StatusException) mappedReturnType; + result = statusException.getTrailers(); + } else if (mappedReturnType instanceof StatusRuntimeException) { + StatusRuntimeException statusException = (StatusRuntimeException) mappedReturnType; + result = statusException.getTrailers(); + } + return (result == null) ? new Metadata() : result; + } + + private void handleThrownExceptionByImplementation(Throwable throwable) { + log.error("Exception thrown during invocation of annotated @GrpcExceptionHandler method: ", throwable); + serverCall.close(Status.INTERNAL.withCause(throwable) + .withDescription("There was a server error trying to handle an exception"), new Metadata()); + } + +} diff --git a/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/advice/GrpcAdviceIsPresentCondition.java b/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/advice/GrpcAdviceIsPresentCondition.java new file mode 100644 index 000000000..70114cf09 --- /dev/null +++ b/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/advice/GrpcAdviceIsPresentCondition.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2016-2021 Michael Zhang + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.devh.boot.grpc.server.advice; + +import static java.util.Objects.requireNonNull; + +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.context.annotation.ConfigurationCondition; +import org.springframework.core.type.AnnotatedTypeMetadata; + +/** + * Condition to check if {@link GrpcAdvice @GrpcAdvice} is present. Mainly checking if {@link GrpcAdviceDiscoverer} + * should be a instantiated. + * + * @author Andjelko Perisic (andjelko.perisic@gmail.com) + * @see GrpcAdviceDiscoverer + */ +public class GrpcAdviceIsPresentCondition implements ConfigurationCondition { + + @Override + public ConfigurationPhase getConfigurationPhase() { + return ConfigurationPhase.REGISTER_BEAN; + } + + @Override + public boolean matches(final ConditionContext context, final AnnotatedTypeMetadata metadata) { + final ConfigurableListableBeanFactory safeBeanFactory = + requireNonNull(context.getBeanFactory(), "ConfigurableListableBeanFactory is null"); + return !safeBeanFactory.getBeansWithAnnotation(GrpcAdvice.class).isEmpty(); + } + +} diff --git a/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/advice/GrpcExceptionHandler.java b/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/advice/GrpcExceptionHandler.java new file mode 100644 index 000000000..32a7f55d2 --- /dev/null +++ b/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/advice/GrpcExceptionHandler.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2016-2021 Michael Zhang + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.devh.boot.grpc.server.advice; + +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; + +/** + * Methods annotated with {@link GrpcExceptionHandler @GrpcExceptionHandler} are being mapped to a corresponding + * Exception, by declaring either in {@link GrpcExceptionHandler#value() @GrpcExceptionHandler(value = ...)} as value or + * as annotated methods parameter (both is working too). + *

+ * Return type of annotated methods has to be of type {@link io.grpc.Status}, {@link io.grpc.StatusException}, + * {@link io.grpc.StatusRuntimeException} or {@link Throwable}. + *

+ * + * An example without {@link io.grpc.Metadata}: + * + *

+ * {@code @GrpcExceptionHandler
+ *    public Status handleIllegalArgumentException(IllegalArgumentException e){
+ *      return Status.INVALID_ARGUMENT
+ *                   .withDescription(e.getMessage())
+ *                   .withCause(e);
+ *    }
+ *  }
+ * 
+ * + * With {@link io.grpc.Metadata}: + * + *
+ * {@code @GrpcExceptionHandler
+ *    public StatusRuntimeException handleIllegalArgumentException(IllegalArgumentException e){
+ *      Status status = Status.INVALID_ARGUMENT
+ *                            .withDescription(e.getMessage())
+ *                            .withCause(e);
+ *      Metadata myMetadata = new Metadata();
+ *      myMetadata = ...
+ *      return status.asRuntimeException(myMetadata);
+ *    }
+ *  }
+ * 
+ * + * @author Andjelko Perisic (andjelko.perisic@gmail.com) + * @see GrpcAdvice + * @see GrpcExceptionHandlerMethodResolver + * @see GrpcAdviceExceptionHandler + */ +@Documented +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface GrpcExceptionHandler { + + /** + * Exceptions handled by the annotated method. + *

+ * If empty, will default to any exceptions listed in the method argument list. + *

+ * Note: When exception types are set within value, they are prioritized in mapping the exceptions over + * listed method arguments. And in case method arguments are provided, they must match the types declared + * with this value. + */ + Class[] value() default {}; +} diff --git a/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/advice/GrpcExceptionHandlerMethodResolver.java b/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/advice/GrpcExceptionHandlerMethodResolver.java new file mode 100644 index 000000000..2a070e60f --- /dev/null +++ b/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/advice/GrpcExceptionHandlerMethodResolver.java @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2016-2021 Michael Zhang + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.devh.boot.grpc.server.advice; + +import static java.util.Objects.requireNonNull; + +import java.lang.reflect.Method; +import java.util.AbstractMap.SimpleImmutableEntry; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.lang.NonNull; +import org.springframework.util.Assert; + +/** + * Given an annotated {@link GrpcAdvice @GrpcAdvice} class and annotated methods with + * {@link GrpcExceptionHandler @GrpcExceptionHandler}, {@link GrpcExceptionHandlerMethodResolver} resolves given + * exception type and maps it to the corresponding method to be executed, when this exception is being raised. + * + *

+ * For an example how to make use of it, please have a look at {@link GrpcExceptionHandler @GrpcExceptionHandler}. + *

+ * + * @author Andjelko Perisic (andjelko.perisic@gmail.com) + * @see GrpcAdvice + * @see GrpcExceptionHandler + * @see GrpcAdviceExceptionHandler + */ +public class GrpcExceptionHandlerMethodResolver implements InitializingBean { + + private final Map, Method> mappedMethods = new HashMap<>(16); + + private final GrpcAdviceDiscoverer grpcAdviceDiscoverer; + + private Class[] annotatedExceptions; + + /** + * Creates a new GrpcExceptionHandlerMethodResolver. + * + * @param grpcAdviceDiscoverer The advice discoverer to use. + */ + public GrpcExceptionHandlerMethodResolver(final GrpcAdviceDiscoverer grpcAdviceDiscoverer) { + this.grpcAdviceDiscoverer = requireNonNull(grpcAdviceDiscoverer, "grpcAdviceDiscoverer"); + } + + @Override + public void afterPropertiesSet() throws Exception { + grpcAdviceDiscoverer.getAnnotatedMethods() + .forEach(this::extractAndMapExceptionToMethod); + } + + private void extractAndMapExceptionToMethod(Method method) { + + GrpcExceptionHandler annotation = method.getDeclaredAnnotation(GrpcExceptionHandler.class); + Assert.notNull(annotation, "@GrpcExceptionHandler annotation not found."); + annotatedExceptions = annotation.value(); + + checkForPresentExceptionToMap(method); + Set> exceptionsToMap = extractExceptions(method.getParameterTypes()); + exceptionsToMap.forEach(exceptionType -> addExceptionMapping(exceptionType, method)); + } + + private void checkForPresentExceptionToMap(Method method) { + if (method.getParameterTypes().length == 0 && annotatedExceptions.length == 0) { + throw new IllegalStateException( + String.format("@GrpcExceptionHandler annotated method [%s] has no mapped exception!", + method.getName())); + } + } + + private Set> extractExceptions(Class[] methodParamTypes) { + + Set> exceptionsToBeMapped = new HashSet<>(); + for (Class annoClass : annotatedExceptions) { + if (methodParamTypes.length > 0) + validateAppropriateParentException(annoClass, methodParamTypes); + exceptionsToBeMapped.add(annoClass); + } + + addMappingInCaseAnnotationIsEmpty(methodParamTypes, exceptionsToBeMapped); + return exceptionsToBeMapped; + } + + private void validateAppropriateParentException(Class annoClass, Class[] methodParamTypes) { + + boolean paramTypeIsNotSuperclass = + Arrays.stream(methodParamTypes).noneMatch(param -> param.isAssignableFrom(annoClass)); + if (paramTypeIsNotSuperclass) { + throw new IllegalStateException( + String.format( + "no listed parameter argument [%s] is equal or superclass " + + "of annotated @GrpcExceptionHandler method declared exception [%s].", + Arrays.toString(methodParamTypes), annoClass)); + } + } + + private void addMappingInCaseAnnotationIsEmpty( + Class[] methodParamTypes, + Set> exceptionsToBeMapped) { + + @SuppressWarnings("unchecked") + Function, Class> convertSafely = clazz -> (Class) clazz; + + Arrays.stream(methodParamTypes) + .filter(param -> exceptionsToBeMapped.isEmpty()) + .filter(Throwable.class::isAssignableFrom) + .map(convertSafely) // safe to call, since check for Throwable superclass + .forEach(exceptionsToBeMapped::add); + } + + private void addExceptionMapping(Class exceptionType, Method method) { + + Method oldMethod = mappedMethods.put(exceptionType, method); + if (oldMethod != null && !oldMethod.equals(method)) { + throw new IllegalStateException("Ambiguous @GrpcExceptionHandler method mapped for [" + + exceptionType + "]: {" + oldMethod + ", " + method + "}"); + } + } + + + /** + * When given exception type is subtype of already provided mapped exception, this returns a valid mapped method to + * be later executed. + * + * @param exceptionType exception to check + * @param type of exception + * @return mapped method instance with its method + */ + @NonNull + public Map.Entry resolveMethodWithInstance(Class exceptionType) { + + Method value = extractExtendedThrowable(exceptionType); + if (value == null) { + return new SimpleImmutableEntry<>(null, null); + } + + Class methodClass = value.getDeclaringClass(); + Object key = grpcAdviceDiscoverer.getAnnotatedBeans() + .values() + .stream() + .filter(obj -> methodClass.isAssignableFrom(obj.getClass())) + .findFirst() + .orElse(null); + return new SimpleImmutableEntry<>(key, value); + } + + /** + * Lookup if a method is mapped to given exception. + * + * @param exception exception to check + * @param type of exception + * @return true if mapped to valid method + */ + public boolean isMethodMappedForException(Class exception) { + return extractExtendedThrowable(exception) != null; + } + + private Method extractExtendedThrowable(Class exception) { + return mappedMethods.keySet() + .stream() + .filter(clazz -> clazz.isAssignableFrom(exception)) + .findAny() + .map(mappedMethods::get) + .orElse(null); + } + +} diff --git a/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/autoconfigure/GrpcAdviceAutoConfiguration.java b/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/autoconfigure/GrpcAdviceAutoConfiguration.java new file mode 100644 index 000000000..16630c014 --- /dev/null +++ b/grpc-server-spring-boot-autoconfigure/src/main/java/net/devh/boot/grpc/server/autoconfigure/GrpcAdviceAutoConfiguration.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2016-2020 Michael Zhang + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.devh.boot.grpc.server.autoconfigure; + +import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; + +import net.devh.boot.grpc.common.util.InterceptorOrder; +import net.devh.boot.grpc.server.advice.GrpcAdvice; +import net.devh.boot.grpc.server.advice.GrpcAdviceDiscoverer; +import net.devh.boot.grpc.server.advice.GrpcAdviceExceptionHandler; +import net.devh.boot.grpc.server.advice.GrpcAdviceExceptionInterceptor; +import net.devh.boot.grpc.server.advice.GrpcAdviceIsPresentCondition; +import net.devh.boot.grpc.server.advice.GrpcExceptionHandler; +import net.devh.boot.grpc.server.advice.GrpcExceptionHandlerMethodResolver; +import net.devh.boot.grpc.server.interceptor.GrpcGlobalServerInterceptor; + +/** + * The auto configuration that will create necessary beans to provide a proper exception handling via annotations + * {@link GrpcAdvice @GrpcAdvice} and {@link GrpcExceptionHandler @GrpcExceptionHandler}. + * + *

+ * Exception handling via global server interceptors {@link GrpcGlobalServerInterceptor @GrpcGlobalServerInterceptor}. + *

+ * + * @author Andjelko Perisic (andjelko.perisic@gmail.com) + * @see GrpcAdvice + * @see GrpcExceptionHandler + * @see GrpcAdviceExceptionInterceptor + */ +@Configuration(proxyBeanMethods = false) +@Conditional(GrpcAdviceIsPresentCondition.class) +@AutoConfigureBefore(GrpcServerFactoryAutoConfiguration.class) +public class GrpcAdviceAutoConfiguration { + + @Bean + public GrpcAdviceDiscoverer grpcAdviceDiscoverer() { + return new GrpcAdviceDiscoverer(); + } + + @Bean + public GrpcExceptionHandlerMethodResolver grpcExceptionHandlerMethodResolver( + final GrpcAdviceDiscoverer grpcAdviceDiscoverer) { + return new GrpcExceptionHandlerMethodResolver(grpcAdviceDiscoverer); + } + + @Bean + public GrpcAdviceExceptionHandler grpcAdviceExceptionHandler( + GrpcExceptionHandlerMethodResolver grpcExceptionHandlerMethodResolver) { + return new GrpcAdviceExceptionHandler(grpcExceptionHandlerMethodResolver); + } + + @GrpcGlobalServerInterceptor + @Order(InterceptorOrder.ORDER_GLOBAL_EXCEPTION_HANDLING) + public GrpcAdviceExceptionInterceptor grpcAdviceExceptionInterceptor( + GrpcAdviceExceptionHandler grpcAdviceExceptionHandler) { + return new GrpcAdviceExceptionInterceptor(grpcAdviceExceptionHandler); + } + +} diff --git a/grpc-server-spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories b/grpc-server-spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories index efe0424ad..3ece6438d 100644 --- a/grpc-server-spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories +++ b/grpc-server-spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories @@ -8,4 +8,5 @@ net.devh.boot.grpc.server.autoconfigure.GrpcServerAutoConfiguration,\ net.devh.boot.grpc.server.autoconfigure.GrpcServerFactoryAutoConfiguration,\ net.devh.boot.grpc.server.autoconfigure.GrpcServerSecurityAutoConfiguration,\ net.devh.boot.grpc.server.autoconfigure.GrpcServerMetricAutoConfiguration,\ -net.devh.boot.grpc.server.autoconfigure.GrpcServerTraceAutoConfiguration +net.devh.boot.grpc.server.autoconfigure.GrpcServerTraceAutoConfiguration,\ +net.devh.boot.grpc.server.autoconfigure.GrpcAdviceAutoConfiguration diff --git a/grpc-server-spring-boot-autoconfigure/src/test/java/net/devh/boot/grpc/server/advice/GrpcAdviceDiscovererTest.java b/grpc-server-spring-boot-autoconfigure/src/test/java/net/devh/boot/grpc/server/advice/GrpcAdviceDiscovererTest.java new file mode 100644 index 000000000..84e5c7b5e --- /dev/null +++ b/grpc-server-spring-boot-autoconfigure/src/test/java/net/devh/boot/grpc/server/advice/GrpcAdviceDiscovererTest.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2016-2021 Michael Zhang + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.devh.boot.grpc.server.advice; + +import static java.util.Collections.singletonMap; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationContext; + +import io.grpc.Status; + +/** + * Tests for {@link GrpcAdviceDiscoverer}. + */ +class GrpcAdviceDiscovererTest { + + private final ApplicationContext context = mock(ApplicationContext.class); + + @BeforeEach + void beforeEach() { + reset(this.context); + } + + /** + * Tests that the {@link GrpcAdviceDiscoverer} discovers inherited methods. + */ + @Test + void testDiscoversInheritedMethods() { + when(this.context.getBeansWithAnnotation(GrpcAdvice.class)) + .thenReturn(singletonMap("bean", new Extended())); + + final GrpcAdviceDiscoverer disco = new GrpcAdviceDiscoverer(); + disco.setApplicationContext(this.context); + disco.afterPropertiesSet(); + + assertThat(disco.getAnnotatedMethods()) + .containsExactlyInAnyOrder( + findMethod(Base.class, "handleRuntimeException", RuntimeException.class), + findMethod(Extended.class, "handleIllegalArgument", IllegalArgumentException.class)); + } + + @Test + void testOverriddenMethods() { + when(this.context.getBeansWithAnnotation(GrpcAdvice.class)) + .thenReturn(singletonMap("bean", new Overriden())); + + final GrpcAdviceDiscoverer disco = new GrpcAdviceDiscoverer(); + disco.setApplicationContext(this.context); + disco.afterPropertiesSet(); + + assertThat(disco.getAnnotatedMethods()) + .containsExactly(findMethod(Overriden.class, "handleRuntimeException", RuntimeException.class)); + } + + private static Method findMethod(final Class clazz, final String method, final Class... parameters) { + try { + return clazz.getDeclaredMethod(method, parameters); + } catch (NoSuchMethodException | SecurityException e) { + throw new IllegalStateException("Failed to find method", e); + } + } + + @GrpcAdvice + private class Base { + + @GrpcExceptionHandler + Status handleRuntimeException(final RuntimeException e) { + return Status.INTERNAL; + } + + } + + @GrpcAdvice + private class Extended extends Base { + + @GrpcExceptionHandler + Status handleIllegalArgument(final IllegalArgumentException e) { + return Status.INVALID_ARGUMENT; + } + + } + + @GrpcAdvice + private class Overriden extends Base { + + @Override + @GrpcExceptionHandler + Status handleRuntimeException(final RuntimeException e) { + return Status.INVALID_ARGUMENT; + } + + } + +} diff --git a/tests/src/test/java/net/devh/boot/grpc/test/advice/AbstractSimpleServerClientTest.java b/tests/src/test/java/net/devh/boot/grpc/test/advice/AbstractSimpleServerClientTest.java new file mode 100644 index 000000000..e1bd1ca9d --- /dev/null +++ b/tests/src/test/java/net/devh/boot/grpc/test/advice/AbstractSimpleServerClientTest.java @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2016-2021 Michael Zhang + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.devh.boot.grpc.test.advice; + +import static net.devh.boot.grpc.test.util.FutureAssertions.assertFutureThrows; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.concurrent.TimeUnit; + +import javax.annotation.PostConstruct; + +import com.google.protobuf.Empty; + +import io.grpc.Channel; +import io.grpc.Metadata; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.internal.testing.StreamRecorder; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.client.inject.GrpcClient; +import net.devh.boot.grpc.test.proto.SomeType; +import net.devh.boot.grpc.test.proto.TestServiceGrpc; +import net.devh.boot.grpc.test.proto.TestServiceGrpc.TestServiceBlockingStub; +import net.devh.boot.grpc.test.proto.TestServiceGrpc.TestServiceFutureStub; +import net.devh.boot.grpc.test.proto.TestServiceGrpc.TestServiceStub; + +/** + * A test checking that the server and client can start and connect to each other with proper exception handling. + * + * @author Andjelko Perisic (andjelko.perisic@gmail.com) + */ +@Slf4j +abstract class AbstractSimpleServerClientTest { + + @GrpcClient("test") + protected Channel channel; + @GrpcClient("test") + protected TestServiceStub testServiceStub; + @GrpcClient("test") + protected TestServiceBlockingStub testServiceBlockingStub; + @GrpcClient("test") + protected TestServiceFutureStub testServiceFutureStub; + + @PostConstruct + protected void init() { + // Test injection + assertNotNull(this.channel, "channel"); + assertNotNull(this.testServiceBlockingStub, "testServiceBlockingStub"); + assertNotNull(this.testServiceFutureStub, "testServiceFutureStub"); + assertNotNull(this.testServiceStub, "testServiceStub"); + } + + /** + * Test template call to check for every exception. + */ + void testGrpcCallAndVerifyMappedException(Status expectedStatus, Metadata metadata) { + + verifyManualBlockingStubCall(expectedStatus, metadata); + verifyBlockingStubCall(expectedStatus, metadata); + verifyManualFutureStubCall(expectedStatus, metadata); + verifyFutureStubCall(expectedStatus, metadata); + } + + private void verifyManualBlockingStubCall( + Status expectedStatus, Metadata expectedMetadata) { + + StatusRuntimeException actualException = + assertThrows(StatusRuntimeException.class, + () -> TestServiceGrpc.newBlockingStub(this.channel).normal(Empty.getDefaultInstance())); + + verifyStatusAndMetadata(actualException, expectedStatus, expectedMetadata); + } + + private void verifyBlockingStubCall(Status expectedStatus, Metadata expectedMetadata) { + + StatusRuntimeException actualException = + assertThrows(StatusRuntimeException.class, + () -> this.testServiceBlockingStub.normal(Empty.getDefaultInstance())); + + verifyStatusAndMetadata(actualException, expectedStatus, expectedMetadata); + } + + + private void verifyManualFutureStubCall( + Status expectedStatus, Metadata expectedMetadata) { + + final StreamRecorder streamRecorder = StreamRecorder.create(); + this.testServiceStub.normal(Empty.getDefaultInstance(), streamRecorder); + StatusRuntimeException actualException = + assertFutureThrows(StatusRuntimeException.class, streamRecorder.firstValue(), 5, TimeUnit.SECONDS); + + verifyStatusAndMetadata(actualException, expectedStatus, expectedMetadata); + } + + + private void verifyFutureStubCall( + Status expectedStatus, Metadata expectedMetadata) { + + StatusRuntimeException actualException = + assertFutureThrows(StatusRuntimeException.class, + this.testServiceFutureStub.normal(Empty.getDefaultInstance()), + 5, + TimeUnit.SECONDS); + + verifyStatusAndMetadata(actualException, expectedStatus, expectedMetadata); + } + + private void verifyStatusAndMetadata( + StatusRuntimeException actualException, Status expectedStatus, Metadata expectedMetadata) { + + assertThat(actualException.getTrailers()) + .usingRecursiveComparison() + .isEqualTo(expectedMetadata); + assertThat(actualException.getStatus()) + .usingRecursiveComparison() + .isEqualTo(expectedStatus); + } + +} diff --git a/tests/src/test/java/net/devh/boot/grpc/test/advice/AdviceExceptionHandlingTest.java b/tests/src/test/java/net/devh/boot/grpc/test/advice/AdviceExceptionHandlingTest.java new file mode 100644 index 000000000..93175efe2 --- /dev/null +++ b/tests/src/test/java/net/devh/boot/grpc/test/advice/AdviceExceptionHandlingTest.java @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2016-2021 Michael Zhang + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.devh.boot.grpc.test.advice; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.security.AccessControlException; + +import org.assertj.core.groups.Tuple; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import io.grpc.Metadata; +import io.grpc.Status; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.server.advice.GrpcAdviceExceptionHandler; +import net.devh.boot.grpc.server.advice.GrpcAdviceExceptionListener; +import net.devh.boot.grpc.server.autoconfigure.GrpcAdviceAutoConfiguration; +import net.devh.boot.grpc.test.config.BaseAutoConfiguration; +import net.devh.boot.grpc.test.config.GrpcAdviceConfig; +import net.devh.boot.grpc.test.config.GrpcAdviceConfig.TestAdviceWithMetadata.FirstLevelException; +import net.devh.boot.grpc.test.config.GrpcAdviceConfig.TestAdviceWithMetadata.MyRootRuntimeException; +import net.devh.boot.grpc.test.config.GrpcAdviceConfig.TestAdviceWithMetadata.StatusMappingException; +import net.devh.boot.grpc.test.config.GrpcAdviceConfig.TestGrpcAdviceService; +import net.devh.boot.grpc.test.config.InProcessConfiguration; +import net.devh.boot.grpc.test.util.LoggerTestUtil; + +/** + * A test checking that the server and client can start and connect to each other with minimal config and a exception + * advice is applied. + * + * @author Andjelko Perisic (andjelko.perisic@gmail.com) + */ +@Slf4j +@SpringBootTest +@SpringJUnitConfig(classes = { + InProcessConfiguration.class, + GrpcAdviceConfig.class, + BaseAutoConfiguration.class +}) +@ImportAutoConfiguration(GrpcAdviceAutoConfiguration.class) +@DirtiesContext +class AdviceExceptionHandlingTest extends AbstractSimpleServerClientTest { + + private ListAppender loggingEventListAppender; + + @Autowired + private TestGrpcAdviceService testGrpcAdviceService; + + @BeforeEach + void setup() { + loggingEventListAppender = LoggerTestUtil.getListAppenderForClasses( + GrpcAdviceExceptionListener.class, + GrpcAdviceExceptionHandler.class); + } + + @Test + @DirtiesContext + void testThrownIllegalArgumentException_IsMappedAsStatus() { + + IllegalArgumentException exceptionToMap = new IllegalArgumentException("Trigger Advice"); + testGrpcAdviceService.setExceptionToSimulate(exceptionToMap); + Status expectedStatus = Status.INVALID_ARGUMENT.withDescription(exceptionToMap.getMessage()); + Metadata metadata = new Metadata(); + + testGrpcCallAndVerifyMappedException(expectedStatus, metadata); + } + + @Test + @DirtiesContext + void testThrownAccessControlException_IsMappedAsThrowable() { + + AccessControlException exceptionToMap = new AccessControlException("Trigger Advice"); + testGrpcAdviceService.setExceptionToSimulate(exceptionToMap); + Status expectedStatus = Status.UNKNOWN; + Metadata metadata = new Metadata(); + + testGrpcCallAndVerifyMappedException(expectedStatus, metadata); + } + + @Test + @DirtiesContext + void testThrownClassCastException_IsMappedAsStatusRuntimeExceptionAndWithMetadata() { + + ClassCastException exceptionToMap = new ClassCastException("Casting with classes failed."); + testGrpcAdviceService.setExceptionToSimulate(exceptionToMap); + Status expectedStatus = Status.FAILED_PRECONDITION.withDescription(exceptionToMap.getMessage()); + Metadata metadata = GrpcMetaDataUtils.createExpectedAsciiHeader(); + + testGrpcCallAndVerifyMappedException(expectedStatus, metadata); + } + + @Test + @DirtiesContext + void testThrownMyRootRuntimeException_IsNotMappedAndResultsInInvocationException() { + + MyRootRuntimeException exceptionToMap = new MyRootRuntimeException("Trigger Advice"); + testGrpcAdviceService.setExceptionToSimulate(exceptionToMap); + Status expectedStatus = + Status.INTERNAL.withDescription("There was a server error trying to handle an exception"); + Metadata metadata = new Metadata(); + + testGrpcCallAndVerifyMappedException(expectedStatus, metadata); + assertThat(loggingEventListAppender.list) + .extracting(ILoggingEvent::getMessage, ILoggingEvent::getLevel) + .contains(Tuple.tuple("Exception caught during gRPC execution: ", Level.DEBUG)) + .contains(Tuple.tuple( + "Exception thrown during invocation of annotated @GrpcExceptionHandler method: ", + Level.ERROR)); + } + + @Test + @DirtiesContext + void testThrownFirstLevelException_IsMappedAsStatusExceptionWithMetadata() { + + FirstLevelException exceptionToMap = new FirstLevelException("Trigger Advice"); + testGrpcAdviceService.setExceptionToSimulate(exceptionToMap); + Status expectedStatus = Status.NOT_FOUND.withDescription(exceptionToMap.getMessage()); + Metadata metadata = GrpcMetaDataUtils.createExpectedAsciiHeader(); + + testGrpcCallAndVerifyMappedException(expectedStatus, metadata); + } + + @Test + @DirtiesContext + void testThrownStatusMappingException_IsResolvedAsInternalServerError() { + + StatusMappingException exceptionToMap = new StatusMappingException("Trigger Advice"); + testGrpcAdviceService.setExceptionToSimulate(exceptionToMap); + Status expectedStatus = + Status.INTERNAL.withDescription("There was a server error trying to handle an exception"); + Metadata metadata = new Metadata(); + + testGrpcCallAndVerifyMappedException(expectedStatus, metadata); + + assertThat(loggingEventListAppender.list) + .extracting(ILoggingEvent::getMessage, ILoggingEvent::getLevel) + .contains(Tuple.tuple("Exception caught during gRPC execution: ", Level.DEBUG)) + .contains(Tuple.tuple( + "Exception thrown during invocation of annotated @GrpcExceptionHandler method: ", + Level.ERROR)); + } + + + + @BeforeAll + public static void beforeAll() { + log.info("--- Starting tests with successful advice exception handling ---"); + } + + @AfterAll + public static void afterAll() { + log.info("--- Ending tests with successful advice exception handling ---"); + } + +} diff --git a/tests/src/test/java/net/devh/boot/grpc/test/advice/AdviceIsPresentAutoConfigurationTest.java b/tests/src/test/java/net/devh/boot/grpc/test/advice/AdviceIsPresentAutoConfigurationTest.java new file mode 100644 index 000000000..b7366df52 --- /dev/null +++ b/tests/src/test/java/net/devh/boot/grpc/test/advice/AdviceIsPresentAutoConfigurationTest.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2016-2021 Michael Zhang + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.devh.boot.grpc.test.advice; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.server.advice.GrpcAdviceDiscoverer; +import net.devh.boot.grpc.server.advice.GrpcExceptionHandler; +import net.devh.boot.grpc.server.autoconfigure.GrpcAdviceAutoConfiguration; +import net.devh.boot.grpc.test.config.BaseAutoConfiguration; +import net.devh.boot.grpc.test.config.GrpcAdviceConfig; +import net.devh.boot.grpc.test.config.GrpcAdviceConfig.TestAdviceWithMetadata; +import net.devh.boot.grpc.test.config.GrpcAdviceConfig.TestAdviceWithOutMetadata; + +/** + * A test to verify that the grpc exception advice auto configuration works. + * + * @author Andjelko Perisic (andjelko.perisic@gmail.com) + */ +@Slf4j +@SpringBootTest +@SpringJUnitConfig(classes = {GrpcAdviceConfig.class, BaseAutoConfiguration.class}) +@ImportAutoConfiguration(GrpcAdviceAutoConfiguration.class) +@DirtiesContext +class AdviceIsPresentAutoConfigurationTest { + + private static final int ADVICE_CLASSES = 2; + private static final int ADVICE_METHODS = 5; + + + @Autowired + private GrpcAdviceDiscoverer grpcAdviceDiscoverer; + + @Autowired + private TestAdviceWithOutMetadata testAdviceWithOutMetadata; + @Autowired + private TestAdviceWithMetadata testAdviceWithMetadata; + + + @Test + @DirtiesContext + void testAdviceIsPresentWithExceptionMapping() { + log.info("--- Starting tests with advice auto discovery ---"); + + Map expectedAdviceBeans = new HashMap<>(); + expectedAdviceBeans.put("grpcAdviceWithBean", testAdviceWithOutMetadata); + expectedAdviceBeans.put(TestAdviceWithMetadata.class.getName(), testAdviceWithMetadata); + Set expectedAdviceMethods = expectedMethods(); + + Map actualAdviceBeans = grpcAdviceDiscoverer.getAnnotatedBeans(); + Set actualAdviceMethods = grpcAdviceDiscoverer.getAnnotatedMethods(); + + assertThat(actualAdviceBeans) + .hasSize(ADVICE_CLASSES) + .containsExactlyInAnyOrderEntriesOf(expectedAdviceBeans); + assertThat(actualAdviceMethods) + .hasSize(ADVICE_METHODS) + .containsExactlyInAnyOrderElementsOf(expectedAdviceMethods); + } + + // ################### + // ### H E L P E R ### + // ################### + + private Set expectedMethods() { + new HashSet<>(); + Set methodsWithMetadata = + Arrays.stream(testAdviceWithMetadata.getClass().getDeclaredMethods()).collect(Collectors.toSet()); + Set methodsWithOutMetadata = + Arrays.stream(testAdviceWithOutMetadata.getClass().getDeclaredMethods()).collect(Collectors.toSet()); + + return Stream.of(methodsWithMetadata, methodsWithOutMetadata) + .flatMap(Collection::stream) + .filter(method -> method.isAnnotationPresent(GrpcExceptionHandler.class)) + .collect(Collectors.toSet()); + } + + @BeforeAll + public static void beforeAll() { + log.info("--- Starting tests with successful advice pickup ---"); + } + + @AfterAll + public static void afterAll() { + log.info("--- Ending tests with successful advice pickup ---"); + } + +} diff --git a/tests/src/test/java/net/devh/boot/grpc/test/advice/AdviceNotPresentAutoConfigurationTest.java b/tests/src/test/java/net/devh/boot/grpc/test/advice/AdviceNotPresentAutoConfigurationTest.java new file mode 100644 index 000000000..3137b299d --- /dev/null +++ b/tests/src/test/java/net/devh/boot/grpc/test/advice/AdviceNotPresentAutoConfigurationTest.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2016-2021 Michael Zhang + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.devh.boot.grpc.test.advice; + +import java.util.Map; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.server.advice.GrpcAdvice; +import net.devh.boot.grpc.server.advice.GrpcExceptionHandler; +import net.devh.boot.grpc.server.autoconfigure.GrpcAdviceAutoConfiguration; +import net.devh.boot.grpc.test.config.BaseAutoConfiguration; + +/** + * A test to verify that the grpc exception advice is not automatically picked up, if no {@link GrpcAdvice @GrpcAdvice} + * is present. + * + * @author Andjelko Perisic (andjelko.perisic@gmail.com) + */ +@Slf4j +@SpringBootTest +@SpringJUnitConfig(classes = BaseAutoConfiguration.class) +@ImportAutoConfiguration(GrpcAdviceAutoConfiguration.class) +@DirtiesContext +class AdviceNotPresentAutoConfigurationTest { + + @Autowired + private ApplicationContext applicationContext; + + @Test + @DirtiesContext + void testGrpcAdviceNotPresent() { + log.info("--- Starting tests with no present advice - auto discovery ---"); + + Map actualAdviceBeans = applicationContext.getBeansWithAnnotation(GrpcAdvice.class); + + Assertions.assertThat(actualAdviceBeans).isEmpty(); + } + + @Test + @DirtiesContext + void testGrpcExceptionHandlerNotPresent() { + log.info("--- Starting tests with no present advice - auto discovery ---"); + + Map actualExceptionHandler = + applicationContext.getBeansWithAnnotation(GrpcExceptionHandler.class); + + Assertions.assertThat(actualExceptionHandler).isEmpty(); + } + + + @BeforeAll + public static void beforeAll() { + log.info("--- Starting tests for no present advice ---"); + } + + @AfterAll + public static void afterAll() { + log.info("--- Ending tests with for no present advice ---"); + } + +} diff --git a/tests/src/test/java/net/devh/boot/grpc/test/advice/GrpcMetaDataUtils.java b/tests/src/test/java/net/devh/boot/grpc/test/advice/GrpcMetaDataUtils.java new file mode 100644 index 000000000..228a319b1 --- /dev/null +++ b/tests/src/test/java/net/devh/boot/grpc/test/advice/GrpcMetaDataUtils.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2016-2021 Michael Zhang + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.devh.boot.grpc.test.advice; + +import io.grpc.Metadata; + +public class GrpcMetaDataUtils { + + private GrpcMetaDataUtils() { + throw new UnsupportedOperationException("Util class not to be instantiated."); + } + + public static Metadata createExpectedAsciiHeader() { + + return createAsciiHeader("HEADER_KEY", "HEADER_VALUE"); + } + + + public static Metadata createAsciiHeader(String key, String value) { + + Metadata metadata = new Metadata(); + Metadata.Key asciiKey = Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER); + metadata.put(asciiKey, value); + return metadata; + } +} diff --git a/tests/src/test/java/net/devh/boot/grpc/test/config/GrpcAdviceConfig.java b/tests/src/test/java/net/devh/boot/grpc/test/config/GrpcAdviceConfig.java new file mode 100644 index 000000000..d7f2c5b15 --- /dev/null +++ b/tests/src/test/java/net/devh/boot/grpc/test/config/GrpcAdviceConfig.java @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2016-2020 Michael Zhang + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.devh.boot.grpc.test.config; + +import java.security.AccessControlException; + +import org.assertj.core.api.Assertions; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.ConversionFailedException; + +import com.google.protobuf.Empty; + +import io.grpc.Metadata; +import io.grpc.Status; +import io.grpc.StatusException; +import io.grpc.StatusRuntimeException; +import io.grpc.stub.StreamObserver; +import net.devh.boot.grpc.server.advice.GrpcAdvice; +import net.devh.boot.grpc.server.advice.GrpcExceptionHandler; +import net.devh.boot.grpc.server.service.GrpcService; +import net.devh.boot.grpc.test.advice.GrpcMetaDataUtils; +import net.devh.boot.grpc.test.proto.SomeType; +import net.devh.boot.grpc.test.proto.TestServiceGrpc; + +@Configuration +public class GrpcAdviceConfig { + + @GrpcService + public static class TestGrpcAdviceService extends TestServiceGrpc.TestServiceImplBase { + + private RuntimeException throwableToSimulate; + + @Override + public void normal(final Empty request, final StreamObserver responseObserver) { + + Assertions.assertThat(throwableToSimulate).isNotNull(); + throw throwableToSimulate; + } + + public void setExceptionToSimulate(E exception) { + throwableToSimulate = exception; + } + } + + @GrpcAdvice + @Bean + public TestAdviceWithOutMetadata grpcAdviceWithBean() { + return new TestAdviceWithOutMetadata(); + } + + public static class TestAdviceWithOutMetadata { + + @GrpcExceptionHandler + public Status handleIllegalArgumentException(IllegalArgumentException e) { + return Status.INVALID_ARGUMENT.withCause(e).withDescription(e.getMessage()); + } + + @GrpcExceptionHandler({ConversionFailedException.class, AccessControlException.class}) + public Throwable handleConversionFailedExceptionAndAccessControlException( + ConversionFailedException e1, + AccessControlException e2) { + return (e1 != null) ? e1 : ((e2 != null) ? e2 : new RuntimeException("Should not happen.")); + } + + public Status methodNotToBePickup(IllegalArgumentException e) { + Assertions.fail("Not supposed to be picked up."); + return Status.FAILED_PRECONDITION; + } + } + + @GrpcAdvice + public static class TestAdviceWithMetadata { + + @GrpcExceptionHandler(FirstLevelException.class) + public StatusException handleFirstLevelException(MyRootRuntimeException e) { + + Status status = Status.NOT_FOUND.withCause(e).withDescription(e.getMessage()); + Metadata metadata = GrpcMetaDataUtils.createExpectedAsciiHeader(); + return status.asException(metadata); + } + + @GrpcExceptionHandler(ClassCastException.class) + public StatusRuntimeException handleClassCastException() { + + Status status = Status.FAILED_PRECONDITION.withDescription("Casting with classes failed."); + Metadata metadata = GrpcMetaDataUtils.createExpectedAsciiHeader(); + return status.asRuntimeException(metadata); + } + + @GrpcExceptionHandler + public StatusRuntimeException handleStatusMappingException(StatusMappingException e) { + + throw new NullPointerException("Simulate developer error"); + } + + + public static class MyRootRuntimeException extends RuntimeException { + + public MyRootRuntimeException(String msg) { + super(msg); + } + } + + public static class FirstLevelException extends MyRootRuntimeException { + + public FirstLevelException(String msg) { + super(msg); + } + } + + public static class StatusMappingException extends RuntimeException { + + public StatusMappingException(String msg) { + super(msg); + } + } + + } + + +} diff --git a/tests/src/test/java/net/devh/boot/grpc/test/util/LoggerTestUtil.java b/tests/src/test/java/net/devh/boot/grpc/test/util/LoggerTestUtil.java new file mode 100644 index 000000000..3a4f46329 --- /dev/null +++ b/tests/src/test/java/net/devh/boot/grpc/test/util/LoggerTestUtil.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2016-2020 Michael Zhang + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.devh.boot.grpc.test.util; + +import java.util.Arrays; + +import org.slf4j.LoggerFactory; +import org.springframework.lang.Nullable; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; + +/** + * Class to test proper @Slf4j logging. + * + * @author Andjelko Perisic (andjelko.perisic@gmail.com) + */ +public class LoggerTestUtil { + + private LoggerTestUtil() { + throw new UnsupportedOperationException("Util class not to be instantiated."); + } + + + public static ListAppender getListAppenderForClasses(@Nullable Class... classList) { + + ListAppender loggingEventListAppender = new ListAppender<>(); + loggingEventListAppender.start(); + + if (classList == null) { + return loggingEventListAppender; + } + + Arrays.stream(classList) + .map(clazz -> (Logger) LoggerFactory.getLogger(clazz)) + .forEach(log -> log.addAppender(loggingEventListAppender)); + + return loggingEventListAppender; + } + +}