Add a gRPC server generator for blocking unary methods

This commit is contained in:
Jon Chambers 2025-03-25 10:24:52 -04:00 committed by GitHub
parent 63f6fc8634
commit 59cf445a2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1112 additions and 0 deletions

81
pom.xml
View File

@ -1,4 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright 2025 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
@ -7,11 +12,87 @@
<groupId>org.signal</groupId>
<artifactId>simple-grpc</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>simple-grpc-generator</module>
<module>simple-grpc-runtime</module>
</modules>
<properties>
<maven.compiler.source>23</maven.compiler.source>
<maven.compiler.target>23</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<os.maven.plugin.version>1.7.1</os.maven.plugin.version>
<protobuf.plugin.version>0.6.1</protobuf.plugin.version>
<grpc.version>1.71.0</grpc.version>
<jprotoc.version>1.2.2</jprotoc.version>
<junit.version>5.12.1</junit.version>
<mockito.version>5.16.1</mockito.version>
<protoc.version>3.25.5</protoc.version> <!-- Same version as grpc-protobuf -->
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.salesforce.servicelibs</groupId>
<artifactId>jprotoc</artifactId>
<version>${jprotoc.version}</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-inprocess</artifactId>
<version>${grpc.version}</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-protobuf</artifactId>
<version>${grpc.version}</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-stub</artifactId>
<version>${grpc.version}</version>
</dependency>
<!-- necessary for Java 9+ -->
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>annotations-api</artifactId>
<version>6.0.53</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.xolstice.maven.plugins</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<version>${protobuf.plugin.version}</version>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>

View File

