From 8ac23f4eeb44dc489a0e82f84ef9e9d9f99ffe5b Mon Sep 17 00:00:00 2001 From: Daniel Theuke Date: Wed, 3 Apr 2019 17:54:53 +0200 Subject: [PATCH] Start grpc-spring-boot-starter-web project --- grpc-spring-boot-starter-web/build.gradle | 16 + .../GrpcServerWebAutoConfiguration.java | 58 ++ .../grpc/web/autoconfigure/package-info.java | 5 + .../grpc/web/bridge/GrpcMethodResult.java | 109 ++++ .../grpc/web/bridge/GrpcMethodWrapper.java | 199 +++++++ .../grpc/web/bridge/GrpcWebController.java | 253 +++++++++ .../boot/grpc/web/bridge/package-info.java | 5 + .../boot/grpc/web/conversion/PojoFormat.java | 530 ++++++++++++++++++ .../grpc/web/conversion/ProtoDataMerger.java | 29 + .../grpc/web/conversion/package-info.java | 5 + .../main/resources/META-INF/spring.factories | 3 + settings.gradle | 1 + 12 files changed, 1213 insertions(+) create mode 100644 grpc-spring-boot-starter-web/build.gradle create mode 100644 grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/autoconfigure/GrpcServerWebAutoConfiguration.java create mode 100644 grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/autoconfigure/package-info.java create mode 100644 grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/bridge/GrpcMethodResult.java create mode 100644 grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/bridge/GrpcMethodWrapper.java create mode 100644 grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/bridge/GrpcWebController.java create mode 100644 grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/bridge/package-info.java create mode 100644 grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/conversion/PojoFormat.java create mode 100644 grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/conversion/ProtoDataMerger.java create mode 100644 grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/conversion/package-info.java create mode 100644 grpc-spring-boot-starter-web/src/main/resources/META-INF/spring.factories diff --git a/grpc-spring-boot-starter-web/build.gradle b/grpc-spring-boot-starter-web/build.gradle new file mode 100644 index 000000000..eb424d752 --- /dev/null +++ b/grpc-spring-boot-starter-web/build.gradle @@ -0,0 +1,16 @@ +apply from: '../deploy.gradle' + +group = "net.devh" +version = "${projectVersion}" + +compileJava.dependsOn(processResources) + +dependencies { + annotationProcessor("org.springframework.boot:spring-boot-autoconfigure-processor") + + compile("org.springframework.boot:spring-boot-starter") + compile("org.springframework.boot:spring-boot-starter-actuator") + compile("org.springframework.boot:spring-boot-starter-web") + compile("io.grpc:grpc-core:${grpcVersion}") + compile("io.grpc:grpc-protobuf:${grpcVersion}") +} diff --git a/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/autoconfigure/GrpcServerWebAutoConfiguration.java b/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/autoconfigure/GrpcServerWebAutoConfiguration.java new file mode 100644 index 000000000..00aec9aba --- /dev/null +++ b/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/autoconfigure/GrpcServerWebAutoConfiguration.java @@ -0,0 +1,58 @@ +/* + * 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.web.autoconfigure; + +import java.util.Collection; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.bind.annotation.RestController; + +import io.grpc.BindableService; +import net.devh.boot.grpc.web.bridge.GrpcWebController; +import net.devh.boot.grpc.web.conversion.PojoFormat; + +/** + * The auto configuration used by Spring-Boot that configures the web to grpc bridge components. + * + * @author Daniel Theuke (daniel.theuke@heuboe.de) + */ +@Configuration +@ConditionalOnClass(RestController.class) +public class GrpcServerWebAutoConfiguration { + + @ConditionalOnMissingBean + @Bean + public PojoFormat defaultGrpcWebPojoFormat() { + return new PojoFormat(false, false); + } + + @ConditionalOnMissingBean + @Bean + public GrpcWebController defaultGrpcWebController(final PojoFormat format, + final Collection services) { + final GrpcWebController grpcController = new GrpcWebController(format); + for (final BindableService service : services) { + grpcController.register(service); + } + return grpcController; + } + +} diff --git a/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/autoconfigure/package-info.java b/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/autoconfigure/package-info.java new file mode 100644 index 000000000..1d343a647 --- /dev/null +++ b/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/autoconfigure/package-info.java @@ -0,0 +1,5 @@ +/** + * The Spring-Boot auto configuration classes that setup the web server bridge to the grpc-service implementation. + */ + +package net.devh.boot.grpc.web.autoconfigure; diff --git a/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/bridge/GrpcMethodResult.java b/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/bridge/GrpcMethodResult.java new file mode 100644 index 000000000..4f7aaf3cb --- /dev/null +++ b/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/bridge/GrpcMethodResult.java @@ -0,0 +1,109 @@ +/* + * 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.web.bridge; + +import static java.util.Objects.requireNonNull; + +import java.util.List; + +import io.grpc.Metadata; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; + +public final class GrpcMethodResult { + + public static GrpcMethodResult from(final StatusRuntimeException e) { + return new GrpcMethodResult<>(e.getStatus(), e.getTrailers()); + } + + private final Status status; + private final Metadata headers; + private final List messages; + + /** + * Creates a new grpc method result for a failed call. + * + * @param status The result status of the call. + */ + public GrpcMethodResult(final Status status) { + this(status, null); + } + + /** + * Creates a new grpc method result for a failed call. + * + * @param status The result status of the call. + * @param headers The headers of the call. Null will be replaced by a new empty instance. + */ + public GrpcMethodResult(final Status status, final Metadata headers) { + this(status, headers, null); + } + + /** + * Creates a new grpc method result with the given messages. + * + * @param status The result status of the call. + * @param headers The headers of the call. Null will be replaced by a new empty instance. + * @param messages The result messages of call or null if there are no results. + */ + public GrpcMethodResult(final Status status, final Metadata headers, final List messages) { + this.status = requireNonNull(status, "status"); + this.headers = headers == null ? new Metadata() : headers; + this.messages = messages; + } + + /** + * Gets the result status of the grpc call. + * + * @return The result status of the call. + */ + public Status getStatus() { + return this.status; + } + + /** + * Gets whether the grpc method call was successful. + * + * @return True, if the call was successful (code = OK). False otherwise. + */ + public boolean wasSuccessful() { + return this.status.getCode() == Status.Code.OK; + } + + /** + * Gets the response headers that were delivered during the request or its completion. + * + * @return The response headers of the call. + */ + public Metadata getHeaders() { + return this.headers; + } + + /** + * Gets the messages of the call in the same order they were delivered. + * + * @return The results of the call. + */ + public List getMessages() { + if (!wasSuccessful()) { + throw new IllegalStateException("Cannot access results of failed call"); + } + return this.messages; + } + +} diff --git a/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/bridge/GrpcMethodWrapper.java b/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/bridge/GrpcMethodWrapper.java new file mode 100644 index 000000000..83b6be8a0 --- /dev/null +++ b/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/bridge/GrpcMethodWrapper.java @@ -0,0 +1,199 @@ +/* + * 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.web.bridge; + +import static java.util.Objects.requireNonNull; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +import com.google.protobuf.Descriptors.Descriptor; +import com.google.protobuf.Message; + +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.MethodDescriptor.PrototypeMarshaller; +import io.grpc.ServerCall; +import io.grpc.ServerCall.Listener; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.ServerMethodDefinition; +import io.grpc.Status; + +public class GrpcMethodWrapper + implements ServerCallHandler { + + @SuppressWarnings({"rawtypes", "unchecked"}) + public static GrpcMethodWrapper ofRaw(final ServerMethodDefinition method) { + return of(method); + } + + public static GrpcMethodWrapper of( + final ServerMethodDefinition method) { + final MethodDescriptor methodDescriptor = method.getMethodDescriptor(); + return new GrpcMethodWrapper<>( + method.getMethodDescriptor(), + getRequestBuilderFor(methodDescriptor), + getRequestDescriptorFor(methodDescriptor), + method.getServerCallHandler()); + } + + protected static Supplier getRequestBuilderFor( + final MethodDescriptor method) { + final RequestT requestPrototype = getRequestPrototypeFor(method); + return requestPrototype::newBuilderForType; + } + + protected static Descriptor getRequestDescriptorFor( + final MethodDescriptor method) { + final RequestT requestPrototype = getRequestPrototypeFor(method); + return requestPrototype.getDescriptorForType(); + } + + @SuppressWarnings("unchecked") + protected static RequestT getRequestPrototypeFor( + final MethodDescriptor method) { + final PrototypeMarshaller requestMarshaller = (PrototypeMarshaller) method.getRequestMarshaller(); + return (RequestT) requestMarshaller.getMessagePrototype(); + } + + private final MethodDescriptor methodDescriptor; + private final Supplier requestBuilderSupplier; + private final Descriptor requestDescriptor; + private final ServerCallHandler delegate; + + public GrpcMethodWrapper(final MethodDescriptor methodDescriptor, + final Supplier requestBuilderSupplier, + final Descriptor requestDescriptor, + final ServerCallHandler delegate) { + this.methodDescriptor = methodDescriptor; + this.requestBuilderSupplier = requestBuilderSupplier; + this.requestDescriptor = requestDescriptor; + this.delegate = delegate; + } + + public GrpcMethodWrapper intercept(final ServerInterceptor interceptor) { + return new GrpcMethodWrapper<>(this.methodDescriptor, this.requestBuilderSupplier, this.requestDescriptor, + InterceptCallHandler.create(interceptor, this.delegate)); + } + + public WrappedServerCall prepare() { + return new WrappedServerCall<>(this.methodDescriptor); + } + + @Override + public Listener startCall(final ServerCall call, final Metadata headers) { + return this.delegate.startCall(call, headers); + } + + public Supplier getRequestBuilderSupplier() { + return this.requestBuilderSupplier; + } + + public Descriptor getRequestDescriptor() { + return this.requestDescriptor; + } + + public String getFullMethodName() { + return this.methodDescriptor.getFullMethodName(); + } + + static final class InterceptCallHandler implements ServerCallHandler { + + public static InterceptCallHandler create( + final ServerInterceptor interceptor, final ServerCallHandler callHandler) { + return new InterceptCallHandler<>(interceptor, callHandler); + } + + private final ServerInterceptor interceptor; + private final ServerCallHandler callHandler; + + private InterceptCallHandler(final ServerInterceptor interceptor, + final ServerCallHandler callHandler) { + this.interceptor = requireNonNull(interceptor, "interceptor"); + this.callHandler = callHandler; + } + + @Override + public ServerCall.Listener startCall(final ServerCall call, final Metadata headers) { + return this.interceptor.interceptCall(call, headers, this.callHandler); + } + + } + + static final class WrappedServerCall extends ServerCall { + + private final MethodDescriptor methodDescriptor; + + private Status status; + private Metadata headers; + private final List messages = new ArrayList<>(2); + + public WrappedServerCall(final MethodDescriptor methodDescriptor) { + this.methodDescriptor = methodDescriptor; + } + + @Override + public void request(final int numMessages) { + // Does nothing + } + + @Override + public void sendHeaders(final Metadata headers) { + if (this.headers != null) { + throw new IllegalStateException("Headers already send"); + } + this.headers = headers; + } + + @Override + public void sendMessage(final ResponseT message) { + this.messages.add(message); + } + + @Override + public void close(final Status status, final Metadata trailers) { + this.status = status; + if (this.headers == null) { + this.headers = trailers; + } else { + this.headers.merge(trailers); + } + } + + @Override + public boolean isCancelled() { + return false; + } + + @Override + public MethodDescriptor getMethodDescriptor() { + return this.methodDescriptor; + } + + public GrpcMethodResult getResult() { + if (this.status == null) { + throw new IllegalStateException("Call not yet closed!"); + } + return new GrpcMethodResult<>(this.status, this.headers, this.messages); + } + + } + +} diff --git a/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/bridge/GrpcWebController.java b/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/bridge/GrpcWebController.java new file mode 100644 index 000000000..47e9cb898 --- /dev/null +++ b/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/bridge/GrpcWebController.java @@ -0,0 +1,253 @@ +/* + * 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.web.bridge; + +import static java.util.Objects.requireNonNull; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.google.protobuf.Descriptors.Descriptor; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; + +import io.grpc.BindableService; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.ServerCall.Listener; +import io.grpc.ServerMethodDefinition; +import io.grpc.ServerServiceDefinition; +import io.grpc.Status; +import io.grpc.StatusException; +import io.grpc.StatusRuntimeException; +import net.devh.boot.grpc.web.bridge.GrpcMethodWrapper.WrappedServerCall; +import net.devh.boot.grpc.web.conversion.PojoFormat; + +/** + * The grpc controller that bridges the web requests to the grpc-service implementations. + * + * @author Daniel Theuke (daniel.theuke@heuboe.de) + */ +@RestController +@RequestMapping("/grpc") +public class GrpcWebController { + + private static final Logger log = LoggerFactory.getLogger(GrpcWebController.class); + + /** + * A map with the fully qualified method names and the associated grpc methods. + */ + protected final Map> methods = new LinkedHashMap<>(); + + protected final PojoFormat format; + + /** + * Creates a new grpc rest controller with the given map format. + * + * @param format The map format used to translate the incoming generic maps to protobuf types. + */ + public GrpcWebController(final PojoFormat format) { + this.format = requireNonNull(format, "format"); + } + + /** + * Registers the given service with all of its method in this controller and thus make them accessible via web. + * + * @param service The service to register. + */ + public void register(final BindableService service) { + register(service.bindService()); + } + + /** + * Registers the given service definition with all of its method in this controller and thus make them accessible + * via web. + * + * @param service The service to register. + */ + public void register(final ServerServiceDefinition service) { + for (final ServerMethodDefinition method : service.getMethods()) { + register(method); + } + } + + /** + * Registers the given method in this controller and thus make it accessible via web. + * + * @param method The method to register. + */ + public void register(final ServerMethodDefinition method) { + final GrpcMethodWrapper wrapper = configure(GrpcMethodWrapper.ofRaw(method)); + this.methods.put(wrapper.getFullMethodName(), wrapper); + this.format.register(wrapper.getRequestDescriptor(), wrapper.getRequestBuilderSupplier()); + log.debug("Registered web-grpc-bridge for {}", wrapper.getFullMethodName()); + } + + /** + * Hook that can be used to configure the given grpc method wrapper. Subclasses can overwrite this method to + * configure interceptors. This method is called during the {@link #register(ServerMethodDefinition) grpc method + * registration}.The default implementation does nothing. + * + * @param wrapper The wrapper to configure. + * @return The configured wrapper. + */ + protected GrpcMethodWrapper configure(final GrpcMethodWrapper wrapper) { + // Empty - @Overrideable in subclasses + return wrapper; + } + + /** + * Gets a set that contains all fully qualified method names that are supported by this controller. + * + * @return A set with all registered method names. + */ + @GetMapping + public Set allMethods() { + return this.methods.keySet(); + } + + /** + * Calls the specified grpc service method. + * + *

+ * Note: This call blocks until the grpc method completes or errors. + *

+ * + * @param service The fully qualified service name to call. + * @param method The method name inside the service to call. + * @param request The request body. Can either be a single request or a collection of many request. + * @return Either a single response or a list of responses. + * @throws InvalidProtocolBufferException If something went wrong during the transformation of the protobuf classes. + * @throws StatusException If the method returned with a non-OK status code. + * @throws StatusRuntimeException If something went wrong during the grpc stub calls. + */ + @PostMapping(path = "/{service}/{method}") + public Object call( + @PathVariable("service") final String service, + @PathVariable("method") final String method, + @RequestBody final Object request) + throws InvalidProtocolBufferException, StatusException { + return call(MethodDescriptor.generateFullMethodName(service, method), request); + } + + /** + * Calls the specified grpc service method. + * + *

+ * Note: This call blocks until the grpc method completes or errors. + *

+ * + * @param function The fully qualified method name. + * @param request The request body. Can either be a single request or a collection of many request. + * @return Either a single response or a list of responses. + * @throws InvalidProtocolBufferException If something went wrong during the transformation of the protobuf classes. + * @throws StatusException If the method returned with a non-OK status code. + * @throws StatusRuntimeException If something went wrong during the grpc stub calls. + */ + public Object call(final String function, final Object request) + throws InvalidProtocolBufferException, StatusException { + final GrpcMethodWrapper grpcMethod = this.methods.get(function); + return transformAndInvoke(grpcMethod, request, new Metadata()); + } + + /** + * Transforms the given request(s) to protobuf, executes the specified grpc service method and then transform the + * results back to the original. + * + *

+ * Note: This call blocks until the grpc method completes or errors. + *

+ * + * @param The type of the request(s). + * @param The type of the response(s). + * @param grpcMethod The grpc method to call. + * @param request Either a single response or a list of responses. + * @param headers The headers that were send along with the request. + * @return Either a single response or a list of responses. + * @throws InvalidProtocolBufferException If something went wrong during the transformation of the protobuf classes. + * @throws StatusException If the method returned with a non-OK status code. + * @throws StatusRuntimeException If something went wrong during the grpc stub calls. + */ + public Object transformAndInvoke( + final GrpcMethodWrapper grpcMethod, + final Object request, final Metadata headers) + throws InvalidProtocolBufferException, StatusException { + final Descriptor requestDescriptor = grpcMethod.getRequestDescriptor(); + + final GrpcMethodResult result = + invoke(grpcMethod, headers, this.format.toManyMessages(requestDescriptor, request)); + + if (result.wasSuccessful()) { + return this.format.convert(result.getMessages()); + } else { + throw new StatusException(result.getStatus(), result.getHeaders()); + } + } + + /** + * Invokes the given grpc method and passes the given requests to it. + * + *

+ * Note: This call blocks until the grpc method completes or errors. + *

+ * + * @param The type of the request(s). + * @param The type of the response(s). + * @param grpcMethod The grpc method to invoke. + * @param headers The headers that should be send along with the request. + * @param requests The requests that should be send to the method. + * @return The grpc method result. + */ + public GrpcMethodResult invoke( + final GrpcMethodWrapper grpcMethod, + final Metadata headers, + final Iterable requests) { + + final WrappedServerCall serverCall = grpcMethod.prepare(); + final Listener listener = grpcMethod.startCall(serverCall, headers); + + try { + listener.onReady(); + for (final RequestT message : requests) { + listener.onMessage(message); + } + listener.onHalfClose(); + listener.onComplete(); + return serverCall.getResult(); + } catch (final StatusRuntimeException e) { + listener.onCancel(); + return GrpcMethodResult.from(e); + } catch (final RuntimeException e) { + listener.onCancel(); + return new GrpcMethodResult<>(Status.INTERNAL + .withDescription("Error executing " + grpcMethod.getFullMethodName() + ": " + e.getMessage()) + .withCause(e)); + } + } + +} diff --git a/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/bridge/package-info.java b/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/bridge/package-info.java new file mode 100644 index 000000000..fa2d6538f --- /dev/null +++ b/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/bridge/package-info.java @@ -0,0 +1,5 @@ +/** + * The classes related to calling grpc methods and using protobuf messages. + */ + +package net.devh.boot.grpc.web.bridge; diff --git a/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/conversion/PojoFormat.java b/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/conversion/PojoFormat.java new file mode 100644 index 000000000..858aa5d94 --- /dev/null +++ b/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/conversion/PojoFormat.java @@ -0,0 +1,530 @@ +/* + * 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.web.conversion; + +import static java.util.Objects.requireNonNull; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.function.Supplier; + +import com.google.protobuf.ByteString; +import com.google.protobuf.Descriptors.Descriptor; +import com.google.protobuf.Descriptors.EnumDescriptor; +import com.google.protobuf.Descriptors.EnumValueDescriptor; +import com.google.protobuf.Descriptors.FieldDescriptor; +import com.google.protobuf.Descriptors.FileDescriptor; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; +import com.google.protobuf.Message.Builder; +import com.google.protobuf.MessageLite; +import com.google.protobuf.NullValue; +import com.google.protobuf.Value; + +/** + * Converter that transforms the map like data structures to protobuf instances and vice versa. + * + * @author Daniel Theuke (daniel.theuke@heuboe.de) + */ +public class PojoFormat { + + private final Map> fieldNameMaps = new HashMap<>(); + private final Map> builders = new HashMap<>(); + private final Map mergers = new HashMap<>(); + private final ProtoDataMerger defaultMerger = (descriptor, input, builder) -> { + if (input instanceof Map) { + mergeFromMap(descriptor, (Map) input, builder); + } else if (input instanceof Message) { + builder.mergeFrom((Message) input); + } else if (input instanceof MessageLite) { + builder.mergeFrom((MessageLite) input); + } else { + throw new InvalidProtocolBufferException("Cannot convert '" + input + "' to a " + descriptor.getFullName()); + } + }; + + private final boolean ignoreUnknown; + private final boolean strictTypeMatch; + + public PojoFormat(final boolean ignoreUnknown, final boolean strictTypeMatch) { + this.ignoreUnknown = ignoreUnknown; + this.strictTypeMatch = strictTypeMatch; + } + + public void register(final Descriptor descriptor, final Supplier builder) { + this.builders.putIfAbsent(descriptor, builder); + } + + /** + * Creates a new builder for the given descriptor. + * + * @param The type of the builder. + * @param descriptor The descriptor to the get a builder for. + * @return A newly created builder instance for the given descriptor. + */ + @SuppressWarnings("unchecked") + protected B newBuilderFor(final Descriptor descriptor) { + return (B) this.builders.get(descriptor).get(); + } + + /** + * Gets the merger that should be used for the given descriptor. + * + * @param descriptor The descriptor to get the merger for. + * @return The map merger that should be used. + */ + protected ProtoDataMerger getMergerFor(final Descriptor descriptor) { + return this.mergers.getOrDefault(descriptor, this.defaultMerger); + } + + /** + * Creates a new builder for the given descriptor and populate it with the values from the given input object. + * + * @param The type of the builder. + * @param descriptor The descriptor to the get a builder for. + * @param input The input object used to populate the builder. This should usually be a map. + * @return The populated builder instance for the given descriptor. + * @throws InvalidProtocolBufferException If something went wrong during the population. + */ + public B toBuilder(final Descriptor descriptor, final Object input) + throws InvalidProtocolBufferException { + final B builder = newBuilderFor(descriptor); + merge(descriptor, input, builder); + return builder; + } + + /** + * Creates a new message for the given descriptor that is populated with the values from the given input object. + * + * @param The type of the message. + * @param descriptor The descriptor to the get a message for. + * @param input The input object used to populate the message. This should usually be a map. + * @return The populated message instance for the given descriptor. + * @throws InvalidProtocolBufferException If something went wrong during the population. + */ + @SuppressWarnings("unchecked") + public T toMessage(final Descriptor descriptor, final Object input) + throws InvalidProtocolBufferException { + return (T) toBuilder(descriptor, input).build(); + } + + /** + * Creates new messages for the given inputs. + * + * @param The type of the messages. + * @param descriptor The descriptor to the get the messages for. + * @param inputs The input object used to populate the messages. This should usually be maps. + * @return The populated message instances for the given descriptor. + * @throws InvalidProtocolBufferException If something went wrong during the population. + */ + @SuppressWarnings("unchecked") + public List toManyMessages(final Descriptor descriptor, final Iterable inputs) + throws InvalidProtocolBufferException { + final List messages = new ArrayList<>(); + final Builder builder = newBuilderFor(descriptor); + final ProtoDataMerger merger = getMergerFor(descriptor); + for (final Object input : inputs) { + merger.merge(descriptor, input, builder); + messages.add((T) builder.build()); + builder.clear(); + } + return messages; + } + + /** + * Creates new messages for the given input. If the input is a collection or array then this method will create a + * message for each element. Otherwise it will try convert the input directly to the described message type. + * + * @param The type of the messages. + * @param descriptor The descriptor to the get the messages for. + * @param input A single instance or a collection/array of instances that should be used to populate messages. + * @return The populated message instances for the given descriptor. + * @throws InvalidProtocolBufferException If something went wrong during the population. + */ + public List toManyMessages(final Descriptor descriptor, final Object input) + throws InvalidProtocolBufferException { + if (input instanceof Iterable) { + return toManyMessages(descriptor, (Iterable) input); + } else if (input instanceof Object[]) { + return toManyMessages(descriptor, Arrays.asList((Object[]) input)); + } else { + return toManyMessages(descriptor, Arrays.asList(input)); + } + } + + /** + * Merge the given input into the given builder. + * + * @param input he input object used to populate the builder. This should usually be a map. + * @param builder The builder that should be populated. + * + * @throws InvalidProtocolBufferException If something went wrong during the population. + * @see #toBuilder(Descriptor, Object) + */ + public void merge(final Object input, final Builder builder) + throws InvalidProtocolBufferException { + merge(builder.getDescriptorForType(), input, builder); + } + + /** + * Merge the given input into the given builder. + * + * @param descriptor The data descriptor belonging to the builder. + * @param input he input object used to populate the builder. This should usually be a map. + * @param builder The builder that should be populated. + * @throws InvalidProtocolBufferException If something went wrong during the population. + * @see #toBuilder(Descriptor, Object) + */ + public void merge(final Descriptor descriptor, final Object input, final Builder builder) + throws InvalidProtocolBufferException { + getMergerFor(descriptor).merge(descriptor, input, builder); + } + + /** + * Merges the data from given map into the given builder. + * + * @param descriptor The data descriptor belonging to the builder. + * @param inputMap The input data used to populate the builder. + * @param builder The builder that should be populated. + * @throws InvalidProtocolBufferException If something went wrong during the population. + */ + public void mergeFromMap(final Descriptor descriptor, final Map inputMap, final Builder builder) + throws InvalidProtocolBufferException { + final Map fieldNameMap = getFieldNameMap(descriptor); + for (final Entry entry : inputMap.entrySet()) { + final String key = requireNonNull(entry.getKey(), "map.key").toString(); + final FieldDescriptor field = fieldNameMap.get(key); + if (field == null) { + if (!this.ignoreUnknown) { + throw new IllegalArgumentException("Unknown field: " + key); + } + } else { + mergeField(field, entry.getValue(), builder); + } + } + } + + /** + * Gets the map with all field names, their aliases and their associated field descriptors. + * + * @param descriptor The descriptor to resolve the fields for. + * @return A cached map that contains all field names and their field descriptors. + */ + private Map getFieldNameMap(final Descriptor descriptor) { + return this.fieldNameMaps.computeIfAbsent(descriptor, this::createFieldNameMap); + } + + /** + * Creates a map with all field names, their aliases and their associated field descriptors. + * + * @param descriptor The descriptor to resolve the fields for. + * @return A newly created map that contains all field names and their field descriptors. + * @see #getFieldNameMap(Descriptor) + */ + private Map createFieldNameMap(final Descriptor descriptor) { + final Map fieldNameMap = new HashMap<>(); + for (final FieldDescriptor field : descriptor.getFields()) { + fieldNameMap.put(field.getName(), field); + fieldNameMap.put(field.getJsonName(), field); + } + return fieldNameMap; + } + + private void mergeField(final FieldDescriptor field, final Object input, final Builder builder) + throws InvalidProtocolBufferException { + if (field.isMapField()) { + // TODO mergeMapField(field, input, builder); + throw new UnsupportedOperationException("Not yet implemented"); + } else if (field.isRepeated()) { + // TODO mergeRepeatedField(field, input, builder); + throw new UnsupportedOperationException("Not yet implemented"); + } else { + final Object value = parseFieldValue(field, input, builder); + if (value != null) { + builder.setField(field, value); + } + } + } + + private static final String VALUE_FULL_NAME = Value.getDescriptor().getFullName(); + + private Object parseFieldValue(final FieldDescriptor field, final Object input, final Message.Builder builder) + throws InvalidProtocolBufferException { + if (input == null) { + if (field.getJavaType() == FieldDescriptor.JavaType.MESSAGE + && field.getMessageType().getFullName().equals(VALUE_FULL_NAME)) { + // For every other type, "null" means absence, but for the special + // Value message, it means the "null_value" field has been set. + final Value protoValue = Value.newBuilder().setNullValueValue(0).build(); + return builder.newBuilderForField(field).mergeFrom(protoValue.toByteString()).build(); + } else if (field.getJavaType() == FieldDescriptor.JavaType.ENUM + && field.getEnumType().getFullName().equals(NullValue.getDescriptor().getFullName())) { + // If the type of the field is a NullValue, then the value should be explicitly set. + return field.getEnumType().findValueByNumber(0); + } + return null; + } + switch (field.getType()) { + case INT32: + case SINT32: + case SFIXED32: + return parseInt32(input); + + case INT64: + case SINT64: + case SFIXED64: + return parseInt64(input); + + case BOOL: + return parseBool(input); + + case FLOAT: + return parseFloat(input); + + case DOUBLE: + return parseDouble(input); + + case UINT32: + case FIXED32: + // TODO return parseUint32(input); + return parseInt32(input); + + case UINT64: + case FIXED64: + // TODO return parseUint64(input); + return parseInt64(input); + + case STRING: + return parseString(input); + + case BYTES: + return ByteString.copyFrom(parseBytes(input)); + + case ENUM: + return parseEnum(field.getEnumType(), input); + + case MESSAGE: + case GROUP: + // TODO infinite depth prevention + final Message.Builder subBuilder = builder.newBuilderForField(field); + merge(input, subBuilder); + return subBuilder.build(); + + default: + throw new InvalidProtocolBufferException("Invalid field type: " + field.getType()); + } + } + + private boolean parseBool(final Object input) throws InvalidProtocolBufferException { + if (input instanceof Boolean) { + return (boolean) input; + } + if (!this.strictTypeMatch) { + final String string = input.toString(); + if ("true".equals(string)) { + return true; + } else if ("false".equals(string)) { + return false; + } + } + throw new InvalidProtocolBufferException("Not a bool value: " + input); + } + + private int parseInt32(final Object input) throws InvalidProtocolBufferException { + if (input instanceof Integer) { + return (int) input; + } + if (!this.strictTypeMatch) { + final String string = input.toString(); + try { + return Integer.parseInt(string); + } catch (final NumberFormatException e) { + // Fallthrough + } + } + throw new InvalidProtocolBufferException("Not an int32 value: " + input); + } + + private long parseInt64(final Object input) throws InvalidProtocolBufferException { + if (input instanceof Long) { + return (long) input; + } + if (!this.strictTypeMatch) { + final String string = input.toString(); + try { + return Long.parseLong(string); + } catch (final NumberFormatException e) { + // Fallthrough + } + } + throw new InvalidProtocolBufferException("Not an int64 value: " + input); + } + + private float parseFloat(final Object input) throws InvalidProtocolBufferException { + if (input instanceof Float) { + return (float) input; + } + if (!this.strictTypeMatch) { + final String string = input.toString(); + try { + return Float.parseFloat(string); + } catch (final NumberFormatException e) { + // Fallthrough + } + } + throw new InvalidProtocolBufferException("Not a float value: " + input); + } + + private double parseDouble(final Object input) throws InvalidProtocolBufferException { + if (input instanceof Double) { + return (double) input; + } + if (!this.strictTypeMatch) { + final String string = input.toString(); + try { + return Double.parseDouble(string); + } catch (final NumberFormatException e) { + // Fallthrough + } + } + throw new InvalidProtocolBufferException("Not a double value: " + input); + } + + private String parseString(final Object input) throws InvalidProtocolBufferException { + if (input instanceof String) { + return (String) input; + } + if (!this.strictTypeMatch) { + return input.toString(); + } + throw new InvalidProtocolBufferException("Not a string value: " + input); + } + + @SuppressWarnings("squid:S1166") + private byte[] parseBytes(final Object input) throws InvalidProtocolBufferException { + if (input instanceof byte[]) { + return (byte[]) input; + } + if (!this.strictTypeMatch) { + final String string = input.toString(); + try { + return Base64.getDecoder().decode(string); + } catch (final IllegalArgumentException e) { + return Base64.getUrlDecoder().decode(string); + } + } + throw new InvalidProtocolBufferException("Not a byte value: " + input); + } + + @SuppressWarnings("squid:S1166") + private EnumValueDescriptor parseEnum(final EnumDescriptor enumDescriptor, final Object input) + throws InvalidProtocolBufferException { + if (input instanceof String) { + final String value = (String) input; + final EnumValueDescriptor result = enumDescriptor.findValueByName(value); + if (result != null) { + return result; + } + } else if (input instanceof Integer) { + final int numericValue = (int) input; + if (enumDescriptor.getFile().getSyntax() == FileDescriptor.Syntax.PROTO3) { + return enumDescriptor.findValueByNumberCreatingIfUnknown(numericValue); + } else { + return enumDescriptor.findValueByNumber(numericValue); + } + } + if (!this.strictTypeMatch) { + final String value = input.toString(); + final EnumValueDescriptor result = enumDescriptor.findValueByName(value); + if (result != null) { + return result; + } + try { + final int numericValue = parseInt32(input); + if (enumDescriptor.getFile().getSyntax() == FileDescriptor.Syntax.PROTO3) { + return enumDescriptor.findValueByNumberCreatingIfUnknown(numericValue); + } else { + return enumDescriptor.findValueByNumber(numericValue); + } + } catch (final Exception e) { + // Fallthrough + } + } + throw new InvalidProtocolBufferException( + "Invalid enum value: " + input + " for enum type: " + enumDescriptor.getFullName()); + } + + // Proto->Map + + public Object convert(final Collection messages) { + final int count = messages.size(); + if (count == 1) { + return convert(messages.iterator().next()); + } + final List converted = new ArrayList<>(count); + for (final Message message : messages) { + converted.add(convert(message)); + } + return converted; + } + + public Object convert(final Message message) { + if (message == message.getDefaultInstanceForType()) { + return null; + } + final Map data = new LinkedHashMap<>(); + + final Map allFields = message.getAllFields(); + for (final Entry field : allFields.entrySet()) { + final FieldDescriptor descriptor = field.getKey(); + final Object value = field.getValue(); + Object converted; + if (descriptor.isRepeated() && value instanceof Collection) { + final Collection collection = (Collection) value; + final List result = new ArrayList<>(); + for (final Object object : collection) { + result.add(convertField(descriptor, object)); + } + converted = result; + } else { + converted = convertField(descriptor, value); + } + data.put(descriptor.getName(), converted); + } + + return data; + } + + public Object convertField(final FieldDescriptor field, final Object value) { + switch (field.getType()) { + case GROUP: + case MESSAGE: + return convert((Message) value); + default: + return value; + } + } + +} diff --git a/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/conversion/ProtoDataMerger.java b/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/conversion/ProtoDataMerger.java new file mode 100644 index 000000000..d73a1b1cf --- /dev/null +++ b/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/conversion/ProtoDataMerger.java @@ -0,0 +1,29 @@ +/* + * 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.web.conversion; + +import com.google.protobuf.Descriptors.Descriptor; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; + +@FunctionalInterface +public interface ProtoDataMerger { + + void merge(Descriptor descriptor, Object input, Message.Builder builder) throws InvalidProtocolBufferException; + +} diff --git a/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/conversion/package-info.java b/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/conversion/package-info.java new file mode 100644 index 000000000..3d1229c6e --- /dev/null +++ b/grpc-spring-boot-starter-web/src/main/java/net/devh/boot/grpc/web/conversion/package-info.java @@ -0,0 +1,5 @@ +/** + * Classes related to transforming various data structures to and from protobuf. + */ + +package net.devh.boot.grpc.web.conversion; diff --git a/grpc-spring-boot-starter-web/src/main/resources/META-INF/spring.factories b/grpc-spring-boot-starter-web/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000..8d8a79369 --- /dev/null +++ b/grpc-spring-boot-starter-web/src/main/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +# AutoConfiguration +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +net.devh.boot.grpc.web.autoconfigure.GrpcServerWebAutoConfiguration diff --git a/settings.gradle b/settings.gradle index f289fa428..d2adc6da0 100644 --- a/settings.gradle +++ b/settings.gradle @@ -5,6 +5,7 @@ include "grpc-client-spring-boot-autoconfigure" include "grpc-client-spring-boot-starter" include "grpc-server-spring-boot-autoconfigure" include "grpc-server-spring-boot-starter" +include "grpc-spring-boot-starter-web" include "tests"