diff --git a/pom.xml b/pom.xml index 8ef45312..531830b1 100644 --- a/pom.xml +++ b/pom.xml @@ -19,6 +19,7 @@ spring-grpc-core spring-grpc-dependencies spring-grpc-docs + spring-grpc-transcoding diff --git a/samples/grpc-transcoding/pom.xml b/samples/grpc-transcoding/pom.xml new file mode 100644 index 00000000..cb331a10 --- /dev/null +++ b/samples/grpc-transcoding/pom.xml @@ -0,0 +1,152 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 4.1.1-SNAPSHOT + + + org.springframework.grpc + grpc-transcoding-sample + 1.1.1-SNAPSHOT + Spring gRPC Transcoding Sample + Demo project for gRPC-JSON transcoding + + 17 + 0.0.43 + 2.72.0 + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-grpc-server + + + org.springframework.boot + spring-boot-starter-grpc-client + + + org.springframework.grpc + spring-grpc-transcoding + ${project.version} + + + com.google.api.grpc + proto-google-common-protos + ${google-common-protos.version} + + + io.grpc + grpc-inprocess + + + + org.springframework.boot + spring-boot-starter-grpc-client-test + test + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-deploy-plugin + + true + + + + io.spring.javaformat + spring-javaformat-maven-plugin + ${spring-javaformat-maven-plugin.version} + + + + validate + true + + validate + + + + + + io.github.ascopes + protobuf-maven-plugin + + + ${protobuf-java.version} + + + + io.grpc + protoc-gen-grpc-java + ${grpc-java.version} + @generated=omit + + + org.springframework.grpc + spring-grpc-transcoding + ${project.version} + + + + + + + + + + spring-milestones + Spring Milestones + https://repo.spring.io/milestone + + false + + + + spring-snapshots + Spring Snapshots + https://repo.spring.io/snapshot + + false + + + + + + spring-milestones + Spring Milestones + https://repo.spring.io/milestone + + false + + + + spring-snapshots + Spring Snapshots + https://repo.spring.io/snapshot + + false + + + + + diff --git a/samples/grpc-transcoding/src/main/java/org/springframework/grpc/sample/GrpcServerApplication.java b/samples/grpc-transcoding/src/main/java/org/springframework/grpc/sample/GrpcServerApplication.java new file mode 100644 index 00000000..2a1a5f14 --- /dev/null +++ b/samples/grpc-transcoding/src/main/java/org/springframework/grpc/sample/GrpcServerApplication.java @@ -0,0 +1,16 @@ +package org.springframework.grpc.sample; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Import; +import org.springframework.grpc.transcoding.config.GrpcTranscodingConfiguration; + +@SpringBootApplication +@Import(GrpcTranscodingConfiguration.class) +public class GrpcServerApplication { + + public static void main(String[] args) { + SpringApplication.run(GrpcServerApplication.class, args); + } + +} diff --git a/samples/grpc-transcoding/src/main/java/org/springframework/grpc/sample/GrpcServerService.java b/samples/grpc-transcoding/src/main/java/org/springframework/grpc/sample/GrpcServerService.java new file mode 100644 index 00000000..f80b87c2 --- /dev/null +++ b/samples/grpc-transcoding/src/main/java/org/springframework/grpc/sample/GrpcServerService.java @@ -0,0 +1,52 @@ +package org.springframework.grpc.sample; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.grpc.sample.proto.HelloReply; +import org.springframework.grpc.sample.proto.HelloRequest; +import org.springframework.grpc.sample.proto.SimpleGrpc; +import org.springframework.stereotype.Service; + +import io.grpc.stub.StreamObserver; + +@Service +public class GrpcServerService extends SimpleGrpc.SimpleImplBase { + + private static Log log = LogFactory.getLog(GrpcServerService.class); + + @Override + public void sayHello(HelloRequest req, StreamObserver responseObserver) { + log.info("Hello " + req.getName()); + HelloReply reply = HelloReply.newBuilder() + .setMessage("Hello ==> " + req.getName() + " [note=" + req.getNote() + "]") + .build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } + + @Override + public void updateHello(HelloRequest req, StreamObserver responseObserver) { + sayHello(req, responseObserver); + } + + @Override + public void streamHello(HelloRequest req, StreamObserver responseObserver) { + log.info("Hello " + req.getName()); + int count = 0; + while (count < 10) { + HelloReply reply = HelloReply.newBuilder().setMessage("Hello(" + count + ") ==> " + req.getName()).build(); + responseObserver.onNext(reply); + count++; + try { + Thread.sleep(1000L); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + responseObserver.onError(e); + return; + } + } + responseObserver.onCompleted(); + } + +} diff --git a/samples/grpc-transcoding/src/main/proto/hello.proto b/samples/grpc-transcoding/src/main/proto/hello.proto new file mode 100644 index 00000000..ad801698 --- /dev/null +++ b/samples/grpc-transcoding/src/main/proto/hello.proto @@ -0,0 +1,33 @@ +syntax = "proto3"; + +option java_multiple_files = true; +option java_package = "org.springframework.grpc.sample.proto"; +option java_outer_classname = "HelloWorldProto"; + +import "google/api/annotations.proto"; + +// The greeting service definition. +service Simple { + // Sends a greeting + rpc SayHello (HelloRequest) returns (HelloReply) { + option (google.api.http) = { get: "/v1/hello/{name}" }; + } + rpc UpdateHello (HelloRequest) returns (HelloReply) { + option (google.api.http) = { + post: "/v1/hello/{name}" + body: "*" + }; + } + rpc StreamHello(HelloRequest) returns (stream HelloReply) {} +} + +// The request message containing the user's name. +message HelloRequest { + string name = 1; + string note = 2; +} + +// The response message containing the greetings +message HelloReply { + string message = 1; +} diff --git a/samples/grpc-transcoding/src/main/resources/application.properties b/samples/grpc-transcoding/src/main/resources/application.properties new file mode 100644 index 00000000..6e8e018f --- /dev/null +++ b/samples/grpc-transcoding/src/main/resources/application.properties @@ -0,0 +1,2 @@ +spring.application.name=grpc-transcoding +spring.grpc.server.inprocess.name=grpc-transcoding \ No newline at end of file diff --git a/samples/grpc-transcoding/src/test/java/org/springframework/grpc/sample/GrpcTranscodingIntegrationTests.java b/samples/grpc-transcoding/src/test/java/org/springframework/grpc/sample/GrpcTranscodingIntegrationTests.java new file mode 100644 index 00000000..bb412678 --- /dev/null +++ b/samples/grpc-transcoding/src/test/java/org/springframework/grpc/sample/GrpcTranscodingIntegrationTests.java @@ -0,0 +1,178 @@ +package org.springframework.grpc.sample; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.grpc.client.GlobalClientInterceptor; +import org.springframework.grpc.sample.proto.HelloRequest; +import org.springframework.grpc.server.GlobalServerInterceptor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientResponseException; + +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.ForwardingServerCallListener; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(GrpcTranscodingIntegrationTests.InterceptorConfiguration.class) +class GrpcTranscodingIntegrationTests { + + @LocalServerPort + private int port; + + private RestClient restClient; + + @Autowired + private AtomicInteger interceptedCalls; + + @Autowired + private AtomicReference interceptedRequest; + + @BeforeEach + void setUp() { + this.interceptedCalls.set(0); + this.interceptedRequest.set(null); + this.restClient = RestClient.builder().baseUrl("http://localhost:" + this.port).build(); + } + + @Test + void pathParameterIsBound() { + String body = getHello(); + + assertThat(body).contains("Hello ==> World"); + assertThat(capturedRequest()).isEqualTo(request().build()); + } + + @Test + void queryParameterIsBound() { + getHello("note", "hello"); + + assertThat(capturedRequest()).isEqualTo(request().setNote("hello").build()); + } + + @Test + void pathParameterOverridesRequestBody() { + this.restClient.post().uri("/v1/hello/Path").contentType(MediaType.APPLICATION_JSON).body(""" + { + "name": "Body", + "note": "from-body" + } + """).retrieve().toBodilessEntity(); + + assertThat(capturedRequest()).isEqualTo(request().setName("Path").setNote("from-body").build()); + } + + @Test + void globalClientInterceptorAppliesToTranscodingChannel() { + getHello(); + + assertThat(this.interceptedCalls).hasValue(1); + } + + @Test + void duplicateQueryParameterReturns400() { + RestClientResponseException exception = getHelloError("note", List.of("one", "two")); + + assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(this.interceptedRequest).hasValue(null); + } + + private String getHello() { + return this.restClient.get().uri("/v1/hello/World").retrieve().body(String.class); + } + + private String getHello(String name, String value) { + return this.restClient.get() + .uri((builder) -> builder.path("/v1/hello/World").queryParam(name, value).build()) + .retrieve() + .body(String.class); + } + + private RestClientResponseException getHelloError(String name, List values) { + return catchThrowableOfType(() -> this.restClient.get() + .uri((builder) -> builder.path("/v1/hello/World").queryParam(name, values.toArray()).build()) + .retrieve() + .toBodilessEntity(), RestClientResponseException.class); + } + + private HelloRequest capturedRequest() { + assertThat(this.interceptedRequest.get()).as("request captured by the gRPC server").isNotNull(); + return this.interceptedRequest.get(); + } + + private static HelloRequest.Builder request() { + return HelloRequest.newBuilder().setName("World"); + } + + @TestConfiguration(proxyBeanMethods = false) + static class InterceptorConfiguration { + + @Bean + AtomicInteger interceptedCalls() { + return new AtomicInteger(); + } + + @Bean + AtomicReference interceptedRequest() { + return new AtomicReference<>(); + } + + @Bean + @GlobalClientInterceptor + ClientInterceptor countingClientInterceptor(AtomicInteger interceptedCalls) { + return new ClientInterceptor() { + @Override + public ClientCall interceptCall(MethodDescriptor method, + CallOptions callOptions, Channel next) { + interceptedCalls.incrementAndGet(); + return next.newCall(method, callOptions); + } + }; + } + + @Bean + @GlobalServerInterceptor + ServerInterceptor capturingServerInterceptor(AtomicReference interceptedRequest) { + return new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, + ServerCallHandler next) { + ServerCall.Listener delegate = next.startCall(call, headers); + return new ForwardingServerCallListener.SimpleForwardingServerCallListener<>(delegate) { + @Override + public void onMessage(RequestT message) { + if (message instanceof HelloRequest request) { + interceptedRequest.set(request); + } + super.onMessage(message); + } + }; + } + }; + } + + } + +} diff --git a/samples/pom.xml b/samples/pom.xml index 5a461923..4145ed65 100644 --- a/samples/pom.xml +++ b/samples/pom.xml @@ -32,6 +32,7 @@ grpc-tomcat-secure grpc-webflux grpc-webflux-secure + grpc-transcoding diff --git a/spring-grpc-docs/src/main/antora/modules/ROOT/nav.adoc b/spring-grpc-docs/src/main/antora/modules/ROOT/nav.adoc index 166ab9b8..86a6e0e8 100644 --- a/spring-grpc-docs/src/main/antora/modules/ROOT/nav.adoc +++ b/spring-grpc-docs/src/main/antora/modules/ROOT/nav.adoc @@ -3,5 +3,6 @@ * xref:system-requirements.adoc[System Requirements] * xref:server.adoc[GRPC Server] * xref:client.adoc[GRPC Clients] +* xref:transcoding.adoc[HTTP/JSON Transcoding] * xref:contribution-guidelines.adoc[Contribution Guidelines] * xref:appendix.adoc[] diff --git a/spring-grpc-docs/src/main/antora/modules/ROOT/pages/transcoding.adoc b/spring-grpc-docs/src/main/antora/modules/ROOT/pages/transcoding.adoc new file mode 100644 index 00000000..82137c67 --- /dev/null +++ b/spring-grpc-docs/src/main/antora/modules/ROOT/pages/transcoding.adoc @@ -0,0 +1,168 @@ +[[transcoding]] += HTTP/JSON Transcoding + +Spring gRPC provides a transcoding module that auto-exposes unary gRPC services as HTTP/JSON endpoints, derived from `google.api.http` annotations on your proto definitions. +HTTP-served calls route through the in-process gRPC server, so server interceptors and exception handling (`@GrpcExceptionHandler`) execute for transcoded calls as well as native gRPC calls. +The incoming HTTP request context is not automatically equivalent to native gRPC metadata. + +NOTE: This feature is a proof of concept in v1 and supports a subset of the `google.api.http` specification. + +== How it works + +At build time, a JVM-based protoc plugin reads the `google.api.http` annotations from your `.proto` files and generates one `@RestController` per gRPC service. +At runtime, these controllers bridge HTTP requests into the existing gRPC services through an in-process gRPC channel, without a network socket. +This means the entire gRPC interceptor chain (`@GrpcSecurity`, `@GrpcExceptionHandler`, custom `ServerInterceptor`s) applies uniformly to HTTP-served calls. + +== Setup + +=== 1. Add the dependency + +[source,xml] +---- + + org.springframework.grpc + spring-grpc-transcoding + + + org.springframework.boot + spring-boot-starter-grpc-client + +---- + +The client starter provides the `GrpcChannelFactory` used to create, customize and manage the internal in-process channel. + +You also need the `google.api.http` proto definitions on the protoc classpath: + +[source,xml] +---- + + com.google.api.grpc + proto-google-common-protos + +---- + +=== 2. Configure the protoc plugin + +The `spring-grpc-transcoding` JAR is itself the protoc plugin (its main class is `org.springframework.grpc.transcoding.codegen.TranscodingProtocPlugin`). +Register it as a `jvm-maven` plugin in the `protobuf-maven-plugin`: + +[source,xml] +---- + + io.github.ascopes + protobuf-maven-plugin + + + ${protobuf-java.version} + + + + io.grpc + protoc-gen-grpc-java + ${grpc-java.version} + + + org.springframework.grpc + spring-grpc-transcoding + ${spring-grpc.version} + + + + +---- + +=== 3. Annotate your proto + +[source,proto] +---- +syntax = "proto3"; +option java_multiple_files = true; +import "google/api/annotations.proto"; + +service Simple { + rpc SayHello(HelloRequest) returns (HelloReply) { + option (google.api.http) = { get: "/v1/hello/{name}" }; + } +} + +message HelloRequest { string name = 1; } +message HelloReply { string message = 1; } +---- + +=== 4. Import the Spring configuration + +Transcoding is explicitly enabled by importing `GrpcTranscodingConfiguration`: + +[source,java] +---- +@SpringBootApplication +@Import(GrpcTranscodingConfiguration.class) +public class GrpcServerApplication { ... } +---- + +This is ordinary Spring configuration, not Spring Boot auto-configuration. +It registers the transcoding channel and protobuf JSON converter. +Applications that need to replace this infrastructure should omit the convenience configuration. +They can configure the channel and converter beans directly. + +Generated controllers are placed in the protobuf `java_package` and use ordinary Spring component scanning. +Ensure that package is beneath your application's scan root, or include it explicitly with `@ComponentScan`. + +=== 5. Configure the in-process server + +The transcoding channel targets the in-process gRPC server by name, so both sides must agree on the name: + +[source,properties] +---- +spring.grpc.server.inprocess.name=grpc-transcoding +---- + +== Supported HttpRule subset (v1) + +The initial version provides a strict, explicitly documented subset of `google.api.http`: + +* *RPC type*: unary RPCs +* *Verbs*: `get`, `post`, `put`, `delete` +* *Java layout*: `java_multiple_files = true` +* *Path variables*: simple top-level singular string fields such as `{name}` and `{id}` +* *Body*: no request body, or `body: "*"` +* *Query parameters*: top-level singular string fields not bound by the path, when the rule has no request body +* *Response*: the entire protobuf response message + +With `body: "*"`, the JSON body supplies request fields that are not bound by the path. +Path values take precedence if the body also contains those fields. +Query parameters do not contribute to the protobuf request when `body: "*"` is used. +Query parameter names use the protobuf JSON name (`json_name`, or its derived lower-camel-case name). +A singular query parameter supplied more than once is rejected with HTTP 400. + +An annotated method outside this subset fails protobuf code generation with a diagnostic identifying the protobuf file, service, method and unsupported rule element. +The plugin does not silently skip the annotated method or generate a route with changed semantics. +Methods without `google.api.http` annotations remain gRPC-only and do not produce an error. + +The following features are not supported in v1: + +* `patch` and custom verbs +* Named-field `body`, such as `body: "book"` +* Nested or constrained path templates, such as `{book.name}` or `{name=publishers/*/books/*}` +* `additional_bindings` +* `response_body` +* Annotated streaming RPCs +* Non-string, repeated, map, message or group path/query fields +* Multipart / binary uploads +* `HttpBody` responses + +== Error handling + +The module does not define an HTTP error response contract. +Errors from the gRPC server propagate from generated controller methods as `StatusRuntimeException`. +Applications can translate those exceptions into their preferred HTTP status and body with normal Spring MVC exception handling such as `@RestControllerAdvice`. + +== Configuration properties + +[cols="1,3,2", options="header"] +|=== +|Property |Default |Description +|`spring.grpc.transcoding.in-process-name` |`grpc-transcoding` |The in-process server name to connect to (must match the server config) +|=== + +Importing `GrpcTranscodingConfiguration` enables transcoding; omitting the import disables it. diff --git a/spring-grpc-transcoding/pom.xml b/spring-grpc-transcoding/pom.xml new file mode 100644 index 00000000..75f0a553 --- /dev/null +++ b/spring-grpc-transcoding/pom.xml @@ -0,0 +1,105 @@ + + + 4.0.0 + + org.springframework.grpc + spring-grpc + 1.1.1-SNAPSHOT + + spring-grpc-transcoding + jar + Spring gRPC Transcoding + HTTP/JSON transcoding for gRPC services via google.api.http annotations + + + 2.72.0 + + + + + org.springframework.grpc + spring-grpc-core + + + org.springframework + spring-web + + + org.springframework + spring-context + + + org.springframework + spring-core + + + io.grpc + grpc-api + + + io.grpc + grpc-inprocess + + + com.google.protobuf + protobuf-java + + + com.google.protobuf + protobuf-java-util + + runtime + + + com.google.api.grpc + proto-google-common-protos + ${google-common-protos.version} + + + org.assertj + assertj-core + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-params + test + + + org.mockito + mockito-core + test + + + org.springframework + spring-beans + test + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + ${project.artifactId} + ${project.version} + org.springframework.grpc.transcoding.codegen.TranscodingProtocPlugin + + + + + + + + diff --git a/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/ControllerEmitter.java b/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/ControllerEmitter.java new file mode 100644 index 00000000..3d9c8632 --- /dev/null +++ b/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/ControllerEmitter.java @@ -0,0 +1,146 @@ +/* + * Copyright 2024-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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 org.springframework.grpc.transcoding.codegen; + +import java.util.ArrayList; +import java.util.List; + +/** + * Renders a validated transcoding model as a Spring MVC controller. + * + * @author Aleksander Brzozowski + */ +final class ControllerEmitter { + + private static final String IMPORTS = """ + import java.util.List; + + import io.grpc.Channel; + + import org.springframework.beans.factory.annotation.Qualifier; + import org.springframework.http.HttpStatus; + import org.springframework.web.bind.annotation.DeleteMapping; + import org.springframework.web.bind.annotation.GetMapping; + import org.springframework.web.bind.annotation.PathVariable; + import org.springframework.web.bind.annotation.PostMapping; + import org.springframework.web.bind.annotation.PutMapping; + import org.springframework.web.bind.annotation.RequestBody; + import org.springframework.web.bind.annotation.RequestParam; + import org.springframework.web.bind.annotation.RestController; + import org.springframework.web.server.ResponseStatusException; + + """; + + String emit(TranscodingModel.Service service) { + String controllerName = service.serviceName() + "TranscodingController"; + StringBuilder source = new StringBuilder("// GENERATED CODE - DO NOT EDIT.\n"); + if (!service.javaPackage().isEmpty()) { + source.append("package ").append(service.javaPackage()).append(";\n\n"); + } + source.append(IMPORTS) + .append("@RestController\nclass ") + .append(controllerName) + .append(" {\n\n\tprivate final ") + .append(service.stubType()) + .append('.') + .append(service.serviceName()) + .append("BlockingStub stub;\n\n\t") + .append(controllerName) + .append("(@Qualifier(\"grpcTranscodingChannel\") Channel grpcTranscodingChannel) {\n\t\tthis.stub = ") + .append(service.stubType()) + .append(".newBlockingStub(grpcTranscodingChannel);\n\t}\n"); + for (TranscodingModel.Method method : service.methods()) { + emitMethod(source, method); + } + return source.append("}\n").toString(); + } + + private void emitMethod(StringBuilder source, TranscodingModel.Method method) { + source.append("\n\t@") + .append(mappingAnnotation(method.verb())) + .append("(\"") + .append(method.path()) + .append("\")\n\t") + .append(method.outputType()) + .append(' ') + .append(method.methodName()) + .append('(') + .append(String.join(", ", parameterDeclarations(method))) + .append(") {\n\t\t") + .append(method.inputType()) + .append(".Builder requestBuilder = ") + .append(method.body() ? "request.toBuilder()" : method.inputType() + ".newBuilder()") + .append(";\n"); + for (TranscodingModel.FieldBinding binding : method.queryBindings()) { + emitQueryBinding(source, binding); + } + for (TranscodingModel.FieldBinding binding : method.pathBindings()) { + source.append("\t\trequestBuilder.") + .append(binding.setterName()) + .append('(') + .append(binding.parameterName()) + .append(");\n"); + } + source.append("\t\treturn this.stub.").append(method.methodName()).append("(requestBuilder.build());\n\t}\n"); + } + + private List parameterDeclarations(TranscodingModel.Method method) { + List declarations = new ArrayList<>(); + for (TranscodingModel.FieldBinding binding : method.pathBindings()) { + declarations.add("@PathVariable(\"" + binding.httpName() + "\") String " + binding.parameterName()); + } + for (TranscodingModel.FieldBinding binding : method.queryBindings()) { + declarations.add("@RequestParam(name = \"" + binding.httpName() + "\", required = false) List " + + binding.parameterName()); + } + if (method.body()) { + declarations.add("@RequestBody " + method.inputType() + " request"); + } + return declarations; + } + + private void emitQueryBinding(StringBuilder source, TranscodingModel.FieldBinding binding) { + String parameterName = binding.parameterName(); + source.append("\t\tif (") + .append(parameterName) + .append(" != null && ") + .append(parameterName) + .append(".size() > 1) {\n\t\t\tthrow new ResponseStatusException(HttpStatus.BAD_REQUEST, ") + .append("\"Query parameter '") + .append(binding.httpName()) + .append("' must not occur more than once\");\n\t\t}\n\t\tif (") + .append(parameterName) + .append(" != null && !") + .append(parameterName) + .append(".isEmpty()) {\n\t\t\trequestBuilder.") + .append(binding.setterName()) + .append('(') + .append(parameterName) + .append(".get(0));\n\t\t}\n"); + } + + private String mappingAnnotation(String verb) { + return switch (verb) { + case "GET" -> "GetMapping"; + case "POST" -> "PostMapping"; + case "PUT" -> "PutMapping"; + case "DELETE" -> "DeleteMapping"; + default -> throw new IllegalStateException("Unsupported verb: " + verb); + }; + } + +} diff --git a/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/DescriptorRegistry.java b/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/DescriptorRegistry.java new file mode 100644 index 00000000..189d1093 --- /dev/null +++ b/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/DescriptorRegistry.java @@ -0,0 +1,78 @@ +/* + * Copyright 2024-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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 org.springframework.grpc.transcoding.codegen; + +import java.util.LinkedHashMap; +import java.util.Map; + +import com.google.protobuf.DescriptorProtos.DescriptorProto; +import com.google.protobuf.DescriptorProtos.FileDescriptorProto; + +/** + * Index of protobuf descriptors and their generated Java types. + * + * @author Aleksander Brzozowski + */ +final class DescriptorRegistry { + + private final Map messages = new LinkedHashMap<>(); + + DescriptorRegistry(Iterable files) { + for (FileDescriptorProto file : files) { + String protoPrefix = file.getPackage().isEmpty() ? "" : "." + file.getPackage(); + String javaPackage = javaPackage(file); + for (DescriptorProto message : file.getMessageTypeList()) { + indexMessage(file, message, protoPrefix + "." + message.getName(), + qualify(javaPackage, message.getName())); + } + } + } + + MessageType message(String protoName) { + MessageType type = this.messages.get(normalize(protoName)); + if (type == null) { + throw new GenerationException("cannot resolve message type '" + protoName + "'"); + } + return type; + } + + static String javaPackage(FileDescriptorProto file) { + if (file.hasOptions() && !file.getOptions().getJavaPackage().isEmpty()) { + return file.getOptions().getJavaPackage(); + } + return file.getPackage(); + } + + private void indexMessage(FileDescriptorProto file, DescriptorProto message, String protoName, String javaName) { + this.messages.put(protoName, new MessageType(protoName, javaName, file, message)); + for (DescriptorProto nested : message.getNestedTypeList()) { + indexMessage(file, nested, protoName + "." + nested.getName(), javaName + "." + nested.getName()); + } + } + + private String normalize(String protoName) { + return protoName.startsWith(".") ? protoName : "." + protoName; + } + + private String qualify(String javaPackage, String typeName) { + return javaPackage.isEmpty() ? typeName : javaPackage + "." + typeName; + } + + record MessageType(String protoName, String javaName, FileDescriptorProto file, DescriptorProto descriptor) { + } + +} diff --git a/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/FieldBindingResolver.java b/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/FieldBindingResolver.java new file mode 100644 index 00000000..9998d25e --- /dev/null +++ b/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/FieldBindingResolver.java @@ -0,0 +1,127 @@ +/* + * Copyright 2024-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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 org.springframework.grpc.transcoding.codegen; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.google.protobuf.DescriptorProtos.DescriptorProto; +import com.google.protobuf.DescriptorProtos.FieldDescriptorProto; +import com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Label; +import com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type; + +/** + * Resolves path and query bindings against a request message descriptor. + * + * @author Aleksander Brzozowski + */ +final class FieldBindingResolver { + + private static final Set FORBIDDEN_ACCESSOR_NAMES = Set.of("Class", "DefaultInstanceForType", + "ParserForType", "SerializedSize", "UnknownFields", "AllFields", "DescriptorForType", + "InitializationErrorString", "CachedSize"); + + Bindings resolve(DescriptorProto request, List pathVariables, boolean hasBody) { + Map fields = new HashMap<>(); + for (FieldDescriptorProto field : request.getFieldList()) { + fields.put(field.getName(), field); + } + + Set pathNames = new HashSet<>(); + List pathBindings = new java.util.ArrayList<>(); + for (int i = 0; i < pathVariables.size(); i++) { + String variable = pathVariables.get(i); + FieldDescriptorProto field = fields.get(variable); + if (field == null) { + throw new GenerationException("path variable '" + variable + "' does not name a request field"); + } + validateBindable(field, "path"); + pathNames.add(variable); + pathBindings.add(binding(field, variable, "pathField" + i)); + } + + List queryBindings = new java.util.ArrayList<>(); + if (!hasBody) { + int index = 0; + Set queryNames = new HashSet<>(); + for (FieldDescriptorProto field : request.getFieldList()) { + if (!pathNames.contains(field.getName())) { + validateBindable(field, "query"); + String queryName = jsonName(field); + if (!queryNames.add(queryName)) { + throw new GenerationException("multiple query fields use HTTP name '" + queryName + "'"); + } + queryBindings.add(binding(field, queryName, "queryField" + index++)); + } + } + } + return new Bindings(List.copyOf(pathBindings), List.copyOf(queryBindings)); + } + + private TranscodingModel.FieldBinding binding(FieldDescriptorProto field, String httpName, String parameterName) { + return new TranscodingModel.FieldBinding(field.getName(), httpName, parameterName, + "set" + accessorSuffix(field.getName())); + } + + private void validateBindable(FieldDescriptorProto field, String location) { + if (field.getLabel() == Label.LABEL_REPEATED) { + throw new GenerationException(location + " field '" + field.getName() + "' must not be repeated or a map"); + } + if (field.getType() != Type.TYPE_STRING) { + throw new GenerationException(location + " field '" + field.getName() + "' must be a string"); + } + } + + static void requireMultipleFiles(com.google.protobuf.DescriptorProtos.FileDescriptorProto file, String subject) { + if (!file.hasOptions() || !file.getOptions().getJavaMultipleFiles()) { + throw new GenerationException( + subject + " is declared in '" + file.getName() + "', which must set java_multiple_files = true"); + } + } + + private String jsonName(FieldDescriptorProto field) { + if (!field.getJsonName().isEmpty()) { + return field.getJsonName(); + } + String suffix = accessorSuffix(field.getName()); + return Character.toLowerCase(suffix.charAt(0)) + suffix.substring(1); + } + + private String accessorSuffix(String protoName) { + StringBuilder result = new StringBuilder(); + boolean capitalizeNext = true; + for (int i = 0; i < protoName.length(); i++) { + char candidate = protoName.charAt(i); + if (Character.isLetterOrDigit(candidate)) { + result.append(capitalizeNext ? Character.toUpperCase(candidate) : candidate); + capitalizeNext = Character.isDigit(candidate); + } + else { + capitalizeNext = true; + } + } + String suffix = result.toString(); + return FORBIDDEN_ACCESSOR_NAMES.contains(suffix) ? suffix + "_" : suffix; + } + + record Bindings(List path, List query) { + } + +} diff --git a/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/GenerationException.java b/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/GenerationException.java new file mode 100644 index 00000000..3370f1cb --- /dev/null +++ b/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/GenerationException.java @@ -0,0 +1,31 @@ +/* + * Copyright 2024-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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 org.springframework.grpc.transcoding.codegen; + +/** + * Signals that an annotated protobuf method cannot be represented by the supported + * transcoding subset. + * + * @author Aleksander Brzozowski + */ +final class GenerationException extends RuntimeException { + + GenerationException(String message) { + super(message); + } + +} diff --git a/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/HttpRuleResolver.java b/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/HttpRuleResolver.java new file mode 100644 index 00000000..76a70073 --- /dev/null +++ b/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/HttpRuleResolver.java @@ -0,0 +1,80 @@ +/* + * Copyright 2024-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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 org.springframework.grpc.transcoding.codegen; + +import com.google.api.AnnotationsProto; +import com.google.api.HttpRule; +import com.google.protobuf.DescriptorProtos.MethodDescriptorProto; + +/** + * Resolves and validates the supported {@code google.api.http} subset. + * + * @author Aleksander Brzozowski + */ +final class HttpRuleResolver { + + private HttpRuleResolver() { + } + + static boolean hasRule(MethodDescriptorProto method) { + return method.hasOptions() && method.getOptions().hasExtension(AnnotationsProto.http); + } + + static ResolvedRoute resolve(MethodDescriptorProto method) { + HttpRule rule = method.getOptions().getExtension(AnnotationsProto.http); + String verb; + String path; + switch (rule.getPatternCase()) { + case GET -> { + verb = "GET"; + path = rule.getGet(); + } + case POST -> { + verb = "POST"; + path = rule.getPost(); + } + case PUT -> { + verb = "PUT"; + path = rule.getPut(); + } + case DELETE -> { + verb = "DELETE"; + path = rule.getDelete(); + } + case PATCH -> throw new GenerationException("PATCH is not supported"); + case CUSTOM -> throw new GenerationException("custom HTTP verbs are not supported"); + default -> throw new GenerationException("HTTP rule does not declare a supported path pattern"); + } + + if (!rule.getBody().isEmpty() && !"*".equals(rule.getBody())) { + throw new GenerationException("named request bodies are not supported: '" + rule.getBody() + "'"); + } + if (!rule.getResponseBody().isEmpty()) { + throw new GenerationException("response_body is not supported"); + } + if (rule.getAdditionalBindingsCount() > 0) { + throw new GenerationException("additional_bindings are not supported"); + } + + PathTemplate template = PathTemplate.parse(path); + return new ResolvedRoute(verb, template.getPattern(), template.getVariables(), "*".equals(rule.getBody())); + } + + record ResolvedRoute(String verb, String path, java.util.List pathVariables, boolean body) { + } + +} diff --git a/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/PathTemplate.java b/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/PathTemplate.java new file mode 100644 index 00000000..22d9eb17 --- /dev/null +++ b/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/PathTemplate.java @@ -0,0 +1,83 @@ +/* + * Copyright 2024-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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 org.springframework.grpc.transcoding.codegen; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Validated representation of the simple path-template subset. + * + * @author Aleksander Brzozowski + */ +final class PathTemplate { + + private static final Pattern VARIABLE = Pattern.compile("\\{([A-Za-z_][A-Za-z0-9_]*)}"); + + private static final Pattern LITERAL = Pattern.compile("[A-Za-z0-9._~-]+"); + + private final String pattern; + + private final List variables; + + private PathTemplate(String pattern, List variables) { + this.pattern = pattern; + this.variables = variables; + } + + String getPattern() { + return this.pattern; + } + + List getVariables() { + return this.variables; + } + + static PathTemplate parse(String template) { + if (template == null || !template.startsWith("/")) { + throw new GenerationException("HTTP path must start with '/'"); + } + if (template.length() > 1 && template.endsWith("/")) { + throw new GenerationException("HTTP path must not have a trailing '/'"); + } + + List variables = new ArrayList<>(); + Set uniqueVariables = new HashSet<>(); + String[] segments = template.substring(1).split("/", -1); + for (String segment : segments) { + if (segment.isEmpty()) { + throw new GenerationException("HTTP path must not contain empty segments"); + } + var matcher = VARIABLE.matcher(segment); + if (matcher.matches()) { + String variable = matcher.group(1); + if (!uniqueVariables.add(variable)) { + throw new GenerationException("duplicate path variable '" + variable + "'"); + } + variables.add(variable); + } + else if (!LITERAL.matcher(segment).matches()) { + throw new GenerationException("unsupported HTTP path segment '" + segment + "'"); + } + } + return new PathTemplate(template, List.copyOf(variables)); + } + +} diff --git a/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/TranscodingModel.java b/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/TranscodingModel.java new file mode 100644 index 00000000..87df25f5 --- /dev/null +++ b/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/TranscodingModel.java @@ -0,0 +1,41 @@ +/* + * Copyright 2024-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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 org.springframework.grpc.transcoding.codegen; + +import java.util.List; + +/** + * Validated intermediate representation consumed by the source emitter. + * + * @author Aleksander Brzozowski + */ +final class TranscodingModel { + + private TranscodingModel() { + } + + record Service(String javaPackage, String serviceName, String stubType, List methods) { + } + + record Method(String methodName, String inputType, String outputType, String verb, String path, boolean body, + List pathBindings, List queryBindings) { + } + + record FieldBinding(String protoName, String httpName, String parameterName, String setterName) { + } + +} diff --git a/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/TranscodingModelBuilder.java b/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/TranscodingModelBuilder.java new file mode 100644 index 00000000..7b7f5943 --- /dev/null +++ b/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/TranscodingModelBuilder.java @@ -0,0 +1,94 @@ +/* + * Copyright 2024-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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 org.springframework.grpc.transcoding.codegen; + +import java.util.ArrayList; +import java.util.List; + +import com.google.protobuf.DescriptorProtos.FileDescriptorProto; +import com.google.protobuf.DescriptorProtos.MethodDescriptorProto; +import com.google.protobuf.DescriptorProtos.ServiceDescriptorProto; + +/** + * Converts descriptors and HTTP rules into a validated generation model. + * + * @author Aleksander Brzozowski + */ +final class TranscodingModelBuilder { + + private final DescriptorRegistry registry; + + private final FieldBindingResolver fieldBindings; + + TranscodingModelBuilder(DescriptorRegistry registry) { + this.registry = registry; + this.fieldBindings = new FieldBindingResolver(); + } + + List build(FileDescriptorProto file) { + List result = new ArrayList<>(); + for (ServiceDescriptorProto service : file.getServiceList()) { + List methods = new ArrayList<>(); + for (MethodDescriptorProto method : service.getMethodList()) { + if (!HttpRuleResolver.hasRule(method)) { + continue; + } + try { + methods.add(buildMethod(file, method)); + } + catch (GenerationException ex) { + throw new GenerationException(file.getName() + ": " + service.getName() + "." + method.getName() + + ": " + ex.getMessage()); + } + } + if (!methods.isEmpty()) { + String javaPackage = DescriptorRegistry.javaPackage(file); + String stubType = javaPackage.isEmpty() ? service.getName() + "Grpc" + : javaPackage + "." + service.getName() + "Grpc"; + result + .add(new TranscodingModel.Service(javaPackage, service.getName(), stubType, List.copyOf(methods))); + } + } + return List.copyOf(result); + } + + private TranscodingModel.Method buildMethod(FileDescriptorProto serviceFile, MethodDescriptorProto method) { + FieldBindingResolver.requireMultipleFiles(serviceFile, "service"); + if (method.getClientStreaming() || method.getServerStreaming()) { + throw new GenerationException("annotated streaming methods are not supported"); + } + + DescriptorRegistry.MessageType input = this.registry.message(method.getInputType()); + DescriptorRegistry.MessageType output = this.registry.message(method.getOutputType()); + FieldBindingResolver.requireMultipleFiles(input.file(), "request type '" + method.getInputType() + "'"); + FieldBindingResolver.requireMultipleFiles(output.file(), "response type '" + method.getOutputType() + "'"); + + HttpRuleResolver.ResolvedRoute route = HttpRuleResolver.resolve(method); + FieldBindingResolver.Bindings bindings = this.fieldBindings.resolve(input.descriptor(), route.pathVariables(), + route.body()); + return new TranscodingModel.Method(javaMethodName(method.getName()), input.javaName(), output.javaName(), + route.verb(), route.path(), route.body(), bindings.path(), bindings.query()); + } + + private String javaMethodName(String protoName) { + if (protoName.isEmpty()) { + return protoName; + } + return Character.toLowerCase(protoName.charAt(0)) + protoName.substring(1); + } + +} diff --git a/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/TranscodingProtocPlugin.java b/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/TranscodingProtocPlugin.java new file mode 100644 index 00000000..8220b718 --- /dev/null +++ b/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/TranscodingProtocPlugin.java @@ -0,0 +1,81 @@ +/* + * Copyright 2024-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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 org.springframework.grpc.transcoding.codegen; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.HashSet; +import java.util.Set; + +import com.google.api.AnnotationsProto; +import com.google.protobuf.ExtensionRegistry; +import com.google.protobuf.compiler.PluginProtos.CodeGeneratorRequest; +import com.google.protobuf.compiler.PluginProtos.CodeGeneratorResponse; + +/** + * Protoc plugin that generates Spring MVC controllers for supported HTTP rules. + * + * @author Aleksander Brzozowski + */ +public final class TranscodingProtocPlugin { + + private TranscodingProtocPlugin() { + } + + public static void main(String[] args) throws IOException { + run(System.in, System.out); + } + + static void run(InputStream stdin, OutputStream stdout) throws IOException { + ExtensionRegistry extensions = ExtensionRegistry.newInstance(); + AnnotationsProto.registerAllExtensions(extensions); + CodeGeneratorRequest request = CodeGeneratorRequest.parseFrom(stdin, extensions); + CodeGeneratorResponse response; + try { + response = generate(request); + } + catch (GenerationException ex) { + response = CodeGeneratorResponse.newBuilder().setError(ex.getMessage()).build(); + } + response.writeTo(stdout); + } + + private static CodeGeneratorResponse generate(CodeGeneratorRequest request) { + DescriptorRegistry registry = new DescriptorRegistry(request.getProtoFileList()); + TranscodingModelBuilder modelBuilder = new TranscodingModelBuilder(registry); + ControllerEmitter emitter = new ControllerEmitter(); + CodeGeneratorResponse.Builder response = CodeGeneratorResponse.newBuilder(); + Set filesToGenerate = new HashSet<>(request.getFileToGenerateList()); + + for (var file : request.getProtoFileList()) { + if (!filesToGenerate.contains(file.getName())) { + continue; + } + for (TranscodingModel.Service service : modelBuilder.build(file)) { + String fileName = service.javaPackage().replace('.', '/') + "/" + service.serviceName() + + "TranscodingController.java"; + response.addFile(CodeGeneratorResponse.File.newBuilder() + .setName(fileName) + .setContent(emitter.emit(service)) + .build()); + } + } + return response.build(); + } + +} diff --git a/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/package-info.java b/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/package-info.java new file mode 100644 index 00000000..1b54e232 --- /dev/null +++ b/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/codegen/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2024-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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. + */ + +/** + * Code-generation components for gRPC-JSON transcoding. + */ +package org.springframework.grpc.transcoding.codegen; diff --git a/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/config/GrpcTranscodingConfiguration.java b/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/config/GrpcTranscodingConfiguration.java new file mode 100644 index 00000000..ddbdf75a --- /dev/null +++ b/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/config/GrpcTranscodingConfiguration.java @@ -0,0 +1,52 @@ +/* + * Copyright 2024-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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 org.springframework.grpc.transcoding.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.grpc.client.ChannelBuilderOptions; +import org.springframework.grpc.client.GrpcChannelFactory; +import org.springframework.http.converter.protobuf.ProtobufJsonFormatHttpMessageConverter; + +import io.grpc.Channel; +import io.grpc.inprocess.InProcessChannelBuilder; + +/** + * Configuration for gRPC-JSON transcoding. Applications opt in to transcoding by + * importing this configuration. + * + * @author Aleksander Brzozowski + */ +@Configuration(proxyBeanMethods = false) +public class GrpcTranscodingConfiguration { + + @Bean(destroyMethod = "") + Channel grpcTranscodingChannel(Environment environment, GrpcChannelFactory channelFactory) { + String inProcessName = environment.getProperty("spring.grpc.transcoding.in-process-name", "grpc-transcoding"); + ChannelBuilderOptions options = ChannelBuilderOptions.defaults() + .withInterceptorsMerge(true) + .withCustomizer((__, builder) -> builder.directExecutor()); + return channelFactory.createChannel("in-process:" + inProcessName, options); + } + + @Bean + ProtobufJsonFormatHttpMessageConverter protobufJsonFormatHttpMessageConverter() { + return new ProtobufJsonFormatHttpMessageConverter(); + } + +} diff --git a/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/config/package-info.java b/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/config/package-info.java new file mode 100644 index 00000000..70ef0e14 --- /dev/null +++ b/spring-grpc-transcoding/src/main/java/org/springframework/grpc/transcoding/config/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2024-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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. + */ + +/** + * Configuration support for gRPC-JSON transcoding. + */ +package org.springframework.grpc.transcoding.config; diff --git a/spring-grpc-transcoding/src/test/java/org/springframework/grpc/transcoding/codegen/PathTemplateTests.java b/spring-grpc-transcoding/src/test/java/org/springframework/grpc/transcoding/codegen/PathTemplateTests.java new file mode 100644 index 00000000..a20fe651 --- /dev/null +++ b/spring-grpc-transcoding/src/test/java/org/springframework/grpc/transcoding/codegen/PathTemplateTests.java @@ -0,0 +1,78 @@ +/* + * Copyright 2024-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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 org.springframework.grpc.transcoding.codegen; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link PathTemplate}. + * + * @author Aleksander Brzozowski + */ +class PathTemplateTests { + + @Test + void singleVariable() { + PathTemplate template = PathTemplate.parse("/v1/hello/{name}"); + assertThat(template.getVariables()).containsExactly("name"); + assertThat(template.getPattern()).isEqualTo("/v1/hello/{name}"); + } + + @Test + void multipleVariables() { + PathTemplate template = PathTemplate.parse("/v1/users/{user_id}/posts/{post_id}"); + assertThat(template.getVariables()).containsExactly("user_id", "post_id"); + } + + @Test + void noVariables() { + PathTemplate template = PathTemplate.parse("/v1/noVars"); + assertThat(template.getVariables()).isEmpty(); + } + + @Test + void interleavedVariables() { + PathTemplate template = PathTemplate.parse("/{a}/b/{c}"); + assertThat(template.getVariables()).containsExactly("a", "c"); + } + + @Test + void underscorePrefixedVariable() { + PathTemplate template = PathTemplate.parse("/{__id}"); + assertThat(template.getVariables()).containsExactly("__id"); + } + + @Test + void patternIsPreservedVerbatim() { + String raw = "/v1/items/{item_id}/sub/{sub_id}"; + PathTemplate template = PathTemplate.parse(raw); + assertThat(template.getPattern()).isEqualTo(raw); + } + + @Test + void rejectsUnsupportedAndMalformedTemplates() { + assertThatThrownBy(() -> PathTemplate.parse("v1/items")).isInstanceOf(GenerationException.class); + assertThatThrownBy(() -> PathTemplate.parse("/v1/{name=items/*}")).isInstanceOf(GenerationException.class); + assertThatThrownBy(() -> PathTemplate.parse("/v1/{parent.name}")).isInstanceOf(GenerationException.class); + assertThatThrownBy(() -> PathTemplate.parse("/v1/{name}/{name}")).isInstanceOf(GenerationException.class) + .hasMessageContaining("duplicate path variable"); + } + +} diff --git a/spring-grpc-transcoding/src/test/java/org/springframework/grpc/transcoding/codegen/TranscodingProtocPluginTests.java b/spring-grpc-transcoding/src/test/java/org/springframework/grpc/transcoding/codegen/TranscodingProtocPluginTests.java new file mode 100644 index 00000000..78279ce6 --- /dev/null +++ b/spring-grpc-transcoding/src/test/java/org/springframework/grpc/transcoding/codegen/TranscodingProtocPluginTests.java @@ -0,0 +1,402 @@ +/* + * Copyright 2024-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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 org.springframework.grpc.transcoding.codegen; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import javax.tools.DiagnosticCollector; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.ToolProvider; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import com.google.api.AnnotationsProto; +import com.google.api.HttpRule; +import com.google.protobuf.DescriptorProtos.DescriptorProto; +import com.google.protobuf.DescriptorProtos.FieldDescriptorProto; +import com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Label; +import com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type; +import com.google.protobuf.DescriptorProtos.FileDescriptorProto; +import com.google.protobuf.DescriptorProtos.FileOptions; +import com.google.protobuf.DescriptorProtos.MethodDescriptorProto; +import com.google.protobuf.DescriptorProtos.MethodOptions; +import com.google.protobuf.DescriptorProtos.ServiceDescriptorProto; +import com.google.protobuf.compiler.PluginProtos.CodeGeneratorRequest; +import com.google.protobuf.compiler.PluginProtos.CodeGeneratorResponse; + +/** + * End-to-end tests for {@link TranscodingProtocPlugin}. + * + * @author Aleksander Brzozowski + */ +class TranscodingProtocPluginTests { + + @TempDir + Path temporaryDirectory; + + @Test + void controllerWithImportedNestedRequestAndStringQueryCompiles() throws IOException { + FileDescriptorProto types = importedTypesFile(); + FileDescriptorProto service = serviceFile("GET", HttpRule.newBuilder().setGet("/v1/fetch").build(), true, + false); + + CodeGeneratorResponse response = runPlugin(request(service, types)); + CodeGeneratorResponse.File generated = onlyGeneratedFile(response); + + String source = generated.getContent(); + assertThat(source).contains("types.api.Container.Nested.Builder requestBuilder") + .contains("@RequestParam(name = \"queryText\", required = false) List queryField0") + .contains("requestBuilder.setQueryText(queryField0.get(0))") + .contains("@Qualifier(\"grpcTranscodingChannel\")"); + assertCompiles(generated); + } + + @Test + void bodyIsDecodedBeforePathBinding() throws IOException { + FileDescriptorProto service = localServiceFile( + HttpRule.newBuilder().setPost("/v1/items/{id}").setBody("*").build(), false, true); + + CodeGeneratorResponse response = runPlugin(request(service)); + + assertThat(response.getError()).isEmpty(); + assertThat(response.getFile(0).getContent()).contains(".Builder requestBuilder = request.toBuilder()") + .contains("requestBuilder.setId(pathField0)") + .doesNotContain("@RequestParam"); + } + + @Test + void duplicateQueryValuesAreRejected() throws IOException { + CodeGeneratorResponse response = runPlugin( + request(serviceFile("GET", HttpRule.newBuilder().setGet("/v1/fetch").build(), true, false), + importedTypesFile())); + + assertThat(response.getFile(0).getContent()).contains("if (queryField0 != null && queryField0.size() > 1)") + .contains("Query parameter 'queryText' must not occur more than once"); + } + + @Test + void unsupportedAnnotatedRuleFailsGenerationWithoutPartialFiles() throws IOException { + FileDescriptorProto supported = localServiceFile(HttpRule.newBuilder().setGet("/v1/items/{id}").build(), false, + true); + FileDescriptorProto unsupported = localServiceFile(HttpRule.newBuilder().setPatch("/v1/items/{id}").build(), + false, true) + .toBuilder() + .setName("unsupported.proto") + .build(); + + CodeGeneratorResponse response = runPlugin(CodeGeneratorRequest.newBuilder() + .addFileToGenerate(supported.getName()) + .addFileToGenerate(unsupported.getName()) + .addProtoFile(supported) + .addProtoFile(unsupported) + .build()); + + assertThat(response.getFileCount()).isZero(); + assertThat(response.getError()).contains("unsupported.proto: Items.UpdateItem: PATCH is not supported"); + } + + @ParameterizedTest(name = "{0}") + @CsvSource(delimiter = '|', textBlock = """ + GET | @GetMapping + POST | @PostMapping + PUT | @PutMapping + DELETE | @DeleteMapping + """) + void eachSupportedVerbGeneratesItsSpringMapping(String verb, String expectedMapping) throws IOException { + CodeGeneratorResponse.File generated = onlyGeneratedFile( + runPlugin(request(localServiceFile(httpRule(verb), false, true)))); + + assertThat(generated.getContent()).contains(expectedMapping); + } + + @ParameterizedTest(name = "{0}") + @CsvSource(delimiter = '|', textBlock = """ + named body | named request bodies are not supported + response body | response_body is not supported + additional binding | additional_bindings are not supported + unknown path variable | does not name a request field + """) + void unsupportedHttpRuleFeaturesProduceContextualErrors(String feature, String expectedError) throws IOException { + CodeGeneratorResponse response = runPlugin( + request(localServiceFile(unsupportedHttpRule(feature), false, true))); + + assertThat(response.getFileCount()).isZero(); + assertThat(response.getError()).contains("items.proto: Items.UpdateItem:", expectedError); + } + + @Test + void unsupportedQueryFieldsFailGeneration() throws IOException { + HttpRule get = HttpRule.newBuilder().setGet("/v1/items/{id}").build(); + FileDescriptorProto base = localServiceFile(get, false, true); + DescriptorProto repeatedRequest = base.getMessageType(0) + .toBuilder() + .addField(field("tags", 2, Type.TYPE_STRING).toBuilder().setLabel(Label.LABEL_REPEATED)) + .build(); + CodeGeneratorResponse repeatedResponse = runPlugin( + request(base.toBuilder().setMessageType(0, repeatedRequest).build())); + assertThat(repeatedResponse.getError()).contains("query field 'tags' must not be repeated or a map"); + + DescriptorProto messageRequest = base.getMessageType(0) + .toBuilder() + .addField(field("child", 2, Type.TYPE_MESSAGE).toBuilder().setTypeName(".items.ItemReply")) + .build(); + CodeGeneratorResponse messageResponse = runPlugin( + request(base.toBuilder().setMessageType(0, messageRequest).build())); + assertThat(messageResponse.getError()).contains("query field 'child' must be a string"); + + DescriptorProto numericRequest = base.getMessageType(0) + .toBuilder() + .addField(field("page_size", 2, Type.TYPE_INT32)) + .build(); + CodeGeneratorResponse numericResponse = runPlugin( + request(base.toBuilder().setMessageType(0, numericRequest).build())); + assertThat(numericResponse.getError()).contains("query field 'page_size' must be a string"); + } + + @Test + void nonStringPathFieldFailsGeneration() throws IOException { + FileDescriptorProto base = localServiceFile(HttpRule.newBuilder().setGet("/v1/items/{id}").build(), false, + true); + DescriptorProto numericRequest = base.getMessageType(0) + .toBuilder() + .setField(0, field("id", 1, Type.TYPE_INT64)) + .build(); + + CodeGeneratorResponse response = runPlugin(request(base.toBuilder().setMessageType(0, numericRequest).build())); + + assertThat(response.getError()).contains("path field 'id' must be a string"); + } + + @Test + void outerClassLayoutFailsGeneration() throws IOException { + FileDescriptorProto service = localServiceFile(HttpRule.newBuilder().setGet("/v1/items/{id}").build(), false, + false); + + CodeGeneratorResponse response = runPlugin(request(service)); + + assertThat(response.getFileCount()).isZero(); + assertThat(response.getError()).contains("Items.UpdateItem").contains("must set java_multiple_files = true"); + } + + @Test + void annotatedStreamingMethodFailsGeneration() throws IOException { + FileDescriptorProto service = localServiceFile(HttpRule.newBuilder().setGet("/v1/items/{id}").build(), true, + true); + + CodeGeneratorResponse response = runPlugin(request(service)); + + assertThat(response.getError()).contains("annotated streaming methods are not supported"); + } + + @Test + void unannotatedStreamingMethodIsIgnored() throws IOException { + FileDescriptorProto service = localServiceFile(null, true, true); + + CodeGeneratorResponse response = runPlugin(request(service)); + + assertThat(response.getError()).isEmpty(); + assertThat(response.getFileCount()).isZero(); + } + + private FileDescriptorProto importedTypesFile() { + DescriptorProto nested = DescriptorProto.newBuilder() + .setName("Nested") + .addField(field("query_text", 1, Type.TYPE_STRING).toBuilder().setJsonName("queryText")) + .build(); + DescriptorProto container = DescriptorProto.newBuilder().setName("Container").addNestedType(nested).build(); + return FileDescriptorProto.newBuilder() + .setName("types.proto") + .setPackage("types") + .setOptions(options("types.api", true)) + .addMessageType(container) + .build(); + } + + private FileDescriptorProto serviceFile(String verb, HttpRule rule, boolean importedInput, boolean streaming) { + MethodDescriptorProto.Builder method = MethodDescriptorProto.newBuilder() + .setName("Fetch") + .setInputType(importedInput ? ".types.Container.Nested" : ".service.Request") + .setOutputType(".service.Reply") + .setServerStreaming(streaming); + if (rule != null) { + method.setOptions(MethodOptions.newBuilder().setExtension(AnnotationsProto.http, rule)); + } + return FileDescriptorProto.newBuilder() + .setName("service.proto") + .setPackage("service") + .setOptions(options("service.api", true)) + .addDependency(importedInput ? "types.proto" : "") + .addMessageType(DescriptorProto.newBuilder().setName("Reply")) + .addService(ServiceDescriptorProto.newBuilder().setName("Test").addMethod(method)) + .build(); + } + + private FileDescriptorProto localServiceFile(HttpRule rule, boolean streaming, boolean multipleFiles) { + MethodDescriptorProto.Builder method = MethodDescriptorProto.newBuilder() + .setName("UpdateItem") + .setInputType(".items.ItemRequest") + .setOutputType(".items.ItemReply") + .setServerStreaming(streaming); + if (rule != null) { + method.setOptions(MethodOptions.newBuilder().setExtension(AnnotationsProto.http, rule)); + } + return FileDescriptorProto.newBuilder() + .setName("items.proto") + .setPackage("items") + .setOptions(options("items.api", multipleFiles)) + .addMessageType( + DescriptorProto.newBuilder().setName("ItemRequest").addField(field("id", 1, Type.TYPE_STRING))) + .addMessageType(DescriptorProto.newBuilder().setName("ItemReply")) + .addService(ServiceDescriptorProto.newBuilder().setName("Items").addMethod(method)) + .build(); + } + + private FieldDescriptorProto field(String name, int number, Type type) { + return FieldDescriptorProto.newBuilder() + .setName(name) + .setNumber(number) + .setLabel(Label.LABEL_OPTIONAL) + .setType(type) + .build(); + } + + private FileOptions options(String javaPackage, boolean multipleFiles) { + return FileOptions.newBuilder().setJavaPackage(javaPackage).setJavaMultipleFiles(multipleFiles).build(); + } + + private CodeGeneratorRequest request(FileDescriptorProto generated, FileDescriptorProto... dependencies) { + CodeGeneratorRequest.Builder request = CodeGeneratorRequest.newBuilder() + .addFileToGenerate(generated.getName()) + .addProtoFile(generated); + for (FileDescriptorProto dependency : dependencies) { + request.addProtoFile(dependency); + } + return request.build(); + } + + private CodeGeneratorResponse runPlugin(CodeGeneratorRequest request) throws IOException { + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + TranscodingProtocPlugin.run(new ByteArrayInputStream(request.toByteArray()), stdout); + return CodeGeneratorResponse.parseFrom(stdout.toByteArray()); + } + + private CodeGeneratorResponse.File onlyGeneratedFile(CodeGeneratorResponse response) { + assertThat(response.getError()).isEmpty(); + assertThat(response.getFileCount()).isOne(); + return response.getFile(0); + } + + private void assertCompiles(CodeGeneratorResponse.File generated) throws IOException { + List sources = new ArrayList<>(); + sources.add(writeSource(generated.getName(), generated.getContent())); + sources.add(writeSource("types/api/Container.java", containerStub())); + sources.add(writeSource("service/api/Reply.java", replyStub())); + sources.add(writeSource("service/api/TestGrpc.java", grpcStub())); + + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, null, null)) { + Iterable units = fileManager.getJavaFileObjectsFromPaths(sources); + Path output = Files.createDirectories(this.temporaryDirectory.resolve("classes")); + boolean compiled = compiler.getTask(null, fileManager, diagnostics, + List.of("-classpath", System.getProperty("java.class.path"), "-d", output.toString()), null, units) + .call(); + assertThat(compiled).as("Generated source diagnostics: %s", diagnostics.getDiagnostics()).isTrue(); + } + } + + private Path writeSource(String relativePath, String source) throws IOException { + Path path = this.temporaryDirectory.resolve(relativePath); + Files.createDirectories(path.getParent()); + return Files.writeString(path, source); + } + + private String containerStub() { + return """ + package types.api; + public final class Container { + public static final class Nested { + public static Builder newBuilder() { return new Builder(); } + public Builder toBuilder() { return new Builder(); } + public static final class Builder { + public Builder setQueryText(String value) { return this; } + public Nested build() { return new Nested(); } + } + } + } + """; + } + + private String replyStub() { + return """ + package service.api; + public final class Reply {} + """; + } + + private String grpcStub() { + return """ + package service.api; + import io.grpc.Channel; + import types.api.Container; + public final class TestGrpc { + public static TestBlockingStub newBlockingStub(Channel channel) { return new TestBlockingStub(); } + public static final class TestBlockingStub { + public Reply fetch(Container.Nested request) { return new Reply(); } + } + } + """; + } + + private HttpRule httpRule(String verb) { + HttpRule.Builder rule = HttpRule.newBuilder(); + return switch (verb) { + case "GET" -> rule.setGet("/v1/items/{id}").build(); + case "POST" -> rule.setPost("/v1/items/{id}").setBody("*").build(); + case "PUT" -> rule.setPut("/v1/items/{id}").setBody("*").build(); + case "DELETE" -> rule.setDelete("/v1/items/{id}").build(); + default -> throw new IllegalArgumentException("Unsupported test verb: " + verb); + }; + } + + private HttpRule unsupportedHttpRule(String feature) { + return switch (feature) { + case "named body" -> HttpRule.newBuilder().setPost("/v1/items/{id}").setBody("item").build(); + case "response body" -> HttpRule.newBuilder().setGet("/v1/items/{id}").setResponseBody("result").build(); + case "additional binding" -> HttpRule.newBuilder() + .setGet("/v1/items/{id}") + .addAdditionalBindings(HttpRule.newBuilder().setGet("/v1/other/{id}")) + .build(); + case "unknown path variable" -> HttpRule.newBuilder().setGet("/v1/items/{missing}").build(); + default -> throw new IllegalArgumentException("Unsupported test feature: " + feature); + }; + } + +} diff --git a/spring-grpc-transcoding/src/test/java/org/springframework/grpc/transcoding/config/GrpcTranscodingConfigurationTests.java b/spring-grpc-transcoding/src/test/java/org/springframework/grpc/transcoding/config/GrpcTranscodingConfigurationTests.java new file mode 100644 index 00000000..1184396b --- /dev/null +++ b/spring-grpc-transcoding/src/test/java/org/springframework/grpc/transcoding/config/GrpcTranscodingConfigurationTests.java @@ -0,0 +1,94 @@ +/* + * Copyright 2024-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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 org.springframework.grpc.transcoding.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.core.env.MapPropertySource; +import org.springframework.grpc.client.ChannelBuilderOptions; +import org.springframework.grpc.client.GrpcChannelFactory; +import org.springframework.http.converter.protobuf.ProtobufJsonFormatHttpMessageConverter; + +import io.grpc.Channel; +import io.grpc.ManagedChannel; +import io.grpc.inprocess.InProcessChannelBuilder; + +/** + * Tests for {@link GrpcTranscodingConfiguration}. + * + * @author Aleksander Brzozowski + */ +class GrpcTranscodingConfigurationTests { + + @Test + void registersRuntimeInfrastructureWithDefaultInProcessName() { + GrpcChannelFactory channelFactory = mock(GrpcChannelFactory.class); + ManagedChannel channel = mock(ManagedChannel.class); + ArgumentCaptor optionsCaptor = ArgumentCaptor.forClass(ChannelBuilderOptions.class); + when(channelFactory.createChannel(eq("in-process:grpc-transcoding"), optionsCaptor.capture())) + .thenReturn(channel); + + try (AnnotationConfigApplicationContext context = applicationContext(channelFactory, Map.of())) { + assertThat(context.getBean("grpcTranscodingChannel", Channel.class)).isSameAs(channel); + assertThat(context.getBean(ProtobufJsonFormatHttpMessageConverter.class)).isNotNull(); + } + + verify(channelFactory).createChannel(eq("in-process:grpc-transcoding"), any(ChannelBuilderOptions.class)); + ChannelBuilderOptions options = optionsCaptor.getValue(); + assertThat(options.mergeWithGlobalInterceptors()).isTrue(); + InProcessChannelBuilder builder = mock(InProcessChannelBuilder.class); + options.customizer().customize("in-process:grpc-transcoding", builder); + verify(builder).directExecutor(); + } + + @Test + void usesCustomInProcessNameFromEnvironment() { + GrpcChannelFactory channelFactory = mock(GrpcChannelFactory.class); + ManagedChannel channel = mock(ManagedChannel.class); + when(channelFactory.createChannel(eq("in-process:custom-server"), any(ChannelBuilderOptions.class))) + .thenReturn(channel); + + try (AnnotationConfigApplicationContext context = applicationContext(channelFactory, + Map.of("spring.grpc.transcoding.in-process-name", "custom-server"))) { + assertThat(context.getBean("grpcTranscodingChannel", Channel.class)).isSameAs(channel); + } + + verify(channelFactory).createChannel(eq("in-process:custom-server"), any(ChannelBuilderOptions.class)); + } + + private AnnotationConfigApplicationContext applicationContext(GrpcChannelFactory channelFactory, + Map properties) { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.getEnvironment().getPropertySources().addFirst(new MapPropertySource("test", properties)); + context.registerBean(GrpcChannelFactory.class, () -> channelFactory); + context.register(GrpcTranscodingConfiguration.class); + context.refresh(); + return context; + } + +}