@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright 2025 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.signal</groupId>
<artifactId>simple-grpc</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>simple-grpc-generator</artifactId>
<properties>
<maven.compiler.source>23</maven.compiler.source>
<maven.compiler.target>23</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.salesforce.servicelibs</groupId>
<artifactId>jprotoc</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-protobuf</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-stub</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<extensions>
<extension>
<groupId>kr.motd.maven</groupId>
<artifactId>os-maven-plugin</artifactId>
<version>${os.maven.plugin.version}</version>
</extension>
</extensions>
<plugins>
<plugin>
<groupId>org.xolstice.maven.plugins</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<!-- TODO: If we just need the dump, can we skip the default gRPC generation? -->
<configuration>
<protocArtifact>com.google.protobuf:protoc:${protoc.version}:exe:${os.detected.classifier}</protocArtifact>
<pluginId>grpc-java</pluginId>
<pluginArtifact>io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}</pluginArtifact>
</configuration>
<executions>
<execution>
<goals>
<goal>test-compile</goal>
<goal>test-compile-custom</goal>
</goals>
<configuration>
<protocPlugins>
<protocPlugin>
<id>dump</id>
<groupId>com.salesforce.servicelibs</groupId>
<artifactId>jprotoc</artifactId>
<version>${jprotoc.version}</version>
<mainClass>com.salesforce.jprotoc.dump.DumpGenerator</mainClass>
</protocPlugin>
</protocPlugins>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,307 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.grpc.simple;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
import com.google.common.html.HtmlEscapers;
import com.google.protobuf.DescriptorProtos;
import com.google.protobuf.compiler.PluginProtos;
import com.salesforce.jprotoc.Generator;
import com.salesforce.jprotoc.GeneratorException;
import com.salesforce.jprotoc.ProtoTypeMap;
import com.salesforce.jprotoc.ProtocPlugin;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
/**
* Generates "simple" gRPC server stubs from gRPC service definitions. "Simple" server stubs:
* <ul>
* <li>Generate blocking, just-return-the-response methods for unary gRPC methods</li>
* <li>Generate methods that accept a {@link java.util.concurrent.Flow.Publisher} and return a
* {@link java.util.concurrent.CompletionStage} for client-streaming gRPC methods</li>
* <li>Generate methods that return a {@code Flow.Publisher} for server-streaming gRPC methods</li>
* <li>Generate methods that accept and return a {@code Flow.Publisher} for bidirectional-streaming gRPC methods</li>
* </ul>
* Callers should take care to provide an appropriate executor to the gRPC server for blocking methods; although many
* different kinds of executors could work, this generator was designed with
* <a href="https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html">virtual threads</a> in mind.
* <p>
* This generator borrows heavily and with gratitude from the
* <a href="https://github.com/salesforce/reactive-grpc">reactive-grpc</a> generator.
*/
public class SimpleGrpcGenerator extends Generator {
private static final String CLASS_PREFIX = "Simple";
private static final String SERVICE_JAVADOC_PREFIX = " ";
private static final String METHOD_JAVADOC_PREFIX = " ";
private static final int SERVICE_NUMBER_OF_PATHS = 2;
private static final int METHOD_NUMBER_OF_PATHS = 4;
public static void main(final String... args) {
if (args.length == 0) {
// Generate from protoc via stdin
ProtocPlugin.generate(new SimpleGrpcGenerator());
} else if (args.length == 1) {
// Process from a descriptor_dump file via command line arg
ProtocPlugin.debug(new SimpleGrpcGenerator(), args[0]);
} else {
System.err.println("Usage: SimpleGrpcGenerator [DESCRIPTOR_DUMP_FILE]");
}
}
@Override
public List<PluginProtos.CodeGeneratorResponse.File> generateFiles(final PluginProtos.CodeGeneratorRequest request) throws GeneratorException {
final List<DescriptorProtos.FileDescriptorProto> protosToGenerate = request.getProtoFileList().stream()
.filter(protoFile -> request.getFileToGenerateList().contains(protoFile.getName()))
.toList();
return generateFiles(findServices(protosToGenerate, ProtoTypeMap.of(request.getProtoFileList())));
}
@Override
protected List<PluginProtos.CodeGeneratorResponse.Feature> supportedFeatures() {
return List.of(PluginProtos.CodeGeneratorResponse.Feature.FEATURE_PROTO3_OPTIONAL);
}
private List<ServiceContext> findServices(final List<DescriptorProtos.FileDescriptorProto> fileDescriptorProtos,
final ProtoTypeMap typeMap) {
return fileDescriptorProtos.stream()
.flatMap(fileDescriptorProto -> IntStream.range(0, fileDescriptorProto.getServiceCount())
.mapToObj(serviceNumber -> buildServiceContext(fileDescriptorProto, typeMap, serviceNumber)))
.toList();
}
private static ServiceContext buildServiceContext(final DescriptorProtos.FileDescriptorProto fileDescriptorProto,
final ProtoTypeMap typeMap,
final int serviceNumber) {
final DescriptorProtos.ServiceDescriptorProto serviceProto = fileDescriptorProto.getService(serviceNumber);
final List<DescriptorProtos.SourceCodeInfo.Location> locations =
fileDescriptorProto.getSourceCodeInfo().getLocationList();
final DescriptorProtos.SourceCodeInfo.Location serviceLocation = locations.stream()
.filter(location ->
location.getPathCount() >= 2 &&
location.getPath(0) == DescriptorProtos.FileDescriptorProto.SERVICE_FIELD_NUMBER &&
location.getPath(1) == serviceNumber)
.filter(location -> location.getPathCount() == SERVICE_NUMBER_OF_PATHS)
.findFirst()
.orElseGet(DescriptorProtos.SourceCodeInfo.Location::getDefaultInstance);
final ServiceContext serviceContext = new ServiceContext();
serviceContext.fileName = CLASS_PREFIX + serviceProto.getName() + "Grpc.java";
serviceContext.className = CLASS_PREFIX + serviceProto.getName() + "Grpc";
serviceContext.serviceName = serviceProto.getName();
serviceContext.deprecated = serviceProto.getOptions() != null && serviceProto.getOptions().getDeprecated();
serviceContext.protoName = fileDescriptorProto.getName();
serviceContext.packageName = extractPackageName(fileDescriptorProto);
serviceContext.javaDoc = getJavaDoc(getComments(serviceLocation), SERVICE_JAVADOC_PREFIX);
for (int methodNumber = 0; methodNumber < serviceProto.getMethodCount(); methodNumber++) {
serviceContext.methods.add(buildMethodContext(serviceProto.getMethod(methodNumber), typeMap, locations, methodNumber));
}
return serviceContext;
}
private static String extractPackageName(final DescriptorProtos.FileDescriptorProto proto) {
final DescriptorProtos.FileOptions options = proto.getOptions();
if (options != null) {
final String javaPackage = options.getJavaPackage();
if (!Strings.isNullOrEmpty(javaPackage)) {
return javaPackage;
}
}
return Strings.nullToEmpty(proto.getPackage());
}
private static MethodContext buildMethodContext(final DescriptorProtos.MethodDescriptorProto methodProto,
final ProtoTypeMap typeMap,
final List<DescriptorProtos.SourceCodeInfo.Location> locations,
final int methodNumber) {
final DescriptorProtos.SourceCodeInfo.Location methodLocation = locations.stream()
.filter(location ->
location.getPathCount() == METHOD_NUMBER_OF_PATHS &&
location.getPath(METHOD_NUMBER_OF_PATHS - 1) == methodNumber)
.findFirst()
.orElseGet(DescriptorProtos.SourceCodeInfo.Location::getDefaultInstance);
final MethodContext methodContext = new MethodContext();
methodContext.methodName = lowerCaseFirst(methodProto.getName());
methodContext.inputType = typeMap.toJavaTypeName(methodProto.getInputType());
methodContext.outputType = typeMap.toJavaTypeName(methodProto.getOutputType());
methodContext.deprecated = methodProto.getOptions() != null && methodProto.getOptions().getDeprecated();
methodContext.isManyInput = methodProto.getClientStreaming();
methodContext.isManyOutput = methodProto.getServerStreaming();
methodContext.methodNumber = methodNumber;
methodContext.javaDoc = getJavaDoc(getComments(methodLocation), METHOD_JAVADOC_PREFIX);
if (!methodProto.getClientStreaming() && !methodProto.getServerStreaming()) {
methodContext.simpleCallsMethodName = "unaryCall";
methodContext.grpcCallsMethodName = "asyncUnaryCall";
}
if (!methodProto.getClientStreaming() && methodProto.getServerStreaming()) {
methodContext.simpleCallsMethodName = "serverStreamingCall";
methodContext.grpcCallsMethodName = "asyncServerStreamingCall";
}
if (methodProto.getClientStreaming() && !methodProto.getServerStreaming()) {
methodContext.simpleCallsMethodName = "clientStreamingCall";
methodContext.grpcCallsMethodName = "asyncClientStreamingCall";
}
if (methodProto.getClientStreaming() && methodProto.getServerStreaming()) {
methodContext.simpleCallsMethodName = "bidirectionalStreamingCall";
methodContext.grpcCallsMethodName = "asyncBidiStreamingCall";
}
return methodContext;
}
private static String lowerCaseFirst(final String s) {
return Character.toLowerCase(s.charAt(0)) + s.substring(1);
}
private List<PluginProtos.CodeGeneratorResponse.File> generateFiles(final List<ServiceContext> services) {
return services.stream()
.map(this::buildFile)
.collect(Collectors.toList());
}
private PluginProtos.CodeGeneratorResponse.File buildFile(final ServiceContext serviceContext) {
return PluginProtos.CodeGeneratorResponse.File
.newBuilder()
.setName(getGeneratedFilename(serviceContext))
.setContent(applyTemplate("SimpleStub.mustache", serviceContext))
.build();
}
/**
* Gets the relative path for a generated file. As the schema notes, this is:
* <p>
* <blockquote>The file name, relative to the output directory. The name must not contain "." or ".." components and
* must be relative, not be absolute (so, the file cannot lie outside the output directory). "/" must be used as the
* path separator, not "\".</blockquote>
*
* @param serviceContext the service context for which to get a generated filename
*
* @return the relative path to the generated file
*
* @see <a href="https://github.com/protocolbuffers/protobuf/blob/e8edc5d5e72fa091b0086b4a6d12af0bb66d664b/src/google/protobuf/compiler/plugin.proto#L119-L130">CodeGeneratorResponse.File.name</a>
*/
@VisibleForTesting
static String getGeneratedFilename(final ServiceContext serviceContext) {
final String dir = serviceContext.packageName != null
? serviceContext.packageName.replace('.', '/')
: null;
if (Strings.isNullOrEmpty(dir)) {
return serviceContext.fileName;
} else {
return dir + "/" + serviceContext.fileName;
}
}
private static String getComments(final DescriptorProtos.SourceCodeInfo.Location location) {
return location.getLeadingComments().isEmpty() ? location.getTrailingComments() : location.getLeadingComments();
}
private static String getJavaDoc(final String comments, final String prefix) {
if (comments.isEmpty()) {
return null;
}
final StringBuilder builder = new StringBuilder("/**\n")
.append(prefix).append(" * <pre>\n");
Arrays.stream(HtmlEscapers.htmlEscaper().escape(comments).split("\n"))
.map(line -> line.replace("*/", "&#42;&#47;").replace("*", "&#42;"))
.forEach(line -> builder.append(prefix).append(" * ").append(line).append("\n"));
builder
.append(prefix).append(" * </pre>\n")
.append(prefix).append(" */");
return builder.toString();
}
/**
* Template class for proto Service objects.
*/
@VisibleForTesting
static class ServiceContext {
public String fileName;
public String protoName;
public String packageName;
public String className;
public String serviceName;
public boolean deprecated;
public String javaDoc;
public List<MethodContext> methods = new ArrayList<>();
// Although not used directly by the generator, templates may invoke this method
@SuppressWarnings("unused")
public List<MethodContext> unaryRequestMethods() {
return methods.stream().filter(m -> !m.isManyInput).collect(Collectors.toList());
}
}
/**
* Template class for proto RPC objects.
*/
@VisibleForTesting
static class MethodContext {
public String methodName;
public String inputType;
public String outputType;
public boolean deprecated;
public boolean isManyInput;
public boolean isManyOutput;
public String simpleCallsMethodName;
public String grpcCallsMethodName;
public int methodNumber;
public String javaDoc;
// This method mimics the upper-casing method of gRPC to ensure compatibility
// See https://github.com/grpc/grpc-java/blob/v1.8.0/compiler/src/java_plugin/cpp/java_generator.cpp#L58
// Although not used directly by the generator, templates may invoke this method
@SuppressWarnings("unused")
public String methodNameUpperUnderscore() {
StringBuilder s = new StringBuilder();
for (int i = 0; i < methodName.length(); i++) {
char c = methodName.charAt(i);
s.append(Character.toUpperCase(c));
if ((i < methodName.length() - 1) && Character.isLowerCase(c) && Character.isUpperCase(methodName.charAt(i + 1))) {
s.append('_');
}
}
return s.toString();
}
// Although not used directly by the generator, templates may invoke this method
@SuppressWarnings("unused")
public String methodNamePascalCase() {
String mn = methodName.replace("_", "");
return Character.toUpperCase(mn.charAt(0)) + mn.substring(1);
}
// Although not used directly by the generator, templates may invoke this method
@SuppressWarnings("unused")
public String methodNameCamelCase() {
String mn = methodName.replace("_", "");
return Character.toLowerCase(mn.charAt(0)) + mn.substring(1);
}
}
}

