diff --git a/all/pom.xml b/all/pom.xml index 8a423d9567e..2ea3cf4682b 100644 --- a/all/pom.xml +++ b/all/pom.xml @@ -299,6 +299,10 @@ io.helidon.microprofile.grpc helidon-microprofile-grpc-server + + io.helidon.microprofile.grpc + helidon-microprofile-grpc-client + io.helidon.microprofile.grpc helidon-microprofile-grpc-tracing diff --git a/bom/pom.xml b/bom/pom.xml index b5532308d1a..d6b6d76825a 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -115,6 +115,11 @@ helidon-microprofile-grpc-server ${helidon.version} + + io.helidon.microprofile.grpc + helidon-microprofile-grpc-client + ${helidon.version} + io.helidon.microprofile.grpc helidon-microprofile-grpc-tracing diff --git a/grpc/api/src/main/java/io/helidon/grpc/api/Grpc.java b/grpc/api/src/main/java/io/helidon/grpc/api/Grpc.java index 4b763f4ec22..4f5a50c2e70 100644 --- a/grpc/api/src/main/java/io/helidon/grpc/api/Grpc.java +++ b/grpc/api/src/main/java/io/helidon/grpc/api/Grpc.java @@ -25,10 +25,6 @@ import io.grpc.MethodDescriptor; -import static java.lang.annotation.ElementType.ANNOTATION_TYPE; -import static java.lang.annotation.ElementType.TYPE; -import static java.lang.annotation.RetentionPolicy.RUNTIME; - /** * The Helidon gRPC API. */ @@ -163,8 +159,8 @@ public interface Grpc { * It is required when a {@linkplain io.helidon.grpc.api.Grpc.GrpcInterceptorBinding interceptor binding} * is used.

*/ - @Retention(RUNTIME) - @Target(TYPE) + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.TYPE) @Documented @interface GrpcInterceptor { } @@ -173,8 +169,8 @@ public interface Grpc { * Specifies that an annotation type is a gRPC interceptor binding type. A gRPC Interceptor binding is * used to specify the binding of a gRPC client or server interceptor to target gRPC service and methods. */ - @Target(ANNOTATION_TYPE) - @Retention(RUNTIME) + @Target(ElementType.ANNOTATION_TYPE) + @Retention(RetentionPolicy.RUNTIME) @Documented @interface GrpcInterceptorBinding { } @@ -198,8 +194,8 @@ public interface Grpc { * public void updateOrder(Order order) { ... } * */ - @Target({TYPE, ElementType.METHOD}) - @Retention(RUNTIME) + @Target({ElementType.TYPE, ElementType.METHOD}) + @Retention(RetentionPolicy.RUNTIME) @interface GrpcInterceptors { /** * An ordered list of interceptors. @@ -213,8 +209,8 @@ public interface Grpc { * An annotation used to annotate a type or method to specify the * named marshaller supplier to use for rpc method calls. */ - @Target({TYPE, ElementType.METHOD}) - @Retention(RUNTIME) + @Target({ElementType.TYPE, ElementType.METHOD}) + @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited @interface GrpcMarshaller { @@ -240,8 +236,8 @@ public interface Grpc { * An annotation to mark a class as representing a gRPC service * or a method as a gRPC service method. */ - @Target({ElementType.METHOD, ANNOTATION_TYPE}) - @Retention(RUNTIME) + @Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE}) + @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited @interface GrpcMethod { @@ -265,8 +261,8 @@ public interface Grpc { /** * An annotation to indicate the request type of gRPC method. */ - @Target({ElementType.METHOD}) - @Retention(RUNTIME) + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited @interface RequestType { @@ -281,8 +277,8 @@ public interface Grpc { /** * An annotation to indicate the response type of a gRPC method. */ - @Target({ElementType.METHOD}) - @Retention(RUNTIME) + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited @interface ResponseType { @@ -293,4 +289,27 @@ public interface Grpc { */ Class value(); } + + /** + * An annotation that can be used to specify the name of a configured gRPC channel. + */ + @Target({ElementType.TYPE, ElementType.METHOD}) + @Retention(RetentionPolicy.RUNTIME) + @interface GrpcChannel { + + /** + * The name of the configured channel. + * + * @return name of the channel + */ + String value(); + } + + /** + * An annotation used to mark an injection point for a gRPC service client proxy. + */ + @Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER}) + @Retention(RetentionPolicy.RUNTIME) + @interface GrpcProxy { + } } diff --git a/grpc/core/src/main/java/io/helidon/grpc/core/package-info.java b/grpc/core/src/main/java/io/helidon/grpc/core/package-info.java index a10d014bfed..4c5124c1aa9 100644 --- a/grpc/core/src/main/java/io/helidon/grpc/core/package-info.java +++ b/grpc/core/src/main/java/io/helidon/grpc/core/package-info.java @@ -15,6 +15,6 @@ */ /** - * Core classes used by both the gRPC server API and gRPC client API. + * Core classes used by both the gRPC server and gRPC client. */ package io.helidon.grpc.core; diff --git a/microprofile/grpc/client/pom.xml b/microprofile/grpc/client/pom.xml new file mode 100644 index 00000000000..f2b2c481fba --- /dev/null +++ b/microprofile/grpc/client/pom.xml @@ -0,0 +1,179 @@ + + + + + 4.0.0 + + io.helidon.microprofile.grpc + helidon-microprofile-grpc-project + 4.1.0-SNAPSHOT + + + helidon-microprofile-grpc-client + Helidon Microprofile gRPC Client + + + + io.helidon.grpc + helidon-grpc-core + + + io.helidon.grpc + helidon-grpc-api + + + io.helidon.microprofile.grpc + helidon-microprofile-grpc-core + + + io.helidon.config + helidon-config + + + io.helidon.webclient + helidon-webclient-grpc + + + io.helidon.common + helidon-common-tls + + + jakarta.inject + jakarta.inject-api + + + jakarta.enterprise + jakarta.enterprise.cdi-api + + + io.helidon.webserver + helidon-webserver-grpc + test + + + io.helidon.microprofile.grpc + helidon-microprofile-grpc-server + test + + + io.helidon.config + helidon-config-yaml + test + + + io.helidon.microprofile.testing + helidon-microprofile-testing-junit5 + test + + + io.helidon.webserver.testing.junit5 + helidon-webserver-testing-junit5-grpc + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.hamcrest + hamcrest-all + test + + + + javax.annotation + javax.annotation-api + true + + + org.mockito + mockito-core + test + + + + + + + kr.motd.maven + os-maven-plugin + ${version.plugin.os} + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + io.helidon.codegen + helidon-codegen-apt + ${helidon.version} + + + io.helidon.builder + helidon-builder-codegen + ${helidon.version} + + + io.helidon.codegen + helidon-codegen-helidon-copyright + ${helidon.version} + + + + + + io.helidon.codegen + helidon-codegen-apt + ${helidon.version} + + + io.helidon.builder + helidon-builder-codegen + ${helidon.version} + + + io.helidon.codegen + helidon-codegen-helidon-copyright + ${helidon.version} + + + + + org.xolstice.maven.plugins + protobuf-maven-plugin + + + + test-compile + test-compile-custom + + + + + + + \ No newline at end of file diff --git a/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/ChannelProducer.java b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/ChannelProducer.java new file mode 100644 index 00000000000..9a1cb1f3c5f --- /dev/null +++ b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/ChannelProducer.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2019, 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.microprofile.grpc.client; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.locks.ReentrantLock; + +import io.helidon.config.Config; +import io.helidon.grpc.api.Grpc; + +import io.grpc.Channel; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.enterprise.inject.spi.InjectionPoint; +import jakarta.inject.Inject; + +/** + * A producer of gRPC {@link io.grpc.Channel Channels}. + */ +@ApplicationScoped +public class ChannelProducer { + + private final GrpcChannelsProvider provider; + private final ReentrantLock lock = new ReentrantLock(); + private final Map channelMap = new HashMap<>(); + + /** + * Create a {@link ChannelProducer}. + * + * @param config the {@link io.helidon.config.Config} to use to configure + * the provided {@link io.grpc.Channel}s + */ + @Inject + ChannelProducer(Config config) { + provider = GrpcChannelsProvider.create(config.get("grpc.client")); + } + + /** + * Produces a gRPC {@link io.grpc.Channel}. + * + * @param injectionPoint the injection point + * @return a gRPC {@link io.grpc.Channel} + */ + @Produces + @Grpc.GrpcChannel(GrpcChannelsProvider.DEFAULT_CHANNEL_NAME) + public Channel get(InjectionPoint injectionPoint) { + Grpc.GrpcChannel qualifier = injectionPoint.getQualifiers() + .stream() + .filter(q -> q.annotationType().equals(Grpc.GrpcChannel.class)) + .map(q -> (Grpc.GrpcChannel) q) + .findFirst() + .orElse(null); + + String name = (qualifier == null) ? GrpcChannelsProvider.DEFAULT_CHANNEL_NAME : qualifier.value(); + return findChannel(name); + } + + /** + * Produces the default gRPC {@link io.grpc.Channel}. + * + * @return the default gRPC {@link io.grpc.Channel} + */ + @Produces + public Channel getDefaultChannel() { + return findChannel(GrpcChannelsProvider.DEFAULT_CHANNEL_NAME); + } + + /** + * Obtain the named {@link io.grpc.Channel}. + * + * @param name the channel name + * @return the named {@link io.grpc.Channel} + */ + public Channel findChannel(String name) { + try { + lock.lock(); + return channelMap.computeIfAbsent(name, provider::channel); + } finally { + lock.unlock(); + } + } +} diff --git a/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/ClientMethodDescriptor.java b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/ClientMethodDescriptor.java new file mode 100644 index 00000000000..01a1260ac71 --- /dev/null +++ b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/ClientMethodDescriptor.java @@ -0,0 +1,414 @@ +/* + * Copyright (c) 2019, 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.microprofile.grpc.client; + +import java.util.Arrays; + +import io.helidon.grpc.core.InterceptorWeights; +import io.helidon.grpc.core.MarshallerSupplier; +import io.helidon.grpc.core.MethodHandler; +import io.helidon.grpc.core.WeightedBag; + +import io.grpc.CallCredentials; +import io.grpc.ClientInterceptor; +import io.grpc.MethodDescriptor; + +/** + * Encapsulates all metadata necessary to define a gRPC method. In addition to wrapping a {@link MethodDescriptor}, + * this class also holds the request and response types of the gRPC method. A + * {@link ClientServiceDescriptor} can contain zero or more {@link MethodDescriptor}. + *

+ * An instance of ClientMethodDescriptor can be created either from an existing {@link MethodDescriptor} or + * from one of the factory methods {@link #bidirectional(String, String)}, {@link #clientStreaming(String, String)}, + * {@link #serverStreaming(String, String)} or {@link #unary(String, String)}. + */ +public final class ClientMethodDescriptor { + + /** + * The simple name of the method. + */ + private final String name; + + /** + * The {@link MethodDescriptor} for this method. This is usually obtained from protocol buffer + * method getDescriptor (from service getDescriptor). + */ + private final MethodDescriptor descriptor; + + /** + * The list of client interceptors for this method. + */ + private final WeightedBag interceptors; + + /** + * The {@link CallCredentials} for this method. + */ + private final CallCredentials callCredentials; + + /** + * The method handler for this method. + */ + private final MethodHandler methodHandler; + + private ClientMethodDescriptor(String name, + MethodDescriptor descriptor, + WeightedBag interceptors, + CallCredentials callCredentials, + MethodHandler methodHandler) { + this.name = name; + this.descriptor = descriptor; + this.interceptors = interceptors; + this.callCredentials = callCredentials; + this.methodHandler = methodHandler; + } + + /** + * Creates a new {@link Builder} with the specified name and {@link MethodDescriptor}. + * + * @param serviceName the name of the owning gRPC service + * @param name the simple method name + * @param descriptor the underlying gRPC {@link MethodDescriptor.Builder} + * @return A new instance of a {@link Builder} + */ + static Builder builder(String serviceName, + String name, + MethodDescriptor.Builder descriptor) { + return new Builder(serviceName, name, descriptor); + } + + /** + * Creates a new {@link Builder} with the specified + * name and {@link MethodDescriptor}. + * + * @param serviceName the name of the owning gRPC service + * @param name the simple method name + * @param descriptor the underlying gRPC {@link MethodDescriptor.Builder} + * @return a new instance of a {@link Builder} + */ + static ClientMethodDescriptor create(String serviceName, + String name, + MethodDescriptor.Builder descriptor) { + return builder(serviceName, name, descriptor).build(); + } + + /** + * Creates a new unary {@link Builder} with + * the specified name. + * + * @param serviceName the name of the owning gRPC service + * @param name the method name + * @return a new instance of a {@link Builder} + */ + static Builder unary(String serviceName, String name) { + return builder(serviceName, name, MethodDescriptor.MethodType.UNARY); + } + + /** + * Creates a new client Streaming {@link Builder} with + * the specified name. + * + * @param serviceName the name of the owning gRPC service + * @param name the method name + * @return a new instance of a {@link Builder} + */ + static Builder clientStreaming(String serviceName, String name) { + return builder(serviceName, name, MethodDescriptor.MethodType.CLIENT_STREAMING); + } + + /** + * Creates a new server streaming {@link Builder} with + * the specified name. + * + * @param serviceName the name of the owning gRPC service + * @param name the method name + * @return a new instance of a {@link Builder} + */ + static Builder serverStreaming(String serviceName, String name) { + return builder(serviceName, name, MethodDescriptor.MethodType.SERVER_STREAMING); + } + + /** + * Creates a new bidirectional {@link Builder} with + * the specified name. + * + * @param serviceName the name of the owning gRPC service + * @param name the method name + * @return a new instance of a {@link Builder} + */ + static Builder bidirectional(String serviceName, String name) { + return builder(serviceName, name, MethodDescriptor.MethodType.BIDI_STREAMING); + } + + /** + * Return the {@link CallCredentials} set on this service. + * + * @return the {@link CallCredentials} set on this service + */ + public CallCredentials callCredentials() { + return this.callCredentials; + } + + /** + * Creates a new {@link Builder} with the specified name. + * + * @param serviceName the name of the owning gRPC service + * @param name the method name + * @param methodType the gRPC method type + * @return a new instance of a {@link Builder} + */ + static Builder builder(String serviceName, + String name, + MethodDescriptor.MethodType methodType) { + + MethodDescriptor.Builder builder = MethodDescriptor.newBuilder() + .setFullMethodName(serviceName + "/" + name) + .setType(methodType); + + return new Builder(serviceName, name, builder) + .requestType(Object.class) + .responseType(Object.class); + } + + /** + * Returns the simple name of the method. + * + * @return The simple name of the method. + */ + public String name() { + return name; + } + + /** + * Returns the {@link MethodDescriptor} of this method. + * + * @param the request type + * @param the response type + * @return The {@link MethodDescriptor} of this method. + */ + @SuppressWarnings("unchecked") + public MethodDescriptor descriptor() { + return (MethodDescriptor) descriptor; + } + + /** + * Obtain the {@link ClientInterceptor}s to use for this method. + * + * @return the {@link ClientInterceptor}s to use for this method + */ + WeightedBag interceptors() { + return interceptors.readOnly(); + } + + /** + * Obtain the {@link MethodHandler} to use to make client calls. + * + * @return the {@link MethodHandler} to use to make client calls + */ + public MethodHandler methodHandler() { + return methodHandler; + } + + /** + * ClientMethod configuration API. + */ + public interface Rules { + + /** + * Sets the type of parameter of this method. + * + * @param type The type of parameter of this method. + * @return this {@link Rules} instance for fluent call chaining + */ + Rules requestType(Class type); + + /** + * Sets the type of parameter of this method. + * + * @param type The type of parameter of this method. + * @return this {@link Rules} instance for fluent call chaining + */ + Rules responseType(Class type); + + /** + * Register one or more {@link ClientInterceptor interceptors} for the method. + * + * @param interceptors the interceptor(s) to register + * @return this {@link Rules} instance for fluent call chaining + */ + Rules intercept(ClientInterceptor... interceptors); + + /** + * Register one or more {@link ClientInterceptor interceptors} for the method. + *

+ * The added interceptors will be applied using the specified priority. + * + * @param priority the priority to assign to the interceptors + * @param interceptors one or more {@link ClientInterceptor}s to register + * @return this {@link Rules} to allow fluent method chaining + */ + Rules intercept(int priority, ClientInterceptor... interceptors); + + /** + * Register the {@link MarshallerSupplier} for the method. + *

+ * If not set the default {@link MarshallerSupplier} from the service will be used. + * + * @param marshallerSupplier the {@link MarshallerSupplier} for the service + * @return this {@link Rules} instance for fluent call chaining + */ + Rules marshallerSupplier(MarshallerSupplier marshallerSupplier); + + /** + * Register the specified {@link CallCredentials} to be used for this method. This overrides + * any {@link CallCredentials} set on the {@link ClientServiceDescriptor}. + * + * @param callCredentials the {@link CallCredentials} to set. + * @return this {@link Rules} instance for fluent call chaining + */ + Rules callCredentials(CallCredentials callCredentials); + + /** + * Set the {@link MethodHandler} that can be used to invoke the method. + * + * @param methodHandler the {@link MethodHandler} to use + * @return this {@link Rules} instance for fluent call chaining + */ + Rules methodHandler(MethodHandler methodHandler); + } + + /** + * {@link MethodDescriptor} builder implementation. + */ + public static class Builder + implements Rules, io.helidon.common.Builder { + + private String name; + private final MethodDescriptor.Builder descriptor; + private Class requestType; + private Class responseType; + private final WeightedBag interceptors = WeightedBag.create(InterceptorWeights.USER); + private MarshallerSupplier defaultMarshallerSupplier = MarshallerSupplier.create(); + private MarshallerSupplier marshallerSupplier; + private CallCredentials callCredentials; + private MethodHandler methodHandler; + + /** + * Constructs a new Builder instance. + * + * @param serviceName The name of the service ths method belongs to + * @param name the name of this method + * @param descriptor The gRPC method descriptor builder + */ + Builder(String serviceName, String name, MethodDescriptor.Builder descriptor) { + this.name = name; + this.descriptor = descriptor.setFullMethodName(serviceName + "/" + name); + } + + @Override + public Builder requestType(Class type) { + this.requestType = type; + return this; + } + + @Override + public Builder responseType(Class type) { + this.responseType = type; + return this; + } + + @Override + public Builder intercept(ClientInterceptor... interceptors) { + this.interceptors.addAll(Arrays.asList(interceptors)); + return this; + } + + @Override + public Builder intercept(int priority, ClientInterceptor... interceptors) { + this.interceptors.addAll(Arrays.asList(interceptors), priority); + return this; + } + + @Override + public Builder marshallerSupplier(MarshallerSupplier supplier) { + this.marshallerSupplier = supplier; + return this; + } + + Builder defaultMarshallerSupplier(MarshallerSupplier supplier) { + if (supplier == null) { + this.defaultMarshallerSupplier = MarshallerSupplier.create(); + } else { + this.defaultMarshallerSupplier = supplier; + } + return this; + } + + @Override + public Builder methodHandler(MethodHandler methodHandler) { + this.methodHandler = methodHandler; + return this; + } + + /** + * Sets the full name of this Method. + * + * @param fullName the full name of the method + * @return this builder instance for fluent API + */ + Builder fullName(String fullName) { + descriptor.setFullMethodName(fullName); + this.name = fullName.substring(fullName.lastIndexOf('/') + 1); + return this; + } + + @Override + public Rules callCredentials(CallCredentials callCredentials) { + this.callCredentials = callCredentials; + return this; + } + + /** + * Builds and returns a new instance of {@link ClientMethodDescriptor}. + * + * @return a new instance of {@link ClientMethodDescriptor} + */ + @Override + @SuppressWarnings("unchecked") + public ClientMethodDescriptor build() { + MarshallerSupplier supplier = this.marshallerSupplier; + + if (supplier == null) { + supplier = defaultMarshallerSupplier; + } + + if (requestType != null) { + descriptor.setRequestMarshaller((MethodDescriptor.Marshaller) supplier.get(requestType)); + } + + if (responseType != null) { + descriptor.setResponseMarshaller((MethodDescriptor.Marshaller) supplier.get(responseType)); + } + + return new ClientMethodDescriptor(name, + descriptor.build(), + interceptors, + callCredentials, + methodHandler); + } + + } +} diff --git a/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/ClientProxy.java b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/ClientProxy.java new file mode 100644 index 00000000000..18883921d95 --- /dev/null +++ b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/ClientProxy.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2019, 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.microprofile.grpc.client; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.util.Map; + +/** + * A dynamic proxy that forwards methods to gRPC call handlers. + */ +class ClientProxy implements InvocationHandler { + + private final GrpcServiceClient client; + + /** + * A map of Java method name to gRPR method name. + */ + private final Map names; + + /** + * Create a {@link ClientProxy}. + * + * @param client the {@link GrpcServiceClient} to use + * @param names a map of Java method names to gRPC method names + */ + private ClientProxy(GrpcServiceClient client, Map names) { + this.client = client; + this.names = names; + } + + /** + * Create a {@link ClientProxy} instance. + * + * @param client the {@link GrpcServiceClient} to use + * @param names a map of Java method names to gRPC method names + * @return a {@link ClientProxy} instance for the specified service client + */ + static ClientProxy create(GrpcServiceClient client, Map names) { + return new ClientProxy(client, names); + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + return client.invoke(names.get(method.getName()), args); + } + + /** + * Obtain the underlying {@link GrpcServiceClient}. + * + * @return the underlying {@link GrpcServiceClient} + */ + public GrpcServiceClient getClient() { + return client; + } +} diff --git a/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/ClientRequestAttribute.java b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/ClientRequestAttribute.java new file mode 100644 index 00000000000..4e47dfd6b34 --- /dev/null +++ b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/ClientRequestAttribute.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2019, 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.microprofile.grpc.client; + +/** + * An enum of possible gRPC client call attributes to attach to + * call tracing spans. + */ +public enum ClientRequestAttribute { + /** + * Add the method type to the tracing span. + */ + METHOD_TYPE, + + /** + * Add the method name to the tracing span. + */ + METHOD_NAME, + + /** + * Add the call deadline to the tracing span. + */ + DEADLINE, + + /** + * Add the compressor type to the tracing span. + */ + COMPRESSOR, + + /** + * Add the security authority to the tracing span. + */ + AUTHORITY, + + /** + * Add the method call options to the tracing span. + */ + ALL_CALL_OPTIONS, + + /** + * Add the method call headers to the tracing span. + */ + HEADERS +} diff --git a/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/ClientServiceDescriptor.java b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/ClientServiceDescriptor.java new file mode 100644 index 00000000000..f31ed4a9754 --- /dev/null +++ b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/ClientServiceDescriptor.java @@ -0,0 +1,655 @@ +/* + * Copyright (c) 2019, 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.microprofile.grpc.client; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Consumer; + +import io.helidon.grpc.core.InterceptorWeights; +import io.helidon.grpc.core.MarshallerSupplier; +import io.helidon.grpc.core.WeightedBag; + +import com.google.protobuf.DescriptorProtos; +import com.google.protobuf.Descriptors; +import io.grpc.BindableService; +import io.grpc.CallCredentials; +import io.grpc.ClientInterceptor; +import io.grpc.MethodDescriptor.MethodType; +import io.grpc.ServiceDescriptor; + +import static io.helidon.grpc.core.GrpcHelper.extractMethodName; + +/** + * Encapsulates all details about a client side gRPC service. + */ +public class ClientServiceDescriptor { + + private final String serviceName; + private final Map methods; + private final WeightedBag interceptors; + private final CallCredentials callCredentials; + + private ClientServiceDescriptor(String serviceName, + Map methods, + WeightedBag interceptors, + CallCredentials callCredentials) { + this.serviceName = serviceName; + this.methods = methods; + this.interceptors = interceptors; + this.callCredentials = callCredentials; + } + + /** + * Create a {@link ClientServiceDescriptor} from a {@link ServiceDescriptor}. + * + * @param descriptor the {@link ServiceDescriptor} + * @return a {@link ClientServiceDescriptor} + */ + public static ClientServiceDescriptor create(ServiceDescriptor descriptor) { + return builder(descriptor).build(); + } + + /** + * Create a {@link ClientServiceDescriptor} from a {@link BindableService}. + * + * @param service the BindableService + * @return a {@link ClientServiceDescriptor} + */ + public static ClientServiceDescriptor create(BindableService service) { + return builder(service).build(); + } + + /** + * Create a {@link Builder} from a {@link ServiceDescriptor}. + * + * @param service the {@link ServiceDescriptor} + * @return a {@link Builder} + */ + public static Builder builder(ServiceDescriptor service) { + return new Builder(service); + } + + /** + * Create a {@link Builder} from a {@link BindableService}. + * + * @param service the {@link BindableService} + * @return a {@link Builder} + */ + public static Builder builder(BindableService service) { + return new Builder(service); + } + + /** + * Create a {@link Builder} form a name and type. + *

+ * The {@link Class#getSimpleName() class simple name} will be used for the service name. + * + * @param serviceClass the service class + * @return a {@link Builder} + */ + public static Builder builder(Class serviceClass) { + try { + Method method = serviceClass.getMethod("getServiceDescriptor"); + if (method.getReturnType() == ServiceDescriptor.class) { + ServiceDescriptor svcDesc = (ServiceDescriptor) method.invoke(null); + return builder(svcDesc); + } + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException itEx) { + // Ignored. + } + return builder(serviceClass.getSimpleName(), serviceClass); + } + + /** + * Create a {@link Builder} form a name and type. + * + * @param serviceName the getName of the service to use to initialise the builder + * @param serviceClass the service class + * @return a {@link Builder} + */ + public static Builder builder(String serviceName, Class serviceClass) { + return new Builder(serviceName, serviceClass); + } + + /** + * Obtain the service name. + * + * @return the service name + */ + public String name() { + return serviceName; + } + + /** + * Return {@link ClientMethodDescriptor} for a specified method getName. + * + * @param name method getName + * @return method getDescriptor for the specified getName + */ + public ClientMethodDescriptor method(String name) { + return methods.get(name); + } + + /** + * Return the collections of methods that make up this service. + * + * @return service methods + */ + public Collection methods() { + return Collections.unmodifiableCollection(methods.values()); + } + + /** + * Return service interceptors. + * + * @return service interceptors + */ + public WeightedBag interceptors() { + return interceptors.readOnly(); + } + + /** + * Return the {@link CallCredentials} set on this service. + * + * @return the {@link CallCredentials} set on this service + */ + public CallCredentials callCredentials() { + return this.callCredentials; + } + + @Override + public String toString() { + return "ClientServiceDescriptor(name='" + serviceName + "')"; + } + + // ---- inner interface: Rules ----------------------------------------- + + /** + * Fluent configuration interface for the {@link ClientServiceDescriptor}. + */ + public interface Rules { + /** + * Obtain the name fo the service this configuration configures. + * + * @return the name fo the service this configuration configures + */ + String name(); + + /** + * Set the name for the service. + * + * @param name the name of service + * @return this {@link Rules} instance for fluent call chaining + * @throws NullPointerException if the getName is null + * @throws IllegalArgumentException if the getName is a blank String + */ + Rules name(String name); + + /** + * Register the proto file for the service. + * + * @param proto the service proto + * @return this {@link Rules} instance for fluent call chaining + */ + Rules proto(Descriptors.FileDescriptor proto); + + /** + * Register the {@link MarshallerSupplier} for the service. + * + * @param marshallerSupplier the {@link MarshallerSupplier} for the service + * @return this {@link Rules} instance for fluent call chaining + */ + Rules marshallerSupplier(MarshallerSupplier marshallerSupplier); + + /** + * Register one or more {@link ClientInterceptor interceptors} for the service. + * + * @param interceptors the interceptor(s) to register + * @return this {@link Rules} instance for fluent call chaining + */ + Rules intercept(ClientInterceptor... interceptors); + + /** + * Add one or more {@link ClientInterceptor} instances that will intercept calls + * to this service. + *

+ * The added interceptors will be applied using the specified priority. + * + * @param priority the priority to assign to the interceptors + * @param interceptors one or more {@link ClientInterceptor}s to add + * @return this builder to allow fluent method chaining + */ + Rules intercept(int priority, ClientInterceptor... interceptors); + + /** + * Register one or more {@link ClientInterceptor interceptors} for a named method of the service. + * + * @param methodName the name of the method to intercept + * @param interceptors the interceptor(s) to register + * @return this {@link Rules} instance for fluent call chaining + * @throws IllegalArgumentException if no method exists for the specified getName + */ + Rules intercept(String methodName, ClientInterceptor... interceptors); + + /** + * Register one or more {@link ClientInterceptor interceptors} for a named method of the service. + *

+ * The added interceptors will be applied using the specified priority. + * + * @param methodName the name of the method to intercept + * @param priority the priority to assign to the interceptors + * @param interceptors the interceptor(s) to register + * @return this {@link Rules} instance for fluent call chaining + * + * @throws IllegalArgumentException if no method exists for the specified name + */ + Rules intercept(String methodName, int priority, ClientInterceptor... interceptors); + + /** + * Register unary method for the service. + * + * @param name The getName of the method + * @return this {@link Rules} instance for fluent call chaining + */ + Rules unary(String name); + + /** + * Register unary method for the service. + * + * @param name the name of the method + * @param configurer the method configurer + * @return this {@link Rules} instance for fluent call chaining + */ + Rules unary(String name, Consumer configurer); + + /** + * Register server streaming method for the service. + * + * @param name The name of the method + * @return this {@link Rules} instance for fluent call chaining + */ + Rules serverStreaming(String name); + + /** + * Register server streaming method for the service. + * + * @param name the name of the method + * @param configurer the method configurer + * @return this {@link Rules} instance for fluent call chaining + */ + Rules serverStreaming(String name, Consumer configurer); + + /** + * Register client streaming method for the service. + * + * @param name The name of the method + * @return this {@link Rules} instance for fluent call chaining + */ + Rules clientStreaming(String name); + + /** + * Register client streaming method for the service. + * + * @param name the name of the method + * @param configurer the method configurer + * @return this {@link Rules} instance for fluent call chaining + */ + Rules clientStreaming(String name, Consumer configurer); + + /** + * Register bi-directional streaming method for the service. + * + * @param name The name of the method + * @return this {@link Rules} instance for fluent call chaining + */ + Rules bidirectional(String name); + + /** + * Register bi-directional streaming method for the service. + * + * @param name the name of the method + * @param configurer the method configurer + * @return this {@link Rules} instance for fluent call chaining + */ + Rules bidirectional(String name, Consumer configurer); + + /** + * Register the {@link CallCredentials} to be used for this service. + * + * @param callCredentials the {@link CallCredentials} to set. + * @return this {@link Rules} instance for fluent call chaining + */ + Rules callCredentials(CallCredentials callCredentials); + + /** + * Register the {@link CallCredentials} to be used for the specified method in this service. This overrides + * any {@link CallCredentials} set on this {@link ClientServiceDescriptor} + * + * @param name the method name + * @param callCredentials the {@link CallCredentials} to set. + * @return this {@link Rules} instance for fluent call chaining + */ + Rules callCredentials(String name, CallCredentials callCredentials); + + } + + // ---- inner class: BaseBuilder -------------------------------------------- + + /** + * A {@link ClientServiceDescriptor} builder. + */ + public static final class Builder implements Rules, io.helidon.common.Builder { + + private String name; + private final WeightedBag interceptors = WeightedBag.create(InterceptorWeights.USER); + private final Class serviceClass; + private Descriptors.FileDescriptor proto; + private MarshallerSupplier marshallerSupplier = MarshallerSupplier.create(); + private CallCredentials callCredentials; + + private final Map methodBuilders = new HashMap<>(); + + /** + * Builds the ClientService from a {@link BindableService}. + * + * @param service the {@link BindableService} to use to initialize the builder + */ + private Builder(BindableService service) { + this(service.bindService().getServiceDescriptor()); + } + + /** + * Builds the ClientService from a {@link BindableService}. + * + * @param serviceDescriptor the {@link ServiceDescriptor} to use to initialize the builder + */ + private Builder(ServiceDescriptor serviceDescriptor) { + this.name = serviceDescriptor.getName(); + this.serviceClass = serviceDescriptor.getClass(); + + for (io.grpc.MethodDescriptor md : serviceDescriptor.getMethods()) { + String methodName = extractMethodName(md.getFullMethodName()); + + methodBuilders.put(methodName, ClientMethodDescriptor.builder(this.name, methodName, md.toBuilder())); + } + } + + /** + * Create a new {@link Builder}. + * + * @param name the service name + * @param serviceClass the service class + */ + private Builder(String name, Class serviceClass) { + this.name = name; + this.serviceClass = serviceClass; + } + + @Override + public String name() { + return name; + } + + @Override + public Builder name(String serviceName) { + if (serviceName == null) { + throw new NullPointerException("Service getName cannot be null"); + } + + if (serviceName.trim().isEmpty()) { + throw new IllegalArgumentException("Service getName cannot be blank"); + } + + this.name = serviceName.trim(); + for (Map.Entry e : methodBuilders.entrySet()) { + e.getValue().fullName(io.grpc.MethodDescriptor.generateFullMethodName(this.name, e.getKey())); + } + return this; + } + + @Override + public Builder proto(Descriptors.FileDescriptor proto) { + this.proto = proto; + return this; + } + + @Override + public Builder marshallerSupplier(MarshallerSupplier marshallerSupplier) { + this.marshallerSupplier = marshallerSupplier; + return this; + } + + @Override + public Builder unary(String name) { + return unary(name, null); + } + + @Override + public Builder unary(String name, Consumer configurer) { + methodBuilders.put(name, createMethodDescriptor(name, MethodType.UNARY, configurer)); + return this; + } + + @Override + public Builder serverStreaming(String name) { + return serverStreaming(name, null); + } + + @Override + public Builder serverStreaming(String name, + Consumer configurer) { + methodBuilders.put(name, createMethodDescriptor(name, MethodType.SERVER_STREAMING, configurer)); + return this; + } + + @Override + public Builder clientStreaming(String name) { + return clientStreaming(name, null); + } + + @Override + public Builder clientStreaming(String name, + Consumer configurer) { + methodBuilders.put(name, createMethodDescriptor(name, MethodType.CLIENT_STREAMING, configurer)); + return this; + } + + @Override + public Builder bidirectional(String name) { + return bidirectional(name, null); + } + + @Override + public Builder bidirectional(String name, + Consumer configurer) { + methodBuilders.put(name, createMethodDescriptor(name, MethodType.BIDI_STREAMING, configurer)); + return this; + } + + @Override + public Builder intercept(ClientInterceptor... interceptors) { + this.interceptors.addAll(Arrays.asList(interceptors)); + return this; + } + + @Override + public Rules intercept(int priority, ClientInterceptor... interceptors) { + this.interceptors.addAll(Arrays.asList(interceptors), priority); + return this; + } + + @Override + public Builder intercept(String methodName, ClientInterceptor... interceptors) { + ClientMethodDescriptor.Builder method = methodBuilders.get(methodName); + + if (method == null) { + throw new IllegalArgumentException("No method exists with getName '" + methodName + "'"); + } + + method.intercept(interceptors); + + return this; + } + + @Override + public Builder intercept(String methodName, int priority, ClientInterceptor... interceptors) { + ClientMethodDescriptor.Builder method = methodBuilders.get(methodName); + + if (method == null) { + throw new IllegalArgumentException("No method exists with getName '" + methodName + "'"); + } + + method.intercept(priority, interceptors); + + return this; + } + + @Override + public Builder callCredentials(CallCredentials callCredentials) { + this.callCredentials = callCredentials; + return this; + } + + @Override + public Builder callCredentials(String methodName, CallCredentials callCredentials) { + ClientMethodDescriptor.Builder method = methodBuilders.get(methodName); + + if (method == null) { + throw new IllegalArgumentException("No method exists with getName '" + methodName + "'"); + } + + method.callCredentials(callCredentials); + return this; + } + + @Override + public ClientServiceDescriptor build() { + Map methods = new LinkedHashMap<>(); + for (Map.Entry entry : methodBuilders.entrySet()) { + methods.put(entry.getKey(), entry.getValue().build()); + } + + return new ClientServiceDescriptor(name, methods, interceptors, callCredentials); + } + + // ---- helpers ----------------------------------------------------- + + private ClientMethodDescriptor.Builder createMethodDescriptor( + String methodName, + MethodType methodType, + Consumer configurer) { + + io.grpc.MethodDescriptor.Builder grpcDesc = io.grpc.MethodDescriptor.newBuilder() + .setFullMethodName(io.grpc.MethodDescriptor.generateFullMethodName(this.name, methodName)) + .setType(methodType) + .setSampledToLocalTracing(true); + + Class requestType = getTypeFromMethodDescriptor(methodName, true); + Class responseType = getTypeFromMethodDescriptor(methodName, false); + + ClientMethodDescriptor.Builder builder = ClientMethodDescriptor.builder(this.name, methodName, grpcDesc) + .defaultMarshallerSupplier(this.marshallerSupplier) + .requestType(requestType) + .responseType(responseType); + + if (configurer != null) { + configurer.accept(builder); + } + + return builder; + } + + private Class getTypeFromMethodDescriptor(String methodName, boolean fInput) { + + // if the proto is not present, assume that we are not using + // protobuf for marshalling and that whichever marshaller is used + // doesn't need type information (basically, that the serialized + // stream is self-describing) + if (proto == null) { + return Object.class; + } + + // todo: add error handling here, and fail fast with a more + // todo: meaningful exception (and message) than a NPE + // todo: if the service or the method cannot be found + Descriptors.ServiceDescriptor svc = proto.findServiceByName(name); + Descriptors.MethodDescriptor mtd = svc.findMethodByName(methodName); + Descriptors.Descriptor type = fInput ? mtd.getInputType() : mtd.getOutputType(); + + String pkg = getPackageName(); + String outerClass = getOuterClassName(); + + // make sure that any nested protobuf class names are converted + // into a proper Java binary class getName + String className = pkg + "." + outerClass + type.getFullName().replace('.', '$'); + + // the assumption here is that the protobuf generated classes can always + // be loaded by the same class loader that loaded the service class, + // as the service implementation is bound to depend on them + try { + return serviceClass != null + ? serviceClass.getClassLoader().loadClass(className) + : this.getClass().getClassLoader().loadClass(className); + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } + } + + private String getPackageName() { + String pkg = proto.getOptions().getJavaPackage(); + return "".equals(pkg) ? proto.getPackage() : pkg; + } + + private String getOuterClassName() { + DescriptorProtos.FileOptions options = proto.getOptions(); + if (options.getJavaMultipleFiles()) { + // there is no outer class -- each message will have its own top-level class + return ""; + } + + String outerClass = options.getJavaOuterClassname(); + if ("".equals(outerClass)) { + outerClass = getOuterClassFromFileName(proto.getName()); + } + + // append $ in order to timed a proper binary getName for the nested message class + return outerClass + "$"; + } + + private String getOuterClassFromFileName(String name) { + // strip .proto extension + name = name.substring(0, name.lastIndexOf(".proto")); + + String[] words = name.split("_"); + StringBuilder sb = new StringBuilder(name.length()); + + for (String word : words) { + sb.append(Character.toUpperCase(word.charAt(0))) + .append(word.substring(1)); + } + + return sb.toString(); + } + } +} diff --git a/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/DelegatingBeanAttributes.java b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/DelegatingBeanAttributes.java new file mode 100644 index 00000000000..04333d9fd81 --- /dev/null +++ b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/DelegatingBeanAttributes.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2019, 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.microprofile.grpc.client; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; +import java.util.Collections; +import java.util.Objects; +import java.util.Set; + +import jakarta.enterprise.inject.spi.BeanAttributes; + +/** + * A {@link jakarta.enterprise.inject.spi.BeanAttributes} implementation. + * + * @param the class of the bean instance + */ +class DelegatingBeanAttributes implements BeanAttributes { + + private final BeanAttributes delegate; + private final Set types; + + /** + * Create a {@link DelegatingBeanAttributes}. + * + * @param delegate the {@link jakarta.enterprise.inject.spi.BeanAttributes} to delegate to + * @param types the {@link java.lang.reflect.Type}s for this bean + */ + private DelegatingBeanAttributes(BeanAttributes delegate, Set types) { + super(); + Objects.requireNonNull(delegate); + this.delegate = delegate; + this.types = Collections.unmodifiableSet(types); + } + + /** + * Create a {@link DelegatingBeanAttributes}. + * + * @param delegate the {@link jakarta.enterprise.inject.spi.BeanAttributes} to delegate to + * @param types the {@link java.lang.reflect.Type}s for this bean + */ + static DelegatingBeanAttributes create(BeanAttributes delegate, Set types) { + return new DelegatingBeanAttributes<>(delegate, types); + } + + @Override + public String getName() { + return this.delegate.getName(); + } + + @Override + public Set getQualifiers() { + return this.delegate.getQualifiers(); + } + + @Override + public Class getScope() { + return this.delegate.getScope(); + } + + @Override + public Set> getStereotypes() { + return this.delegate.getStereotypes(); + } + + @Override + public Set getTypes() { + if (types == null || types.isEmpty()) { + return this.delegate.getTypes(); + } else { + return types; + } + } + + @Override + public boolean isAlternative() { + return this.delegate.isAlternative(); + } + + @Override + public String toString() { + return this.delegate.toString(); + } +} diff --git a/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcChannelDescriptorBlueprint.java b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcChannelDescriptorBlueprint.java new file mode 100644 index 00000000000..54e7b8da1a9 --- /dev/null +++ b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcChannelDescriptorBlueprint.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.microprofile.grpc.client; + +import java.util.Optional; + +import io.helidon.builder.api.Option; +import io.helidon.builder.api.Prototype; +import io.helidon.common.tls.Tls; + +@Prototype.Blueprint +@Prototype.Configured +interface GrpcChannelDescriptorBlueprint { + + /** + * The name of this channel. + * + * @return channel name + */ + @Option.Configured + String name(); + + /** + * The host to connect to. + * + * @return the host + */ + @Option.Configured + @Option.Default(GrpcChannelsProvider.DEFAULT_HOST) + String host(); + + /** + * The port to connect to. + * + * @return the port + */ + @Option.Configured + @Option.DefaultInt(GrpcChannelsProvider.DEFAULT_PORT) + int port(); + + /** + * The target URI. + * + * @return the URI + */ + @Option.Configured + Optional target(); + + /** + * TLS configuration for the connection. + * + * @return the TLS config + */ + @Option.Configured + Optional tls(); +} diff --git a/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcChannelsDescriptorBlueprint.java b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcChannelsDescriptorBlueprint.java new file mode 100644 index 00000000000..c6932921197 --- /dev/null +++ b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcChannelsDescriptorBlueprint.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.microprofile.grpc.client; + +import java.util.List; + +import io.helidon.builder.api.Option; +import io.helidon.builder.api.Prototype; + +@Prototype.Blueprint +@Prototype.Configured +interface GrpcChannelsDescriptorBlueprint { + + /** + * List of channel descriptors. + * + * @return channel descriptors + */ + @Option.Configured + List channels(); +} diff --git a/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcChannelsProvider.java b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcChannelsProvider.java new file mode 100644 index 00000000000..aacbc1f25d6 --- /dev/null +++ b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcChannelsProvider.java @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2019, 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.microprofile.grpc.client; + +import java.util.HashMap; +import java.util.Map; + +import io.helidon.common.tls.Tls; +import io.helidon.config.Config; +import io.helidon.webclient.grpc.GrpcClient; + +import io.grpc.Channel; + +/** + * GrpcChannelsProvider is a factory for pre-configured gRPC Channel instances. + */ +public class GrpcChannelsProvider { + + /** + * A constant for holding the default channel configuration name (which is "default"). + */ + public static final String DEFAULT_CHANNEL_NAME = "default"; + + /** + * A constant for holding the default host name (which is "localhost"). + */ + public static final String DEFAULT_HOST = "localhost"; + + /** + * A constant for holding the default port (which is "1408"). + */ + public static final int DEFAULT_PORT = 1408; + + private final Map channelConfigs; + + private GrpcChannelsProvider(Map channelDescriptors) { + this.channelConfigs = new HashMap<>(channelDescriptors); + } + + /** + * Builds a new instance of {@link GrpcChannelsProvider} using default configuration. The + * default configuration connects to "localhost:1408" without TLS. + * + * @return a new instance of {@link GrpcChannelsProvider} + */ + public static GrpcChannelsProvider create() { + return GrpcChannelsProvider.builder().build(); + } + + /** + * Creates a {@link GrpcChannelsProvider} using the specified configuration. + * + * @param config The externalized configuration. + * @return a new instance of {@link GrpcChannelsProvider} + */ + public static GrpcChannelsProvider create(Config config) { + return new Builder(config).build(); + } + + /** + * Create a new {@link Builder}. + * + * @return a new {@link Builder} + */ + public static Builder builder() { + return builder(null); + } + + /** + * Create a new {@link Builder}. + * + * @param config the {@link Config} to bootstrap from + * @return a new {@link Builder} + */ + public static Builder builder(Config config) { + return new Builder(config); + } + + /** + * Returns a {@link Channel} for the specified channel or host name. + *

+ * If the specified channel name does not exist in the configuration, we will assume + * that it represents the name of the gRPC host to connect to and will create a plain text + * channel to the host with the specified {@code name}, on a default port (1408). + * + * @param name the name of the channel configuration as specified in the configuration file, + * or the name of the host to connect to + * @return a new instance of {@link Channel} + * @throws NullPointerException if name is null + * @throws IllegalArgumentException if name is empty + */ + public Channel channel(String name) { + if (name == null) { + throw new NullPointerException("name cannot be null."); + } + if (name.trim().isEmpty()) { + throw new IllegalArgumentException("name cannot be empty or blank."); + } + GrpcChannelDescriptor chCfg = channelConfigs.computeIfAbsent(name, hostName -> + GrpcChannelDescriptor.builder().name(name).host(name).build()); + return createChannel(name, chCfg); + } + + /** + * Creates a channel from a channel descriptor. + * + * @param name the channel name + * @param descriptor the channel descriptor + * @return the channel + */ + private Channel createChannel(String name, GrpcChannelDescriptor descriptor) { + Tls clientTls = descriptor.tls().orElse(null); + if (clientTls == null) { + throw new IllegalArgumentException("Client TLS must be configured for gRPC proxy client"); + } + int port = descriptor.port(); + GrpcClient grpcClient = GrpcClient.builder() + .tls(clientTls) + .baseUri("https://" + descriptor.host() + ":" + port) + .build(); + return grpcClient.channel(); + } + + /** + * Builder builds an instance of {@link GrpcChannelsProvider}. + */ + public static class Builder implements io.helidon.common.Builder { + + private final Map channelConfigs = new HashMap<>(); + + private Builder(Config config) { + // Add the default channel (which can be overridden in the config) + channel(DEFAULT_CHANNEL_NAME, GrpcChannelDescriptor.builder().name(DEFAULT_CHANNEL_NAME).build()); + + if (config == null) { + return; + } + + GrpcChannelsDescriptor channelsDescriptor = GrpcChannelsDescriptor.builder().config(config).build(); + channelsDescriptor.channels().forEach(ch -> channelConfigs.put(ch.name(), ch)); + } + + /** + * Add or replace the specified {@link GrpcChannelDescriptor}. + * + * @param name the name of the configuration + * @param descriptor the {@link GrpcChannelDescriptor} to be added + * @return this Builder instance + */ + public Builder channel(String name, GrpcChannelDescriptor descriptor) { + channelConfigs.put(name, descriptor); + return this; + } + + /** + * Create a new instance of {@link GrpcChannelsProvider} from this Builder. + * + * @return a new instance of {@link GrpcChannelsProvider} + */ + public GrpcChannelsProvider build() { + return new GrpcChannelsProvider(channelConfigs); + } + } +} diff --git a/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcClientBuilder.java b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcClientBuilder.java new file mode 100644 index 00000000000..b391de0116a --- /dev/null +++ b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcClientBuilder.java @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2019, 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.microprofile.grpc.client; + +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import io.helidon.common.Builder; +import io.helidon.grpc.api.Grpc; +import io.helidon.grpc.core.MethodHandler; +import io.helidon.microprofile.grpc.core.AbstractServiceBuilder; +import io.helidon.microprofile.grpc.core.AnnotatedMethod; +import io.helidon.microprofile.grpc.core.AnnotatedMethodList; +import io.helidon.microprofile.grpc.core.InstanceSupplier; +import io.helidon.microprofile.grpc.core.ModelHelper; + +import static java.lang.System.Logger.Level; + +/** + * A builder for constructing a {@link io.helidon.microprofile.grpc.client.ClientServiceDescriptor} instances + * from an annotated POJO. + */ +class GrpcClientBuilder extends AbstractServiceBuilder + implements Builder { + + private static final System.Logger LOGGER = System.getLogger(GrpcClientBuilder.class.getName()); + + /** + * Create a {@link GrpcClientBuilder} for a given gRPC service class. + * + * @param serviceClass gRPC service (handler) class. + * @param instance the target instance to call gRPC handler methods on + * @throws NullPointerException if the service or instance parameters are null + */ + private GrpcClientBuilder(Class serviceClass, Supplier instance) { + super(serviceClass, instance); + } + + /** + * Create a {@link GrpcClientBuilder} for a given gRPC service. + * + * @param service the service to call gRPC handler methods on + * @throws NullPointerException if the service is null + * @return a {@link GrpcClientBuilder} + */ + static GrpcClientBuilder create(Object service) { + return new GrpcClientBuilder(service.getClass(), InstanceSupplier.singleton(service)); + } + + /** + * Create a {@link GrpcClientBuilder} for a given gRPC service class. + * + * @param serviceClass gRPC service (handler) class. + * @throws NullPointerException if the service class is null + * @return a {@link GrpcClientBuilder} + */ + static GrpcClientBuilder create(Class serviceClass) { + return new GrpcClientBuilder(Objects.requireNonNull(serviceClass), createInstanceSupplier(serviceClass)); + } + + /** + * Create a new resource model builder for the introspected class. + *

+ * The model returned is filled with the introspected data. + *

+ * + * @return new resource model builder for the introspected class. + */ + @Override + public ClientServiceDescriptor.Builder build() { + checkForNonPublicMethodIssues(); + + Class annotatedServiceClass = annotatedServiceClass(); + AnnotatedMethodList methodList = AnnotatedMethodList.create(annotatedServiceClass); + String name = determineServiceName(annotatedServiceClass); + + ClientServiceDescriptor.Builder builder = ClientServiceDescriptor.builder(serviceClass()) + .name(name) + .marshallerSupplier(getMarshallerSupplier()); + + addServiceMethods(builder, methodList); + + LOGGER.log(Level.DEBUG, () -> String.format("A new gRPC service was created by ServiceModeller: %s", builder)); + + return builder; + } + + /** + * Add methods to the {@link io.helidon.microprofile.grpc.client.ClientServiceDescriptor.Builder}. + * + * @param builder the {@link io.helidon.microprofile.grpc.client.ClientServiceDescriptor.Builder} to add the method to + * @param methodList the list of methods to add + */ + private void addServiceMethods(ClientServiceDescriptor.Builder builder, AnnotatedMethodList methodList) { + for (AnnotatedMethod am : methodList.withAnnotation(Grpc.GrpcMethod.class)) { + addServiceMethod(builder, am); + } + for (AnnotatedMethod am : methodList.withMetaAnnotation(Grpc.GrpcMethod.class)) { + addServiceMethod(builder, am); + } + } + + /** + * Add a method to the {@link io.helidon.microprofile.grpc.client.ClientServiceDescriptor.Builder}. + *

+ * The method configuration will be determined by the annotations present on the + * method and the method signature. + * + * @param builder the {@link io.helidon.microprofile.grpc.client.ClientServiceDescriptor.Builder} to add the method to + * @param method the {@link io.helidon.microprofile.grpc.core.AnnotatedMethod} representing the method to add + */ + private void addServiceMethod(ClientServiceDescriptor.Builder builder, AnnotatedMethod method) { + Grpc.GrpcMethod annotation = method.firstAnnotationOrMetaAnnotation(Grpc.GrpcMethod.class); + String name = determineMethodName(method, annotation); + + MethodHandler handler = handlerSuppliers().stream() + .filter(supplier -> supplier.supplies(method)) + .findFirst() + .map(supplier -> supplier.get(name, method, instanceSupplier())) + .orElseThrow(() -> new IllegalArgumentException("Cannot locate a method handler supplier for method " + method)); + + Class requestType = handler.getRequestType(); + Class responseType = handler.getResponseType(); + AnnotatedMethodConfigurer configurer = new AnnotatedMethodConfigurer(method, requestType, responseType, handler); + + switch (annotation.value()) { + case UNARY: + builder.unary(name, configurer); + break; + case CLIENT_STREAMING: + builder.clientStreaming(name, configurer); + break; + case SERVER_STREAMING: + builder.serverStreaming(name, configurer); + break; + case BIDI_STREAMING: + builder.bidirectional(name, configurer); + break; + case UNKNOWN: + default: + LOGGER.log(Level.ERROR, () -> "Unrecognized method type " + annotation.value()); + } + } + + /** + * A {@link java.util.function.Consumer} of {@link io.helidon.microprofile.grpc.client.ClientMethodDescriptor.Rules} + * that applies configuration changes based on annotations present on the gRPC + * method. + */ + private static class AnnotatedMethodConfigurer + implements Consumer { + + private final AnnotatedMethod method; + private final Class requestType; + private final Class responseType; + private final MethodHandler methodHandler; + + private AnnotatedMethodConfigurer(AnnotatedMethod method, + Class requestType, + Class responseType, + MethodHandler methodHandler) { + this.method = method; + this.requestType = requestType; + this.responseType = responseType; + this.methodHandler = methodHandler; + } + + @Override + public void accept(ClientMethodDescriptor.Rules config) { + config.requestType(requestType) + .responseType(responseType) + .methodHandler(methodHandler); + + if (method.isAnnotationPresent(Grpc.GrpcMarshaller.class)) { + config.marshallerSupplier(ModelHelper.getMarshallerSupplier( + method.getAnnotation(Grpc.GrpcMarshaller.class))); + } + } + } +} diff --git a/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcClientCdiExtension.java b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcClientCdiExtension.java new file mode 100644 index 00000000000..d0ec1033a71 --- /dev/null +++ b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcClientCdiExtension.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2019, 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.microprofile.grpc.client; + +import java.lang.reflect.Type; +import java.util.HashSet; +import java.util.Set; + +import io.helidon.grpc.api.Grpc; + +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.inject.spi.AfterBeanDiscovery; +import jakarta.enterprise.inject.spi.Annotated; +import jakarta.enterprise.inject.spi.AnnotatedMethod; +import jakarta.enterprise.inject.spi.AnnotatedType; +import jakarta.enterprise.inject.spi.BeanAttributes; +import jakarta.enterprise.inject.spi.BeanManager; +import jakarta.enterprise.inject.spi.BeforeBeanDiscovery; +import jakarta.enterprise.inject.spi.Extension; +import jakarta.enterprise.inject.spi.ProcessInjectionPoint; +import jakarta.enterprise.inject.spi.ProducerFactory; + +/** + * A CDI extension to add gRPC client functionality. + */ +public class GrpcClientCdiExtension implements Extension { + + private final Set proxyTypes = new HashSet<>(); + + /** + * Adds beans to the bean manager. + * + * @param event before bean discovery event + */ + public void addBeans(@Observes BeforeBeanDiscovery event) { + event.addAnnotatedType(ChannelProducer.class, ChannelProducer.class.getName()); + } + + /** + * Process injection points. + *

+ * In this method injection points that have the {@link io.helidon.grpc.api.Grpc.GrpcProxy} are processed + * and their types are stored so that in the {@link #afterBean( + *jakarta.enterprise.inject.spi.AfterBeanDiscovery, jakarta.enterprise.inject.spi.BeanManager)} + * we can manually create a producer for the correct service proxy type. + * + * @param pip the injection point + * @param the declared type of the injection point. + * @param the bean class of the bean that declares the injection point + */ + public void gatherApplications(@Observes ProcessInjectionPoint pip) { + Annotated annotated = pip.getInjectionPoint().getAnnotated(); + if (annotated.isAnnotationPresent(Grpc.GrpcProxy.class)) { + Type type = pip.getInjectionPoint().getType(); + proxyTypes.add(type); + } + } + + /** + * Process the previously captured {@link io.helidon.grpc.api.Grpc.GrpcProxy} injection points. + *

+ * For each {@link io.helidon.grpc.api.Grpc.GrpcProxy} injection point we create a producer bean + * for the required type. + * + * @param event the {@link jakarta.enterprise.inject.spi.AfterBeanDiscovery} event + * @param beanManager the CDI bean manager + */ + public void afterBean(@Observes AfterBeanDiscovery event, BeanManager beanManager) { + AnnotatedType producerType = beanManager.createAnnotatedType(GrpcProxyProducer.class); + AnnotatedMethod producerMethod = producerType.getMethods() + .stream() + .filter(m -> m.isAnnotationPresent(Grpc.GrpcProxy.class)) + .filter(m -> m.isAnnotationPresent(Grpc.GrpcChannel.class)) + .findFirst() + .orElse(null); + if (producerMethod != null) { + for (Type type : proxyTypes) { + addProducerBean(event, beanManager, producerMethod, type); + } + } + } + + private void addProducerBean(AfterBeanDiscovery event, + BeanManager beanManager, + AnnotatedMethod producerMethod, + Type type) { + BeanAttributes producerAttributes = beanManager.createBeanAttributes(producerMethod); + ProducerFactory factory = beanManager.getProducerFactory(producerMethod, null); + Set types = Set.of(Object.class, type); + BeanAttributes beanAttributes = DelegatingBeanAttributes.create(producerAttributes, types); + event.addBean(beanManager.createBean(beanAttributes, GrpcProxyProducer.class, factory)); + } +} diff --git a/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcConfigurablePort.java b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcConfigurablePort.java new file mode 100644 index 00000000000..9742daf7bac --- /dev/null +++ b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcConfigurablePort.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.microprofile.grpc.client; + +/** + * Interface implemented by all gRPC client proxies. The method {@link #channelPort} can be + * called at runtime to override the client URI port from config. Typically used for testing. + */ +public interface GrpcConfigurablePort { + + /** + * Name of single setter method on this interface. + */ + String CHANNEL_PORT = "channelPort"; + + /** + * Overrides client URI port. + * + * @param value the new port value + */ + void channelPort(int value); +} diff --git a/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcProxyBuilder.java b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcProxyBuilder.java new file mode 100644 index 00000000000..82d9bea7f00 --- /dev/null +++ b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcProxyBuilder.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2019, 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.microprofile.grpc.client; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import io.helidon.common.Builder; + +import io.grpc.Channel; + +/** + * A builder for gRPC clients dynamic proxies. + * + * @param the type of the interface to be proxied + */ +public class GrpcProxyBuilder implements Builder, T> { + + private static final Map, ClientServiceDescriptor> DESCRIPTORS = new ConcurrentHashMap<>(); + + private final GrpcServiceClient client; + + private final Class type; + + private GrpcProxyBuilder(GrpcServiceClient client, Class type) { + this.client = client; + this.type = type; + } + + /** + * Create a {@code GrpcProxyBuilder} that can build gRPC dynamic proxies + * for a given gRPC service interface. + *

+ * The class passed to this method should be properly annotated with + * {@link io.helidon.grpc.api.Grpc.GrpcService} and + * {@link io.helidon.grpc.api.Grpc.GrpcMethod} annotations + * so that the proxy can properly route calls to the server. + * + * @param channel the {@link io.grpc.Channel} to connect to the server + * @param type the service type + * @param the service type + * @return a {@link io.helidon.microprofile.grpc.client.GrpcProxyBuilder} that can build dynamic proxies + * for the gRPC service + */ + public static GrpcProxyBuilder create(Channel channel, Class type) { + ClientServiceDescriptor descriptor = DESCRIPTORS.computeIfAbsent(type, GrpcProxyBuilder::createDescriptor); + return new GrpcProxyBuilder<>(GrpcServiceClient.builder(channel, descriptor).build(), type); + } + + /** + * Build a gRPC client dynamic proxy of the required type. + * + * @return a gRPC client dynamic proxy + */ + @Override + public T build() { + return client.proxy(type, GrpcConfigurablePort.class); + } + + private static ClientServiceDescriptor createDescriptor(Class type) { + GrpcClientBuilder builder = GrpcClientBuilder.create(type); + ClientServiceDescriptor.Builder descriptorBuilder = builder.build(); + return descriptorBuilder.build(); + } +} diff --git a/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcProxyProducer.java b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcProxyProducer.java new file mode 100644 index 00000000000..20fc3f9d5fa --- /dev/null +++ b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcProxyProducer.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2019, 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.microprofile.grpc.client; + +import io.helidon.grpc.api.Grpc; +import io.helidon.microprofile.grpc.core.ModelHelper; + +import io.grpc.Channel; +import jakarta.enterprise.inject.spi.InjectionPoint; + +/** + * A utility class of gRPC CDI producer stubs. + *

+ * The methods in this class are not real CDI producer methods, + * they act as templates that the {@link io.helidon.microprofile.grpc.client.GrpcClientCdiExtension} + * will use to create producers on the fly as injection points + * are observed. + */ +class GrpcProxyProducer { + + private GrpcProxyProducer() { + } + + /** + * A CDI producer method that produces a client proxy for a gRPC service that + * will connect to the server using the channel specified via + * {@link io.helidon.grpc.api.Grpc.GrpcChannel} annotation on the proxy interface + * or injection point, or the default {@link io.grpc.Channel}. + *

+ * This is not a real producer method but is used as a stub by the gRPC client + * CDI extension to create real producers as injection points are discovered. + * + * @param injectionPoint the injection point where the client proxy is to be injected + * @return a gRPC client proxy + */ + @Grpc.GrpcProxy + @Grpc.GrpcChannel(GrpcChannelsProvider.DEFAULT_CHANNEL_NAME) + static Object proxyUsingNamedChannel(InjectionPoint injectionPoint, ChannelProducer producer) { + Class type = ModelHelper.getGenericType(injectionPoint.getType()); + + String channelName; + if (injectionPoint.getAnnotated().isAnnotationPresent(Grpc.GrpcChannel.class)) { + channelName = injectionPoint.getAnnotated().getAnnotation(Grpc.GrpcChannel.class).value(); + } else { + channelName = type.isAnnotationPresent(Grpc.GrpcChannel.class) + ? type.getAnnotation(Grpc.GrpcChannel.class).value() + : GrpcChannelsProvider.DEFAULT_CHANNEL_NAME; + } + + Channel channel = producer.findChannel(channelName); + GrpcProxyBuilder builder = GrpcProxyBuilder.create(channel, type); + + return builder.build(); + } +} diff --git a/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcServiceClient.java b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcServiceClient.java new file mode 100644 index 00000000000..022a2293a36 --- /dev/null +++ b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/GrpcServiceClient.java @@ -0,0 +1,483 @@ +/* + * Copyright (c) 2019, 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.microprofile.grpc.client; + +import java.lang.reflect.Proxy; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +import io.helidon.grpc.core.InterceptorWeights; +import io.helidon.grpc.core.MethodHandler; +import io.helidon.grpc.core.WeightedBag; +import io.helidon.webclient.api.ClientUri; + +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientInterceptor; +import io.grpc.MethodDescriptor.MethodType; +import io.grpc.Status; +import io.grpc.stub.AbstractStub; +import io.grpc.stub.ClientCalls; +import io.grpc.stub.StreamObserver; + +/** + * A gRPC Client for a specific gRPC service. + */ +public class GrpcServiceClient { + + private final Channel channel; + + private final HashMap> methodStubs; + + private final ClientServiceDescriptor clientServiceDescriptor; + + /** + * Creates a {@link Builder}. + * + * @param channel the {@link Channel} to use to connect to the server + * @param descriptor the {@link ClientServiceDescriptor} describing the gRPC service + * + * @return a new instance of {@link Builder} + */ + public static Builder builder(Channel channel, ClientServiceDescriptor descriptor) { + return new Builder(channel, descriptor); + } + + /** + * Creates a {@link GrpcServiceClient}. + * + * @param channel the {@link Channel} to use to connect to the server + * @param descriptor the {@link ClientServiceDescriptor} describing the gRPC service + * + * @return a new instance of {@link Builder} + */ + public static GrpcServiceClient create(Channel channel, ClientServiceDescriptor descriptor) { + return builder(channel, descriptor).build(); + } + + private GrpcServiceClient(Channel channel, + CallOptions callOptions, + ClientServiceDescriptor clientServiceDescriptor) { + this.channel = channel; + this.clientServiceDescriptor = clientServiceDescriptor; + this.methodStubs = new HashMap<>(); + + // Merge Interceptors specified in Channel, ClientServiceDescriptor and ClientMethodDescriptor. + // Add the merged interceptor list to the AbstractStub which will be be used for the invocation + // of the method. + for (ClientMethodDescriptor methodDescriptor : clientServiceDescriptor.methods()) { + GrpcMethodStub methodStub = new GrpcMethodStub<>(channel, callOptions, methodDescriptor); + + WeightedBag priorityInterceptors = WeightedBag.create(InterceptorWeights.USER); + priorityInterceptors.addAll(clientServiceDescriptor.interceptors()); + priorityInterceptors.addAll(methodDescriptor.interceptors()); + List interceptors = priorityInterceptors.stream().toList(); + + if (!interceptors.isEmpty()) { + LinkedHashSet uniqueInterceptors = new LinkedHashSet<>(interceptors.size()); + + // iterate the interceptors in reverse order so that the interceptor chain is in the correct order + for (int i = interceptors.size() - 1; i >= 0; i--) { + ClientInterceptor interceptor = interceptors.get(i); + if (!uniqueInterceptors.contains(interceptor)) { + uniqueInterceptors.add(interceptor); + } + } + + for (ClientInterceptor interceptor : uniqueInterceptors) { + methodStub = methodStub.withInterceptors(interceptor); + } + } + + if (methodDescriptor.callCredentials() != null) { + // Method level CallCredentials take precedence over service level CallCredentials. + methodStub = methodStub.withCallCredentials(methodDescriptor.callCredentials()); + } else if (clientServiceDescriptor.callCredentials() != null) { + methodStub = methodStub.withCallCredentials(clientServiceDescriptor.callCredentials()); + } + + methodStubs.put(methodDescriptor.name(), methodStub); + } + } + + /** + * Obtain the service name. + * + * @return The name of the service + */ + public String serviceName() { + return clientServiceDescriptor.name(); + } + + /** + * Invoke the specified method using the method's + * {@link MethodHandler}. + * + * @param name the name of the method to invoke + * @param args the method arguments + * @return the method response + */ + Object invoke(String name, Object[] args) { + GrpcMethodStub stub = methodStubs.get(name); + if (stub == null) { + // may be used during testing to override a channel's port + if (name.equals(GrpcConfigurablePort.CHANNEL_PORT)) { + io.helidon.webclient.grpc.GrpcChannel grpcChannel = (io.helidon.webclient.grpc.GrpcChannel) channel; + ClientUri uri = grpcChannel.grpcClient().clientConfig().baseUri().orElseThrow(); + uri.port((int) args[0]); + return null; + } + throw Status.INTERNAL.withDescription("gRPC method '" + name + "' does not exist").asRuntimeException(); + } + ClientMethodDescriptor descriptor = stub.descriptor(); + MethodHandler methodHandler = descriptor.methodHandler(); + + return switch (descriptor.descriptor().getType()) { + case UNARY -> methodHandler.unary(args, this::unary); + case CLIENT_STREAMING -> methodHandler.clientStreaming(args, this::clientStreaming); + case SERVER_STREAMING -> methodHandler.serverStreaming(args, this::serverStreaming); + case BIDI_STREAMING -> methodHandler.bidirectional(args, this::bidiStreaming); + default -> throw Status.INTERNAL.withDescription("Unknown or unsupported method type for method " + name) + .asRuntimeException(); + }; + } + + /** + * Create a dynamic proxy for the specified interface that proxies + * calls to the wrapped gRPC service. + * + * @param type the interface to create a proxy for + * @param extraTypes extra types for the proxy to implement + * @param the type of the returned proxy + * @return a dynamic proxy that calls methods on this gRPC service + */ + @SuppressWarnings("unchecked") + public T proxy(Class type, Class... extraTypes) { + Map names = new HashMap<>(); + names.put(GrpcConfigurablePort.CHANNEL_PORT, GrpcConfigurablePort.CHANNEL_PORT); // for testing + for (ClientMethodDescriptor methodDescriptor : clientServiceDescriptor.methods()) { + MethodHandler methodHandler = methodDescriptor.methodHandler(); + if (methodHandler != null) { + names.put(methodHandler.javaMethodName(), methodDescriptor.name()); + } + } + + Class[] proxyTypes; + if (extraTypes == null || extraTypes.length == 0) { + proxyTypes = new Class[] {type}; + } else { + proxyTypes = new Class[extraTypes.length + 1]; + proxyTypes[0] = type; + System.arraycopy(extraTypes, 0, proxyTypes, 1, extraTypes.length); + } + return (T) Proxy.newProxyInstance(type.getClassLoader(), proxyTypes, ClientProxy.create(this, names)); + } + + /** + * Invoke the specified unary method with the specified request object. + * + * @param methodName the method name to be invoked + * @param request the request parameter + * @param the request type + * @param the response type + * + * @return The result of this invocation + */ + public RespT blockingUnary(String methodName, ReqT request) { + GrpcMethodStub stub = ensureMethod(methodName, MethodType.UNARY); + return ClientCalls.blockingUnaryCall( + stub.getChannel(), stub.descriptor().descriptor(), stub.getCallOptions(), request); + } + + /** + * Asynchronously invoke the specified unary method. + * + * @param methodName the method name to be invoked + * @param request the request parameter + * @param the request type + * @param the response type + * + * @return A {@link CompletionStage} that will complete with the result of the unary method call + */ + public CompletionStage unary(String methodName, ReqT request) { + SingleValueStreamObserver observer = new SingleValueStreamObserver<>(); + + GrpcMethodStub stub = ensureMethod(methodName, MethodType.UNARY); + ClientCalls.asyncUnaryCall( + stub.getChannel().newCall(stub.descriptor().descriptor(), stub.getCallOptions()), + request, + observer); + + return observer.completionStage(); + } + + /** + * Invoke the specified unary method. + * + * @param methodName the method name to be invoked + * @param request the request parameter + * @param observer a {@link StreamObserver} to receive the result + * @param the request type + * @param the response type + */ + public void unary(String methodName, ReqT request, StreamObserver observer) { + GrpcMethodStub stub = ensureMethod(methodName, MethodType.UNARY); + ClientCalls.asyncUnaryCall( + stub.getChannel().newCall(stub.descriptor().descriptor(), stub.getCallOptions()), + request, + observer); + } + + /** + * Invoke the specified server streaming method. + * + * @param methodName the method name to be invoked + * @param request the request parameter + * @param the request type + * @param the response type + * + * @return an {@link Iterator} to obtain the streamed results + */ + public Iterator blockingServerStreaming(String methodName, ReqT request) { + GrpcMethodStub stub = ensureMethod(methodName, MethodType.SERVER_STREAMING); + return ClientCalls.blockingServerStreamingCall( + stub.getChannel().newCall(stub.descriptor().descriptor(), stub.getCallOptions()), + request); + } + + /** + * Invoke the specified server streaming method. + * + * @param methodName the method name to be invoked + * @param request the request parameter + * @param observer a {@link StreamObserver} to receive the results + * @param the request type + * @param the response type + */ + public void serverStreaming(String methodName, ReqT request, StreamObserver observer) { + GrpcMethodStub stub = ensureMethod(methodName, MethodType.SERVER_STREAMING); + ClientCalls.asyncServerStreamingCall( + stub.getChannel().newCall(stub.descriptor().descriptor(), stub.getCallOptions()), + request, + observer); + } + + /** + * Invoke the specified client streaming method. + * + * @param methodName the method name to be invoked + * @param items an {@link Iterable} of items to be streamed to the server + * @param the request type + * @param the response type + * @return A {@link StreamObserver} to retrieve the method call result + */ + public CompletionStage clientStreaming(String methodName, Iterable items) { + return clientStreaming(methodName, StreamSupport.stream(items.spliterator(), false)); + } + + /** + * Invoke the specified client streaming method. + * + * @param methodName the method name to be invoked + * @param items a {@link Stream} of items to be streamed to the server + * @param the request type + * @param the response type + * @return A {@link StreamObserver} to retrieve the method call result + */ + public CompletionStage clientStreaming(String methodName, Stream items) { + SingleValueStreamObserver obsv = new SingleValueStreamObserver<>(); + GrpcMethodStub stub = ensureMethod(methodName, MethodType.CLIENT_STREAMING); + StreamObserver reqStream = ClientCalls.asyncClientStreamingCall( + stub.getChannel().newCall(stub.descriptor().descriptor(), stub.getCallOptions()), + obsv); + + items.forEach(reqStream::onNext); + reqStream.onCompleted(); + + return obsv.completionStage(); + } + + /** + * Invoke the specified client streaming method. + * + * @param methodName the method name to be invoked + * @param observer a {@link StreamObserver} to receive the result + * @param the request type + * @param the response type + * @return a {@link StreamObserver} to use to stream requests to the server + */ + public StreamObserver clientStreaming(String methodName, StreamObserver observer) { + GrpcMethodStub stub = ensureMethod(methodName, MethodType.CLIENT_STREAMING); + return ClientCalls.asyncClientStreamingCall( + stub.getChannel().newCall(stub.descriptor().descriptor(), stub.getCallOptions()), + observer); + } + + /** + * Invoke the specified bidirectional streaming method. + * + * @param methodName the method name to be invoked. + * @param observer a {@link StreamObserver} to receive the result + * @param the request type + * @param the response type + * @return A {@link StreamObserver} to use to stream requests to the server + */ + public StreamObserver bidiStreaming(String methodName, StreamObserver observer) { + GrpcMethodStub stub = ensureMethod(methodName, MethodType.BIDI_STREAMING); + return ClientCalls.asyncBidiStreamingCall( + stub.getChannel().newCall(stub.descriptor().descriptor(), stub.getCallOptions()), + observer); + } + + @SuppressWarnings("unchecked") + private GrpcMethodStub ensureMethod(String methodName, MethodType methodType) { + GrpcMethodStub stub = (GrpcMethodStub) methodStubs.get(methodName); + if (stub == null) { + throw new IllegalArgumentException("No method named " + methodName + " registered with this service"); + } + ClientMethodDescriptor cmd = stub.descriptor(); + if (cmd.descriptor().getType() != methodType) { + throw new IllegalArgumentException("Method (" + methodName + ") already registered with a different method type."); + } + + return stub; + } + + /** + * GrpcMethodStub can be used to configure method specific Interceptors, Metrics, Tracing, Deadlines, etc. + */ + private static class GrpcMethodStub + extends AbstractStub> { + + private final ClientMethodDescriptor cmd; + + GrpcMethodStub(Channel channel, CallOptions callOptions, ClientMethodDescriptor cmd) { + super(channel, callOptions); + this.cmd = cmd; + } + + @Override + protected GrpcMethodStub build(Channel channel, CallOptions callOptions) { + return new GrpcMethodStub<>(channel, callOptions, cmd); + } + + public ClientMethodDescriptor descriptor() { + return cmd; + } + } + + /** + * Builder to build an instance of {@link GrpcServiceClient}. + */ + public static class Builder { + + private final Channel channel; + + private CallOptions callOptions = CallOptions.DEFAULT; + + private final ClientServiceDescriptor clientServiceDescriptor; + + private Builder(Channel channel, ClientServiceDescriptor descriptor) { + this.channel = channel; + this.clientServiceDescriptor = descriptor; + } + + /** + * Set the {@link CallOptions} to use. + * + * @param callOptions the {@link CallOptions} to use + * @return This {@link Builder} for fluent method chaining + */ + public Builder callOptions(CallOptions callOptions) { + this.callOptions = callOptions; + return this; + } + + /** + * Build an instance of {@link GrpcServiceClient}. + * + * @return an new instance of a {@link GrpcServiceClient} + */ + public GrpcServiceClient build() { + return new GrpcServiceClient(channel, callOptions, clientServiceDescriptor); + } + } + + /** + * A simple {@link StreamObserver} adapter class that completes + * a {@link CompletableFuture} when the observer is completed. + *

+ * This observer uses the value passed to its {@link #onNext(Object)} method to complete + * the {@link CompletableFuture}. + *

+ * This observer should only be used in cases where a single result is expected. If more + * that one call is made to {@link #onNext(Object)} then future will be completed with + * an exception. + * + * @param The type of objects received in this stream. + */ + public static class SingleValueStreamObserver implements StreamObserver { + + private int count; + + private T result; + + private final CompletableFuture resultFuture = new CompletableFuture<>(); + + /** + * Create a SingleValueStreamObserver. + */ + public SingleValueStreamObserver() { + } + + /** + * Obtain the {@link CompletableFuture} that will be completed + * when the {@link StreamObserver} completes. + * + * @return The CompletableFuture + */ + public CompletionStage completionStage() { + return resultFuture; + } + + @Override + public void onNext(T value) { + if (count++ == 0) { + result = value; + } else { + resultFuture.completeExceptionally(new IllegalStateException("More than one result received.")); + } + } + + @Override + public void onError(Throwable t) { + resultFuture.completeExceptionally(t); + } + + @Override + public void onCompleted() { + resultFuture.complete(result); + } + } +} diff --git a/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/package-info.java b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/package-info.java new file mode 100644 index 00000000000..a3232b59001 --- /dev/null +++ b/microprofile/grpc/client/src/main/java/io/helidon/microprofile/grpc/client/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Client gRPC microprofile classes. + */ +package io.helidon.microprofile.grpc.client; diff --git a/microprofile/grpc/client/src/main/java/module-info.java b/microprofile/grpc/client/src/main/java/module-info.java new file mode 100644 index 00000000000..b4fa484d1e3 --- /dev/null +++ b/microprofile/grpc/client/src/main/java/module-info.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * gRPC microprofile client module. + */ +module io.helidon.microprofile.grpc.client { + + requires io.helidon.common; + requires io.helidon.common.tls; + requires io.helidon.config; + requires io.helidon.webclient.grpc; + requires io.helidon.grpc.api; + requires io.helidon.microprofile.grpc.core; + + requires io.grpc; + requires jakarta.cdi; + requires jakarta.inject; + + requires transitive io.helidon.grpc.core; + + exports io.helidon.microprofile.grpc.client; + + provides jakarta.enterprise.inject.spi.Extension with + io.helidon.microprofile.grpc.client.GrpcClientCdiExtension; + +} \ No newline at end of file diff --git a/microprofile/grpc/client/src/test/java/io/helidon/microprofile/grpc/client/ClientMethodDescriptorTest.java b/microprofile/grpc/client/src/test/java/io/helidon/microprofile/grpc/client/ClientMethodDescriptorTest.java new file mode 100644 index 00000000000..fc4b49190d8 --- /dev/null +++ b/microprofile/grpc/client/src/test/java/io/helidon/microprofile/grpc/client/ClientMethodDescriptorTest.java @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2019, 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.microprofile.grpc.client; + +import io.helidon.microprofile.grpc.client.test.Echo; +import io.helidon.microprofile.grpc.client.test.EchoServiceGrpc; + +import io.grpc.MethodDescriptor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.collection.IsEmptyIterable.emptyIterable; + +public class ClientMethodDescriptorTest { + + private MethodDescriptor.Builder grpcDescriptor; + + @BeforeEach + public void setup() { + grpcDescriptor = EchoServiceGrpc.getServiceDescriptor() + .getMethods() + .stream() + .filter(md -> md.getFullMethodName().equals("EchoService/Echo")) + .findFirst() + .orElseThrow(() -> new AssertionError("Could not find echo method")) + .toBuilder(); + } + + @Test + public void shouldCreateMethodDescriptorFromGrpcDescriptor() { + ClientMethodDescriptor descriptor = ClientMethodDescriptor.create("FooService", + "foo", + grpcDescriptor); + + assertThat(descriptor, is(notNullValue())); + assertThat(descriptor.name(), is("foo")); + assertThat(descriptor.interceptors(), is(emptyIterable())); + + MethodDescriptor expected = grpcDescriptor.build(); + MethodDescriptor methodDescriptor = descriptor.descriptor(); + assertThat(methodDescriptor.getFullMethodName(), is("FooService/foo")); + assertThat(methodDescriptor.getType(), is(expected.getType())); + assertThat(methodDescriptor.getRequestMarshaller(), is(expected.getRequestMarshaller())); + assertThat(methodDescriptor.getResponseMarshaller(), is(expected.getResponseMarshaller())); + } + + @Test + public void shouldCreateBidirectionalMethod() { + ClientMethodDescriptor descriptor = ClientMethodDescriptor.bidirectional("FooService", "foo") + .defaultMarshallerSupplier(new JavaMarshaller.Supplier()) + .build(); + assertThat(descriptor, is(notNullValue())); + assertThat(descriptor.name(), is("foo")); + MethodDescriptor methodDescriptor = descriptor.descriptor(); + assertThat(methodDescriptor.getFullMethodName(), is("FooService/foo")); + assertThat(methodDescriptor.getType(), is(MethodDescriptor.MethodType.BIDI_STREAMING)); + } + + @Test + public void shouldCreateClientStreamingMethod() { + ClientMethodDescriptor descriptor = ClientMethodDescriptor.clientStreaming("FooService", "foo") + .defaultMarshallerSupplier(new JavaMarshaller.Supplier()) + .build(); + assertThat(descriptor, is(notNullValue())); + assertThat(descriptor.name(), is("foo")); + MethodDescriptor methodDescriptor = descriptor.descriptor(); + assertThat(methodDescriptor.getFullMethodName(), is("FooService/foo")); + assertThat(methodDescriptor.getType(), is(MethodDescriptor.MethodType.CLIENT_STREAMING)); + } + + @Test + public void shouldCreateServerStreamingMethod() { + ClientMethodDescriptor descriptor = ClientMethodDescriptor.serverStreaming("FooService", "foo") + .defaultMarshallerSupplier(new JavaMarshaller.Supplier()) + .build(); + assertThat(descriptor, is(notNullValue())); + assertThat(descriptor.name(), is("foo")); + MethodDescriptor methodDescriptor = descriptor.descriptor(); + assertThat(methodDescriptor.getFullMethodName(), is("FooService/foo")); + assertThat(methodDescriptor.getType(), is(MethodDescriptor.MethodType.SERVER_STREAMING)); + } + + @Test + public void shouldCreateUnaryMethod() { + ClientMethodDescriptor descriptor = ClientMethodDescriptor.unary("FooService", "foo") + .defaultMarshallerSupplier(new JavaMarshaller.Supplier()) + .build(); + assertThat(descriptor, is(notNullValue())); + assertThat(descriptor.name(), is("foo")); + MethodDescriptor methodDescriptor = descriptor.descriptor(); + assertThat(methodDescriptor.getFullMethodName(), is("FooService/foo")); + assertThat(methodDescriptor.getType(), is(MethodDescriptor.MethodType.UNARY)); + } + + @Test + public void shouldSetName() { + ClientMethodDescriptor.Builder builder = ClientMethodDescriptor + .unary("FooService", "foo") + .defaultMarshallerSupplier(new JavaMarshaller.Supplier()); + + builder.fullName("Foo/Bar"); + + ClientMethodDescriptor descriptor = builder.build(); + + assertThat(descriptor.name(), is("Bar")); + assertThat(descriptor.descriptor().getFullMethodName(), is("Foo/Bar")); + } + + @Test + public void testMarshallerTypesForProtoBuilder() { + ClientMethodDescriptor descriptor = ClientMethodDescriptor + .unary("EchoService", "Echo") + .requestType(Echo.EchoRequest.class) + .responseType(Echo.EchoResponse.class) + .build(); + + MethodDescriptor methodDescriptor = descriptor.descriptor(); + assertThat(methodDescriptor.getRequestMarshaller(), instanceOf(MethodDescriptor.PrototypeMarshaller.class)); + assertThat(methodDescriptor.getResponseMarshaller(), instanceOf(MethodDescriptor.PrototypeMarshaller.class)); + } +} diff --git a/microprofile/grpc/client/src/test/java/io/helidon/microprofile/grpc/client/ClientServiceDescriptorTest.java b/microprofile/grpc/client/src/test/java/io/helidon/microprofile/grpc/client/ClientServiceDescriptorTest.java new file mode 100644 index 00000000000..7d945d45fd3 --- /dev/null +++ b/microprofile/grpc/client/src/test/java/io/helidon/microprofile/grpc/client/ClientServiceDescriptorTest.java @@ -0,0 +1,292 @@ +/* + * Copyright (c) 2019, 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.microprofile.grpc.client; + +import java.util.Collection; + +import io.helidon.microprofile.grpc.client.test.StringServiceGrpc; + +import io.grpc.ClientInterceptor; +import io.grpc.MethodDescriptor; +import io.grpc.ServerMethodDefinition; +import io.grpc.ServiceDescriptor; +import org.junit.jupiter.api.Test; +import services.TreeMapService; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.collection.IsEmptyIterable.emptyIterable; +import static org.hamcrest.collection.IsIterableContainingInOrder.contains; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; + +public class ClientServiceDescriptorTest { + + @Test + public void shouldCreateDescriptorFromGrpcServiceDescriptor() { + ServiceDescriptor grpcDescriptor = StringServiceGrpc.getServiceDescriptor(); + ClientServiceDescriptor descriptor = ClientServiceDescriptor.create(grpcDescriptor); + String serviceName = "StringService"; + assertThat(descriptor.name(), is(serviceName)); + assertThat(descriptor.interceptors(), is(emptyIterable())); + + Collection> expectedMethods = grpcDescriptor.getMethods(); + Collection actualMethods = descriptor.methods(); + assertThat(actualMethods.size(), is(expectedMethods.size())); + + for (MethodDescriptor methodDescriptor : expectedMethods) { + String name = methodDescriptor.getFullMethodName().substring(serviceName.length() + 1); + ClientMethodDescriptor method = descriptor.method(name); + assertThat(method.name(), is(name)); + assertThat(method.interceptors(), is(emptyIterable())); + MethodDescriptor actualDescriptor = method.descriptor(); + assertThat(actualDescriptor.getType(), is(methodDescriptor.getType())); + } + } + + @Test + public void shouldCreateDescriptorFromBindableService() { + StringServiceBindableService bindableService = new StringServiceBindableService(); + ClientServiceDescriptor descriptor = ClientServiceDescriptor.create(bindableService); + String serviceName = "StringService"; + assertThat(descriptor.name(), is(serviceName)); + assertThat(descriptor.interceptors(), is(emptyIterable())); + + Collection> expectedMethods = bindableService.bindService().getMethods(); + Collection actualMethods = descriptor.methods(); + assertThat(actualMethods.size(), is(expectedMethods.size())); + + for (ServerMethodDefinition expectedMethod : expectedMethods) { + MethodDescriptor methodDescriptor = expectedMethod.getMethodDescriptor(); + String name = methodDescriptor.getFullMethodName().substring(serviceName.length() + 1); + ClientMethodDescriptor method = descriptor.method(name); + assertThat(method.name(), is(name)); + assertThat(method.interceptors(), is(emptyIterable())); + MethodDescriptor actualDescriptor = method.descriptor(); + assertThat(actualDescriptor.getType(), is(methodDescriptor.getType())); + } + } + + @Test + public void testServiceName() { + ClientServiceDescriptor.Builder builder = ClientServiceDescriptor.builder("TreeMapService", + TreeMapService.class); + assertThat(builder.name(), is("TreeMapService")); + + ClientServiceDescriptor descriptor = builder.build(); + assertThat(descriptor.name(), is("TreeMapService")); + } + + @Test + public void testDefaultMethodCount() { + ClientServiceDescriptor svcDesc = newClientServiceDescriptorBuilder(TreeMapService.class).build(); + assertThat(svcDesc.methods().size(), equalTo(0)); + } + + @Test + public void shouldNotAllowNullName() { + ClientServiceDescriptor.Builder builder = newClientServiceDescriptorBuilder(TreeMapService.class); + + assertThrows(NullPointerException.class, () -> builder.name(null)); + } + + @Test + public void shouldNotAllowEmptyStringName() { + ClientServiceDescriptor.Builder builder = newClientServiceDescriptorBuilder(TreeMapService.class); + + assertThrows(IllegalArgumentException.class, () -> builder.name("")); + } + + @Test + public void shouldNotAllowBlankName() { + ClientServiceDescriptor.Builder builder = newClientServiceDescriptorBuilder(TreeMapService.class); + + assertThrows(IllegalArgumentException.class, () -> builder.name(" \t ")); + } + + @Test + public void shouldAddBidirectionalMethod() { + ClientServiceDescriptor descriptor = newClientServiceDescriptorBuilder(TreeMapService.class) + .bidirectional("foo") + .build(); + + ClientMethodDescriptor method = descriptor.method("foo"); + assertThat(method, is(notNullValue())); + MethodDescriptor methodDescriptor = method.descriptor(); + assertThat(methodDescriptor.getType(), is(MethodDescriptor.MethodType.BIDI_STREAMING)); + assertThat(methodDescriptor.getFullMethodName(), is("TreeMapService/foo")); + } + + @Test + public void shouldAddBidirectionalMethodWithConfigurer() { + ClientInterceptor interceptor = mock(ClientInterceptor.class); + ClientServiceDescriptor descriptor = newClientServiceDescriptorBuilder(TreeMapService.class) + .bidirectional("foo", cfg -> cfg.intercept(interceptor)) + .build(); + + ClientMethodDescriptor method = descriptor.method("foo"); + assertThat(method, is(notNullValue())); + assertThat(method.interceptors(), contains(interceptor)); + MethodDescriptor methodDescriptor = method.descriptor(); + assertThat(methodDescriptor.getType(), is(MethodDescriptor.MethodType.BIDI_STREAMING)); + assertThat(methodDescriptor.getFullMethodName(), is("TreeMapService/foo")); + } + + @Test + public void shouldAddClientStreamingMethod() { + ClientServiceDescriptor descriptor = newClientServiceDescriptorBuilder(TreeMapService.class) + .clientStreaming("foo") + .build(); + + ClientMethodDescriptor method = descriptor.method("foo"); + assertThat(method, is(notNullValue())); + MethodDescriptor methodDescriptor = method.descriptor(); + assertThat(methodDescriptor.getType(), is(MethodDescriptor.MethodType.CLIENT_STREAMING)); + assertThat(methodDescriptor.getFullMethodName(), is("TreeMapService/foo")); + } + + @Test + public void shouldAddClientStreamingMethodWithConfigurer() { + ClientInterceptor interceptor = mock(ClientInterceptor.class); + ClientServiceDescriptor descriptor = newClientServiceDescriptorBuilder(TreeMapService.class) + .clientStreaming("foo", cfg -> cfg.intercept(interceptor)) + .build(); + + ClientMethodDescriptor method = descriptor.method("foo"); + assertThat(method, is(notNullValue())); + assertThat(method.interceptors(), contains(interceptor)); + MethodDescriptor methodDescriptor = method.descriptor(); + assertThat(methodDescriptor.getType(), is(MethodDescriptor.MethodType.CLIENT_STREAMING)); + assertThat(methodDescriptor.getFullMethodName(), is("TreeMapService/foo")); + } + + @Test + public void shouldAddServerStreamingMethod() { + ClientServiceDescriptor descriptor = newClientServiceDescriptorBuilder(TreeMapService.class) + .serverStreaming("foo") + .build(); + + ClientMethodDescriptor method = descriptor.method("foo"); + assertThat(method, is(notNullValue())); + MethodDescriptor methodDescriptor = method.descriptor(); + assertThat(methodDescriptor.getType(), is(MethodDescriptor.MethodType.SERVER_STREAMING)); + assertThat(methodDescriptor.getFullMethodName(), is("TreeMapService/foo")); + } + + @Test + public void shouldAddServerStreamingMethodWithConfigurer() { + ClientInterceptor interceptor = mock(ClientInterceptor.class); + ClientServiceDescriptor descriptor = newClientServiceDescriptorBuilder(TreeMapService.class) + .serverStreaming("foo", cfg -> cfg.intercept(interceptor)) + .build(); + + ClientMethodDescriptor method = descriptor.method("foo"); + assertThat(method, is(notNullValue())); + assertThat(method.interceptors(), contains(interceptor)); + MethodDescriptor methodDescriptor = method.descriptor(); + assertThat(methodDescriptor.getType(), is(MethodDescriptor.MethodType.SERVER_STREAMING)); + assertThat(methodDescriptor.getFullMethodName(), is("TreeMapService/foo")); + } + + @Test + public void shouldAddUnaryMethod() { + ClientServiceDescriptor descriptor = newClientServiceDescriptorBuilder(TreeMapService.class) + .unary("foo") + .build(); + + ClientMethodDescriptor method = descriptor.method("foo"); + assertThat(method, is(notNullValue())); + MethodDescriptor methodDescriptor = method.descriptor(); + assertThat(methodDescriptor.getType(), is(MethodDescriptor.MethodType.UNARY)); + assertThat(methodDescriptor.getFullMethodName(), is("TreeMapService/foo")); + } + + @Test + public void shouldAddUnaryMethodWithConfigurer() { + ClientInterceptor interceptor = mock(ClientInterceptor.class); + ClientServiceDescriptor descriptor = newClientServiceDescriptorBuilder(TreeMapService.class) + .unary("foo", cfg -> cfg.intercept(interceptor)) + .build(); + + ClientMethodDescriptor method = descriptor.method("foo"); + assertThat(method, is(notNullValue())); + assertThat(method.interceptors(), contains(interceptor)); + MethodDescriptor methodDescriptor = method.descriptor(); + assertThat(methodDescriptor.getType(), is(MethodDescriptor.MethodType.UNARY)); + assertThat(methodDescriptor.getFullMethodName(), is("TreeMapService/foo")); + } + + @Test + public void shouldAddInterceptor() { + ClientInterceptor interceptor = mock(ClientInterceptor.class); + ClientServiceDescriptor descriptor = newClientServiceDescriptorBuilder(TreeMapService.class) + .intercept(interceptor) + .build(); + + assertThat(descriptor.interceptors(), contains(interceptor)); + } + + @Test + public void shouldAddInterceptors() { + ClientInterceptor interceptorOne = mock(ClientInterceptor.class); + ClientInterceptor interceptorTwo = mock(ClientInterceptor.class); + ClientInterceptor interceptorThree = mock(ClientInterceptor.class); + ClientServiceDescriptor descriptor = newClientServiceDescriptorBuilder(TreeMapService.class) + .intercept(interceptorOne) + .intercept(interceptorTwo, interceptorThree) + .build(); + + assertThat(descriptor.interceptors(), containsInAnyOrder(interceptorOne, interceptorTwo, interceptorThree)); + } + + @Test + public void shouldAddInterceptorToMethod() { + ClientInterceptor interceptor = mock(ClientInterceptor.class); + ClientServiceDescriptor descriptor = newClientServiceDescriptorBuilder(TreeMapService.class) + .unary("foo") + .intercept("foo", interceptor) + .build(); + + ClientMethodDescriptor method = descriptor.method("foo"); + assertThat(method, is(notNullValue())); + assertThat(method.interceptors(), contains(interceptor)); + } + + @Test + public void shouldSetNameOnMethods() { + ClientServiceDescriptor.Builder builder = newClientServiceDescriptorBuilder(TreeMapService.class); + + ClientServiceDescriptor descriptor = builder.unary("bar") + .name("Foo") + .build(); + + ClientMethodDescriptor method = descriptor.method("bar"); + assertThat(method.descriptor().getFullMethodName(), is("Foo/bar")); + } + + public static class StringServiceBindableService + extends StringServiceGrpc.StringServiceImplBase { + } + + private ClientServiceDescriptor.Builder newClientServiceDescriptorBuilder(Class service) { + return ClientServiceDescriptor.builder(service) + .marshallerSupplier(new JavaMarshaller.Supplier()); + } +} diff --git a/microprofile/grpc/client/src/test/java/io/helidon/microprofile/grpc/client/EchoServiceTest.java b/microprofile/grpc/client/src/test/java/io/helidon/microprofile/grpc/client/EchoServiceTest.java new file mode 100644 index 00000000000..68db09e9b91 --- /dev/null +++ b/microprofile/grpc/client/src/test/java/io/helidon/microprofile/grpc/client/EchoServiceTest.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.microprofile.grpc.client; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import io.helidon.common.configurable.Resource; +import io.helidon.common.tls.Tls; +import io.helidon.grpc.api.Grpc; +import io.helidon.microprofile.grpc.server.GrpcMpCdiExtension; +import io.helidon.microprofile.testing.junit5.AddBean; +import io.helidon.microprofile.testing.junit5.AddExtension; +import io.helidon.microprofile.testing.junit5.HelidonTest; +import io.helidon.webclient.grpc.GrpcClient; + +import io.grpc.stub.StreamObserver; +import jakarta.inject.Inject; +import jakarta.ws.rs.client.WebTarget; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static io.helidon.grpc.core.ResponseHelper.complete; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +@HelidonTest +@AddBean(EchoServiceTest.EchoService.class) +@AddBean(JavaMarshaller.Supplier.class) +@AddExtension(GrpcMpCdiExtension.class) +@AddExtension(GrpcClientCdiExtension.class) +class EchoServiceTest { + + @Inject + private WebTarget webTarget; + + @Inject + @Grpc.GrpcProxy + private EchoServiceClient proxyClient; + + @BeforeEach + void updatePort() { + if (proxyClient instanceof GrpcConfigurablePort client) { + client.channelPort(webTarget.getUri().getPort()); + } + } + + @Test + void testEcho() throws InterruptedException, ExecutionException, TimeoutException { + Tls clientTls = Tls.builder() + .trust(trust -> trust + .keystore(store -> store + .passphrase("password") + .trustStore(true) + .keystore(Resource.create("client.p12")))) + .build(); + GrpcClient grpcClient = GrpcClient.builder() + .tls(clientTls) + .baseUri("https://localhost:" + webTarget.getUri().getPort()) + .build(); + + ClientServiceDescriptor descriptor = ClientServiceDescriptor.builder(EchoService.class) + .name("EchoService") + .marshallerSupplier(new JavaMarshaller.Supplier()) + .unary("Echo") + .build(); + + CompletableFuture future = new CompletableFuture<>(); + GrpcServiceClient client = GrpcServiceClient.create(grpcClient.channel(), descriptor); + StreamObserver observer = new StreamObserver<>() { + @Override + public void onNext(String value) { + future.complete(value); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + } + }; + client.unary("Echo", "Howdy", observer); + assertThat(future.get(5, TimeUnit.SECONDS), is("Howdy")); + } + + @Test + void testEchoInject() throws InterruptedException, ExecutionException, TimeoutException { + CompletableFuture future = new CompletableFuture<>(); + StreamObserver observer = new StreamObserver<>() { + @Override + public void onNext(String value) { + future.complete(value); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + } + }; + proxyClient.echo("Howdy", observer); + assertThat(future.get(5, TimeUnit.SECONDS), is("Howdy")); + } + + @Grpc.GrpcService + @Grpc.GrpcMarshaller("java") + public static class EchoService { + + @Grpc.Unary("Echo") + public void echo(String request, StreamObserver observer) { + try { + complete(observer, request); + } catch (IllegalStateException e) { + observer.onError(e); + } + } + } + + @Grpc.GrpcService("EchoService") + @Grpc.GrpcMarshaller("java") + @Grpc.GrpcChannel("echo-channel") + public interface EchoServiceClient { + + @Grpc.Unary("Echo") + void echo(String request, StreamObserver observer); + } +} diff --git a/microprofile/grpc/client/src/test/java/io/helidon/microprofile/grpc/client/JavaMarshaller.java b/microprofile/grpc/client/src/test/java/io/helidon/microprofile/grpc/client/JavaMarshaller.java new file mode 100644 index 00000000000..de585accde5 --- /dev/null +++ b/microprofile/grpc/client/src/test/java/io/helidon/microprofile/grpc/client/JavaMarshaller.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.microprofile.grpc.client; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; + +import io.helidon.grpc.core.MarshallerSupplier; + +import io.grpc.MethodDescriptor; +import jakarta.enterprise.context.Dependent; +import jakarta.inject.Named; + +/** + * An implementation of a gRPC {@link io.grpc.MethodDescriptor.Marshaller} that + * uses Java serialization for testing. + */ +public class JavaMarshaller implements MethodDescriptor.Marshaller { + + @Override + public InputStream stream(T obj) { + try (ByteArrayOutputStream out = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(out)) { + oos.writeObject(obj); + return new ByteArrayInputStream(out.toByteArray()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + @SuppressWarnings("unchecked") + public T parse(InputStream in) { + try (ObjectInputStream ois = new ObjectInputStream(in)) { + return (T) ois.readObject(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * A {@link io.helidon.grpc.core.MarshallerSupplier} implementation that supplies + * instances of {@link io.helidon.microprofile.grpc.client.JavaMarshaller}. + */ + @Dependent + @Named("java") + public static class Supplier implements MarshallerSupplier { + + @Override + public MethodDescriptor.Marshaller get(Class clazz) { + return new JavaMarshaller<>(); + } + } +} diff --git a/microprofile/grpc/client/src/test/java/services/EchoService.java b/microprofile/grpc/client/src/test/java/services/EchoService.java new file mode 100644 index 00000000000..a930b24555a --- /dev/null +++ b/microprofile/grpc/client/src/test/java/services/EchoService.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2019, 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package services; + +import io.helidon.microprofile.grpc.client.test.Echo; +import io.helidon.webserver.grpc.GrpcService; + +import com.google.protobuf.Descriptors; +import io.grpc.stub.StreamObserver; + +import static io.helidon.grpc.core.ResponseHelper.complete; + +/** + * A simple test gRPC echo service. + */ +public class EchoService implements GrpcService { + + @Override + public Descriptors.FileDescriptor proto() { + return Echo.getDescriptor(); + } + + @Override + public void update(Routing routing) { + routing.unary("Echo", this::echo); + } + + /** + * Echo the message back to the caller. + * + * @param request the echo request containing the message to echo + * @param observer the call response + */ + public void echo(Echo.EchoRequest request, StreamObserver observer) { + String message = request.getMessage(); + Echo.EchoResponse response = Echo.EchoResponse.newBuilder().setMessage(message).build(); + complete(observer, response); + } +} diff --git a/microprofile/grpc/client/src/test/java/services/TreeMapService.java b/microprofile/grpc/client/src/test/java/services/TreeMapService.java new file mode 100644 index 00000000000..973b87f8953 --- /dev/null +++ b/microprofile/grpc/client/src/test/java/services/TreeMapService.java @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2019, 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package services; + +import java.io.Serializable; +import java.util.Arrays; +import java.util.Objects; +import java.util.TreeMap; + +import io.helidon.webserver.grpc.GrpcService; + +import com.google.protobuf.Descriptors; +import io.grpc.stub.StreamObserver; + +import static io.helidon.grpc.core.ResponseHelper.complete; + +/** + * A simple class that implements gRPC service. Used for testing. + */ +public class TreeMapService implements GrpcService { + + /** + * A reference to a {@link services.TreeMapService.Person} named "Bilbo". + */ + public static Person BILBO = new Person(1, "Bilbo", 111, "Male", + new String[] {"Burglaring", "Pipe smoking"}); + + /** + * A reference to a {@link services.TreeMapService.Person} named "Frodo". + */ + public static Person FRODO = new Person(2, "Frodo", 33, "Male", + new String[] {"Long hikes"}); + + /** + * A reference to a {@link services.TreeMapService.Person} named "Aragon". + */ + public static Person ARAGON = new Person(3, "Aragon", 87, "Male", + new String[] {"Pipe smoking", "Hitting on elvish women"}); + + /** + * A reference to a {@link services.TreeMapService.Person} named "Galadriel". + */ + public static Person GALARDRIEL = new Person(4, "Galadriel", 8372, "Female", + new String[] {"Dwarves"}); + + /** + * A reference to a {@link services.TreeMapService.Person} named "Gandalf". + */ + public static Person GANDALF = new Person(5, "Gandalf", 32767, "Male", + new String[] {"Wizardry"}); + + private final TreeMap lorMap = new TreeMap<>(); + + public TreeMapService() { + lorMap.put(1, BILBO); + lorMap.put(2, FRODO); + lorMap.put(3, ARAGON); + lorMap.put(4, GALARDRIEL); + lorMap.put(5, GANDALF); + } + + @Override + public Descriptors.FileDescriptor proto() { + return null; + } + + @Override + public void update(Routing routing) { + // TODO routing.marshallerSupplier(new JavaMarshaller.Supplier()); + routing.unary("get", this::get); + routing.serverStream("greaterOrEqualTo", this::greaterOrEqualTo); + routing.clientStream("sumOfAges", this::sumOfAges); + routing.bidi("persons", this::persons); + } + + /** + * Retrieve the person from the TreeMap. This is a UNARY call. + * + * @param id The id of the person. + * @param observer the call response + */ + public void get(Integer id, StreamObserver observer) { + complete(observer, lorMap.get(id)); + } + + /** + * Return the Persons whose Ids are greater than or equal to the specified key. This is a ServerStreaming call. + * + * @param id The id to use. + * @param observer A {@link io.grpc.stub.StreamObserver} into which {@link services.TreeMapService.Person}s whose ids + * are greater than or equal to the specified id will be emitted. + */ + public void greaterOrEqualTo(Integer id, StreamObserver observer) { + for (Person p : lorMap.tailMap(id).values()) { + observer.onNext(p); + } + observer.onCompleted(); + } + + /** + * Return the sum of ages of all Persons whose Ids are streamed from the client. This is a Client streaming call. + * + * @param observer A {@link io.grpc.stub.StreamObserver} into which the sum of ages of + * all {@link services.TreeMapService.Person}s will be emitted. + * @return A {@link io.grpc.stub.StreamObserver} into which the ids of {@link services.TreeMapService.Person}s + * should be emitted into. + */ + public StreamObserver sumOfAges(StreamObserver observer) { + return new StreamObserver() { + private int sum = 0; + + public void onNext(Integer id) { + System.out.println("Received id: ==> " + id); + Person p = lorMap.get(id); + sum += p != null ? p.age : 0; + } + + public void onError(Throwable t) { + t.printStackTrace(); + } + + public void onCompleted() { + observer.onNext(sum); + observer.onCompleted(); + } + }; + } + + /** + * Streams the {@link services.TreeMapService.Person} into the specified observer for each of the id that is + * streamed (from the client). This is a bi-directional streaming call. + * + * @param observer A {@link io.grpc.stub.StreamObserver} into which the sum of ages of + * all {@link services.TreeMapService.Person}s will be emitted. + * @return A {@link io.grpc.stub.StreamObserver} into which the ids of {@link services.TreeMapService.Person}s + * should be emitted into. + */ + public StreamObserver persons(StreamObserver observer) { + return new StreamObserver() { + public void onNext(Integer id) { + Person p = lorMap.get(id); + if (p != null) { + observer.onNext(p); + } + } + + public void onError(Throwable t) { + t.printStackTrace(); + } + + public void onCompleted() { + observer.onCompleted(); + } + }; + } + + /** + * A person class used in the test code. + */ + public static class Person implements Serializable { + + private final int id; + private final String name; + private final int age; + private final String gender; + private final String[] hobbies; + + /** + * Creates a new Person. + * @param id The id of the person. + * @param name The name of the person. + * @param age The age of the person. + * @param gender The gender of the person. + * @param hobbies The hobbies of the person. + */ + public Person(int id, String name, int age, String gender, String[] hobbies) { + this.id = id; + this.name = name; + this.age = age; + this.gender = gender; + this.hobbies = hobbies; + } + + /** + * Returns the Id of the person. + * @return The id of the person. + */ + public int getId() { + return id; + } + + /** + * Returns the name of the person. + * @return The name of the person. + */ + public String getName() { + return name; + } + + /** + * Returns the gender of the person. + * @return The gender of the person. + */ + public String getGender() { + return gender; + } + + /** + * Returns the hobbies of the person. + * @return The hobbies of the person. + */ + public String[] getHobbies() { + return hobbies; + } + + /** + * Returns the age of the person. + * @return The age of the person. + */ + public int getAge() { + return age; + } + + @Override + public String toString() { + return "Person{" + + "id=" + id + + ", name='" + name + '\'' + + ", age=" + age + + ", gender='" + gender + '\'' + + ", hobbies=" + Arrays.toString(hobbies) + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Person person)) { + return false; + } + return getId() == person.getId() && + getAge() == person.getAge() && + Objects.equals(getName(), person.getName()) && + Objects.equals(getGender(), person.getGender()) && + Arrays.equals(getHobbies(), person.getHobbies()); + } + + @Override + public int hashCode() { + int result = Objects.hash(getId(), getName(), getAge(), getGender()); + result = 31 * result + Arrays.hashCode(getHobbies()); + return result; + } + } +} diff --git a/microprofile/grpc/client/src/test/proto/echo.proto b/microprofile/grpc/client/src/test/proto/echo.proto new file mode 100644 index 00000000000..68911ff5c12 --- /dev/null +++ b/microprofile/grpc/client/src/test/proto/echo.proto @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2019, 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +syntax = "proto3"; +option java_package = "io.helidon.microprofile.grpc.client.test"; + +service EchoService { + rpc Echo (EchoRequest) returns (EchoResponse) {} +} + +message EchoRequest { + string message = 1; +} + +message EchoResponse { + string message = 1; +} diff --git a/microprofile/grpc/client/src/test/proto/strings.proto b/microprofile/grpc/client/src/test/proto/strings.proto new file mode 100644 index 00000000000..8e01019e310 --- /dev/null +++ b/microprofile/grpc/client/src/test/proto/strings.proto @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2019, 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +syntax = "proto3"; +option java_package = "io.helidon.microprofile.grpc.client.test"; + +service StringService { + rpc Upper (StringMessage) returns (StringMessage) {} + rpc Lower (StringMessage) returns (StringMessage) {} + rpc Split (StringMessage) returns (stream StringMessage) {} + rpc Join (stream StringMessage) returns (StringMessage) {} + rpc Echo (stream StringMessage) returns (stream StringMessage) {} +} + +message StringMessage { + string text = 1; +} diff --git a/microprofile/grpc/client/src/test/resources/application.yaml b/microprofile/grpc/client/src/test/resources/application.yaml new file mode 100644 index 00000000000..525f7570c65 --- /dev/null +++ b/microprofile/grpc/client/src/test/resources/application.yaml @@ -0,0 +1,48 @@ +# +# Copyright (c) 2024 Oracle and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +server: + port: 0 + tls: + trust: + keystore: + passphrase: "password" + trust-store: true + resource: + resource-path: "server.p12" + private-key: + keystore: + passphrase: "password" + resource: + resource-path: "server.p12" + +grpc: + client: + channels: + - name: "echo-channel" + port: 0 + tls: + trust: + keystore: + passphrase: "password" + trust-store: true + resource: + resource-path: "client.p12" + private-key: + keystore: + passphrase: "password" + resource: + resource-path: "client.p12" \ No newline at end of file diff --git a/microprofile/grpc/client/src/test/resources/client.p12 b/microprofile/grpc/client/src/test/resources/client.p12 new file mode 100644 index 00000000000..4eb3b8325cd Binary files /dev/null and b/microprofile/grpc/client/src/test/resources/client.p12 differ diff --git a/microprofile/grpc/client/src/test/resources/logging.properties b/microprofile/grpc/client/src/test/resources/logging.properties new file mode 100644 index 00000000000..1102463530f --- /dev/null +++ b/microprofile/grpc/client/src/test/resources/logging.properties @@ -0,0 +1,33 @@ +# +# Copyright (c) 2019, 2024 Oracle and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# Example Logging Configuration File +# For more information see $JAVA_HOME/jre/lib/logging.properties + +# Send messages to the console +handlers=io.helidon.logging.jul.HelidonConsoleHandler + +# HelidonConsoleHandler uses a SimpleFormatter subclass that replaces "!thread!" with the current thread +java.util.logging.SimpleFormatter.format=%1$tY.%1$tm.%1$td %1$tH:%1$tM:%1$tS %4$s %3$s !thread!: %5$s%6$s%n + +# Global logging level. Can be overridden by specific loggers +.level=INFO + +# Component specific log levels +#io.helidon.webserver.level=INFO +#io.helidon.config.level=INFO +#io.helidon.security.level=INFO +#io.helidon.common.level=INFO diff --git a/microprofile/grpc/client/src/test/resources/server.p12 b/microprofile/grpc/client/src/test/resources/server.p12 new file mode 100644 index 00000000000..ff8e4ddfc7f Binary files /dev/null and b/microprofile/grpc/client/src/test/resources/server.p12 differ diff --git a/microprofile/grpc/pom.xml b/microprofile/grpc/pom.xml index 286d044c437..89cb26d3b3f 100644 --- a/microprofile/grpc/pom.xml +++ b/microprofile/grpc/pom.xml @@ -34,6 +34,7 @@ core server + client tracing tests diff --git a/webclient/grpc/src/main/java/io/helidon/webclient/grpc/GrpcChannel.java b/webclient/grpc/src/main/java/io/helidon/webclient/grpc/GrpcChannel.java index 3efb29e47f7..f4d5e006cb7 100644 --- a/webclient/grpc/src/main/java/io/helidon/webclient/grpc/GrpcChannel.java +++ b/webclient/grpc/src/main/java/io/helidon/webclient/grpc/GrpcChannel.java @@ -26,7 +26,7 @@ /** * Helidon's implementation of a gRPC {@link Channel}. */ -class GrpcChannel extends Channel { +public class GrpcChannel extends Channel { private final GrpcClientImpl grpcClient; @@ -39,6 +39,15 @@ class GrpcChannel extends Channel { this.grpcClient = (GrpcClientImpl) grpcClient; } + /** + * Underlying gRPC Client for this channel. + * + * @return the gRPC client + */ + public GrpcClient grpcClient() { + return grpcClient; + } + @Override public ClientCall newCall( MethodDescriptor methodDescriptor, CallOptions callOptions) { diff --git a/webclient/grpc/src/main/java/io/helidon/webclient/grpc/GrpcClient.java b/webclient/grpc/src/main/java/io/helidon/webclient/grpc/GrpcClient.java index d786f7840f0..b4ffdc582cf 100644 --- a/webclient/grpc/src/main/java/io/helidon/webclient/grpc/GrpcClient.java +++ b/webclient/grpc/src/main/java/io/helidon/webclient/grpc/GrpcClient.java @@ -114,4 +114,11 @@ static GrpcClient create() { default Channel channel(Collection interceptors) { return channel(interceptors.toArray(new ClientInterceptor[]{})); } + + /** + * Configuration for this gRPC client. + * + * @return the configuration + */ + GrpcClientConfig clientConfig(); } diff --git a/webclient/grpc/src/main/java/io/helidon/webclient/grpc/GrpcClientImpl.java b/webclient/grpc/src/main/java/io/helidon/webclient/grpc/GrpcClientImpl.java index 7a8ce6dfa91..077b8e9a83f 100644 --- a/webclient/grpc/src/main/java/io/helidon/webclient/grpc/GrpcClientImpl.java +++ b/webclient/grpc/src/main/java/io/helidon/webclient/grpc/GrpcClientImpl.java @@ -61,4 +61,9 @@ public Channel channel() { public Channel channel(ClientInterceptor... interceptors) { return ClientInterceptors.intercept(channel(), interceptors); } + + @Override + public GrpcClientConfig clientConfig() { + return clientConfig; + } }