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 extends JavaFileObject> 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;
+ }
+
+}