View File

@ -0,0 +1,112 @@
{{#packageName}}
package {{packageName}};
{{/packageName}}
import static {{packageName}}.{{serviceName}}Grpc.getServiceDescriptor;
import static io.grpc.stub.ServerCalls.asyncUnaryCall;
import static io.grpc.stub.ServerCalls.asyncServerStreamingCall;
import static io.grpc.stub.ServerCalls.asyncClientStreamingCall;
import static io.grpc.stub.ServerCalls.asyncBidiStreamingCall;
{{#deprecated}}
@java.lang.Deprecated
{{/deprecated}}
@javax.annotation.Generated(
value = "by SimpleGrpc generator",
comments = "Source: {{protoName}}")
public final class {{className}} {
private {{className}}() {}
{{#javaDoc}}
{{{javaDoc}}}
{{/javaDoc}}
public static abstract class {{serviceName}}ImplBase implements io.grpc.BindableService {
{{#methods}}
{{#javaDoc}}
{{{javaDoc}}}
{{/javaDoc}}
{{#deprecated}}
@java.lang.Deprecated
{{/deprecated}}
public {{#isManyOutput}}java.util.concurrent.Flow.Publisher<{{outputType}}>{{/isManyOutput}}{{^isManyOutput}}{{outputType}}{{/isManyOutput}} {{methodNameCamelCase}}(final {{#isManyInput}}java.util.concurrent.Flow.Publisher<{{inputType}}>{{/isManyInput}}{{^isManyInput}}{{inputType}}{{/isManyInput}} request){{^isManyOutput}} throws Exception {{/isManyOutput}} {
throw new io.grpc.StatusRuntimeException(io.grpc.Status.UNIMPLEMENTED);
}
{{/methods}}
@java.lang.Override public final io.grpc.ServerServiceDefinition bindService() {
return io.grpc.ServerServiceDefinition.builder(getServiceDescriptor())
{{#methods}}
.addMethod(
{{packageName}}.{{serviceName}}Grpc.get{{methodNamePascalCase}}Method(),
{{grpcCallsMethodName}}(
new MethodHandlers<
{{inputType}},
{{outputType}}>(
this, METHOD_ID_{{methodNameUpperUnderscore}})))
{{/methods}}
.build();
}
protected Throwable mapException(final Exception e) {
return org.signal.grpc.simple.ServerCalls.mapException(e);
}
}
{{#methods}}
public static final int METHOD_ID_{{methodNameUpperUnderscore}} = {{methodNumber}};
{{/methods}}
private static final class MethodHandlers<Req, Resp> implements
io.grpc.stub.ServerCalls.UnaryMethod<Req, Resp>,
io.grpc.stub.ServerCalls.ServerStreamingMethod<Req, Resp>,
io.grpc.stub.ServerCalls.ClientStreamingMethod<Req, Resp>,
io.grpc.stub.ServerCalls.BidiStreamingMethod<Req, Resp> {
private final {{serviceName}}ImplBase serviceImpl;
private final int methodId;
MethodHandlers({{serviceName}}ImplBase serviceImpl, int methodId) {
this.serviceImpl = serviceImpl;
this.methodId = methodId;
}
@java.lang.Override
@java.lang.SuppressWarnings("unchecked")
public void invoke(final Req request, final io.grpc.stub.StreamObserver<Resp> responseObserver) {
switch (methodId) {
{{#methods}}
{{^isManyInput}}
case METHOD_ID_{{methodNameUpperUnderscore}}:
org.signal.grpc.simple.ServerCalls.{{simpleCallsMethodName}}(({{inputType}}) request,
(io.grpc.stub.ServerCallStreamObserver<{{outputType}}>) responseObserver,
serviceImpl::{{methodNameCamelCase}},
serviceImpl::mapException);
break;
{{/isManyInput}}
{{/methods}}
default:
throw new java.lang.AssertionError();
}
}
@java.lang.Override
@java.lang.SuppressWarnings("unchecked")
public io.grpc.stub.StreamObserver<Req> invoke(final io.grpc.stub.StreamObserver<Resp> responseObserver) {
switch (methodId) {
{{#methods}}
{{#isManyInput}}
case METHOD_ID_{{methodNameUpperUnderscore}}:
return (io.grpc.stub.StreamObserver<Req>) org.signal.grpc.simple.ServerCalls.{{simpleCallsMethodName}}(
(io.grpc.stub.ServerCallStreamObserver<{{outputType}}>) responseObserver,
serviceImpl::{{methodNameCamelCase}},
serviceImpl::mapException);
{{/isManyInput}}
{{/methods}}
default:
throw new java.lang.AssertionError();
}
}
}
}

View File

@ -0,0 +1,61 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.grpc.simple;
import com.google.protobuf.compiler.PluginProtos;
import com.salesforce.jprotoc.ProtocPluginTesting;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.io.IOException;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class SimpleGrpcGeneratorTest {
@Test
void verifyGenerator() throws IOException {
final PluginProtos.CodeGeneratorResponse response =
ProtocPluginTesting.test(new SimpleGrpcGenerator(), "target/generated-test-sources/protobuf/java/descriptor_dump");
assertTrue(response.getError().isBlank());
assertFalse(response.getFileList().isEmpty());
}
@ParameterizedTest
@MethodSource
void getGeneratedFilename(final SimpleGrpcGenerator.ServiceContext serviceContext, final String expectedFilename) {
assertEquals(expectedFilename, SimpleGrpcGenerator.getGeneratedFilename(serviceContext));
}
private static List<Arguments> getGeneratedFilename() {
final SimpleGrpcGenerator.ServiceContext serviceContextWithPackage = new SimpleGrpcGenerator.ServiceContext();
serviceContextWithPackage.packageName = "com.example.test";
serviceContextWithPackage.fileName = "Test.java";
final SimpleGrpcGenerator.ServiceContext serviceContextWithBlankPackage = new SimpleGrpcGenerator.ServiceContext();
serviceContextWithBlankPackage.packageName = "";
serviceContextWithBlankPackage.fileName = "Test.java";
final SimpleGrpcGenerator.ServiceContext serviceContextWithNullPackage = new SimpleGrpcGenerator.ServiceContext();
serviceContextWithNullPackage.packageName = null;
serviceContextWithNullPackage.fileName = "Test.java";
return List.of(
Arguments.argumentSet("Service context with package",
serviceContextWithPackage, "com/example/test/Test.java"),
Arguments.argumentSet("Service context with blank package",
serviceContextWithBlankPackage, "Test.java"),
Arguments.argumentSet("Service context with null package",
serviceContextWithNullPackage, "Test.java")
);
}
}

View File

@ -0,0 +1,34 @@
syntax = "proto3";
package org.signal.grpc.simple.test;
service Test {
rpc Unary(UnaryRequest) returns (UnaryResponse) {}
rpc ServerStreaming(ServerStreamingRequest) returns (stream ServerStreamingResponse) {}
rpc ClientStreaming(stream ClientStreamingRequest) returns (ClientStreamingResponse) {}
rpc BidirectionalStreaming(stream BidirectionalStreamingRequest) returns (stream BidirectionalStreamingResponse) {}
}
message UnaryRequest {
}
message UnaryResponse {
}
message ServerStreamingRequest {
}
message ServerStreamingResponse {
}
message ClientStreamingRequest {
}
message ClientStreamingResponse {
}
message BidirectionalStreamingRequest {
}
message BidirectionalStreamingResponse {
}

106
simple-grpc-runtime/pom.xml Normal file
View File

@ -0,0 +1,106 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright 2025 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.signal</groupId>
<artifactId>simple-grpc</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>simple-grpc-runtime</artifactId>
<properties>
<maven.compiler.source>23</maven.compiler.source>
<maven.compiler.target>23</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-stub</artifactId>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-protobuf</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-inprocess</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>annotations-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<extensions>
<extension>
<groupId>kr.motd.maven</groupId>
<artifactId>os-maven-plugin</artifactId>
<version>${os.maven.plugin.version}</version>
</extension>
</extensions>
<plugins>
<plugin>
<groupId>org.xolstice.maven.plugins</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<configuration>
<protocArtifact>com.google.protobuf:protoc:${protoc.version}:exe:${os.detected.classifier}</protocArtifact>
<pluginId>grpc-java</pluginId>
<pluginArtifact>io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}</pluginArtifact>
</configuration>
<executions>
<execution>
<goals>
<goal>test-compile</goal>
<goal>test-compile-custom</goal>
</goals>
<configuration>
<protocPlugins>
<protocPlugin>
<id>simple</id>
<groupId>org.signal</groupId>
<artifactId>simple-grpc-generator</artifactId>
<version>${project.version}</version>
<mainClass>org.signal.grpc.simple.SimpleGrpcGenerator</mainClass>
</protocPlugin>
</protocPlugins>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,17 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.grpc.simple;
/**
* Represents a function that accepts one argument and produces a result and may throw a checked exception.
*
* @param <T> the type of the input to the function
* @param <R> the type of the result of the function
*/
@FunctionalInterface
public interface CheckedFunction<T, R> {
R apply(T argument) throws Exception;
}

View File

@ -0,0 +1,101 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.grpc.simple;
import io.grpc.Status;
import io.grpc.StatusException;
import io.grpc.StatusRuntimeException;
import io.grpc.stub.ServerCallStreamObserver;
import io.grpc.stub.StreamObserver;
import java.util.concurrent.Flow;
import java.util.function.Function;
/**
* Bridges calls from the gRPC framework to application-space service implementations.
*/
public class ServerCalls {
private ServerCalls() {}
/**
* Handles unary (single-request, single-response) calls from a service stub generated by {@code SimpleGrpcGenerator}.
*
* @param request the request object from the client
* @param responseObserver the response observer used by the server to send signals to the client
* @param delegate the service method that will handle the business logic of this request
* @param exceptionMapper a function that maps exceptions thrown by the {@code delegate} function to an appropriate
* gRPC {@link Status}
*
* @param <Req> the type of request object handled by the {@code delegate} function
* @param <Resp> the type of response object returned by the {@code delegate} function
*/
public static <Req, Resp> void unaryCall(
final Req request,
final ServerCallStreamObserver<Resp> responseObserver,
final CheckedFunction<Req, Resp> delegate,
final Function<Exception, Throwable> exceptionMapper) {
try {
final Resp response = delegate.apply(request);
// Don't try to respond if the server has already canceled the request
if (!responseObserver.isCancelled()) {
responseObserver.onNext(response);
responseObserver.onCompleted();
}
} catch (final Exception e) {
final Throwable mappedException = exceptionMapper.apply(e);
// Don't try to respond if the server has already canceled the request
if (!responseObserver.isCancelled()) {
responseObserver.onError(mappedException);
}
}
}
public static <Req, Resp> void serverStreamingCall(
final Req request,
final ServerCallStreamObserver<Resp> responseObserver,
final Function<Req, Flow.Publisher<Resp>> delegate,
final Function<Exception, Throwable> exceptionMapper) {
throw new UnsupportedOperationException();
}
public static <Req, Resp> StreamObserver<Req> clientStreamingCall(
final ServerCallStreamObserver<Resp> responseObserver,
final CheckedFunction<Flow.Publisher<Req>, Resp> delegate,
final Function<Exception, Throwable> exceptionMapper) {
throw new UnsupportedOperationException();
}
public static <Req, Resp> StreamObserver<Req> bidirectionalStreamingCall(
final ServerCallStreamObserver<Resp> responseObserver,
final Function<Flow.Publisher<Req>, Flow.Publisher<Resp>> delegate,
final Function<Exception, Throwable> exceptionMapper) {
throw new UnsupportedOperationException();
}
/**
* Performs a default mapping of exceptions to {@code StatusException}. Exceptions that are already instances of
* {@link StatusException} or {@link StatusRuntimeException} are returned unchanged. If the given exception is not
* a {@code StatusException} or {@code StatusRuntimeException} but is caused by one of those classes, then the
* status-bearing cause is returned.
*
* @param e the exception to map to a gRPC {@code StatusException}
*
* @return a {@code StatusException} or {@code StatusRuntimeException} derived from the given exception
*/
public static Throwable mapException(final Exception e) {
if (e instanceof StatusException || e instanceof StatusRuntimeException) {
return e;
} else {
return Status.fromThrowable(e).asException();
}
}
}

View File

@ -0,0 +1,70 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.grpc.simple;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import io.grpc.Status;
import io.grpc.StatusException;
import io.grpc.stub.ServerCallStreamObserver;
import java.util.function.Function;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class ServerCallsTest {
@ParameterizedTest
@ValueSource(booleans = {true, false})
void unaryCall(final boolean cancelled) {
final String response = "Test response";
@SuppressWarnings("unchecked") final ServerCallStreamObserver<String> responseObserver =
mock(ServerCallStreamObserver.class);
when(responseObserver.isCancelled()).thenReturn(cancelled);
ServerCalls.unaryCall("Test request", responseObserver, ignored -> response, ServerCalls::mapException);
verify(responseObserver, atLeastOnce()).isCancelled();
if (!cancelled) {
verify(responseObserver).onNext(response);
verify(responseObserver).onCompleted();
}
verifyNoMoreInteractions(responseObserver);
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
void unaryCallException(final boolean cancelled) {
@SuppressWarnings("unchecked") final ServerCallStreamObserver<String> responseObserver =
mock(ServerCallStreamObserver.class);
when(responseObserver.isCancelled()).thenReturn(cancelled);
final Function<Exception, Throwable> mapException = ignored -> Status.RESOURCE_EXHAUSTED.asException();
ServerCalls.unaryCall("Test request", responseObserver, ignored -> {
throw new RuntimeException();
}, mapException);
verify(responseObserver, atLeastOnce()).isCancelled();
if (!cancelled) {
verify(responseObserver).onError(argThat(throwable ->
throwable instanceof StatusException &&
((StatusException) throwable).getStatus().getCode() == Status.Code.RESOURCE_EXHAUSTED));
}
verifyNoMoreInteractions(responseObserver);
}
}

View File

@ -0,0 +1,99 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.grpc.simple;
import io.grpc.Channel;
import io.grpc.Server;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.inprocess.InProcessChannelBuilder;
import io.grpc.inprocess.InProcessServerBuilder;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
public class SimpleGrpcIntegrationTest {
private static class CalculatorService extends SimpleCalculatorGrpc.CalculatorImplBase {
@Override
public AdditionResponse add(final AdditionRequest request) {
return AdditionResponse.newBuilder().setSum(request.getAddendList().stream()
.mapToInt(i -> i)
.sum())
.build();
}
}
private static class ExceptionCalculatorService extends SimpleCalculatorGrpc.CalculatorImplBase {
@Override
public AdditionResponse add(final AdditionRequest request) {
throw new IllegalArgumentException();
}
@Override
protected Throwable mapException(final Exception e) {
if (e instanceof IllegalArgumentException) {
return Status.INVALID_ARGUMENT.asException();
}
return super.mapException(e);
}
}
@Test
void testUnary() throws IOException {
final Server server = InProcessServerBuilder.forName("test")
.directExecutor()
.addService(new CalculatorService())
.build()
.start();
try {
final Channel channel = InProcessChannelBuilder.forName("test")
.directExecutor()
.build();
final CalculatorGrpc.CalculatorBlockingStub stub = CalculatorGrpc.newBlockingStub(channel);
final AdditionRequest additionRequest = AdditionRequest.newBuilder()
.addAddend(8)
.addAddend(6)
.build();
assertEquals(14, stub.add(additionRequest).getSum());
} finally {
server.shutdownNow();
}
}
@Test
void testUnaryException() throws IOException {
final Server server = InProcessServerBuilder.forName("test")
.directExecutor()
.addService(new ExceptionCalculatorService())
.build()
.start();
try {
final Channel channel = InProcessChannelBuilder.forName("test")
.directExecutor()
.build();
final CalculatorGrpc.CalculatorBlockingStub stub = CalculatorGrpc.newBlockingStub(channel);
final StatusRuntimeException statusRuntimeException =
assertThrows(StatusRuntimeException.class, () -> stub.add(AdditionRequest.newBuilder().build()));
assertEquals(Status.INVALID_ARGUMENT, statusRuntimeException.getStatus());
} finally {
server.shutdownNow();
}
}
}

View File

@ -0,0 +1,28 @@
syntax = "proto3";
option java_multiple_files = true;
package org.signal.grpc.simple;
service Calculator {
rpc Add(AdditionRequest) returns (AdditionResponse) {}
rpc AddStream(stream AdditionRequest) returns (AdditionResponse) {}
rpc Factor(FactorRequest) returns (stream FactorResponse) {}
rpc RunningAddition(stream AdditionRequest) returns (stream AdditionResponse) {}
}
message AdditionRequest {
repeated int32 addend = 1;
}
message AdditionResponse {
int64 sum = 1;
}
message FactorRequest {
uint64 number = 1;
}
message FactorResponse {
uint64 factor = 1;
}