From f4aa5c5c73e1514036f7f380edebfa7f03b5e1c3 Mon Sep 17 00:00:00 2001 From: Daniel Fiala Date: Wed, 22 Jan 2025 19:53:04 +0100 Subject: [PATCH] Add support for gRPC transcoding --- pom.xml | 3 +- vertx-grpc-docs/src/main/asciidoc/plugin.adoc | 2 + vertx-grpc-docs/src/main/asciidoc/server.adoc | 6 + .../java/examples/VertxGreeterGrpcServer.java | 38 ++ .../examples/VertxStreamingGrpcServer.java | 94 +++ .../io/vertx/grpc/it/TranscodingTest.java | 67 +++ .../test/proto/google/api/annotations.proto | 31 + .../src/test/proto/google/api/http.proto | 371 ++++++++++++ vertx-grpc-it/src/test/proto/helloworld.proto | 6 +- vertx-grpc-protoc-plugin2/pom.xml | 5 + .../grpc/plugin/VertxGrpcGeneratorImpl.java | 75 ++- .../src/main/resources/server.mustache | 99 ++++ vertx-grpc-server/pom.xml | 12 + .../server/GrpcServerOptionsConverter.java | 6 + .../io/vertx/grpc/server/GrpcProtocol.java | 15 +- .../java/io/vertx/grpc/server/GrpcServer.java | 12 + .../vertx/grpc/server/GrpcServerOptions.java | 28 +- .../grpc/server/impl/GrpcServerImpl.java | 132 ++++- .../server/impl/GrpcServerRequestImpl.java | 101 +++- .../server/impl/GrpcServerResponseImpl.java | 46 +- .../src/main/java/module-info.java | 8 +- .../tests/server/ServerTranscodingTest.java | 204 +++++++ .../server/transcoding/TranscodingTest.java | 183 ++++++ .../src/test/java/module-info.java | 10 +- vertx-grpc-transcoding/pom.xml | 64 +++ .../vertx/grpc/transcoding/HttpTemplate.java | 59 ++ .../transcoding/HttpTemplateVariable.java | 91 +++ .../grpc/transcoding/HttpVariableBinding.java | 60 ++ .../vertx/grpc/transcoding/PathMatcher.java | 27 + .../grpc/transcoding/PathMatcherBuilder.java | 118 ++++ .../transcoding/PathMatcherLookupResult.java | 27 + .../ServiceTranscodingOptions.java | 132 +++++ .../transcoding/impl/HttpTemplateImpl.java | 34 ++ .../transcoding/impl/HttpTemplateParser.java | 322 +++++++++++ .../impl/HttpTemplateVariableImpl.java | 53 ++ .../impl/HttpVariableBindingImpl.java | 39 ++ .../impl/PathMatcherBuilderImpl.java | 124 ++++ .../transcoding/impl/PathMatcherImpl.java | 65 +++ .../impl/PathMatcherMethodData.java | 45 ++ .../transcoding/impl/PathMatcherNode.java | 207 +++++++ .../transcoding/impl/PathMatcherUtility.java | 140 +++++ .../transcoding/impl/PercentEncoding.java | 133 +++++ .../vertx/grpc/transcoding/package-info.java | 14 + .../src/main/java/module-info.java | 14 + .../tests/transcoding/HttpTemplateTest.java | 533 ++++++++++++++++++ .../tests/transcoding/PathMatcherTest.java | 326 +++++++++++ .../transcoding/PathMatcherUtilityTest.java | 130 +++++ .../src/test/java/module-info.java | 7 + 48 files changed, 4271 insertions(+), 47 deletions(-) create mode 100644 vertx-grpc-it/src/test/java/io/vertx/grpc/it/TranscodingTest.java create mode 100644 vertx-grpc-it/src/test/proto/google/api/annotations.proto create mode 100644 vertx-grpc-it/src/test/proto/google/api/http.proto create mode 100644 vertx-grpc-server/src/test/java/io/vertx/tests/server/ServerTranscodingTest.java create mode 100644 vertx-grpc-server/src/test/java/io/vertx/tests/server/transcoding/TranscodingTest.java create mode 100644 vertx-grpc-transcoding/pom.xml create mode 100644 vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/HttpTemplate.java create mode 100644 vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/HttpTemplateVariable.java create mode 100644 vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/HttpVariableBinding.java create mode 100644 vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/PathMatcher.java create mode 100644 vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/PathMatcherBuilder.java create mode 100644 vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/PathMatcherLookupResult.java create mode 100644 vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/ServiceTranscodingOptions.java create mode 100644 vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/HttpTemplateImpl.java create mode 100644 vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/HttpTemplateParser.java create mode 100644 vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/HttpTemplateVariableImpl.java create mode 100644 vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/HttpVariableBindingImpl.java create mode 100644 vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/PathMatcherBuilderImpl.java create mode 100644 vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/PathMatcherImpl.java create mode 100644 vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/PathMatcherMethodData.java create mode 100644 vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/PathMatcherNode.java create mode 100644 vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/PathMatcherUtility.java create mode 100644 vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/PercentEncoding.java create mode 100644 vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/package-info.java create mode 100644 vertx-grpc-transcoding/src/main/java/module-info.java create mode 100644 vertx-grpc-transcoding/src/test/java/io/vertx/tests/transcoding/HttpTemplateTest.java create mode 100644 vertx-grpc-transcoding/src/test/java/io/vertx/tests/transcoding/PathMatcherTest.java create mode 100644 vertx-grpc-transcoding/src/test/java/io/vertx/tests/transcoding/PathMatcherUtilityTest.java create mode 100644 vertx-grpc-transcoding/src/test/java/module-info.java diff --git a/pom.xml b/pom.xml index f6b7d067..8b34d21b 100644 --- a/pom.xml +++ b/pom.xml @@ -198,6 +198,7 @@ vertx-grpc-common + vertx-grpc-transcoding vertx-grpc-server vertx-grpc-client vertx-grpcio-common @@ -271,4 +272,4 @@ - \ No newline at end of file + diff --git a/vertx-grpc-docs/src/main/asciidoc/plugin.adoc b/vertx-grpc-docs/src/main/asciidoc/plugin.adoc index 9e715e17..fc67a39f 100644 --- a/vertx-grpc-docs/src/main/asciidoc/plugin.adoc +++ b/vertx-grpc-docs/src/main/asciidoc/plugin.adoc @@ -184,3 +184,5 @@ Vert.x gRPC needs to know to interact with gRPC. - the message encoder They can be used to bind services or interact with a remote server. + +=== Generate transcoding definitions diff --git a/vertx-grpc-docs/src/main/asciidoc/server.adoc b/vertx-grpc-docs/src/main/asciidoc/server.adoc index fc9e4072..3f422571 100644 --- a/vertx-grpc-docs/src/main/asciidoc/server.adoc +++ b/vertx-grpc-docs/src/main/asciidoc/server.adoc @@ -68,6 +68,12 @@ router.route("/com.mycompany.MyService/*").handler(corsHandler); ---- ==== +==== gRPC Transcoding + +The Vert.x gRPC Server supports the gRPC transcoding. The transcoding is disabled by default. + +To enable the gRPC transcoding, configure options with {@link io.vertx.grpc.server.GrpcServerOptions#setGrpcTranscodingEnabled GrpcServerOptions#setGrpcTranscodingEnabled(true)} and then create a server with {@link io.vertx.grpc.server.GrpcServer#server(io.vertx.core.Vertx, io.vertx.grpc.server.GrpcServerOptions) GrpcServer#server(vertx, options)}. + === Server request/response API The gRPC request/response server API provides an alternative way to interact with a client without the need of extending diff --git a/vertx-grpc-docs/src/main/java/examples/VertxGreeterGrpcServer.java b/vertx-grpc-docs/src/main/java/examples/VertxGreeterGrpcServer.java index da3de8a5..f84e9b00 100644 --- a/vertx-grpc-docs/src/main/java/examples/VertxGreeterGrpcServer.java +++ b/vertx-grpc-docs/src/main/java/examples/VertxGreeterGrpcServer.java @@ -3,6 +3,7 @@ import io.vertx.core.Future; import io.vertx.core.Promise; import io.vertx.core.Handler; +import io.vertx.core.http.HttpMethod; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.streams.ReadStream; import io.vertx.core.streams.WriteStream; @@ -13,6 +14,7 @@ import io.vertx.grpc.common.GrpcWriteStream; import io.vertx.grpc.common.GrpcMessageDecoder; import io.vertx.grpc.common.GrpcMessageEncoder; +import io.vertx.grpc.transcoding.ServiceTranscodingOptions; import io.vertx.grpc.server.GrpcServerResponse; import io.vertx.grpc.server.GrpcServer; @@ -31,6 +33,14 @@ public class VertxGreeterGrpcServer { "SayHello", GrpcMessageEncoder.json(), GrpcMessageDecoder.json(() -> examples.HelloRequest.newBuilder())); + public static final ServiceTranscodingOptions SayHello_TRANSCODING = ServiceTranscodingOptions.create( + "", + HttpMethod.valueOf("POST"), + "/Greeter/SayHello", + "", + "", + List.of( + )); public static class GreeterApi { @@ -74,6 +84,24 @@ public GreeterApi bind_sayHello(GrpcServer server, io.vertx.grpc.common.WireForm server.callHandler(serviceMethod, this::handle_sayHello); return this; } + public GreeterApi bind_sayHello_with_transcoding(GrpcServer server) { + return bind_sayHello_with_transcoding(server, io.vertx.grpc.common.WireFormat.PROTOBUF); + } + public GreeterApi bind_sayHello_with_transcoding(GrpcServer server, io.vertx.grpc.common.WireFormat format) { + ServiceMethod serviceMethod; + switch(format) { + case PROTOBUF: + serviceMethod = SayHello; + break; + case JSON: + serviceMethod = SayHello_JSON; + break; + default: + throw new AssertionError(); + } + server.callHandlerWithTranscoding(serviceMethod, this::handle_sayHello, SayHello_TRANSCODING); + return this; + } public final GreeterApi bindAll(GrpcServer server) { bind_sayHello(server); @@ -84,5 +112,15 @@ public final GreeterApi bindAll(GrpcServer server, io.vertx.grpc.common.WireForm bind_sayHello(server, format); return this; } + + public final GreeterApi bindAllWithTranscoding(GrpcServer server) { + bind_sayHello_with_transcoding(server); + return this; + } + + public final GreeterApi bindAllWithTranscoding(GrpcServer server, io.vertx.grpc.common.WireFormat format) { + bind_sayHello_with_transcoding(server, format); + return this; + } } } diff --git a/vertx-grpc-docs/src/main/java/examples/VertxStreamingGrpcServer.java b/vertx-grpc-docs/src/main/java/examples/VertxStreamingGrpcServer.java index 67035a1c..936e8a1b 100644 --- a/vertx-grpc-docs/src/main/java/examples/VertxStreamingGrpcServer.java +++ b/vertx-grpc-docs/src/main/java/examples/VertxStreamingGrpcServer.java @@ -3,6 +3,7 @@ import io.vertx.core.Future; import io.vertx.core.Promise; import io.vertx.core.Handler; +import io.vertx.core.http.HttpMethod; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.streams.ReadStream; import io.vertx.core.streams.WriteStream; @@ -13,6 +14,7 @@ import io.vertx.grpc.common.GrpcWriteStream; import io.vertx.grpc.common.GrpcMessageDecoder; import io.vertx.grpc.common.GrpcMessageEncoder; +import io.vertx.grpc.transcoding.ServiceTranscodingOptions; import io.vertx.grpc.server.GrpcServerResponse; import io.vertx.grpc.server.GrpcServer; @@ -31,6 +33,14 @@ public class VertxStreamingGrpcServer { "Source", GrpcMessageEncoder.json(), GrpcMessageDecoder.json(() -> examples.Empty.newBuilder())); + public static final ServiceTranscodingOptions Source_TRANSCODING = ServiceTranscodingOptions.create( + "", + HttpMethod.valueOf("POST"), + "/Streaming/Source", + "", + "", + List.of( + )); public static final ServiceMethod Sink = ServiceMethod.server( ServiceName.create("streaming", "Streaming"), "Sink", @@ -41,6 +51,14 @@ public class VertxStreamingGrpcServer { "Sink", GrpcMessageEncoder.json(), GrpcMessageDecoder.json(() -> examples.Item.newBuilder())); + public static final ServiceTranscodingOptions Sink_TRANSCODING = ServiceTranscodingOptions.create( + "", + HttpMethod.valueOf("POST"), + "/Streaming/Sink", + "", + "", + List.of( + )); public static final ServiceMethod Pipe = ServiceMethod.server( ServiceName.create("streaming", "Streaming"), "Pipe", @@ -51,6 +69,14 @@ public class VertxStreamingGrpcServer { "Pipe", GrpcMessageEncoder.json(), GrpcMessageDecoder.json(() -> examples.Item.newBuilder())); + public static final ServiceTranscodingOptions Pipe_TRANSCODING = ServiceTranscodingOptions.create( + "", + HttpMethod.valueOf("POST"), + "/Streaming/Pipe", + "", + "", + List.of( + )); public static class StreamingApi { @@ -108,6 +134,24 @@ public StreamingApi bind_source(GrpcServer server, io.vertx.grpc.common.WireForm server.callHandler(serviceMethod, this::handle_source); return this; } + public StreamingApi bind_source_with_transcoding(GrpcServer server) { + return bind_source_with_transcoding(server, io.vertx.grpc.common.WireFormat.PROTOBUF); + } + public StreamingApi bind_source_with_transcoding(GrpcServer server, io.vertx.grpc.common.WireFormat format) { + ServiceMethod serviceMethod; + switch(format) { + case PROTOBUF: + serviceMethod = Source; + break; + case JSON: + serviceMethod = Source_JSON; + break; + default: + throw new AssertionError(); + } + server.callHandlerWithTranscoding(serviceMethod, this::handle_source, Source_TRANSCODING); + return this; + } public final void handle_sink(io.vertx.grpc.server.GrpcServerRequest request) { Promise promise = Promise.promise(); promise.future() @@ -137,6 +181,24 @@ public StreamingApi bind_sink(GrpcServer server, io.vertx.grpc.common.WireFormat server.callHandler(serviceMethod, this::handle_sink); return this; } + public StreamingApi bind_sink_with_transcoding(GrpcServer server) { + return bind_sink_with_transcoding(server, io.vertx.grpc.common.WireFormat.PROTOBUF); + } + public StreamingApi bind_sink_with_transcoding(GrpcServer server, io.vertx.grpc.common.WireFormat format) { + ServiceMethod serviceMethod; + switch(format) { + case PROTOBUF: + serviceMethod = Sink; + break; + case JSON: + serviceMethod = Sink_JSON; + break; + default: + throw new AssertionError(); + } + server.callHandlerWithTranscoding(serviceMethod, this::handle_sink, Sink_TRANSCODING); + return this; + } public final void handle_pipe(io.vertx.grpc.server.GrpcServerRequest request) { try { pipe(request, request.response()); @@ -162,6 +224,24 @@ public final StreamingApi bind_pipe(GrpcServer server, io.vertx.grpc.common.Wire server.callHandler(serviceMethod, this::handle_pipe); return this; } + public StreamingApi bind_pipe_with_transcoding(GrpcServer server) { + return bind_pipe_with_transcoding(server, io.vertx.grpc.common.WireFormat.PROTOBUF); + } + public StreamingApi bind_pipe_with_transcoding(GrpcServer server, io.vertx.grpc.common.WireFormat format) { + ServiceMethod serviceMethod; + switch(format) { + case PROTOBUF: + serviceMethod = Pipe; + break; + case JSON: + serviceMethod = Pipe_JSON; + break; + default: + throw new AssertionError(); + } + server.callHandlerWithTranscoding(serviceMethod, this::handle_pipe, Pipe_TRANSCODING); + return this; + } public final StreamingApi bindAll(GrpcServer server) { bind_source(server); @@ -176,5 +256,19 @@ public final StreamingApi bindAll(GrpcServer server, io.vertx.grpc.common.WireFo bind_pipe(server, format); return this; } + + public final StreamingApi bindAllWithTranscoding(GrpcServer server) { + bind_source_with_transcoding(server); + bind_sink_with_transcoding(server); + bind_pipe_with_transcoding(server); + return this; + } + + public final StreamingApi bindAllWithTranscoding(GrpcServer server, io.vertx.grpc.common.WireFormat format) { + bind_source_with_transcoding(server, format); + bind_sink_with_transcoding(server, format); + bind_pipe_with_transcoding(server, format); + return this; + } } } diff --git a/vertx-grpc-it/src/test/java/io/vertx/grpc/it/TranscodingTest.java b/vertx-grpc-it/src/test/java/io/vertx/grpc/it/TranscodingTest.java new file mode 100644 index 00000000..06fb1647 --- /dev/null +++ b/vertx-grpc-it/src/test/java/io/vertx/grpc/it/TranscodingTest.java @@ -0,0 +1,67 @@ +package io.vertx.grpc.it; + +import io.grpc.examples.helloworld.VertxGreeterGrpcServer; +import io.vertx.core.Future; +import io.vertx.core.http.HttpClient; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.http.HttpServer; +import io.vertx.core.http.RequestOptions; +import io.vertx.core.json.Json; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.grpc.server.GrpcServer; +import io.vertx.grpc.server.GrpcServerOptions; +import org.junit.Test; + +import java.util.Map; + +public class TranscodingTest extends ProxyTestBase { + + @Test + public void testUnary01(TestContext should) { + HttpClient client = vertx.createHttpClient(); + GrpcServerOptions serverOptions = new GrpcServerOptions().setGrpcTranscodingEnabled(true); + + Future server = vertx.createHttpServer() + .requestHandler(GrpcServer.server(vertx, serverOptions).callHandlerWithTranscoding(VertxGreeterGrpcServer.SayHello_JSON, call -> { + call.handler(helloRequest -> { + io.grpc.examples.helloworld.HelloReply helloReply = io.grpc.examples.helloworld.HelloReply.newBuilder().setMessage("Hello " + helloRequest.getName()).build(); + call.response().end(helloReply); + }); + }, VertxGreeterGrpcServer.SayHello_TRANSCODING)).listen(8080, "localhost"); + + RequestOptions options = new RequestOptions().setHost("localhost").setPort(8080).setURI("/Greeter/SayHello").setMethod(HttpMethod.POST); + + Async test = should.async(); + + String data = createRequest("Julien"); + + server.onComplete(should.asyncAssertSuccess(v -> { + client.request(options).compose(req -> { + req.putHeader("Content-Type", "application/json"); + req.putHeader("Accept", "application/json"); + req.putHeader("Content-Length", String.valueOf(data.length())); + req.write(data); + return req.send(); + }).compose(resp -> { + should.assertEquals(200, resp.statusCode()); + should.assertEquals("application/json", resp.getHeader("Content-Type")); + return resp.body(); + }).onComplete(should.asyncAssertSuccess(body -> { + should.assertEquals("Hello Julien", getMessage(body.toString())); + test.complete(); + })); + })); + + test.awaitSuccess(20_000); + } + + private String createRequest(String name) { + return Json.encode(new JsonObject().put("name", name)); + } + + private String getMessage(String message) { + return Json.decodeValue(message, Map.class).get("message").toString(); + } +} diff --git a/vertx-grpc-it/src/test/proto/google/api/annotations.proto b/vertx-grpc-it/src/test/proto/google/api/annotations.proto new file mode 100644 index 00000000..84c48164 --- /dev/null +++ b/vertx-grpc-it/src/test/proto/google/api/annotations.proto @@ -0,0 +1,31 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package google.api; + +import "google/api/http.proto"; +import "google/protobuf/descriptor.proto"; + +option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations"; +option java_multiple_files = true; +option java_outer_classname = "AnnotationsProto"; +option java_package = "com.google.api"; +option objc_class_prefix = "GAPI"; + +extend google.protobuf.MethodOptions { + // See `HttpRule`. + HttpRule http = 72295728; +} diff --git a/vertx-grpc-it/src/test/proto/google/api/http.proto b/vertx-grpc-it/src/test/proto/google/api/http.proto new file mode 100644 index 00000000..e3270371 --- /dev/null +++ b/vertx-grpc-it/src/test/proto/google/api/http.proto @@ -0,0 +1,371 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package google.api; + +option cc_enable_arenas = true; +option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations"; +option java_multiple_files = true; +option java_outer_classname = "HttpProto"; +option java_package = "com.google.api"; +option objc_class_prefix = "GAPI"; + +// Defines the HTTP configuration for an API service. It contains a list of +// [HttpRule][google.api.HttpRule], each specifying the mapping of an RPC method +// to one or more HTTP REST API methods. +message Http { + // A list of HTTP configuration rules that apply to individual API methods. + // + // **NOTE:** All service configuration rules follow "last one wins" order. + repeated HttpRule rules = 1; + + // When set to true, URL path parameters will be fully URI-decoded except in + // cases of single segment matches in reserved expansion, where "%2F" will be + // left encoded. + // + // The default behavior is to not decode RFC 6570 reserved characters in multi + // segment matches. + bool fully_decode_reserved_expansion = 2; +} + +// gRPC Transcoding +// +// gRPC Transcoding is a feature for mapping between a gRPC method and one or +// more HTTP REST endpoints. It allows developers to build a single API service +// that supports both gRPC APIs and REST APIs. Many systems, including [Google +// APIs](https://github.com/googleapis/googleapis), +// [Cloud Endpoints](https://cloud.google.com/endpoints), [gRPC +// Gateway](https://github.com/grpc-ecosystem/grpc-gateway), +// and [Envoy](https://github.com/envoyproxy/envoy) proxy support this feature +// and use it for large scale production services. +// +// `HttpRule` defines the schema of the gRPC/REST mapping. The mapping specifies +// how different portions of the gRPC request message are mapped to the URL +// path, URL query parameters, and HTTP request body. It also controls how the +// gRPC response message is mapped to the HTTP response body. `HttpRule` is +// typically specified as an `google.api.http` annotation on the gRPC method. +// +// Each mapping specifies a URL path template and an HTTP method. The path +// template may refer to one or more fields in the gRPC request message, as long +// as each field is a non-repeated field with a primitive (non-message) type. +// The path template controls how fields of the request message are mapped to +// the URL path. +// +// Example: +// +// service Messaging { +// rpc GetMessage(GetMessageRequest) returns (Message) { +// option (google.api.http) = { +// get: "/v1/{name=messages/*}" +// }; +// } +// } +// message GetMessageRequest { +// string name = 1; // Mapped to URL path. +// } +// message Message { +// string text = 1; // The resource content. +// } +// +// This enables an HTTP REST to gRPC mapping as below: +// +// - HTTP: `GET /v1/messages/123456` +// - gRPC: `GetMessage(name: "messages/123456")` +// +// Any fields in the request message which are not bound by the path template +// automatically become HTTP query parameters if there is no HTTP request body. +// For example: +// +// service Messaging { +// rpc GetMessage(GetMessageRequest) returns (Message) { +// option (google.api.http) = { +// get:"/v1/messages/{message_id}" +// }; +// } +// } +// message GetMessageRequest { +// message SubMessage { +// string subfield = 1; +// } +// string message_id = 1; // Mapped to URL path. +// int64 revision = 2; // Mapped to URL query parameter `revision`. +// SubMessage sub = 3; // Mapped to URL query parameter `sub.subfield`. +// } +// +// This enables a HTTP JSON to RPC mapping as below: +// +// - HTTP: `GET /v1/messages/123456?revision=2&sub.subfield=foo` +// - gRPC: `GetMessage(message_id: "123456" revision: 2 sub: +// SubMessage(subfield: "foo"))` +// +// Note that fields which are mapped to URL query parameters must have a +// primitive type or a repeated primitive type or a non-repeated message type. +// In the case of a repeated type, the parameter can be repeated in the URL +// as `...?param=A¶m=B`. In the case of a message type, each field of the +// message is mapped to a separate parameter, such as +// `...?foo.a=A&foo.b=B&foo.c=C`. +// +// For HTTP methods that allow a request body, the `body` field +// specifies the mapping. Consider a REST update method on the +// message resource collection: +// +// service Messaging { +// rpc UpdateMessage(UpdateMessageRequest) returns (Message) { +// option (google.api.http) = { +// patch: "/v1/messages/{message_id}" +// body: "message" +// }; +// } +// } +// message UpdateMessageRequest { +// string message_id = 1; // mapped to the URL +// Message message = 2; // mapped to the body +// } +// +// The following HTTP JSON to RPC mapping is enabled, where the +// representation of the JSON in the request body is determined by +// protos JSON encoding: +// +// - HTTP: `PATCH /v1/messages/123456 { "text": "Hi!" }` +// - gRPC: `UpdateMessage(message_id: "123456" message { text: "Hi!" })` +// +// The special name `*` can be used in the body mapping to define that +// every field not bound by the path template should be mapped to the +// request body. This enables the following alternative definition of +// the update method: +// +// service Messaging { +// rpc UpdateMessage(Message) returns (Message) { +// option (google.api.http) = { +// patch: "/v1/messages/{message_id}" +// body: "*" +// }; +// } +// } +// message Message { +// string message_id = 1; +// string text = 2; +// } +// +// +// The following HTTP JSON to RPC mapping is enabled: +// +// - HTTP: `PATCH /v1/messages/123456 { "text": "Hi!" }` +// - gRPC: `UpdateMessage(message_id: "123456" text: "Hi!")` +// +// Note that when using `*` in the body mapping, it is not possible to +// have HTTP parameters, as all fields not bound by the path end in +// the body. This makes this option more rarely used in practice when +// defining REST APIs. The common usage of `*` is in custom methods +// which don't use the URL at all for transferring data. +// +// It is possible to define multiple HTTP methods for one RPC by using +// the `additional_bindings` option. Example: +// +// service Messaging { +// rpc GetMessage(GetMessageRequest) returns (Message) { +// option (google.api.http) = { +// get: "/v1/messages/{message_id}" +// additional_bindings { +// get: "/v1/users/{user_id}/messages/{message_id}" +// } +// }; +// } +// } +// message GetMessageRequest { +// string message_id = 1; +// string user_id = 2; +// } +// +// This enables the following two alternative HTTP JSON to RPC mappings: +// +// - HTTP: `GET /v1/messages/123456` +// - gRPC: `GetMessage(message_id: "123456")` +// +// - HTTP: `GET /v1/users/me/messages/123456` +// - gRPC: `GetMessage(user_id: "me" message_id: "123456")` +// +// Rules for HTTP mapping +// +// 1. Leaf request fields (recursive expansion nested messages in the request +// message) are classified into three categories: +// - Fields referred by the path template. They are passed via the URL path. +// - Fields referred by the [HttpRule.body][google.api.HttpRule.body]. They +// are passed via the HTTP +// request body. +// - All other fields are passed via the URL query parameters, and the +// parameter name is the field path in the request message. A repeated +// field can be represented as multiple query parameters under the same +// name. +// 2. If [HttpRule.body][google.api.HttpRule.body] is "*", there is no URL +// query parameter, all fields +// are passed via URL path and HTTP request body. +// 3. If [HttpRule.body][google.api.HttpRule.body] is omitted, there is no HTTP +// request body, all +// fields are passed via URL path and URL query parameters. +// +// Path template syntax +// +// Template = "/" Segments [ Verb ] ; +// Segments = Segment { "/" Segment } ; +// Segment = "*" | "**" | LITERAL | Variable ; +// Variable = "{" FieldPath [ "=" Segments ] "}" ; +// FieldPath = IDENT { "." IDENT } ; +// Verb = ":" LITERAL ; +// +// The syntax `*` matches a single URL path segment. The syntax `**` matches +// zero or more URL path segments, which must be the last part of the URL path +// except the `Verb`. +// +// The syntax `Variable` matches part of the URL path as specified by its +// template. A variable template must not contain other variables. If a variable +// matches a single path segment, its template may be omitted, e.g. `{var}` +// is equivalent to `{var=*}`. +// +// The syntax `LITERAL` matches literal text in the URL path. If the `LITERAL` +// contains any reserved character, such characters should be percent-encoded +// before the matching. +// +// If a variable contains exactly one path segment, such as `"{var}"` or +// `"{var=*}"`, when such a variable is expanded into a URL path on the client +// side, all characters except `[-_.~0-9a-zA-Z]` are percent-encoded. The +// server side does the reverse decoding. Such variables show up in the +// [Discovery +// Document](https://developers.google.com/discovery/v1/reference/apis) as +// `{var}`. +// +// If a variable contains multiple path segments, such as `"{var=foo/*}"` +// or `"{var=**}"`, when such a variable is expanded into a URL path on the +// client side, all characters except `[-_.~/0-9a-zA-Z]` are percent-encoded. +// The server side does the reverse decoding, except "%2F" and "%2f" are left +// unchanged. Such variables show up in the +// [Discovery +// Document](https://developers.google.com/discovery/v1/reference/apis) as +// `{+var}`. +// +// Using gRPC API Service Configuration +// +// gRPC API Service Configuration (service config) is a configuration language +// for configuring a gRPC service to become a user-facing product. The +// service config is simply the YAML representation of the `google.api.Service` +// proto message. +// +// As an alternative to annotating your proto file, you can configure gRPC +// transcoding in your service config YAML files. You do this by specifying a +// `HttpRule` that maps the gRPC method to a REST endpoint, achieving the same +// effect as the proto annotation. This can be particularly useful if you +// have a proto that is reused in multiple services. Note that any transcoding +// specified in the service config will override any matching transcoding +// configuration in the proto. +// +// The following example selects a gRPC method and applies an `HttpRule` to it: +// +// http: +// rules: +// - selector: example.v1.Messaging.GetMessage +// get: /v1/messages/{message_id}/{sub.subfield} +// +// Special notes +// +// When gRPC Transcoding is used to map a gRPC to JSON REST endpoints, the +// proto to JSON conversion must follow the [proto3 +// specification](https://developers.google.com/protocol-buffers/docs/proto3#json). +// +// While the single segment variable follows the semantics of +// [RFC 6570](https://tools.ietf.org/html/rfc6570) Section 3.2.2 Simple String +// Expansion, the multi segment variable **does not** follow RFC 6570 Section +// 3.2.3 Reserved Expansion. The reason is that the Reserved Expansion +// does not expand special characters like `?` and `#`, which would lead +// to invalid URLs. As the result, gRPC Transcoding uses a custom encoding +// for multi segment variables. +// +// The path variables **must not** refer to any repeated or mapped field, +// because client libraries are not capable of handling such variable expansion. +// +// The path variables **must not** capture the leading "/" character. The reason +// is that the most common use case "{var}" does not capture the leading "/" +// character. For consistency, all path variables must share the same behavior. +// +// Repeated message fields must not be mapped to URL query parameters, because +// no client library can support such complicated mapping. +// +// If an API needs to use a JSON array for request or response body, it can map +// the request or response body to a repeated field. However, some gRPC +// Transcoding implementations may not support this feature. +message HttpRule { + // Selects a method to which this rule applies. + // + // Refer to [selector][google.api.DocumentationRule.selector] for syntax + // details. + string selector = 1; + + // Determines the URL pattern is matched by this rules. This pattern can be + // used with any of the {get|put|post|delete|patch} methods. A custom method + // can be defined using the 'custom' field. + oneof pattern { + // Maps to HTTP GET. Used for listing and getting information about + // resources. + string get = 2; + + // Maps to HTTP PUT. Used for replacing a resource. + string put = 3; + + // Maps to HTTP POST. Used for creating a resource or performing an action. + string post = 4; + + // Maps to HTTP DELETE. Used for deleting a resource. + string delete = 5; + + // Maps to HTTP PATCH. Used for updating a resource. + string patch = 6; + + // The custom pattern is used for specifying an HTTP method that is not + // included in the `pattern` field, such as HEAD, or "*" to leave the + // HTTP method unspecified for this rule. The wild-card rule is useful + // for services that provide content to Web (HTML) clients. + CustomHttpPattern custom = 8; + } + + // The name of the request field whose value is mapped to the HTTP request + // body, or `*` for mapping all request fields not captured by the path + // pattern to the HTTP body, or omitted for not having any HTTP request body. + // + // NOTE: the referred field must be present at the top-level of the request + // message type. + string body = 7; + + // Optional. The name of the response field whose value is mapped to the HTTP + // response body. When omitted, the entire response message will be used + // as the HTTP response body. + // + // NOTE: The referred field must be present at the top-level of the response + // message type. + string response_body = 12; + + // Additional HTTP bindings for the selector. Nested bindings must + // not contain an `additional_bindings` field themselves (that is, + // the nesting may only be one level deep). + repeated HttpRule additional_bindings = 11; +} + +// A custom pattern is used for defining custom HTTP verb. +message CustomHttpPattern { + // The name of this custom HTTP verb. + string kind = 1; + + // The path matched by this custom verb. + string path = 2; +} diff --git a/vertx-grpc-it/src/test/proto/helloworld.proto b/vertx-grpc-it/src/test/proto/helloworld.proto index 0bee1fcf..22fa3669 100644 --- a/vertx-grpc-it/src/test/proto/helloworld.proto +++ b/vertx-grpc-it/src/test/proto/helloworld.proto @@ -29,6 +29,8 @@ syntax = "proto3"; +import "google/api/annotations.proto"; + option java_multiple_files = true; option java_package = "io.grpc.examples.helloworld"; option java_outer_classname = "HelloWorldProto"; @@ -39,7 +41,9 @@ package helloworld; // The greeting service definition. service Greeter { // Sends a greeting - rpc SayHello (HelloRequest) returns (HelloReply) {} + rpc SayHello (HelloRequest) returns (HelloReply) { + option (google.api.http) = { get: "/v1/hello/{name}" }; + } } // The request message containing the user's name. diff --git a/vertx-grpc-protoc-plugin2/pom.xml b/vertx-grpc-protoc-plugin2/pom.xml index 978c7dc3..6964f24a 100644 --- a/vertx-grpc-protoc-plugin2/pom.xml +++ b/vertx-grpc-protoc-plugin2/pom.xml @@ -35,6 +35,11 @@ + + com.google.api.grpc + proto-google-common-protos + 2.50.0 + com.salesforce.servicelibs jprotoc diff --git a/vertx-grpc-protoc-plugin2/src/main/java/io/vertx/grpc/plugin/VertxGrpcGeneratorImpl.java b/vertx-grpc-protoc-plugin2/src/main/java/io/vertx/grpc/plugin/VertxGrpcGeneratorImpl.java index dd982f84..4f4481cf 100644 --- a/vertx-grpc-protoc-plugin2/src/main/java/io/vertx/grpc/plugin/VertxGrpcGeneratorImpl.java +++ b/vertx-grpc-protoc-plugin2/src/main/java/io/vertx/grpc/plugin/VertxGrpcGeneratorImpl.java @@ -10,6 +10,8 @@ */ package io.vertx.grpc.plugin; +import com.google.api.AnnotationsProto; +import com.google.api.HttpRule; import com.google.common.base.Strings; import com.google.common.html.HtmlEscapers; import com.google.protobuf.DescriptorProtos; @@ -97,7 +99,8 @@ private String extractPackageName(DescriptorProtos.FileDescriptorProto proto) { return Strings.nullToEmpty(proto.getPackage()); } - private ServiceContext buildServiceContext(DescriptorProtos.ServiceDescriptorProto serviceProto, ProtoTypeMap typeMap, List locations, int serviceNumber) { + private ServiceContext buildServiceContext(DescriptorProtos.ServiceDescriptorProto serviceProto, ProtoTypeMap typeMap, List locations, + int serviceNumber) { ServiceContext serviceContext = new ServiceContext(); // Set Later //serviceContext.fileName = CLASS_PREFIX + serviceProto.getName() + "Grpc.java"; @@ -127,12 +130,18 @@ private ServiceContext buildServiceContext(DescriptorProtos.ServiceDescriptorPro methodNumber ); + if (methodContext.transcodingContext.path == null) { + methodContext.transcodingContext.method = "POST"; + methodContext.transcodingContext.path = "/" + serviceProto.getName() + "/" + methodContext.methodName; + } + serviceContext.methods.add(methodContext); } return serviceContext; } - private MethodContext buildMethodContext(DescriptorProtos.MethodDescriptorProto methodProto, ProtoTypeMap typeMap, List locations, int methodNumber) { + private MethodContext buildMethodContext(DescriptorProtos.MethodDescriptorProto methodProto, ProtoTypeMap typeMap, List locations, + int methodNumber) { MethodContext methodContext = new MethodContext(); methodContext.methodName = methodProto.getName(); methodContext.vertxMethodName = mixedLower(methodProto.getName()); @@ -142,6 +151,7 @@ private MethodContext buildMethodContext(DescriptorProtos.MethodDescriptorProto methodContext.isManyInput = methodProto.getClientStreaming(); methodContext.isManyOutput = methodProto.getServerStreaming(); methodContext.methodNumber = methodNumber; + methodContext.transcodingContext = new TranscodingContext(); DescriptorProtos.SourceCodeInfo.Location methodLocation = locations.stream() .filter(location -> @@ -168,9 +178,55 @@ private MethodContext buildMethodContext(DescriptorProtos.MethodDescriptorProto methodContext.vertxCallsMethodName = "manyToMany"; methodContext.grpcCallsMethodName = "asyncBidiStreamingCall"; } + + if (methodProto.getOptions().hasExtension(AnnotationsProto.http)) { + HttpRule httpRule = methodProto.getOptions().getExtension(AnnotationsProto.http); + methodContext.transcodingContext = buildTranscodingContext(httpRule); + } + return methodContext; } + private TranscodingContext buildTranscodingContext(HttpRule rule) { + TranscodingContext transcodingContext = new TranscodingContext(); + switch (rule.getPatternCase()) { + case GET: + transcodingContext.path = rule.getGet(); + transcodingContext.method = "GET"; + break; + case POST: + transcodingContext.path = rule.getPost(); + transcodingContext.method = "POST"; + break; + case PUT: + transcodingContext.path = rule.getPut(); + transcodingContext.method = "PUT"; + break; + case DELETE: + transcodingContext.path = rule.getDelete(); + transcodingContext.method = "DELETE"; + break; + case PATCH: + transcodingContext.path = rule.getPatch(); + transcodingContext.method = "PATCH"; + break; + case CUSTOM: + transcodingContext.path = rule.getCustom().getPath(); + transcodingContext.method = rule.getCustom().getKind(); + break; + } + + transcodingContext.selector = rule.getSelector(); + transcodingContext.body = rule.getBody(); + transcodingContext.responseBody = rule.getResponseBody(); + + transcodingContext.additionalBindings = rule.getAdditionalBindingsList().stream() + .map(this::buildTranscodingContext) + .collect(Collectors.toList()); + + return transcodingContext; + } + // java keywords from: https://docs.oracle.com/javase/specs/jls/se8/html/jls-3.html#jls-3.9 private static final List JAVA_KEYWORDS = Arrays.asList( "abstract", @@ -229,9 +285,7 @@ private MethodContext buildMethodContext(DescriptorProtos.MethodDescriptorProto ); /** - * Adjust a method name prefix identifier to follow the JavaBean spec: - * - decapitalize the first letter - * - remove embedded underscores & capitalize the following letter + * Adjust a method name prefix identifier to follow the JavaBean spec: - decapitalize the first letter - remove embedded underscores & capitalize the following letter *

* Finally, if the result is a reserved java keyword, append an underscore. * @@ -390,6 +444,8 @@ private static class MethodContext { public int methodNumber; public String javaDoc; + public TranscodingContext transcodingContext; + // This method mimics the upper-casing method ogf gRPC to ensure compatibility // See https://github.com/grpc/grpc-java/blob/v1.8.0/compiler/src/java_plugin/cpp/java_generator.cpp#L58 public String methodNameUpperUnderscore() { @@ -421,4 +477,13 @@ public String methodHeader() { return mh; } } + + private static class TranscodingContext { + public String path; + public String method; + public String selector; + public String body; + public String responseBody; + public List additionalBindings = new ArrayList<>(); + } } diff --git a/vertx-grpc-protoc-plugin2/src/main/resources/server.mustache b/vertx-grpc-protoc-plugin2/src/main/resources/server.mustache index e3acac56..eb112ab9 100644 --- a/vertx-grpc-protoc-plugin2/src/main/resources/server.mustache +++ b/vertx-grpc-protoc-plugin2/src/main/resources/server.mustache @@ -5,6 +5,7 @@ package {{vertxPackageName}}; import io.vertx.core.Future; import io.vertx.core.Promise; import io.vertx.core.Handler; +import io.vertx.core.http.HttpMethod; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.streams.ReadStream; import io.vertx.core.streams.WriteStream; @@ -15,6 +16,7 @@ import io.vertx.grpc.common.GrpcReadStream; import io.vertx.grpc.common.GrpcWriteStream; import io.vertx.grpc.common.GrpcMessageDecoder; import io.vertx.grpc.common.GrpcMessageEncoder; +import io.vertx.grpc.transcoding.ServiceTranscodingOptions; import io.vertx.grpc.server.GrpcServerResponse; import io.vertx.grpc.server.GrpcServer; @@ -34,6 +36,17 @@ public class {{className}} { "{{methodName}}", GrpcMessageEncoder.json(), GrpcMessageDecoder.json(() -> {{inputType}}.newBuilder())); + public static final ServiceTranscodingOptions {{methodName}}_TRANSCODING = ServiceTranscodingOptions.create( + "{{transcodingContext.selector}}", + HttpMethod.valueOf("{{transcodingContext.method}}"), + "{{transcodingContext.path}}", + "{{transcodingContext.body}}", + "{{transcodingContext.responseBody}}", + List.of( + {{#transcodingContext.additionalBindings}} + ServiceTranscodingOptions.create("{{selector}}", HttpMethod.valueOf("{{method}}"), "{{path}}", "{{body}}", "{{responseBody}}"), + {{/transcodingContext.additionalBindings}} + )); {{/allMethods}} public static class {{serviceName}}Api { @@ -113,6 +126,24 @@ public class {{className}} { server.callHandler(serviceMethod, this::handle_{{vertxMethodName}}); return this; } + public {{serviceName}}Api bind_{{vertxMethodName}}_with_transcoding(GrpcServer server) { + return bind_{{vertxMethodName}}_with_transcoding(server, io.vertx.grpc.common.WireFormat.PROTOBUF); + } + public {{serviceName}}Api bind_{{vertxMethodName}}_with_transcoding(GrpcServer server, io.vertx.grpc.common.WireFormat format) { + ServiceMethod<{{inputType}},{{outputType}}> serviceMethod; + switch(format) { + case PROTOBUF: + serviceMethod = {{methodName}}; + break; + case JSON: + serviceMethod = {{methodName}}_JSON; + break; + default: + throw new AssertionError(); + } + server.callHandlerWithTranscoding(serviceMethod, this::handle_{{vertxMethodName}}, {{methodName}}_TRANSCODING); + return this; + } {{/unaryMethods}} {{#unaryManyMethods}} public final void handle_{{vertxMethodName}}(io.vertx.grpc.server.GrpcServerRequest<{{inputType}}, {{outputType}}> request) { @@ -142,6 +173,24 @@ public class {{className}} { server.callHandler(serviceMethod, this::handle_{{vertxMethodName}}); return this; } + public {{serviceName}}Api bind_{{vertxMethodName}}_with_transcoding(GrpcServer server) { + return bind_{{vertxMethodName}}_with_transcoding(server, io.vertx.grpc.common.WireFormat.PROTOBUF); + } + public {{serviceName}}Api bind_{{vertxMethodName}}_with_transcoding(GrpcServer server, io.vertx.grpc.common.WireFormat format) { + ServiceMethod<{{inputType}},{{outputType}}> serviceMethod; + switch(format) { + case PROTOBUF: + serviceMethod = {{methodName}}; + break; + case JSON: + serviceMethod = {{methodName}}_JSON; + break; + default: + throw new AssertionError(); + } + server.callHandlerWithTranscoding(serviceMethod, this::handle_{{vertxMethodName}}, {{methodName}}_TRANSCODING); + return this; + } {{/unaryManyMethods}} {{#manyUnaryMethods}} public final void handle_{{vertxMethodName}}(io.vertx.grpc.server.GrpcServerRequest<{{inputType}}, {{outputType}}> request) { @@ -173,6 +222,24 @@ public class {{className}} { server.callHandler(serviceMethod, this::handle_{{vertxMethodName}}); return this; } + public {{serviceName}}Api bind_{{vertxMethodName}}_with_transcoding(GrpcServer server) { + return bind_{{vertxMethodName}}_with_transcoding(server, io.vertx.grpc.common.WireFormat.PROTOBUF); + } + public {{serviceName}}Api bind_{{vertxMethodName}}_with_transcoding(GrpcServer server, io.vertx.grpc.common.WireFormat format) { + ServiceMethod<{{inputType}},{{outputType}}> serviceMethod; + switch(format) { + case PROTOBUF: + serviceMethod = {{methodName}}; + break; + case JSON: + serviceMethod = {{methodName}}_JSON; + break; + default: + throw new AssertionError(); + } + server.callHandlerWithTranscoding(serviceMethod, this::handle_{{vertxMethodName}}, {{methodName}}_TRANSCODING); + return this; + } {{/manyUnaryMethods}} {{#manyManyMethods}} public final void handle_{{vertxMethodName}}(io.vertx.grpc.server.GrpcServerRequest<{{inputType}}, {{outputType}}> request) { @@ -200,6 +267,24 @@ public class {{className}} { server.callHandler(serviceMethod, this::handle_{{vertxMethodName}}); return this; } + public {{serviceName}}Api bind_{{vertxMethodName}}_with_transcoding(GrpcServer server) { + return bind_{{vertxMethodName}}_with_transcoding(server, io.vertx.grpc.common.WireFormat.PROTOBUF); + } + public {{serviceName}}Api bind_{{vertxMethodName}}_with_transcoding(GrpcServer server, io.vertx.grpc.common.WireFormat format) { + ServiceMethod<{{inputType}},{{outputType}}> serviceMethod; + switch(format) { + case PROTOBUF: + serviceMethod = {{methodName}}; + break; + case JSON: + serviceMethod = {{methodName}}_JSON; + break; + default: + throw new AssertionError(); + } + server.callHandlerWithTranscoding(serviceMethod, this::handle_{{vertxMethodName}}, {{methodName}}_TRANSCODING); + return this; + } {{/manyManyMethods}} public final {{serviceName}}Api bindAll(GrpcServer server) { @@ -212,6 +297,20 @@ public class {{className}} { public final {{serviceName}}Api bindAll(GrpcServer server, io.vertx.grpc.common.WireFormat format) { {{#methods}} bind_{{vertxMethodName}}(server, format); +{{/methods}} + return this; + } + + public final {{serviceName}}Api bindAllWithTranscoding(GrpcServer server) { +{{#methods}} + bind_{{vertxMethodName}}_with_transcoding(server); +{{/methods}} + return this; + } + + public final {{serviceName}}Api bindAllWithTranscoding(GrpcServer server, io.vertx.grpc.common.WireFormat format) { +{{#methods}} + bind_{{vertxMethodName}}_with_transcoding(server, format); {{/methods}} return this; } diff --git a/vertx-grpc-server/pom.xml b/vertx-grpc-server/pom.xml index b799957a..1734f787 100644 --- a/vertx-grpc-server/pom.xml +++ b/vertx-grpc-server/pom.xml @@ -34,6 +34,11 @@ io.vertx vertx-grpc-common + + io.vertx + vertx-grpc-transcoding + ${project.version} + io.vertx @@ -42,6 +47,13 @@ test-jar test + + io.vertx + vertx-grpc-transcoding + ${project.version} + test-jar + test + org.bouncycastle diff --git a/vertx-grpc-server/src/main/generated/io/vertx/grpc/server/GrpcServerOptionsConverter.java b/vertx-grpc-server/src/main/generated/io/vertx/grpc/server/GrpcServerOptionsConverter.java index 9aad9b01..1481052c 100644 --- a/vertx-grpc-server/src/main/generated/io/vertx/grpc/server/GrpcServerOptionsConverter.java +++ b/vertx-grpc-server/src/main/generated/io/vertx/grpc/server/GrpcServerOptionsConverter.java @@ -19,6 +19,11 @@ static void fromJson(Iterable> json, GrpcSer obj.setGrpcWebEnabled((Boolean)member.getValue()); } break; + case "grpcTranscodingEnabled": + if (member.getValue() instanceof Boolean) { + obj.setGrpcTranscodingEnabled((Boolean)member.getValue()); + } + break; case "scheduleDeadlineAutomatically": if (member.getValue() instanceof Boolean) { obj.setScheduleDeadlineAutomatically((Boolean)member.getValue()); @@ -44,6 +49,7 @@ static void toJson(GrpcServerOptions obj, JsonObject json) { static void toJson(GrpcServerOptions obj, java.util.Map json) { json.put("grpcWebEnabled", obj.isGrpcWebEnabled()); + json.put("grpcTranscodingEnabled", obj.isGrpcTranscodingEnabled()); json.put("scheduleDeadlineAutomatically", obj.getScheduleDeadlineAutomatically()); json.put("deadlinePropagation", obj.getDeadlinePropagation()); json.put("maxMessageSize", obj.getMaxMessageSize()); diff --git a/vertx-grpc-server/src/main/java/io/vertx/grpc/server/GrpcProtocol.java b/vertx-grpc-server/src/main/java/io/vertx/grpc/server/GrpcProtocol.java index c2c5c0a2..4c95ddb6 100644 --- a/vertx-grpc-server/src/main/java/io/vertx/grpc/server/GrpcProtocol.java +++ b/vertx-grpc-server/src/main/java/io/vertx/grpc/server/GrpcProtocol.java @@ -2,20 +2,33 @@ public enum GrpcProtocol { - HTTP_2("application/grpc", false), WEB("application/grpc-web", true), WEB_TEXT("application/grpc-web-text", true); + HTTP_2("application/grpc", false), + HTTP_1("application/json", false, true), + WEB("application/grpc-web", true), + WEB_TEXT("application/grpc-web-text", true); private final String mediaType; private final boolean web; + private final boolean text; GrpcProtocol(String mediaType, boolean web) { + this(mediaType, web, false); + } + + GrpcProtocol(String mediaType, boolean web, boolean text) { this.mediaType = mediaType; this.web = web; + this.text = text; } public boolean isWeb() { return web; } + public boolean isText() { + return text; + } + public String mediaType() { return mediaType; } diff --git a/vertx-grpc-server/src/main/java/io/vertx/grpc/server/GrpcServer.java b/vertx-grpc-server/src/main/java/io/vertx/grpc/server/GrpcServer.java index c851ce7a..524e1be6 100644 --- a/vertx-grpc-server/src/main/java/io/vertx/grpc/server/GrpcServer.java +++ b/vertx-grpc-server/src/main/java/io/vertx/grpc/server/GrpcServer.java @@ -19,6 +19,7 @@ import io.vertx.core.http.HttpServerRequest; import io.vertx.grpc.common.ServiceMethod; import io.vertx.grpc.server.impl.GrpcServerImpl; +import io.vertx.grpc.transcoding.ServiceTranscodingOptions; /** *

A gRPC server based on Vert.x HTTP server.

@@ -73,4 +74,15 @@ static GrpcServer server(Vertx vertx, GrpcServerOptions options) { @GenIgnore(GenIgnore.PERMITTED_TYPE) GrpcServer callHandler(ServiceMethod serviceMethod, Handler> handler); + /** + * Set a service method call handler that handles any call made to the server for the {@code fullMethodName } service method. + * You can use this method to bind a service method and pass the transcoding options. + * + * @param handler the service method call handler + * @param serviceMethod the service method + * @param transcodingOptions the transcoding options + * @return a reference to this, so the API can be used fluently + */ + @GenIgnore(GenIgnore.PERMITTED_TYPE) + GrpcServer callHandlerWithTranscoding(ServiceMethod serviceMethod, Handler> handler, ServiceTranscodingOptions transcodingOptions); } diff --git a/vertx-grpc-server/src/main/java/io/vertx/grpc/server/GrpcServerOptions.java b/vertx-grpc-server/src/main/java/io/vertx/grpc/server/GrpcServerOptions.java index 58784d25..6e401492 100644 --- a/vertx-grpc-server/src/main/java/io/vertx/grpc/server/GrpcServerOptions.java +++ b/vertx-grpc-server/src/main/java/io/vertx/grpc/server/GrpcServerOptions.java @@ -28,6 +28,11 @@ public class GrpcServerOptions { */ public static final boolean DEFAULT_GRPC_WEB_ENABLED = true; + /** + * Whether the gRPC transcoding should be enabled, by default = {@code false}. + */ + public static final boolean DEFAULT_GRPC_TRANSCODING_ENABLED = false; + /** * Whether the server schedule deadline automatically when a request carrying a timeout is received, by default = {@code false} */ @@ -44,6 +49,7 @@ public class GrpcServerOptions { public static final long DEFAULT_MAX_MESSAGE_SIZE = 256 * 1024; private boolean grpcWebEnabled; + private boolean grpcTranscodingEnabled; private boolean scheduleDeadlineAutomatically; private boolean deadlinePropagation; private long maxMessageSize; @@ -53,6 +59,7 @@ public class GrpcServerOptions { */ public GrpcServerOptions() { grpcWebEnabled = DEFAULT_GRPC_WEB_ENABLED; + grpcTranscodingEnabled = DEFAULT_GRPC_TRANSCODING_ENABLED; scheduleDeadlineAutomatically = DEFAULT_SCHEDULE_DEADLINE_AUTOMATICALLY; deadlinePropagation = DEFAULT_PROPAGATE_DEADLINE; maxMessageSize = DEFAULT_MAX_MESSAGE_SIZE; @@ -63,6 +70,7 @@ public GrpcServerOptions() { */ public GrpcServerOptions(GrpcServerOptions other) { grpcWebEnabled = other.grpcWebEnabled; + grpcTranscodingEnabled = other.grpcTranscodingEnabled; scheduleDeadlineAutomatically = other.scheduleDeadlineAutomatically; deadlinePropagation = other.deadlinePropagation; maxMessageSize = other.maxMessageSize; @@ -94,6 +102,24 @@ public GrpcServerOptions setGrpcWebEnabled(boolean grpcWebEnabled) { return this; } + /** + * @return {@code true} if the gRPC transcoding should be enabled, {@code false} otherwise + */ + public boolean isGrpcTranscodingEnabled() { + return grpcTranscodingEnabled; + } + + /** + * Whether the gRPC transcoding should be enabled. Defaults to {@code false}. + * + * @param grpcTranscodingEnabled {@code true} if the gRPC transcoding should be enabled, {@code false} otherwise + * @return a reference to this, so the API can be used fluently + */ + public GrpcServerOptions setGrpcTranscodingEnabled(boolean grpcTranscodingEnabled) { + this.grpcTranscodingEnabled = grpcTranscodingEnabled; + return this; + } + /** * @return whether the server will automatically schedule a deadline when a request carrying a timeout is received. */ @@ -138,7 +164,6 @@ public GrpcServerOptions setDeadlinePropagation(boolean deadlinePropagation) { return this; } - /** * @return the maximum message size in bytes accepted by the server */ @@ -148,6 +173,7 @@ public long getMaxMessageSize() { /** * Set the maximum message size in bytes accepted from a client, the maximum value is {@code 0xFFFFFFFF} + * * @param maxMessageSize the size * @return a reference to this, so the API can be used fluently */ diff --git a/vertx-grpc-server/src/main/java/io/vertx/grpc/server/impl/GrpcServerImpl.java b/vertx-grpc-server/src/main/java/io/vertx/grpc/server/impl/GrpcServerImpl.java index 982a27ad..38bc14b3 100644 --- a/vertx-grpc-server/src/main/java/io/vertx/grpc/server/impl/GrpcServerImpl.java +++ b/vertx-grpc-server/src/main/java/io/vertx/grpc/server/impl/GrpcServerImpl.java @@ -21,6 +21,10 @@ import io.vertx.core.spi.context.storage.AccessMode; import io.vertx.grpc.common.*; import io.vertx.grpc.common.impl.GrpcMethodCall; +import io.vertx.grpc.transcoding.*; +import io.vertx.grpc.transcoding.impl.PathMatcherBuilderImpl; +import io.vertx.grpc.transcoding.impl.PathMatcherImpl; +import io.vertx.grpc.transcoding.impl.PathMatcherUtility; import io.vertx.grpc.server.GrpcProtocol; import io.vertx.grpc.server.GrpcServer; import io.vertx.grpc.server.GrpcServerOptions; @@ -44,6 +48,8 @@ public class GrpcServerImpl implements GrpcServer { private final GrpcServerOptions options; private Handler> requestHandler; private final Map>> methodCallHandlers = new HashMap<>(); + private final List pathMatchers = new ArrayList<>(); + private final Map transcodingOptions = new HashMap<>(); public GrpcServerImpl(Vertx vertx, GrpcServerOptions options) { this.options = new GrpcServerOptions(Objects.requireNonNull(options, "options is null")); @@ -56,7 +62,7 @@ public void handle(HttpServerRequest httpRequest) { httpRequest.response().setStatusCode(errorCode).end(); return; } - WireFormat format ; + WireFormat format; String contentType = httpRequest.getHeader(CONTENT_TYPE); GrpcProtocol protocol; if (contentType != null) { @@ -86,18 +92,34 @@ public void handle(HttpServerRequest httpRequest) { format = WireFormat.PROTOBUF; } } else { - httpRequest.response().setStatusCode(415).end(); - return; + if (GrpcProtocol.HTTP_1.mediaType().equals(contentType)) { + protocol = GrpcProtocol.HTTP_1; + format = WireFormat.JSON; + } else { + httpRequest.response().setStatusCode(415).end(); + return; + } } } else { httpRequest.response().setStatusCode(415).end(); return; } - GrpcMethodCall methodCall = new GrpcMethodCall(httpRequest.path()); + GrpcMethodCall methodCall = lookupMethod(httpRequest); + if (methodCall == null) { + log.trace("No method found for " + httpRequest.path()); + httpRequest.response().setStatusCode(404).end(); + return; + } + String fmn = methodCall.fullMethodName(); List> methods = methodCallHandlers.get(fmn); if (methods != null) { for (MethodCallHandler method : methods) { + if (GrpcProtocol.HTTP_1 == protocol && protocol.mediaType().equals(httpRequest.headers().get(CONTENT_TYPE))) { + handle(method, httpRequest, methodCall); + return; + } + if (method.messageEncoder.format() == format && method.messageDecoder.format() == format) { handle(method, httpRequest, methodCall, protocol, format); return; @@ -106,7 +128,7 @@ public void handle(HttpServerRequest httpRequest) { } Handler> handler = requestHandler; if (handler != null) { - handle(httpRequest, methodCall, protocol, format, GrpcMessageDecoder.IDENTITY, GrpcMessageEncoder.IDENTITY, handler); + handle(httpRequest, methodCall, protocol, format, null, null, GrpcMessageDecoder.IDENTITY, GrpcMessageEncoder.IDENTITY, handler); } else { httpRequest.response().setStatusCode(500).end(); } @@ -114,29 +136,84 @@ public void handle(HttpServerRequest httpRequest) { private int refuseRequest(HttpServerRequest request) { if (request.version() != HttpVersion.HTTP_2) { - if (!options.isGrpcWebEnabled()) { - log.trace("gRPC-Web is not enabled, sending error 505"); + if (!options.isGrpcWebEnabled() && !options.isGrpcTranscodingEnabled()) { + log.trace("The server is not configured to handle HTTP/1.1 requests, sending error 505"); return 505; } - if (!GrpcMediaType.isGrpcWeb(request.headers().get(CONTENT_TYPE))) { + + String contentType = request.headers().get(CONTENT_TYPE); + + if (options.isGrpcWebEnabled() && (!GrpcMediaType.isGrpcWeb(contentType) && !GrpcProtocol.HTTP_1.mediaType().equals(contentType))) { log.trace("gRPC-Web is the only media type supported on HTTP/1.1, sending error 415"); return 415; } + + if (options.isGrpcTranscodingEnabled() && !GrpcProtocol.HTTP_1.mediaType().equals(contentType)) { + log.trace("The server is configured to handle transcoding, but the request does not contain application/json, sending error 415"); + return 415; + } } return -1; } + private GrpcMethodCall lookupMethod(HttpServerRequest request) { + if (request.version() == HttpVersion.HTTP_2) { + return new GrpcMethodCall(request.path()); + } + + if (GrpcProtocol.HTTP_1.mediaType().equals(request.headers().get(CONTENT_TYPE))) { + for (PathMatcher pathMatcher : pathMatchers) { + PathMatcherLookupResult result = pathMatcher.lookup(request.method().name(), request.path(), request.query()); + if (result != null) { + return new GrpcMethodCall("/" + result.getMethod()); + } + } + } + + return new GrpcMethodCall(request.path()); + } + + private void handle(MethodCallHandler method, HttpServerRequest request, GrpcMethodCall methodCall) { + if (request.version() == HttpVersion.HTTP_2) { + return; + } + + String contentType = request.getHeader(CONTENT_TYPE); + if (!contentType.equals(GrpcProtocol.HTTP_1.mediaType())) { + return; + } + + List bindings = new ArrayList<>(); + + for (PathMatcher pathMatcher : pathMatchers) { + PathMatcherLookupResult result = pathMatcher.lookup(request.method().name(), request.path(), request.query()); + if (result != null) { + bindings.addAll(result.getVariableBindings()); + break; + } + } + + ServiceTranscodingOptions transcodingOptions = this.transcodingOptions.get(methodCall.fullMethodName()); + if (transcodingOptions == null) { + return; + } + + handle(request, methodCall, GrpcProtocol.HTTP_1, WireFormat.JSON, transcodingOptions, bindings, method.messageDecoder, method.messageEncoder, method); + } + private void handle(MethodCallHandler method, HttpServerRequest httpRequest, GrpcMethodCall methodCall, GrpcProtocol protocol, WireFormat format) { - handle(httpRequest, methodCall, protocol, format, method.messageDecoder, method.messageEncoder, method); + handle(httpRequest, methodCall, protocol, format, null, null, method.messageDecoder, method.messageEncoder, method); } private void handle(HttpServerRequest httpRequest, - GrpcMethodCall methodCall, - GrpcProtocol protocol, - WireFormat format, - GrpcMessageDecoder messageDecoder, - GrpcMessageEncoder messageEncoder, - Handler> handler) { + GrpcMethodCall methodCall, + GrpcProtocol protocol, + WireFormat format, + ServiceTranscodingOptions transcodingOptions, + List bindings, + GrpcMessageDecoder messageDecoder, + GrpcMessageEncoder messageEncoder, + Handler> handler) { io.vertx.core.internal.ContextInternal context = ((HttpServerRequestInternal) httpRequest).context(); GrpcServerRequestImpl grpcRequest = new GrpcServerRequestImpl<>( context, @@ -145,6 +222,9 @@ private void handle(HttpServerRequest httpRequest, format, options.getMaxMessageSize(), httpRequest, + transcodingOptions == null ? null : transcodingOptions.getBody(), + transcodingOptions == null ? null : transcodingOptions.getResponseBody(), + bindings, messageDecoder, messageEncoder, methodCall); @@ -176,7 +256,7 @@ public GrpcServer callHandler(ServiceMethod serviceMethod if (prev == null) { prev = new ArrayList<>(); } - for (int i = 0;i < prev.size();i++) { + for (int i = 0; i < prev.size(); i++) { MethodCallHandler a = prev.get(i); if (a.messageDecoder.format() == serviceMethod.decoder().format() && a.messageEncoder.format() == serviceMethod.encoder().format()) { prev.set(i, p); @@ -189,7 +269,7 @@ public GrpcServer callHandler(ServiceMethod serviceMethod } else { methodCallHandlers.compute(serviceMethod.fullMethodName(), (key, prev) -> { if (prev != null) { - for (int i = 0;i < prev.size();i++) { + for (int i = 0; i < prev.size(); i++) { MethodCallHandler a = prev.get(i); if (a.messageDecoder.format() == serviceMethod.decoder().format() && a.messageEncoder.format() == serviceMethod.encoder().format()) { prev.remove(i); @@ -206,6 +286,24 @@ public GrpcServer callHandler(ServiceMethod serviceMethod return this; } + @Override + public GrpcServer callHandlerWithTranscoding(ServiceMethod serviceMethod, Handler> handler, + ServiceTranscodingOptions transcodingOptions) { + this.callHandler(serviceMethod, handler); + + if (!options.isGrpcTranscodingEnabled()) { + return this; + } + + PathMatcherBuilder pmb = new PathMatcherBuilderImpl(); + PathMatcherUtility.registerByHttpRule(pmb, transcodingOptions, serviceMethod.fullMethodName()); + + this.pathMatchers.add(pmb.build()); + this.transcodingOptions.put(serviceMethod.fullMethodName(), transcodingOptions); + + return this; + } + private static class MethodCallHandler implements Handler> { final GrpcMessageDecoder messageDecoder; diff --git a/vertx-grpc-server/src/main/java/io/vertx/grpc/server/impl/GrpcServerRequestImpl.java b/vertx-grpc-server/src/main/java/io/vertx/grpc/server/impl/GrpcServerRequestImpl.java index 28e925d6..8eb3091d 100644 --- a/vertx-grpc-server/src/main/java/io/vertx/grpc/server/impl/GrpcServerRequestImpl.java +++ b/vertx-grpc-server/src/main/java/io/vertx/grpc/server/impl/GrpcServerRequestImpl.java @@ -21,6 +21,8 @@ import io.vertx.core.http.HttpConnection; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.http.HttpVersion; +import io.vertx.core.json.DecodeException; +import io.vertx.core.json.JsonObject; import io.vertx.grpc.common.*; import io.vertx.grpc.common.impl.GrpcReadStreamBase; import io.vertx.grpc.common.impl.GrpcMethodCall; @@ -28,8 +30,11 @@ import io.vertx.grpc.server.GrpcProtocol; import io.vertx.grpc.server.GrpcServerRequest; import io.vertx.grpc.server.GrpcServerResponse; +import io.vertx.grpc.transcoding.HttpVariableBinding; +import java.nio.charset.StandardCharsets; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; @@ -71,6 +76,8 @@ private static long parseTimeout(String timeout) { private static final BufferInternal EMPTY_BUFFER = BufferInternal.buffer(Unpooled.EMPTY_BUFFER); final HttpServerRequest httpRequest; + final String transcodingRequestBody; + final List bindings; final GrpcServerResponseImpl response; final long timeout; final boolean scheduleDeadline; @@ -80,14 +87,17 @@ private static long parseTimeout(String timeout) { private Timer deadline; public GrpcServerRequestImpl(io.vertx.core.internal.ContextInternal context, - boolean scheduleDeadline, - GrpcProtocol protocol, - WireFormat format, - long maxMessageSize, - HttpServerRequest httpRequest, - GrpcMessageDecoder messageDecoder, - GrpcMessageEncoder messageEncoder, - GrpcMethodCall methodCall) { + boolean scheduleDeadline, + GrpcProtocol protocol, + WireFormat format, + long maxMessageSize, + HttpServerRequest httpRequest, + String transcodingRequestBody, + String transcodingResponseBody, + List bindings, + GrpcMessageDecoder messageDecoder, + GrpcMessageEncoder messageEncoder, + GrpcMethodCall methodCall) { super(context, httpRequest, httpRequest.headers().get("grpc-encoding"), format, maxMessageSize, messageDecoder); String timeoutHeader = httpRequest.getHeader("grpc-timeout"); long timeout = timeoutHeader != null ? parseTimeout(timeoutHeader) : 0L; @@ -97,11 +107,14 @@ public GrpcServerRequestImpl(io.vertx.core.internal.ContextInternal context, this, protocol, httpRequest.response(), + transcodingResponseBody, messageEncoder); response.init(); this.protocol = protocol; this.timeout = timeout; this.httpRequest = httpRequest; + this.transcodingRequestBody = transcodingRequestBody; + this.bindings = bindings; this.response = response; this.methodCall = methodCall; this.scheduleDeadline = scheduleDeadline; @@ -193,9 +206,25 @@ public Timer deadline() { @Override public void handle(Buffer chunk) { if (notGrpcWebText()) { + if (isTranscodable()) { + BufferInternal transcoded = (BufferInternal) mutateMessage(chunk, bindings); + if(transcoded == null) { + return; + } + + Buffer prefixed = BufferInternal.buffer(transcoded.length() + 5); + + prefixed.appendByte((byte) 0); // uncompressed flag + prefixed.appendInt(transcoded.length()); // content length + prefixed.appendBuffer(transcoded); + + chunk = prefixed; + } + super.handle(chunk); return; } + if (grpcWebTextBuffer == EMPTY_BUFFER) { ByteBuf bbuf = ((BufferInternal) chunk).getByteBuf(); if ((chunk.length() & 0b11) == 0) { @@ -204,6 +233,7 @@ public void handle(Buffer chunk) { } else { grpcWebTextBuffer = BufferInternal.buffer(bbuf.copy()); } + return; } bufferAndDecode(chunk); @@ -213,6 +243,61 @@ private boolean notGrpcWebText() { return grpcWebTextBuffer == null; } + public boolean isTranscodable() { + return (httpRequest.version() == HttpVersion.HTTP_1_0 || httpRequest.version() == HttpVersion.HTTP_1_1) && GrpcProtocol.HTTP_1.mediaType() + .equals(httpRequest.getHeader(CONTENT_TYPE)); + } + + private Buffer mutateMessage(Buffer message, List bindings) { + if (bindings.isEmpty() && transcodingRequestBody == null) { + return message; + } + BufferInternal buffer = BufferInternal.buffer(); + + try { + JsonObject json = new JsonObject(message.toString()); + + // Handle bindings + for (HttpVariableBinding binding : bindings) { + JsonObject parent = json; + List fieldPath = binding.getFieldPath(); + for (int i = 0; i < fieldPath.size() - 1; i++) { + String fieldName = fieldPath.get(i); + if (!parent.containsKey(fieldName)) { + parent.put(fieldName, new JsonObject()); + } + parent = parent.getJsonObject(fieldName); + } + parent.put(fieldPath.get(fieldPath.size() - 1), binding.getValue()); + } + + if (transcodingRequestBody != null && !transcodingRequestBody.isEmpty()) { + if (transcodingRequestBody.equals("*")) { + // If transcodingRequestBody is "*", merge the entire message into the root + json = json.mergeIn(new JsonObject(message.toString())); + } else { + JsonObject parent = json; + String[] path = transcodingRequestBody.split("\\."); + for (int i = 0; i < path.length - 1; i++) { + String fieldName = path[i]; + if (!parent.containsKey(fieldName)) { + parent.put(fieldName, new JsonObject()); + } + parent = parent.getJsonObject(fieldName); + } + parent.put(path[path.length - 1], new JsonObject(message.toString())); + } + } + + buffer.appendString(json.encode()); + } catch (DecodeException e) { + response.status(GrpcStatus.INTERNAL).statusMessage("Invalid JSON payload").end(); + return null; + } + + return buffer; + } + private void bufferAndDecode(Buffer chunk) { grpcWebTextBuffer.appendBuffer(chunk); int len = grpcWebTextBuffer.length(); diff --git a/vertx-grpc-server/src/main/java/io/vertx/grpc/server/impl/GrpcServerResponseImpl.java b/vertx-grpc-server/src/main/java/io/vertx/grpc/server/impl/GrpcServerResponseImpl.java index 4e6884bf..bdc89092 100644 --- a/vertx-grpc-server/src/main/java/io/vertx/grpc/server/impl/GrpcServerResponseImpl.java +++ b/vertx-grpc-server/src/main/java/io/vertx/grpc/server/impl/GrpcServerResponseImpl.java @@ -18,6 +18,8 @@ import io.vertx.core.http.HttpHeaders; import io.vertx.core.http.HttpServerResponse; import io.vertx.core.internal.ContextInternal; +import io.vertx.core.json.DecodeException; +import io.vertx.core.json.JsonObject; import io.vertx.grpc.common.*; import io.vertx.grpc.common.impl.GrpcMessageImpl; import io.vertx.grpc.common.impl.GrpcWriteStreamBase; @@ -35,6 +37,7 @@ public class GrpcServerResponseImpl extends GrpcWriteStreamBase request; private final HttpServerResponse httpResponse; + private final String transcodingResponseBody; private final GrpcProtocol protocol; private GrpcStatus status = GrpcStatus.OK; private String statusMessage; @@ -43,13 +46,15 @@ public class GrpcServerResponseImpl extends GrpcWriteStreamBase request, - GrpcProtocol protocol, - HttpServerResponse httpResponse, - GrpcMessageEncoder encoder) { + GrpcServerRequestImpl request, + GrpcProtocol protocol, + HttpServerResponse httpResponse, + String transcodingResponseBody, + GrpcMessageEncoder encoder) { super(context, protocol.mediaType(), httpResponse, encoder); this.request = request; this.httpResponse = httpResponse; + this.transcodingResponseBody = transcodingResponseBody; this.protocol = protocol; } @@ -152,9 +157,41 @@ protected void sendTrailers(MultiMap trailers) { @Override protected Future sendMessage(Buffer message, boolean compressed) { + if (request.isTranscodable()) { + return sendTranscodedMessage(message, compressed); + } + return httpResponse.write(encodeMessage(message, compressed, false)); } + private Future sendTranscodedMessage(Buffer message, boolean compressed) { + try { + if (transcodingResponseBody != null && !transcodingResponseBody.isEmpty()) { + Object value = new JsonObject(message.toString()); + if (!transcodingResponseBody.equals("*")) { + // Extract the value at the specified path + String[] path = transcodingResponseBody.split("\\."); + for (String field : path) { + if (value instanceof JsonObject) { + value = ((JsonObject) value).getValue(field); + } else { + // Handle the case where the path is invalid + throw new IllegalStateException("Invalid transcodingResponseBody path: " + transcodingResponseBody); + } + } + } + message = Buffer.buffer(value.toString()); + } + + BufferInternal transcoded = (BufferInternal) message; + httpResponse.putHeader("content-length", Integer.toString(message.length())); + httpResponse.putHeader("content-type", GrpcProtocol.HTTP_1.mediaType()); + return httpResponse.write(transcoded); + } catch (DecodeException e) { + return Future.failedFuture(e); + } + } + protected Future sendEnd() { request.cancelTimeout(); return httpResponse.end(); @@ -165,6 +202,7 @@ private Buffer encodeMessage(Buffer message, boolean compressed, boolean trailer if (protocol == GrpcProtocol.WEB_TEXT) { return BufferInternal.buffer(Base64.encode(buffer.getByteBuf(), false)); } + return buffer; } } diff --git a/vertx-grpc-server/src/main/java/module-info.java b/vertx-grpc-server/src/main/java/module-info.java index d0812ca8..173eb660 100644 --- a/vertx-grpc-server/src/main/java/module-info.java +++ b/vertx-grpc-server/src/main/java/module-info.java @@ -1,11 +1,11 @@ module io.vertx.grpc.server { - requires io.netty.buffer; - requires io.netty.codec; requires io.vertx.core.logging; requires transitive io.vertx.grpc.common; requires static io.vertx.docgen; - requires static io.vertx.codegen.api; requires static io.vertx.codegen.json; - requires com.google.protobuf; + requires io.vertx.grpc.transcoding; + requires io.vertx.codegen.api; + requires io.netty.codec; + requires io.netty.buffer; exports io.vertx.grpc.server; } diff --git a/vertx-grpc-server/src/test/java/io/vertx/tests/server/ServerTranscodingTest.java b/vertx-grpc-server/src/test/java/io/vertx/tests/server/ServerTranscodingTest.java new file mode 100644 index 00000000..ac45d57d --- /dev/null +++ b/vertx-grpc-server/src/test/java/io/vertx/tests/server/ServerTranscodingTest.java @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2011-2024 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ + +package io.vertx.tests.server; + +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; +import com.google.protobuf.util.JsonFormat; +import io.vertx.core.MultiMap; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.*; +import io.vertx.core.internal.buffer.BufferInternal; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.unit.TestContext; +import io.vertx.grpc.common.*; +import io.vertx.grpc.server.GrpcServer; +import io.vertx.grpc.server.GrpcServerOptions; +import io.vertx.grpc.server.GrpcServerResponse; +import io.vertx.grpc.transcoding.ServiceTranscodingOptions; +import io.vertx.grpcweb.GrpcWebTesting.*; +import io.vertx.tests.common.GrpcTestBase; +import org.junit.Test; + +import static java.util.stream.Collectors.joining; +import static org.junit.Assert.*; + +/** + * A test class for grpc transcoding. + */ +public class ServerTranscodingTest extends GrpcTestBase { + + public static GrpcMessageDecoder EMPTY_DECODER = GrpcMessageDecoder.json(Empty::newBuilder); + public static GrpcMessageEncoder EMPTY_ENCODER = GrpcMessageEncoder.json(); + public static GrpcMessageDecoder ECHO_REQUEST_DECODER = GrpcMessageDecoder.json(EchoRequest::newBuilder); + public static GrpcMessageEncoder ECHO_RESPONSE_ENCODER = GrpcMessageEncoder.json(); + + public static final ServiceName TEST_SERVICE_NAME = ServiceName.create("io.vertx.grpcweb.TestService"); + public static final ServiceMethod EMPTY_CALL = ServiceMethod.server(TEST_SERVICE_NAME, "EmptyCall", EMPTY_ENCODER, EMPTY_DECODER); + public static final ServiceMethod UNARY_CALL = ServiceMethod.server(TEST_SERVICE_NAME, "UnaryCall", ECHO_RESPONSE_ENCODER, ECHO_REQUEST_DECODER); + + public static final ServiceTranscodingOptions EMPTY_TRANSCODING = ServiceTranscodingOptions.create("", HttpMethod.valueOf("POST"), "/hello", "", "", null); + public static final ServiceTranscodingOptions UNARY_TRANSCODING = ServiceTranscodingOptions.create("", HttpMethod.valueOf("GET"), "/hello", "", "", null); + public static final ServiceTranscodingOptions UNARY_TRANSCODING_WITH_PARAM = ServiceTranscodingOptions.create("", HttpMethod.valueOf("GET"), "/hello/{payload}", "", "", null); + + private static final String TEST_SERVICE = "/io.vertx.grpcweb.TestService"; + + private static final CharSequence USER_AGENT = HttpHeaders.createOptimized("X-User-Agent"); + private static final String CONTENT_TYPE = "application/json"; + + private static final MultiMap HEADERS = HttpHeaders.headers() + .add(HttpHeaders.CONTENT_TYPE, CONTENT_TYPE) + .add(HttpHeaders.USER_AGENT, USER_AGENT) + .add(HttpHeaders.ACCEPT, CONTENT_TYPE); + + private static final Empty EMPTY_DEFAULT_INSTANCE = Empty.getDefaultInstance(); + + private HttpClient httpClient; + private HttpServer httpServer; + + @Override + public void setUp(TestContext should) { + super.setUp(should); + httpClient = vertx.createHttpClient(new HttpClientOptions().setDefaultPort(port)); + GrpcServer grpcServer = GrpcServer.server(vertx, new GrpcServerOptions().setGrpcTranscodingEnabled(true)); + grpcServer.callHandlerWithTranscoding(EMPTY_CALL, request -> { + copyHeaders(request.headers(), request.response().headers()); + copyTrailers(request.headers(), request.response().trailers()); + request.response().end(Empty.newBuilder().build()); + }, EMPTY_TRANSCODING); + grpcServer.callHandlerWithTranscoding(UNARY_CALL, request -> { + request.handler(requestMsg -> { + GrpcServerResponse response = request.response(); + copyHeaders(request.headers(), response.headers()); + copyTrailers(request.headers(), response.trailers()); + String payload = requestMsg.getPayload(); + if ("boom".equals(payload)) { + response.trailers().set("x-error-trailer", "boom"); + response.status(GrpcStatus.INTERNAL).end(); + } else { + EchoResponse responseMsg = EchoResponse.newBuilder() + .setPayload(payload) + .build(); + response.end(responseMsg); + } + }); + }, UNARY_TRANSCODING); + grpcServer.callHandlerWithTranscoding(UNARY_CALL, request -> { + request.handler(requestMsg -> { + GrpcServerResponse response = request.response(); + copyHeaders(request.headers(), response.headers()); + copyTrailers(request.headers(), response.trailers()); + String payload = requestMsg.getPayload(); + if ("boom".equals(payload)) { + response.trailers().set("x-error-trailer", "boom"); + response.status(GrpcStatus.INTERNAL).end(); + } else { + EchoResponse responseMsg = EchoResponse.newBuilder() + .setPayload(payload) + .build(); + response.end(responseMsg); + } + }); + }, UNARY_TRANSCODING_WITH_PARAM); + httpServer = vertx.createHttpServer(new HttpServerOptions().setPort(port)).requestHandler(grpcServer); + httpServer.listen().onComplete(should.asyncAssertSuccess()); + } + + @Override + public void tearDown(TestContext should) { + httpServer.close().onComplete(should.asyncAssertSuccess()); + httpClient.close().onComplete(should.asyncAssertSuccess()); + super.tearDown(should); + } + + static void copyHeaders(MultiMap src, MultiMap headers) { + copyMetadata(src, headers, "x-header-text-key", "x-header-bin-key-bin"); + } + + static void copyTrailers(MultiMap src, MultiMap headers) { + copyMetadata(src, headers, "x-trailer-text-key", "x-trailer-bin-key-bin"); + } + + public static void copyMetadata(MultiMap src, MultiMap dst, String... keys) { + for (String key : keys) { + if (src.contains(key)) { + dst.set(key, src.get(key)); + } + } + } + + @Test + public void testEmpty(TestContext should) { + httpClient.request(HttpMethod.POST, "/hello").compose(req -> { + req.headers().addAll(HEADERS); + return req.send(encode(EMPTY_DEFAULT_INSTANCE)).compose(response -> response.body().map(response)); + }) + .onComplete(should.asyncAssertSuccess(response -> { + should.verify(v -> { + assertEquals(200, response.statusCode()); + MultiMap headers = response.headers(); + + assertTrue(headers.contains(HttpHeaders.CONTENT_TYPE, CONTENT_TYPE, true)); + assertEquals(Integer.parseInt(headers.get(HttpHeaders.CONTENT_LENGTH)), response.body().result().length()); + + JsonObject body = decodeBody(response.body().result()); + assertEquals(0, body.size()); + }); + })); + } + + /*@Test + public void testSmallPayload(TestContext should) { + String payload = "foobar"; + httpClient.request(HttpMethod.GET, "/hello/" + payload).compose(req -> { + req.headers().addAll(HEADERS); + return req.send().compose(response -> response.body().map(response)); + }).onComplete(should.asyncAssertSuccess(response -> should.verify(v -> { + assertEquals(200, response.statusCode()); + MultiMap headers = response.headers(); + assertTrue(headers.contains(HttpHeaders.CONTENT_TYPE, CONTENT_TYPE, true)); + JsonObject body = decodeBody(response.body().result()); + assertEquals(payload, body.getString("payload")); + }))); + } + + @Test + public void testSmallPayloadWithQuery(TestContext should) { + String payload = "foobar"; + httpClient.request(HttpMethod.GET, "/hello?payload=" + payload).compose(req -> { + req.headers().addAll(HEADERS); + return req.send().compose(response -> response.body().map(response)); + }).onComplete(should.asyncAssertSuccess(response -> should.verify(v -> { + assertEquals(200, response.statusCode()); + MultiMap headers = response.headers(); + assertTrue(headers.contains(HttpHeaders.CONTENT_TYPE, CONTENT_TYPE, true)); + JsonObject body = decodeBody(response.body().result()); + assertEquals(payload, body.getString("payload")); + }))); + }*/ + + private Buffer encode(Message message) { + Buffer buffer = BufferInternal.buffer(); + try { + String json = JsonFormat.printer().print(message); + buffer.appendString(json); + } catch (InvalidProtocolBufferException e) { + throw new RuntimeException(e); + } + return buffer; + } + + private JsonObject decodeBody(Buffer body) { + String json = body.toString(); + return new JsonObject(json); + } +} diff --git a/vertx-grpc-server/src/test/java/io/vertx/tests/server/transcoding/TranscodingTest.java b/vertx-grpc-server/src/test/java/io/vertx/tests/server/transcoding/TranscodingTest.java new file mode 100644 index 00000000..fcc2e664 --- /dev/null +++ b/vertx-grpc-server/src/test/java/io/vertx/tests/server/transcoding/TranscodingTest.java @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2011-2024 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ + +package io.vertx.tests.server.transcoding; + +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; +import com.google.protobuf.util.JsonFormat; +import io.vertx.core.MultiMap; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.*; +import io.vertx.core.internal.buffer.BufferInternal; +import io.vertx.core.json.Json; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.unit.TestContext; +import io.vertx.grpc.common.*; +import io.vertx.grpc.common.impl.GrpcMessageImpl; +import io.vertx.grpc.server.GrpcServer; +import io.vertx.grpc.server.GrpcServerOptions; +import io.vertx.grpc.server.GrpcServerResponse; +import io.vertx.grpc.transcoding.ServiceTranscodingOptions; +import io.vertx.grpcweb.GrpcWebTesting.*; +import io.vertx.tests.common.GrpcTestBase; +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.IntStream; + +import static io.vertx.core.http.HttpHeaders.CONTENT_LENGTH; +import static io.vertx.core.http.HttpHeaders.CONTENT_TYPE; +import static java.util.stream.Collectors.joining; +import static org.junit.Assert.*; + +/** + * A test class for grpc transcoding. + */ +public class TranscodingTest extends GrpcTestBase { + + public static GrpcMessageDecoder EMPTY_DECODER = GrpcMessageDecoder.json(Empty::newBuilder); + public static GrpcMessageEncoder EMPTY_ENCODER = GrpcMessageEncoder.json(); + public static GrpcMessageDecoder ECHO_REQUEST_DECODER = GrpcMessageDecoder.json(EchoRequest::newBuilder); + public static GrpcMessageEncoder ECHO_RESPONSE_ENCODER = GrpcMessageEncoder.json(); + + public static final ServiceName TEST_SERVICE_NAME = ServiceName.create("io.vertx.grpcweb.TestService"); + public static final ServiceMethod EMPTY_CALL = ServiceMethod.server(TEST_SERVICE_NAME, "EmptyCall", EMPTY_ENCODER, EMPTY_DECODER); + public static final ServiceMethod UNARY_CALL = ServiceMethod.server(TEST_SERVICE_NAME, "UnaryCall", ECHO_RESPONSE_ENCODER, ECHO_REQUEST_DECODER); + + public static final ServiceTranscodingOptions EMPTY_TRANSCODING = ServiceTranscodingOptions.create("", HttpMethod.valueOf("POST"), "/hello", "", "", null); + public static final ServiceTranscodingOptions UNARY_TRANSCODING = ServiceTranscodingOptions.create("", HttpMethod.valueOf("GET"), "/hello/{payload}", "", "", null); + + private static final String TEST_SERVICE = "/io.vertx.grpcweb.TestService"; + + private static final CharSequence USER_AGENT = HttpHeaders.createOptimized("X-User-Agent"); + private static final String CONTENT_TYPE = "application/json"; + + private static final MultiMap HEADERS = HttpHeaders.headers() + .add(HttpHeaders.CONTENT_TYPE, CONTENT_TYPE) + .add(HttpHeaders.USER_AGENT, USER_AGENT) + .add(HttpHeaders.ACCEPT, CONTENT_TYPE); + + private static final Empty EMPTY_DEFAULT_INSTANCE = Empty.getDefaultInstance(); + + private HttpClient httpClient; + private HttpServer httpServer; + + @Override + public void setUp(TestContext should) { + super.setUp(should); + httpClient = vertx.createHttpClient(new HttpClientOptions().setDefaultPort(port)); + GrpcServer grpcServer = GrpcServer.server(vertx, new GrpcServerOptions().setGrpcTranscodingEnabled(true)); + grpcServer.callHandlerWithTranscoding(EMPTY_CALL, request -> { + copyHeaders(request.headers(), request.response().headers()); + copyTrailers(request.headers(), request.response().trailers()); + request.response().end(Empty.newBuilder().build()); + }, EMPTY_TRANSCODING); + grpcServer.callHandlerWithTranscoding(UNARY_CALL, request -> { + request.handler(requestMsg -> { + GrpcServerResponse response = request.response(); + copyHeaders(request.headers(), response.headers()); + copyTrailers(request.headers(), response.trailers()); + String payload = requestMsg.getPayload(); + if ("boom".equals(payload)) { + response.trailers().set("x-error-trailer", "boom"); + response.status(GrpcStatus.INTERNAL).end(); + } else { + EchoResponse responseMsg = EchoResponse.newBuilder() + .setPayload(payload) + .build(); + response.end(responseMsg); + } + }); + }, UNARY_TRANSCODING); + httpServer = vertx.createHttpServer(new HttpServerOptions().setPort(port)).requestHandler(grpcServer); + httpServer.listen().onComplete(should.asyncAssertSuccess()); + } + + @Override + public void tearDown(TestContext should) { + httpServer.close().onComplete(should.asyncAssertSuccess()); + httpClient.close().onComplete(should.asyncAssertSuccess()); + super.tearDown(should); + } + + static void copyHeaders(MultiMap src, MultiMap headers) { + copyMetadata(src, headers, "x-header-text-key", "x-header-bin-key-bin"); + } + + static void copyTrailers(MultiMap src, MultiMap headers) { + copyMetadata(src, headers, "x-trailer-text-key", "x-trailer-bin-key-bin"); + } + + public static void copyMetadata(MultiMap src, MultiMap dst, String... keys) { + for (String key : keys) { + if (src.contains(key)) { + dst.set(key, src.get(key)); + } + } + } + + @Test + public void testEmpty(TestContext should) { + httpClient.request(HttpMethod.POST, "/hello").compose(req -> { + req.headers().addAll(HEADERS); + return req.send(encode(EMPTY_DEFAULT_INSTANCE)).compose(response -> response.body().map(response)); + }) + .onComplete(should.asyncAssertSuccess(response -> { + should.verify(v -> { + assertEquals(200, response.statusCode()); + MultiMap headers = response.headers(); + + assertTrue(headers.contains(HttpHeaders.CONTENT_TYPE, CONTENT_TYPE, true)); + assertEquals(Integer.parseInt(headers.get(HttpHeaders.CONTENT_LENGTH)), response.body().result().length()); + + JsonObject body = decodeBody(response.body().result()); + assertEquals(0, body.size()); + }); + })); + } + + @Test + public void testSmallPayload(TestContext should) { + String payload = "foobar"; + httpClient.request(HttpMethod.GET, "/hello/" + payload).compose(req -> { + req.headers().addAll(HEADERS); + EchoRequest echoRequest = EchoRequest.newBuilder().setPayload(payload).build(); + return req.send(encode(echoRequest)).compose(response -> response.body().map(response)); + }).onComplete(should.asyncAssertSuccess(response -> should.verify(v -> { + assertEquals(200, response.statusCode()); + MultiMap headers = response.headers(); + assertTrue(headers.contains(HttpHeaders.CONTENT_TYPE, CONTENT_TYPE, true)); + JsonObject body = decodeBody(response.body().result()); + assertEquals(payload, body.getString("payload")); + }))); + } + + private Buffer encode(Message message) { + Buffer buffer = BufferInternal.buffer(); + try { + String json = JsonFormat.printer().print(message); + buffer.appendString(json); + } catch (InvalidProtocolBufferException e) { + throw new RuntimeException(e); + } + return buffer; + } + + private JsonObject decodeBody(Buffer body) { + String json = body.toString(); + return new JsonObject(json); + } +} diff --git a/vertx-grpc-server/src/test/java/module-info.java b/vertx-grpc-server/src/test/java/module-info.java index fcc366e2..05174383 100644 --- a/vertx-grpc-server/src/test/java/module-info.java +++ b/vertx-grpc-server/src/test/java/module-info.java @@ -1,15 +1,15 @@ open module io.vertx.tests.server { - requires com.google.protobuf; - requires com.google.common; - requires io.grpc; + requires io.grpc; requires io.grpc.stub; requires io.grpc.util; requires io.grpc.protobuf; - requires io.vertx.core; - requires io.vertx.grpc.common; + requires io.vertx.grpc.common; requires io.vertx.grpc.server; requires io.vertx.testing.unit; requires io.vertx.tests.common; requires junit; requires testcontainers; + requires io.vertx.grpc.transcoding; + requires com.google.protobuf.util; + requires com.google.protobuf; } diff --git a/vertx-grpc-transcoding/pom.xml b/vertx-grpc-transcoding/pom.xml new file mode 100644 index 00000000..63b3294b --- /dev/null +++ b/vertx-grpc-transcoding/pom.xml @@ -0,0 +1,64 @@ + + + + + 4.0.0 + + + io.vertx + vertx-grpc-aggregator + 5.0.0-SNAPSHOT + ../pom.xml + + + vertx-grpc-transcoding + + Vert.x gRPC Transcoding + + + + io.vertx + vertx-core + + + com.google.protobuf + protobuf-java + + + com.google.protobuf + protobuf-java-util + + + + + + + org.xolstice.maven.plugins + protobuf-maven-plugin + + + test-compile + + test-compile + test-compile-custom + + + + + + + diff --git a/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/HttpTemplate.java b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/HttpTemplate.java new file mode 100644 index 00000000..faaaaf2e --- /dev/null +++ b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/HttpTemplate.java @@ -0,0 +1,59 @@ +package io.vertx.grpc.transcoding; + +import io.vertx.codegen.annotations.VertxGen; +import io.vertx.grpc.transcoding.impl.HttpTemplateImpl; +import io.vertx.grpc.transcoding.impl.HttpTemplateParser; + +import java.util.ArrayList; +import java.util.List; + +/** + * Represents an HTTP template used in gRPC transcoding. + *

+ * This interface defines methods for accessing the components of a parsed HTTP template string, including the segments, verb, and variables. + * + * @author Based on grpc-httpjson-transcoding + */ +@VertxGen +public interface HttpTemplate { + + /** + * Parses the given HTTP template string. + * + * @param template The HTTP template string to parse. + * @return The parsed {@code HttpTemplate}, or {@code null} if the parsing failed. + */ + static HttpTemplate parse(String template) { + if (template.equals("/")) { + return new HttpTemplateImpl(new ArrayList<>(), "", new ArrayList<>()); + } + + HttpTemplateParser parser = new HttpTemplateParser(template); + if (!parser.parse() || !parser.validateParts()) { + return null; + } + + return new HttpTemplateImpl(parser.segments(), parser.verb(), parser.variables()); + } + + /** + * Returns the list of segments in the parsed template. + * + * @return The list of segments. + */ + List getSegments(); + + /** + * Returns the verb in the parsed template. + * + * @return The verb. + */ + String getVerb(); + + /** + * Returns the list of variables in the parsed template. + * + * @return The list of variables. + */ + List getVariables(); +} diff --git a/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/HttpTemplateVariable.java b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/HttpTemplateVariable.java new file mode 100644 index 00000000..ef33e549 --- /dev/null +++ b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/HttpTemplateVariable.java @@ -0,0 +1,91 @@ +package io.vertx.grpc.transcoding; + +import io.vertx.codegen.annotations.VertxGen; +import io.vertx.grpc.transcoding.impl.HttpTemplateVariableImpl; + +import java.util.List; + +/** + * Represents a variable within an HTTP template used in gRPC transcoding. + *

+ * This interface defines methods for accessing information about a variable, such as its field path, start and end segments, and whether it represents a wildcard path. + * + * @author Based on grpc-httpjson-transcoding + */ +@VertxGen +public interface HttpTemplateVariable { + + /** + * Creates a new {@code HttpTemplateVariable} instance. + * + * @param fieldPath The field path of the variable. + * @param startSegment The starting segment of the variable. + * @param endSegment The ending segment of the variable. + * @param wildcardPath {@code true} if the variable represents a wildcard path, {@code false} otherwise. + * @return The created {@code HttpTemplateVariable} instance. + */ + static HttpTemplateVariable create(List fieldPath, int startSegment, int endSegment, boolean wildcardPath) { + HttpTemplateVariableImpl variable = new HttpTemplateVariableImpl(); + variable.setFieldPath(fieldPath); + variable.setStartSegment(startSegment); + variable.setEndSegment(endSegment); + variable.setWildcardPath(wildcardPath); + return variable; + } + + /** + * Returns the field path of the variable. + * + * @return The field path. + */ + List getFieldPath(); + + /** + * Sets the field path of the variable. + * + * @param fieldPath The field path to set. + */ + void setFieldPath(List fieldPath); + + /** + * Returns the starting segment of the variable. + * + * @return The starting segment. + */ + int getStartSegment(); + + /** + * Sets the starting segment of the variable. + * + * @param startSegment The starting segment to set. + */ + void setStartSegment(int startSegment); + + /** + * Returns the ending segment of the variable. + * + * @return The ending segment. + */ + int getEndSegment(); + + /** + * Sets the ending segment of the variable. + * + * @param endSegment The ending segment to set. + */ + void setEndSegment(int endSegment); + + /** + * Checks if the variable represents a wildcard path. + * + * @return {@code true} if the variable represents a wildcard path, {@code false} otherwise. + */ + boolean hasWildcardPath(); + + /** + * Sets whether the variable represents a wildcard path. + * + * @param wildcardPath {@code true} if the variable represents a wildcard path, {@code false} otherwise. + */ + void setWildcardPath(boolean wildcardPath); +} diff --git a/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/HttpVariableBinding.java b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/HttpVariableBinding.java new file mode 100644 index 00000000..dd6b51c1 --- /dev/null +++ b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/HttpVariableBinding.java @@ -0,0 +1,60 @@ +package io.vertx.grpc.transcoding; + +import io.vertx.codegen.annotations.VertxGen; +import io.vertx.grpc.transcoding.impl.HttpVariableBindingImpl; + +import java.util.List; + +/** + * Represents a binding between an HTTP request variable (from path or query parameters) + * and its corresponding gRPC message field path. This interface is used during HTTP-to-gRPC + * transcoding to map HTTP request variables to their appropriate locations in the gRPC message. + * + * The binding consists of: + * - A field path representing the location in the gRPC message where the value should be placed + * - The actual value extracted from the HTTP request + */ +@VertxGen +public interface HttpVariableBinding { + + /** + * Creates a new HttpVariableBinding instance. + * + * @param fieldPath A list of field names representing the path in the gRPC message + * where the value should be placed. For example, ["user", "address", "city"] + * would represent a path to the city field in a nested message structure + * @param value The value extracted from the HTTP request that should be bound to this location + * @return A new HttpVariableBinding instance + */ + static HttpVariableBinding create(List fieldPath, String value) { + return new HttpVariableBindingImpl(fieldPath, value); + } + + /** + * Gets the field path that describes where in the gRPC message the value should be placed. + * + * @return A list of field names representing the path in the gRPC message + */ + List getFieldPath(); + + /** + * Sets the field path that describes where in the gRPC message the value should be placed. + * + * @param fieldPath A list of field names representing the path in the gRPC message + */ + void setFieldPath(List fieldPath); + + /** + * Gets the value that was extracted from the HTTP request. + * + * @return The string value to be bound to the specified field path + */ + String getValue(); + + /** + * Sets the value that should be bound to the specified field path. + * + * @param value The string value to be bound to the specified field path + */ + void setValue(String value); +} diff --git a/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/PathMatcher.java b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/PathMatcher.java new file mode 100644 index 00000000..70fe6992 --- /dev/null +++ b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/PathMatcher.java @@ -0,0 +1,27 @@ +package io.vertx.grpc.transcoding; + +/** + * Matches HTTP request paths against registered patterns to look up corresponding gRPC methods. This interface provides functionality to match incoming HTTP requests to their + * corresponding gRPC method handlers based on the HTTP method, path, and optional query parameters. + */ +public interface PathMatcher { + + /** + * Simple lookup method that matches an HTTP request to a gRPC method based on HTTP method and path. + * + * @param httpMethod the HTTP method of the request (e.g., "GET", "POST") + * @param path the request path to match + * @return the corresponding gRPC method name if a match is found, null otherwise + */ + String lookup(String httpMethod, String path); + + /** + * Advanced lookup method that matches an HTTP request to a gRPC method and extracts variable bindings. + * + * @param httpMethod the HTTP method of the request (e.g., "GET", "POST") + * @param path the request path to match + * @param queryParams the query parameters string from the request + * @return the corresponding gRPC method name if a match is found, null otherwise + */ + PathMatcherLookupResult lookup(String httpMethod, String path, String queryParams); +} diff --git a/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/PathMatcherBuilder.java b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/PathMatcherBuilder.java new file mode 100644 index 00000000..06061d1f --- /dev/null +++ b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/PathMatcherBuilder.java @@ -0,0 +1,118 @@ +package io.vertx.grpc.transcoding; + +import io.vertx.grpc.transcoding.impl.PathMatcherMethodData; +import io.vertx.grpc.transcoding.impl.PathMatcherNode; +import io.vertx.grpc.transcoding.impl.PercentEncoding; + +import java.util.List; +import java.util.Set; + +/** + * Builder interface for creating {@link PathMatcher} instances. Provides methods to configure and construct a path matcher with specific behaviors for HTTP-to-gRPC transcoding, + * including URL encoding/decoding options and custom verb handling. + */ +public interface PathMatcherBuilder { + + /** + * Registers a service transcoding configuration with specific query parameter names. + * + * @param transcoding the service transcoding options + * @param queryParameterNames set of query parameter names to handle + * @param method the gRPC method name to associate with this pattern + * @return true if registration was successful, false otherwise + */ + boolean register(ServiceTranscodingOptions transcoding, Set queryParameterNames, String method); + + /** + * Registers a service transcoding configuration with default query parameter handling. + * + * @param transcodingOptions the service transcoding options + * @param method the gRPC method name to associate with this pattern + * @return true if registration was successful, false otherwise + */ + boolean register(ServiceTranscodingOptions transcodingOptions, String method); + + /** + * Gets the root node of the path matching tree. + * + * @return the root PathMatcherNode + */ + PathMatcherNode getRoot(); + + /** + * Gets the set of registered custom verbs. + * + * @return set of custom verb strings + */ + Set getCustomVerbs(); + + /** + * Gets the list of registered method data. + * + * @return list of PathMatcherMethodData objects + */ + List getMethodData(); + + /** + * Sets the URL unescaping specification for path variables. + * + * @param pathUnescapeSpec the URL unescaping specification to use + */ + void setUrlUnescapeSpec(PercentEncoding.UrlUnescapeSpec pathUnescapeSpec); + + /** + * Gets the current URL unescaping specification. + * + * @return the current URL unescaping specification + */ + PercentEncoding.UrlUnescapeSpec getUrlUnescapeSpec(); + + /** + * Sets whether plus signs in query parameters should be unescaped to spaces. + * + * @param queryParamUnescapePlus true to unescape plus signs, false otherwise + */ + void setQueryParamUnescapePlus(boolean queryParamUnescapePlus); + + /** + * Gets whether plus signs in query parameters are being unescaped to spaces. + * + * @return current setting for plus sign unescaping in query parameters + */ + boolean getQueryParamUnescapePlus(); + + /** + * Sets whether unregistered custom verbs should be matched. + * + * @param matchUnregisteredCustomVerb true to match unregistered custom verbs, false otherwise + */ + void setMatchUnregisteredCustomVerb(boolean matchUnregisteredCustomVerb); + + /** + * Gets whether unregistered custom verbs are being matched. + * + * @return current setting for matching unregistered custom verbs + */ + boolean getMatchUnregisteredCustomVerb(); + + /** + * Sets whether registration should fail when attempting to register a duplicate pattern. + * + * @param failRegistrationOnDuplicate true to fail on duplicates, false to silently continue + */ + void setFailRegistrationOnDuplicate(boolean failRegistrationOnDuplicate); + + /** + * Gets whether registration fails on duplicate patterns. + * + * @return current setting for failing on duplicate registrations + */ + boolean getFailRegistrationOnDuplicate(); + + /** + * Builds and returns a new PathMatcher instance based on the current configuration. + * + * @return a new PathMatcher instance + */ + PathMatcher build(); +} diff --git a/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/PathMatcherLookupResult.java b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/PathMatcherLookupResult.java new file mode 100644 index 00000000..d7db6494 --- /dev/null +++ b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/PathMatcherLookupResult.java @@ -0,0 +1,27 @@ +package io.vertx.grpc.transcoding; + +import java.util.List; + +public class PathMatcherLookupResult { + private final String method; + private final List variableBindings; + private final String bodyFieldPath; + + public PathMatcherLookupResult(String method, List variableBindings, String bodyFieldPath) { + this.method = method; + this.variableBindings = variableBindings; + this.bodyFieldPath = bodyFieldPath; + } + + public String getMethod() { + return method; + } + + public List getVariableBindings() { + return variableBindings; + } + + public String getBodyFieldPath() { + return bodyFieldPath; + } +} diff --git a/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/ServiceTranscodingOptions.java b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/ServiceTranscodingOptions.java new file mode 100644 index 00000000..4ce187dd --- /dev/null +++ b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/ServiceTranscodingOptions.java @@ -0,0 +1,132 @@ +package io.vertx.grpc.transcoding; + +import io.vertx.codegen.annotations.VertxGen; +import io.vertx.core.http.HttpMethod; + +import java.util.List; + +/** + * Defines configuration options for transcoding HTTP requests to gRPC service calls. This interface provides the necessary mapping information to translate between HTTP and gRPC, + * including path templates, HTTP methods, body mappings, and additional bindings. + * + * The transcoding options define how incoming HTTP requests should be mapped to gRPC method calls, including: + *

    + *
  • Which gRPC service/method to call (selector)
  • + *
  • What HTTP method and path pattern to match
  • + *
  • How to map the HTTP request/response bodies to gRPC messages
  • + *
+ */ +@VertxGen +public interface ServiceTranscodingOptions { + + /** + * Creates a new ServiceTranscodingOptions instance with all configuration options. + * + * @param selector The fully-qualified name of the gRPC method (e.g., "mypackage.MyService.MyMethod") + * @param httpMethod The HTTP method to match (GET, POST, etc.) + * @param path The URL path template with optional variables (e.g., "/v1/users/{user_id}") + * @param body The field path where the HTTP request body should be mapped in the gRPC request message + * @param responseBody The field path in the gRPC response message to use as the HTTP response body + * @param additionalBindings Additional HTTP bindings for the same gRPC method + * @return A new ServiceTranscodingOptions instance + */ + static ServiceTranscodingOptions create(String selector, HttpMethod httpMethod, String path, String body, String responseBody, + List additionalBindings) { + return new ServiceTranscodingOptions() { + @Override + public String getSelector() { + return selector; + } + + @Override + public HttpMethod getHttpMethod() { + return httpMethod; + } + + @Override + public String getPath() { + return path; + } + + @Override + public String getBody() { + return body; + } + + @Override + public String getResponseBody() { + return responseBody; + } + + @Override + public List getAdditionalBindings() { + return additionalBindings == null ? List.of() : additionalBindings; + } + }; + } + + /** + * Creates a simple binding without additional bindings. + * + * @param selector The fully-qualified name of the gRPC method + * @param httpMethod The HTTP method to match + * @param path The URL path template + * @param body The request body field path + * @param responseBody The response body field path + * @return A new ServiceTranscodingOptions instance + */ + static ServiceTranscodingOptions createBinding(String selector, HttpMethod httpMethod, String path, String body, String responseBody) { + return create(selector, httpMethod, path, body, responseBody, null); + } + + /** + * Gets the fully-qualified name of the gRPC method to be called. + * + * @return The method selector string (e.g., "mypackage.MyService.MyMethod") + */ + String getSelector(); + + /** + * Gets the HTTP method that this binding should match. + * + * @return The HTTP method (GET, POST, etc.) + */ + HttpMethod getHttpMethod(); + + /** + * Gets the URL path template for this binding. + * + * @return The path template string (e.g., "/v1/users/{user_id}") + */ + String getPath(); + + /** + * Parses the path template into a structured HttpTemplate object. + * + * @return An HttpTemplate instance representing the parsed path + */ + default HttpTemplate getHttpTemplate() { + return HttpTemplate.parse(getPath()); + } + + /** + * Gets the field path where the HTTP request body should be mapped in the gRPC request message. + * + * @return The body field path or null if no body mapping is needed + */ + String getBody(); + + /** + * Gets the field path in the gRPC response message to use as the HTTP response body. + * + * @return The response body field path or null if no response body mapping is needed + */ + String getResponseBody(); + + /** + * Gets additional HTTP bindings for the same gRPC method. This allows a single gRPC method to be exposed through multiple HTTP endpoints. + * + * @return A list of additional bindings, or an empty list if none exist + */ + List getAdditionalBindings(); +} diff --git a/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/HttpTemplateImpl.java b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/HttpTemplateImpl.java new file mode 100644 index 00000000..8783699f --- /dev/null +++ b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/HttpTemplateImpl.java @@ -0,0 +1,34 @@ +package io.vertx.grpc.transcoding.impl; + +import io.vertx.grpc.transcoding.HttpTemplate; +import io.vertx.grpc.transcoding.HttpTemplateVariable; + +import java.util.List; + +public class HttpTemplateImpl implements HttpTemplate { + + private final List segments; + private final String verb; + private final List variables; + + public HttpTemplateImpl(List segments, String verb, List variables) { + this.segments = segments; + this.verb = verb; + this.variables = variables; + } + + @Override + public List getSegments() { + return segments; + } + + @Override + public String getVerb() { + return verb; + } + + @Override + public List getVariables() { + return variables; + } +} diff --git a/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/HttpTemplateParser.java b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/HttpTemplateParser.java new file mode 100644 index 00000000..9508d606 --- /dev/null +++ b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/HttpTemplateParser.java @@ -0,0 +1,322 @@ +package io.vertx.grpc.transcoding.impl; + +import io.vertx.grpc.transcoding.HttpTemplateVariable; + +import java.util.ArrayList; +import java.util.List; + +/** + * A parser for HTTP template strings used in gRPC transcoding. + *

+ * This class parses HTTP template strings of the format defined in the gRPC HTTP transcoding specification. It extracts variables, segments, and the verb from the template + * string. + *

+ * For example, the template string {@code "/users/{user_id=*}:get"} would be parsed into: + *

    + *
  • verb: "get"
  • + *
  • segments: ["users", "{user_id=*}"]
  • + *
  • variables: [HttpTemplateVariableImpl{fieldPath=["user_id"], startSegment=1, endSegment=2, wildcardPath=true}]
  • + *
+ */ +public class HttpTemplateParser { + public static final String SINGLE_PARAMETER_KEY = "/."; + public static final String WILD_CARD_PATH_PART_KEY = "*"; + public static final String WILD_CARD_PATH_KEY = "**"; + + private final String templateString; + + private final List segments = new ArrayList<>(); + private final List variables = new ArrayList<>(); + + private String verb = ""; + + private int tokenBegin; + private int tokenEnd; + private boolean parsingVariable; + + public HttpTemplateParser(String templateString) { + this.templateString = templateString; + this.tokenBegin = 0; + this.tokenEnd = 0; + this.parsingVariable = false; + } + + public boolean parse() { + if (!parseTemplate() || !allInputConsumed()) { + return false; + } + finalizeVariables(); + return true; + } + + public List segments() { + return segments; + } + + public String verb() { + return verb; + } + + public List variables() { + return variables; + } + + public boolean validateParts() { + boolean foundWildcardPath = false; + for (String segment : segments) { + if (!foundWildcardPath) { + if (segment.equals(WILD_CARD_PATH_KEY)) { + foundWildcardPath = true; + } + } else if (segment.equals(SINGLE_PARAMETER_KEY) || + segment.equals(WILD_CARD_PATH_PART_KEY) || + segment.equals(WILD_CARD_PATH_KEY)) { + return false; + } + } + return true; + } + + private boolean parseTemplate() { + if (!consumeCharacter('/')) { + return false; + } + if (!parseSegments()) { + return false; + } + + if (hasMoreCharacters() && currentChar() == ':') { + return parseVerb(); + } + return true; + } + + private boolean parseSegments() { + do { + if (!parseSegment()) { + return false; + } + } while (consumeCharacter('/')); + + return true; + } + + private boolean parseSegment() { + if (!hasMoreCharacters()) { + return false; + } + switch (currentChar()) { + case '*': { + consumeCharacter('*'); + if (consumeCharacter('*')) { + segments.add("**"); + if (parsingVariable) { + return markVariableAsWildcard(); + } + } else { + segments.add("*"); + } + return true; + } + + case '{': + return parseVariable(); + default: + return parseLiteralSegment(); + } + } + + private boolean parseVariable() { + if (!consumeCharacter('{')) { + return false; + } + if (!beginVariableParsing()) { + return false; + } + if (!parseFieldPath()) { + return false; + } + if (consumeCharacter('=')) { + if (!parseSegments()) { + return false; + } + } else { + // {fieldPath} is equivalent to {fieldPath=*} + segments.add("*"); + } + if (!endVariableParsing()) { + return false; + } + + return consumeCharacter('}'); + } + + private boolean parseLiteralSegment() { + StringBuilder literalBuilder = new StringBuilder(); + if (!parseLiteral(literalBuilder)) { + return false; + } + segments.add(literalBuilder.toString()); + return true; + } + + private boolean parseFieldPath() { + do { + if (!parseIdentifier()) { + return false; + } + } while (consumeCharacter('.')); + return true; + } + + private boolean parseVerb() { + if (!consumeCharacter(':')) { + return false; + } + StringBuilder verbBuilder = new StringBuilder(); + if (!parseLiteral(verbBuilder)) { + return false; + } + verb = verbBuilder.toString(); + return true; + } + + private boolean parseIdentifier() { + StringBuilder identifierBuilder = new StringBuilder(); + boolean hasContent = false; + + while (advanceToken()) { + char currentCharacter = currentChar(); + switch (currentCharacter) { + case '.': + case '}': + case '=': + return hasContent && addFieldPathIdentifier(identifierBuilder.toString()); + default: + consumeCharacter(currentCharacter); + identifierBuilder.append(currentCharacter); + break; + } + hasContent = true; + } + return hasContent && addFieldPathIdentifier(identifierBuilder.toString()); + } + + private boolean parseLiteral(StringBuilder literalBuilder) { + if (!hasMoreCharacters()) { + return false; + } + + boolean hasContent = false; + + while (true) { + char currentCharacter = currentChar(); + switch (currentCharacter) { + case '/': + case ':': + case '}': + return hasContent; + default: + consumeCharacter(currentCharacter); + literalBuilder.append(currentCharacter); + break; + } + + hasContent = true; + + if (!advanceToken()) { + break; + } + } + return hasContent; + } + + private boolean consumeCharacter(char expected) { + if (tokenBegin >= tokenEnd && !advanceToken()) { + return false; + } + if (currentChar() != expected) { + return false; + } + tokenBegin++; + return true; + } + + private boolean allInputConsumed() { + return tokenBegin >= templateString.length(); + } + + private boolean hasMoreCharacters() { + return tokenBegin < tokenEnd || advanceToken(); + } + + private boolean advanceToken() { + if (tokenEnd < templateString.length()) { + tokenEnd++; + return true; + } + return false; + } + + private char currentChar() { + return tokenBegin < tokenEnd && tokenEnd <= templateString.length() ? templateString.charAt(tokenEnd - 1) : (char) -1; + } + + private HttpTemplateVariable getCurrentVariable() { + return variables.get(variables.size() - 1); + } + + private boolean beginVariableParsing() { + if (!parsingVariable) { + variables.add(new HttpTemplateVariableImpl()); + getCurrentVariable().setStartSegment(segments.size()); + getCurrentVariable().setWildcardPath(false); + parsingVariable = true; + return true; + } + return false; // Nested variables are not allowed + } + + private boolean endVariableParsing() { + if (parsingVariable && !variables.isEmpty()) { + HttpTemplateVariable variable = getCurrentVariable(); + variable.setEndSegment(segments.size()); + parsingVariable = false; + return validateVariable(variable); + } + return false; // Not currently parsing a variable + } + + private boolean addFieldPathIdentifier(String identifier) { + if (parsingVariable && !variables.isEmpty()) { + getCurrentVariable().getFieldPath().add(identifier); + return true; + } + return false; // Not currently parsing a variable + } + + private boolean markVariableAsWildcard() { + if (parsingVariable && !variables.isEmpty()) { + getCurrentVariable().setWildcardPath(true); + return true; + } + return false; // Not currently parsing a variable + } + + private boolean validateVariable(HttpTemplateVariable variable) { + return !variable.getFieldPath().isEmpty() + && (variable.getStartSegment() < variable.getEndSegment()) + && (variable.getEndSegment() <= segments.size()); + } + + private void finalizeVariables() { + for (HttpTemplateVariable variable : variables) { + if (variable.hasWildcardPath()) { + // For wildcard paths ('**'), store end position relative to path end + // -1 corresponds to end of path, allowing matcher to reconstruct + // variable value from URL segments for fixed paths after '**' + variable.setEndSegment(variable.getEndSegment() - segments.size() - 1); + } + } + } +} diff --git a/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/HttpTemplateVariableImpl.java b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/HttpTemplateVariableImpl.java new file mode 100644 index 00000000..0db7bda9 --- /dev/null +++ b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/HttpTemplateVariableImpl.java @@ -0,0 +1,53 @@ +package io.vertx.grpc.transcoding.impl; + +import io.vertx.grpc.transcoding.HttpTemplateVariable; + +import java.util.ArrayList; +import java.util.List; + +public class HttpTemplateVariableImpl implements HttpTemplateVariable { + private List fieldPath = new ArrayList<>(); + private int startSegment; + private int endSegment; + private boolean wildcardPath; + + @Override + public List getFieldPath() { + return fieldPath; + } + + @Override + public void setFieldPath(List fieldPath) { + this.fieldPath = fieldPath; + } + + @Override + public int getStartSegment() { + return startSegment; + } + + @Override + public void setStartSegment(int startSegment) { + this.startSegment = startSegment; + } + + @Override + public int getEndSegment() { + return endSegment; + } + + @Override + public void setEndSegment(int endSegment) { + this.endSegment = endSegment; + } + + @Override + public boolean hasWildcardPath() { + return wildcardPath; + } + + @Override + public void setWildcardPath(boolean wildcardPath) { + this.wildcardPath = wildcardPath; + } +} diff --git a/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/HttpVariableBindingImpl.java b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/HttpVariableBindingImpl.java new file mode 100644 index 00000000..c2e825a8 --- /dev/null +++ b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/HttpVariableBindingImpl.java @@ -0,0 +1,39 @@ +package io.vertx.grpc.transcoding.impl; + +import io.vertx.grpc.transcoding.HttpVariableBinding; + +import java.util.List; + +public class HttpVariableBindingImpl implements HttpVariableBinding { + + private List fieldPath; + private String value; + + public HttpVariableBindingImpl() { + } + + public HttpVariableBindingImpl(List fieldPath, String value) { + this.fieldPath = fieldPath; + this.value = value; + } + + @Override + public List getFieldPath() { + return fieldPath; + } + + @Override + public void setFieldPath(List fieldPath) { + this.fieldPath = fieldPath; + } + + @Override + public String getValue() { + return value; + } + + @Override + public void setValue(String value) { + this.value = value; + } +} diff --git a/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/PathMatcherBuilderImpl.java b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/PathMatcherBuilderImpl.java new file mode 100644 index 00000000..36203a2c --- /dev/null +++ b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/PathMatcherBuilderImpl.java @@ -0,0 +1,124 @@ +package io.vertx.grpc.transcoding.impl; + +import io.vertx.grpc.transcoding.HttpTemplate; +import io.vertx.grpc.transcoding.PathMatcher; +import io.vertx.grpc.transcoding.PathMatcherBuilder; +import io.vertx.grpc.transcoding.ServiceTranscodingOptions; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class PathMatcherBuilderImpl implements PathMatcherBuilder { + private final PathMatcherNode root = new PathMatcherNode(); + private final Set customVerbs = new HashSet<>(); + private final List methods = new ArrayList<>(); + + private PercentEncoding.UrlUnescapeSpec pathUnescapeSpec = PercentEncoding.UrlUnescapeSpec.ALL_CHARACTERS_EXCEPT_RESERVED; + private boolean queryParamUnescapePlus = false; + private boolean matchUnregisteredCustomVerb = false; + private boolean failRegistrationOnDuplicate = true; + + @Override + public boolean register(ServiceTranscodingOptions transcoding, Set queryParameterNames, String method) { + HttpTemplate ht = transcoding.getHttpTemplate(); + if (ht == null) { + return false; + } + + PathMatcherNode.PathInfo info = PathMatcherUtility.transformHttpTemplate(ht); + + PathMatcherMethodData data = new PathMatcherMethodData(); + data.setMethod(method); + data.setVariables(ht.getVariables()); + data.setBodyFieldPath(transcoding.getBody()); + data.setSystemQueryParameterNames(queryParameterNames); + + if (!insertPathToNode(info, data, transcoding.getHttpMethod() + ht.getVerb(), root)) { + return false; + } + + methods.add(data); + + if (!ht.getVerb().isEmpty()) { + customVerbs.add(ht.getVerb()); + } + + return true; + } + + @Override + public boolean register(ServiceTranscodingOptions transcodingOptions, String method) { + return register(transcodingOptions, new HashSet<>(), method); + } + + @Override + public PathMatcherNode getRoot() { + return root; + } + + @Override + public Set getCustomVerbs() { + return customVerbs; + } + + @Override + public List getMethodData() { + return methods; + } + + @Override + public void setUrlUnescapeSpec(PercentEncoding.UrlUnescapeSpec pathUnescapeSpec) { + this.pathUnescapeSpec = pathUnescapeSpec; + } + + @Override + public PercentEncoding.UrlUnescapeSpec getUrlUnescapeSpec() { + return pathUnescapeSpec; + } + + @Override + public void setQueryParamUnescapePlus(boolean queryParamUnescapePlus) { + this.queryParamUnescapePlus = queryParamUnescapePlus; + } + + @Override + public boolean getQueryParamUnescapePlus() { + return queryParamUnescapePlus; + } + + @Override + public void setMatchUnregisteredCustomVerb(boolean matchUnregisteredCustomVerb) { + this.matchUnregisteredCustomVerb = matchUnregisteredCustomVerb; + } + + @Override + public boolean getMatchUnregisteredCustomVerb() { + return matchUnregisteredCustomVerb; + } + + @Override + public void setFailRegistrationOnDuplicate(boolean failRegistrationOnDuplicate) { + this.failRegistrationOnDuplicate = failRegistrationOnDuplicate; + } + + @Override + public boolean getFailRegistrationOnDuplicate() { + return failRegistrationOnDuplicate; + } + + @Override + public PathMatcher build() { + return new PathMatcherImpl(this); + } + + private boolean insertPathToNode(PathMatcherNode.PathInfo path, Object data, String httpMethod, PathMatcherNode root) { + if (!root.insertPath(path, httpMethod, data, true)) { + if (failRegistrationOnDuplicate) { + return false; + } + } + return true; + } +} diff --git a/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/PathMatcherImpl.java b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/PathMatcherImpl.java new file mode 100644 index 00000000..93f552b8 --- /dev/null +++ b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/PathMatcherImpl.java @@ -0,0 +1,65 @@ +package io.vertx.grpc.transcoding.impl; + +import io.vertx.grpc.transcoding.HttpVariableBinding; +import io.vertx.grpc.transcoding.PathMatcher; +import io.vertx.grpc.transcoding.PathMatcherBuilder; +import io.vertx.grpc.transcoding.PathMatcherLookupResult; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class PathMatcherImpl implements PathMatcher { + private final PathMatcherNode root; + private final Set customVerbs = new HashSet<>(); + private final List methods = new ArrayList<>(); + private final PercentEncoding.UrlUnescapeSpec pathUnescapeSpec; + private final boolean queryParamUnescapePlus; + private final boolean matchUnregisteredCustomVerb; + + protected PathMatcherImpl(PathMatcherBuilder builder) { + this.root = builder.getRoot().clone(); + this.customVerbs.addAll(builder.getCustomVerbs()); + this.methods.addAll(builder.getMethodData()); + this.pathUnescapeSpec = builder.getUrlUnescapeSpec(); + this.queryParamUnescapePlus = builder.getQueryParamUnescapePlus(); + this.matchUnregisteredCustomVerb = builder.getMatchUnregisteredCustomVerb(); + } + + @Override + public String lookup(String httpMethod, String path) { + PathMatcherLookupResult result = lookup(httpMethod, path, ""); + if (result == null) { + return null; + } + + return result.getMethod(); + } + + @Override + public PathMatcherLookupResult lookup(String httpMethod, String path, String queryParams) { + String verb = PathMatcherUtility.extractVerb(path, customVerbs, matchUnregisteredCustomVerb); + List parts = PathMatcherUtility.extractRequestParts(path, customVerbs, matchUnregisteredCustomVerb); + if (root == null) { + return null; + } + + PathMatcherNode.PathMatcherLookupResult result = PathMatcherUtility.lookupInPathMatcherNode(root, parts, httpMethod + verb); + + if (result.getData() == null || result.isMultiple()) { + return null; + } + + PathMatcherMethodData data = (PathMatcherMethodData) result.getData(); + + String method = data.getMethod(); + List variableBindings = new ArrayList<>(); + String bodyFieldPath = data.getBodyFieldPath(); + + variableBindings.addAll(PathMatcherUtility.extractBindingsFromPath(data.getVariables(), parts, pathUnescapeSpec)); + variableBindings.addAll(PathMatcherUtility.extractBindingsFromQueryParameters(queryParams, data.getSystemQueryParameterNames(), queryParamUnescapePlus)); + + return new PathMatcherLookupResult(method, variableBindings, bodyFieldPath); + } +} diff --git a/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/PathMatcherMethodData.java b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/PathMatcherMethodData.java new file mode 100644 index 00000000..cd74e08b --- /dev/null +++ b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/PathMatcherMethodData.java @@ -0,0 +1,45 @@ +package io.vertx.grpc.transcoding.impl; + +import io.vertx.grpc.transcoding.HttpTemplateVariable; + +import java.util.List; +import java.util.Set; + +public class PathMatcherMethodData { + private String method; + private List variables; + private String bodyFieldPath; + private Set systemQueryParameterNames; + + public String getMethod() { + return method; + } + + public void setMethod(String method) { + this.method = method; + } + + public List getVariables() { + return variables; + } + + public void setVariables(List variables) { + this.variables = variables; + } + + public String getBodyFieldPath() { + return bodyFieldPath; + } + + public void setBodyFieldPath(String bodyFieldPath) { + this.bodyFieldPath = bodyFieldPath; + } + + public Set getSystemQueryParameterNames() { + return systemQueryParameterNames; + } + + public void setSystemQueryParameterNames(Set systemQueryParameterNames) { + this.systemQueryParameterNames = systemQueryParameterNames; + } +} diff --git a/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/PathMatcherNode.java b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/PathMatcherNode.java new file mode 100644 index 00000000..688f99a4 --- /dev/null +++ b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/PathMatcherNode.java @@ -0,0 +1,207 @@ +package io.vertx.grpc.transcoding.impl; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Implements a trie-based path matching system for HTTP request routing. This class maintains a tree structure where each node represents a path segment and can match both literal + * path parts and variable segments including wildcards. + */ +public class PathMatcherNode { + /** Wildcard string for matching any HTTP method */ + public static final String HTTP_WILD_CARD = "*"; + + private final Map children = new HashMap<>(); + private Map results = new HashMap<>(); + private boolean wildcard; + + /** + * Performs path lookup using depth-first search to find matching handlers. When matching paths, this method follows the Google HTTP Template Spec matching precedence: + *
    + *
  1. Exact literal matches
  2. + *
  3. Single parameter matches
  4. + *
  5. Wildcard matches
  6. + *
+ * + * For wildcard nodes, the search continues until either: - A complete match is found - No valid continuation of the path exists in the trie + * + * @param path List of path segments to match + * @param current Current position in the path list being processed + * @param method HTTP method to match + * @param result Container for the lookup result + */ + public void lookupPath(List path, int current, String method, PathMatcherLookupResult result) { + while (true) { + if (current == path.size()) { + if (!getResultForHttpMethod(method, result)) { + // Check wildcard child for root matches + PathMatcherNode child = children.get(HttpTemplateParser.WILD_CARD_PATH_KEY); + if (child != null) { + child.getResultForHttpMethod(method, result); + } + } + return; + } + if (lookupPathFromChild(path.get(current), path, current, method, result)) { + return; + } + if (!wildcard) { + break; + } + current++; + } + + // Try matching special path parameters in order of precedence + for (String childKey : new String[] { + HttpTemplateParser.SINGLE_PARAMETER_KEY, + HttpTemplateParser.WILD_CARD_PATH_PART_KEY, + HttpTemplateParser.WILD_CARD_PATH_KEY + }) { + if (lookupPathFromChild(childKey, path, current, method, result)) { + return; + } + } + } + + /** + * Inserts a new path pattern into the trie. + * + * @param info Path information containing the segments to insert + * @param method HTTP method for this path + * @param data Handler data to associate with this path + * @param markDuplicates Whether to mark duplicate registrations + * @return true if insertion was successful, false if path already exists + */ + public boolean insertPath(PathInfo info, String method, Object data, boolean markDuplicates) { + return insertTemplate(info.getPathInfo(), 0, method, data, markDuplicates); + } + + private boolean insertTemplate(List path, int current, String method, Object data, boolean markDuplicates) { + if (current == path.size()) { + PathMatcherLookupResult existing = results.putIfAbsent(method, new PathMatcherLookupResult(data, false)); + if (existing != null) { + existing.data = data; + if (markDuplicates) { + existing.multiple = true; + } + return false; + } + return true; + } + PathMatcherNode child = children.computeIfAbsent(path.get(current), k -> new PathMatcherNode()); + if (path.get(current).equals(HttpTemplateParser.WILD_CARD_PATH_KEY)) { + child.setWildcard(true); + } + return child.insertTemplate(path, current + 1, method, data, markDuplicates); + } + + private boolean lookupPathFromChild(String key, List path, int current, String method, PathMatcherLookupResult result) { + PathMatcherNode child = children.get(key); + if (child != null) { + child.lookupPath(path, current + 1, method, result); + if (result != null && result.data != null) { + return true; + } + } + return false; + } + + private boolean getResultForHttpMethod(String key, PathMatcherLookupResult result) { + PathMatcherLookupResult found = results.getOrDefault(key, results.get(HTTP_WILD_CARD)); + if (found != null) { + result.data = found.data; + result.multiple = found.multiple; + return true; + } + return false; + } + + private void setWildcard(boolean wildcard) { + this.wildcard = wildcard; + } + + @Override + public PathMatcherNode clone() { + PathMatcherNode clone = new PathMatcherNode(); + clone.results = new HashMap<>(this.results); + for (Map.Entry entry : children.entrySet()) { + clone.children.put(entry.getKey(), entry.getValue().clone()); + } + clone.wildcard = this.wildcard; + return clone; + } + + /** + * Container class for path matching results. + */ + public static class PathMatcherLookupResult { + private Object data; + private boolean multiple; + + public PathMatcherLookupResult(Object data, boolean multiple) { + this.data = data; + this.multiple = multiple; + } + + public Object getData() { + return data; + } + + public boolean isMultiple() { + return multiple; + } + } + + /** + * Represents structured path information for registration. Uses the Builder pattern to construct valid path patterns. + */ + public static class PathInfo { + private final List pathInfo; + + private PathInfo(Builder builder) { + this.pathInfo = builder.path; + } + + public List getPathInfo() { + return pathInfo; + } + + /** + * Builder for constructing PathInfo instances. Supports adding both literal path segments and parameter segments. + */ + public static class Builder { + private final List path = new ArrayList<>(); + + /** + * Adds a literal path segment. + * + * @param name The literal path segment to add + * @throws IllegalArgumentException if the segment conflicts with parameter syntax + */ + public Builder appendLiteralNode(String name) { + if (name.equals(HttpTemplateParser.SINGLE_PARAMETER_KEY)) { + throw new IllegalArgumentException("Literal node cannot be a single parameter node"); + } + path.add(name); + return this; + } + + /** + * Adds a single parameter segment to the path. + */ + public Builder appendSingleParameterNode() { + path.add(HttpTemplateParser.SINGLE_PARAMETER_KEY); + return this; + } + + /** + * Constructs the final PathInfo instance. + */ + public PathInfo build() { + return new PathInfo(this); + } + } + } +} diff --git a/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/PathMatcherUtility.java b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/PathMatcherUtility.java new file mode 100644 index 00000000..7b05fff0 --- /dev/null +++ b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/PathMatcherUtility.java @@ -0,0 +1,140 @@ +package io.vertx.grpc.transcoding.impl; + +import com.google.common.base.Splitter; +import io.vertx.grpc.transcoding.*; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * @author Based on grpc-httpjson-transcoding + */ +public class PathMatcherUtility { + public static boolean registerByHttpRule(PathMatcherBuilder pmb, ServiceTranscodingOptions transcodingOptions, String method) { + return registerByHttpRule(pmb, transcodingOptions, new HashSet<>(), method); + } + + public static boolean registerByHttpRule(PathMatcherBuilder pmb, ServiceTranscodingOptions transcodingOptions, Set systemQueryParameterNames, String method) { + boolean ok = pmb.register(transcodingOptions, systemQueryParameterNames, method); + + for (ServiceTranscodingOptions binding : transcodingOptions.getAdditionalBindings()) { + if (!ok) { + return ok; + } + ok = registerByHttpRule(pmb, binding, systemQueryParameterNames, method); + } + + return ok; + } + + protected static List extractBindingsFromPath(List vars, List parts, PercentEncoding.UrlUnescapeSpec unescapeSpec) { + if (vars == null || vars.isEmpty()) { + return List.of(); + } + + List bindings = new ArrayList<>(); + + for (HttpTemplateVariable var : vars) { + HttpVariableBinding binding = HttpVariableBinding.create(var.getFieldPath(), null); + int end = var.getEndSegment() >= 0 ? var.getEndSegment() : parts.size() + var.getEndSegment() + 1; + boolean multipart = (end - var.getStartSegment()) > 1 || var.getEndSegment() < 0; + PercentEncoding.UrlUnescapeSpec spec = multipart ? unescapeSpec : PercentEncoding.UrlUnescapeSpec.ALL_CHARACTERS; + + for (int i = var.getStartSegment(); i < end; ++i) { + String currentValue = binding.getValue(); + currentValue = (currentValue == null) ? "" : currentValue; + binding.setValue(currentValue + PercentEncoding.urlUnescapeString(parts.get(i), spec, false)); + if (i < end - 1) { + binding.setValue(binding.getValue() + "/"); + } + } + bindings.add(binding); + } + + return bindings; + } + + protected static List extractBindingsFromQueryParameters(String queryParams, Set systemParams, boolean queryParamUnescapePlus) { + if (queryParams == null) { + return List.of(); + } + + List bindings = new ArrayList<>(); + List params = Splitter.on('&').splitToList(queryParams); + + for (String param : params) { + int pos = param.indexOf('='); + if (pos != 0 && pos != -1) { + String name = param.substring(0, pos); + if (!systemParams.contains(name)) { + HttpVariableBinding binding = HttpVariableBinding.create(Splitter.on('.').splitToList(name), PercentEncoding.urlUnescapeString( + param.substring(pos + 1), + PercentEncoding.UrlUnescapeSpec.ALL_CHARACTERS, + queryParamUnescapePlus)); + bindings.add(binding); + } + } + } + + return bindings; + } + + protected static List extractRequestParts(String path, Set customVerbs, boolean matchUnregisteredCustomVerb) { + if (path.indexOf('?') != -1) { + path = path.substring(0, path.indexOf('?')); + } + + int lastIndexOfColon = path.lastIndexOf(':'); + int lastIndexOfSlash = path.lastIndexOf('/'); + if (lastIndexOfColon != -1 && lastIndexOfColon > lastIndexOfSlash) { + String verb = path.substring(lastIndexOfColon + 1); + if (matchUnregisteredCustomVerb || customVerbs.contains(verb)) { + path = path.substring(0, lastIndexOfColon); + } + } + + List result = new ArrayList<>(); + if (!path.isEmpty()) { + result = new ArrayList<>(Splitter.on('/').splitToList(path.substring(1))); + } + + while (!result.isEmpty() && result.get(result.size() - 1).isEmpty()) { + result.remove(result.size() - 1); + } + + return result; + } + + protected static String extractVerb(String path, Set customVerbs, boolean matchUnregisteredCustomVerb) { + if (path.indexOf('?') != -1) { + path = path.substring(0, path.indexOf('?')); + } + + int lastIndexOfColon = path.lastIndexOf(':'); + int lastIndexOfSlash = path.lastIndexOf('/'); + if (lastIndexOfColon != -1 && lastIndexOfColon > lastIndexOfSlash) { + String verb = path.substring(lastIndexOfColon + 1); + if (matchUnregisteredCustomVerb || customVerbs.contains(verb)) { + return verb; + } + } + + return ""; + } + + protected static PathMatcherNode.PathMatcherLookupResult lookupInPathMatcherNode(PathMatcherNode root, List parts, String httpMethod) { + PathMatcherNode.PathMatcherLookupResult result = new PathMatcherNode.PathMatcherLookupResult(null, false); + root.lookupPath(parts, 0, httpMethod, result); + return result; + } + + protected static PathMatcherNode.PathInfo transformHttpTemplate(HttpTemplate template) { + PathMatcherNode.PathInfo.Builder builder = new PathMatcherNode.PathInfo.Builder(); + for (String part : template.getSegments()) { + builder.appendLiteralNode(part); + } + return builder.build(); + } +} diff --git a/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/PercentEncoding.java b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/PercentEncoding.java new file mode 100644 index 00000000..014b7dcf --- /dev/null +++ b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/impl/PercentEncoding.java @@ -0,0 +1,133 @@ +package io.vertx.grpc.transcoding.impl; + +import java.util.regex.Pattern; + +/** + * @author Based on grpc-httpjson-transcoding + */ +public class PercentEncoding { + + public enum UrlUnescapeSpec { + ALL_CHARACTERS_EXCEPT_RESERVED, + ALL_CHARACTERS_EXCEPT_SLASH, + ALL_CHARACTERS + } + + private static boolean isReservedChar(char c) { + // Reserved characters according to RFC 6570 + switch (c) { + case '!': + case '#': + case '$': + case '&': + case '\'': + case '(': + case ')': + case '*': + case '+': + case ',': + case '/': + case ':': + case ';': + case '=': + case '?': + case '@': + case '[': + case ']': + return true; + default: + return false; + } + } + + private static boolean asciiIsxdigit(char c) { + return ('a' <= c && c <= 'f') + || ('A' <= c && c <= 'F') + || ('0' <= c && c <= '9'); + } + + private static int hexDigitToInt(char c) { + /* Assume ASCII. */ + int x = (int) c; + if (x > '9') { + x += 9; + } + return x & 0xf; + } + + private static int getEscapedChar(String src, int i, UrlUnescapeSpec unescapeSpec, boolean unescapePlus, char[] out) { + if (unescapePlus && src.charAt(i) == '+') { + out[0] = ' '; + return 1; + } + if (i + 2 < src.length() && src.charAt(i) == '%') { + if (asciiIsxdigit(src.charAt(i + 1)) && asciiIsxdigit(src.charAt(i + 2))) { + char c = + (char) ((hexDigitToInt(src.charAt(i + 1)) << 4) | hexDigitToInt(src.charAt(i + 2))); + switch (unescapeSpec) { + case ALL_CHARACTERS_EXCEPT_RESERVED: + if (isReservedChar(c)) { + return 0; + } + break; + case ALL_CHARACTERS_EXCEPT_SLASH: + if (c == '/') { + return 0; + } + break; + case ALL_CHARACTERS: + break; + } + out[0] = c; + return 3; + } + } + return 0; + } + + public static boolean isUrlEscapedString(String part, UrlUnescapeSpec unescapeSpec, boolean unescapePlus) { + char[] ch = new char[1]; + for (int i = 0; i < part.length(); ++i) { + if (getEscapedChar(part, i, unescapeSpec, unescapePlus, ch) > 0) { + return true; + } + } + return false; + } + + public static boolean isUrlEscapedString(String part) { + return isUrlEscapedString(part, UrlUnescapeSpec.ALL_CHARACTERS, false); + } + + public static String urlUnescapeString(String part, UrlUnescapeSpec unescapeSpec, boolean unescapePlus) { + // Check whether we need to escape at all. + if (!isUrlEscapedString(part, unescapeSpec, unescapePlus)) { + return part; + } + + StringBuilder unescaped = new StringBuilder(part.length()); + char[] ch = new char[1]; + + for (int i = 0; i < part.length(); ) { + int skip = getEscapedChar(part, i, unescapeSpec, unescapePlus, ch); + if (skip > 0) { + unescaped.append(ch[0]); + i += skip; + } else { + unescaped.append(part.charAt(i)); + i += 1; + } + } + + return unescaped.toString(); + } + + public static String urlUnescapeString(String part) { + return urlUnescapeString(part, UrlUnescapeSpec.ALL_CHARACTERS, false); + } + + public static String urlEscapeString(String str) { + return Pattern.compile("[^a-zA-Z0-9-_.~]").matcher(str) + .replaceAll(m -> "%" + Integer.toHexString(m.group().charAt(0)).toUpperCase()); + } +} diff --git a/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/package-info.java b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/package-info.java new file mode 100644 index 00000000..1ae75e31 --- /dev/null +++ b/vertx-grpc-transcoding/src/main/java/io/vertx/grpc/transcoding/package-info.java @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2011-2022 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +@ModuleGen(name = "vertx-grpc-transcoding", groupPackage = "io.vertx") +package io.vertx.grpc.transcoding; + +import io.vertx.codegen.annotations.ModuleGen; diff --git a/vertx-grpc-transcoding/src/main/java/module-info.java b/vertx-grpc-transcoding/src/main/java/module-info.java new file mode 100644 index 00000000..16acd8eb --- /dev/null +++ b/vertx-grpc-transcoding/src/main/java/module-info.java @@ -0,0 +1,14 @@ +module io.vertx.grpc.transcoding { + requires io.netty.common; + requires io.netty.buffer; + requires io.netty.codec; + requires io.netty.codec.compression; + requires io.netty.transport; + requires com.google.protobuf; + requires com.google.protobuf.util; + requires transitive io.vertx.core; + requires static io.vertx.codegen.api; + requires com.google.common; + exports io.vertx.grpc.transcoding; + exports io.vertx.grpc.transcoding.impl to io.vertx.tests.common, io.vertx.grpc.server, io.vertx.grpc.client, io.vertx.tests.server, io.vertx.tests.client, io.vertx.tests.transcoding; +} diff --git a/vertx-grpc-transcoding/src/test/java/io/vertx/tests/transcoding/HttpTemplateTest.java b/vertx-grpc-transcoding/src/test/java/io/vertx/tests/transcoding/HttpTemplateTest.java new file mode 100644 index 00000000..da5a6772 --- /dev/null +++ b/vertx-grpc-transcoding/src/test/java/io/vertx/tests/transcoding/HttpTemplateTest.java @@ -0,0 +1,533 @@ +package io.vertx.tests.transcoding; + +import io.vertx.grpc.transcoding.HttpTemplate; +import io.vertx.grpc.transcoding.HttpTemplateVariable; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.*; + +public class HttpTemplateTest { + + @Test + public void testParseTest1() { + HttpTemplate template = HttpTemplate.parse("/shelves/{shelf}/books/{book}"); + assertNotNull(template); + assertEquals(Arrays.asList("shelves", "*", "books", "*"), template.getSegments()); + List expectedVariables = Arrays.asList( + HttpTemplateVariable.create(List.of("shelf"), 1, 2, false), + HttpTemplateVariable.create(List.of("book"), 3, 4, false) + ); + + assertVariableList(expectedVariables, template.getVariables()); + } + + @Test + public void testParseTest2() { + HttpTemplate template = HttpTemplate.parse("/shelves/**"); + assertNotNull(template); + assertEquals(Arrays.asList("shelves", "**"), template.getSegments()); + assertEquals("", template.getVerb()); + assertEquals(Collections.emptyList(), template.getVariables()); + } + + @Test + public void testParseTest3a() { + HttpTemplate template = HttpTemplate.parse("/**"); + assertNotNull(template); + assertEquals(List.of("**"), template.getSegments()); + assertEquals("", template.getVerb()); + assertEquals(Collections.emptyList(), template.getVariables()); + } + + @Test + public void testParseTest3b() { + HttpTemplate template = HttpTemplate.parse("/*"); + assertNotNull(template); + assertEquals(List.of("*"), template.getSegments()); + assertEquals("", template.getVerb()); + assertEquals(Collections.emptyList(), template.getVariables()); + } + + @Test + public void testParseTest4a() { + HttpTemplate template = HttpTemplate.parse("/a:foo"); + assertNotNull(template); + assertEquals(List.of("a"), template.getSegments()); + assertEquals("foo", template.getVerb()); + assertEquals(Collections.emptyList(), template.getVariables()); + } + + @Test + public void testParseTest4b() { + HttpTemplate template = HttpTemplate.parse("/a/b/c:foo"); + assertNotNull(template); + assertEquals(Arrays.asList("a", "b", "c"), template.getSegments()); + assertEquals("foo", template.getVerb()); + assertEquals(Collections.emptyList(), template.getVariables()); + } + + @Test + public void testParseTest5() { + HttpTemplate template = HttpTemplate.parse("/*/**"); + assertNotNull(template); + assertEquals(Arrays.asList("*", "**"), template.getSegments()); + assertEquals("", template.getVerb()); + assertEquals(Collections.emptyList(), template.getVariables()); + } + + @Test + public void testParseTest6() { + HttpTemplate template = HttpTemplate.parse("/*/a/**"); + assertNotNull(template); + assertEquals(Arrays.asList("*", "a", "**"), template.getSegments()); + assertEquals("", template.getVerb()); + assertEquals(Collections.emptyList(), template.getVariables()); + } + + @Test + public void testParseTest7() { + HttpTemplate template = HttpTemplate.parse("/a/{a.b.c}"); + assertNotNull(template); + assertEquals(Arrays.asList("a", "*"), template.getSegments()); + assertEquals("", template.getVerb()); + List expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("a", "b", "c"), 1, 2, false) + ); + + assertVariableList(expectedVariables, template.getVariables()); + } + + @Test + public void testParseTest8() { + HttpTemplate template = HttpTemplate.parse("/a/{a.b.c=*}"); + assertNotNull(template); + assertEquals(Arrays.asList("a", "*"), template.getSegments()); + assertEquals("", template.getVerb()); + List expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("a", "b", "c"), 1, 2, false) + ); + + assertVariableList(expectedVariables, template.getVariables()); + } + + @Test + public void testParseTest9() { + HttpTemplate template = HttpTemplate.parse("/a/{b=*}"); + assertNotNull(template); + assertEquals(Arrays.asList("a", "*"), template.getSegments()); + assertEquals("", template.getVerb()); + List expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("b"), 1, 2, false) + ); + + assertVariableList(expectedVariables, template.getVariables()); + } + + @Test + public void testParseTest10() { + HttpTemplate template = HttpTemplate.parse("/a/{b=**}"); + assertNotNull(template); + assertEquals(Arrays.asList("a", "**"), template.getSegments()); + assertEquals("", template.getVerb()); + List expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("b"), 1, -1, true) + ); + + assertVariableList(expectedVariables, template.getVariables()); + } + + @Test + public void testParseTest11() { + HttpTemplate template = HttpTemplate.parse("/a/{b=c/*}"); + assertNotNull(template); + assertEquals(Arrays.asList("a", "c", "*"), template.getSegments()); + assertEquals("", template.getVerb()); + List expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("b"), 1, 3, false) + ); + + assertVariableList(expectedVariables, template.getVariables()); + } + + @Test + public void testParseTest12() { + HttpTemplate template = HttpTemplate.parse("/a/{b=c/*/d}"); + assertNotNull(template); + assertEquals(Arrays.asList("a", "c", "*", "d"), template.getSegments()); + assertEquals("", template.getVerb()); + List expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("b"), 1, 4, false) + ); + + assertVariableList(expectedVariables, template.getVariables()); + } + + @Test + public void testParseTest13() { + HttpTemplate template = HttpTemplate.parse("/a/{b=c/**}"); + assertNotNull(template); + assertEquals(Arrays.asList("a", "c", "**"), template.getSegments()); + assertEquals("", template.getVerb()); + List expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("b"), 1, -1, true) + ); + + assertVariableList(expectedVariables, template.getVariables()); + } + + @Test + public void testParseTest14() { + HttpTemplate template = HttpTemplate.parse("/a/{b=c/**}/d/e"); + assertNotNull(template); + assertEquals(Arrays.asList("a", "c", "**", "d", "e"), template.getSegments()); + assertEquals("", template.getVerb()); + List expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("b"), 1, -3, true) + ); + + assertVariableList(expectedVariables, template.getVariables()); + } + + @Test + public void testParseTest15() { + HttpTemplate template = HttpTemplate.parse("/a/{b=c/**/d}/e"); + assertNotNull(template); + assertEquals(Arrays.asList("a", "c", "**", "d", "e"), template.getSegments()); + assertEquals("", template.getVerb()); + List expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("b"), 1, -2, true) + ); + + assertVariableList(expectedVariables, template.getVariables()); + } + + @Test + public void testParseTest16() { + HttpTemplate template = HttpTemplate.parse("/a/{b=c/**/d}/e:verb"); + assertNotNull(template); + assertEquals(Arrays.asList("a", "c", "**", "d", "e"), template.getSegments()); + assertEquals("verb", template.getVerb()); + List expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("b"), 1, -2, true) + ); + + assertVariableList(expectedVariables, template.getVariables()); + } + + @Test + public void testCustomVerbTests() { + HttpTemplate template = HttpTemplate.parse("/*:verb"); + assertNotNull(template); + assertEquals(List.of("*"), template.getSegments()); + assertEquals(Collections.emptyList(), template.getVariables()); + + template = HttpTemplate.parse("/**:verb"); + assertNotNull(template); + assertEquals(List.of("**"), template.getSegments()); + assertEquals(Collections.emptyList(), template.getVariables()); + + template = HttpTemplate.parse("/{a}:verb"); + assertNotNull(template); + assertEquals(List.of("*"), template.getSegments()); + List expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("a"), 0, 1, false) + ); + assertVariableList(expectedVariables, template.getVariables()); + + template = HttpTemplate.parse("/a/b/*:verb"); + assertNotNull(template); + assertEquals(Arrays.asList("a", "b", "*"), template.getSegments()); + assertEquals(Collections.emptyList(), template.getVariables()); + + template = HttpTemplate.parse("/a/b/**:verb"); + assertNotNull(template); + assertEquals(Arrays.asList("a", "b", "**"), template.getSegments()); + assertEquals(Collections.emptyList(), template.getVariables()); + + template = HttpTemplate.parse("/a/b/{a}:verb"); + assertNotNull(template); + assertEquals(Arrays.asList("a", "b", "*"), template.getSegments()); + expectedVariables = Collections.singletonList(HttpTemplateVariable.create(List.of("a"), 2, 3, false)); + assertVariableList(expectedVariables, template.getVariables()); + } + + @Test + public void testMoreVariableTests() { + HttpTemplate template = HttpTemplate.parse("/{x}"); + + assertNotNull(template); + assertEquals(List.of("*"), template.getSegments()); + assertEquals("", template.getVerb()); + List expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("x"), 0, 1, false) + ); + assertVariableList(expectedVariables, template.getVariables()); + + template = HttpTemplate.parse("/{x.y.z}"); + + assertNotNull(template); + assertEquals(List.of("*"), template.getSegments()); + assertEquals("", template.getVerb()); + expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("x", "y", "z"), 0, 1, false) + ); + assertVariableList(expectedVariables, template.getVariables()); + + template = HttpTemplate.parse("/{x=*}"); + + assertNotNull(template); + assertEquals(List.of("*"), template.getSegments()); + assertEquals("", template.getVerb()); + expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("x"), 0, 1, false) + ); + assertVariableList(expectedVariables, template.getVariables()); + + template = HttpTemplate.parse("/{x=a/*}"); + + assertNotNull(template); + assertEquals(Arrays.asList("a", "*"), template.getSegments()); + assertEquals("", template.getVerb()); + expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("x"), 0, 2, false) + ); + assertVariableList(expectedVariables, template.getVariables()); + + template = HttpTemplate.parse("/{x.y.z=*/a/b}/c"); + + assertNotNull(template); + assertEquals(Arrays.asList("*", "a", "b", "c"), template.getSegments()); + assertEquals("", template.getVerb()); + expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("x", "y", "z"), 0, 3, false) + ); + assertVariableList(expectedVariables, template.getVariables()); + + template = HttpTemplate.parse("/{x=**}"); + + assertNotNull(template); + assertEquals(List.of("**"), template.getSegments()); + assertEquals("", template.getVerb()); + expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("x"), 0, -1, true) + ); + assertVariableList(expectedVariables, template.getVariables()); + + template = HttpTemplate.parse("/{x.y.z=**}"); + + assertNotNull(template); + assertEquals(List.of("**"), template.getSegments()); + assertEquals("", template.getVerb()); + expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("x", "y", "z"), 0, -1, true) + ); + assertVariableList(expectedVariables, template.getVariables()); + + template = HttpTemplate.parse("/{x.y.z=a/**/b}"); + + assertNotNull(template); + assertEquals(Arrays.asList("a", "**", "b"), template.getSegments()); + assertEquals("", template.getVerb()); + expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("x", "y", "z"), 0, -1, true) + ); + assertVariableList(expectedVariables, template.getVariables()); + + template = HttpTemplate.parse("/{x.y.z=a/**/b}/c/d"); + + assertNotNull(template); + assertEquals(Arrays.asList("a", "**", "b", "c", "d"), template.getSegments()); + assertEquals("", template.getVerb()); + expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("x", "y", "z"), 0, -3, true) + ); + assertVariableList(expectedVariables, template.getVariables()); + } + + @Test + public void testVariableAndCustomVerbTests() { + HttpTemplate template = HttpTemplate.parse("/{x}:verb"); + + assertNotNull(template); + assertEquals(List.of("*"), template.getSegments()); + assertEquals("verb", template.getVerb()); + List expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("x"), 0, 1, false) + ); + assertVariableList(expectedVariables, template.getVariables()); + + template = HttpTemplate.parse("/{x.y.z}:verb"); + + assertNotNull(template); + assertEquals(List.of("*"), template.getSegments()); + assertEquals("verb", template.getVerb()); + expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("x", "y", "z"), 0, 1, false) + ); + assertVariableList(expectedVariables, template.getVariables()); + + template = HttpTemplate.parse("/{x.y.z=*/*}:verb"); + + assertNotNull(template); + assertEquals(Arrays.asList("*", "*"), template.getSegments()); + assertEquals("verb", template.getVerb()); + expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("x", "y", "z"), 0, 2, false) + ); + assertVariableList(expectedVariables, template.getVariables()); + + template = HttpTemplate.parse("/{x=**}:myverb"); + + assertNotNull(template); + assertEquals(List.of("**"), template.getSegments()); + assertEquals("myverb", template.getVerb()); + expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("x"), 0, -1, true) + ); + assertVariableList(expectedVariables, template.getVariables()); + + template = HttpTemplate.parse("/{x.y.z=**}:myverb"); + + assertNotNull(template); + assertEquals(List.of("**"), template.getSegments()); + assertEquals("myverb", template.getVerb()); + expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("x", "y", "z"), 0, -1, true) + ); + assertVariableList(expectedVariables, template.getVariables()); + + template = HttpTemplate.parse("/{x.y.z=a/**/b}:custom"); + + assertNotNull(template); + assertEquals(Arrays.asList("a", "**", "b"), template.getSegments()); + assertEquals("custom", template.getVerb()); + expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("x", "y", "z"), 0, -1, true) + ); + assertVariableList(expectedVariables, template.getVariables()); + + template = HttpTemplate.parse("/{x.y.z=a/**/b}/c/d:custom"); + + assertNotNull(template); + assertEquals(Arrays.asList("a", "**", "b", "c", "d"), template.getSegments()); + assertEquals("custom", template.getVerb()); + expectedVariables = Collections.singletonList( + HttpTemplateVariable.create(List.of("x", "y", "z"), 0, -3, true) + ); + assertVariableList(expectedVariables, template.getVariables()); + } + + @Test + public void testRootPath() { + HttpTemplate template = HttpTemplate.parse("/"); + assertNotNull(template); + assertEquals(Collections.emptyList(), template.getSegments()); + assertEquals("", template.getVerb()); + assertEquals(Collections.emptyList(), template.getVariables()); + } + + @Test + public void testErrorTests() { + assertNull(HttpTemplate.parse("")); + assertNull(HttpTemplate.parse("//")); + assertNull(HttpTemplate.parse("/{}")); + assertNull(HttpTemplate.parse("/a/")); + assertNull(HttpTemplate.parse("/a//b")); + + assertNull(HttpTemplate.parse(":verb")); + assertNull(HttpTemplate.parse("/:verb")); + assertNull(HttpTemplate.parse("/a/:verb")); + + assertNull(HttpTemplate.parse(":")); + assertNull(HttpTemplate.parse("/:")); + assertNull(HttpTemplate.parse("/*:")); + assertNull(HttpTemplate.parse("/**:")); + assertNull(HttpTemplate.parse("/{var}:")); + + assertNull(HttpTemplate.parse("/a/b/:")); + assertNull(HttpTemplate.parse("/a/b/*:")); + assertNull(HttpTemplate.parse("/a/b/**:")); + assertNull(HttpTemplate.parse("/a/b/{var}:")); + + assertNull(HttpTemplate.parse("/a/{")); + assertNull(HttpTemplate.parse("/a/{var")); + assertNull(HttpTemplate.parse("/a/{var.")); + assertNull(HttpTemplate.parse("/a/{x=var:verb}")); + + assertNull(HttpTemplate.parse("a")); + assertNull(HttpTemplate.parse("{x}")); + assertNull(HttpTemplate.parse("{x=/a}")); + assertNull(HttpTemplate.parse("{x=/a/b}")); + assertNull(HttpTemplate.parse("a/b")); + assertNull(HttpTemplate.parse("a/b/{x}")); + assertNull(HttpTemplate.parse("a/{x}/b")); + assertNull(HttpTemplate.parse("a/{x}/b:verb")); + assertNull(HttpTemplate.parse("/a/{var=/b}")); + assertNull(HttpTemplate.parse("/{var=a/{nested=b}}")); + + assertNull(HttpTemplate.parse("/a{x}")); + assertNull(HttpTemplate.parse("/{x}a")); + assertNull(HttpTemplate.parse("/a{x}b")); + assertNull(HttpTemplate.parse("/{x}a{y}")); + assertNull(HttpTemplate.parse("/a/b{x}")); + assertNull(HttpTemplate.parse("/a/{x}b")); + assertNull(HttpTemplate.parse("/a/b{x}c")); + assertNull(HttpTemplate.parse("/a/{x}b{y}")); + assertNull(HttpTemplate.parse("/a/b{x}/s")); + assertNull(HttpTemplate.parse("/a/{x}b/s")); + assertNull(HttpTemplate.parse("/a/b{x}c/s")); + assertNull(HttpTemplate.parse("/a/{x}b{y}/s")); + } + + @Test + public void testParseVerbTest2() { + HttpTemplate template = HttpTemplate.parse("/a/*:verb"); + assertNotNull(template); + assertEquals(template.getSegments(), Arrays.asList("a", "*")); + assertEquals("verb", template.getVerb()); + } + + @Test + public void testParseVerbTest3() { + HttpTemplate template = HttpTemplate.parse("/a/**:verb"); + assertNotNull(template); + assertEquals(template.getSegments(), Arrays.asList("a", "**")); + assertEquals("verb", template.getVerb()); + } + + @Test + public void testParseVerbTest4() { + HttpTemplate template = HttpTemplate.parse("/a/{b=*}/**:verb"); + assertNotNull(template); + assertEquals(template.getSegments(), Arrays.asList("a", "*", "**")); + assertEquals("verb", template.getVerb()); + } + + @Test + public void testParseNonVerbTest() { + assertNull(HttpTemplate.parse(":")); + assertNull(HttpTemplate.parse("/:")); + assertNull(HttpTemplate.parse("/a/:")); + assertNull(HttpTemplate.parse("/a/*:")); + assertNull(HttpTemplate.parse("/a/**:")); + assertNull(HttpTemplate.parse("/a/{b=*}/**:")); + } + + private void assertVariableList(List expected, List actual) { + assertEquals(expected.size(), actual.size()); + for (int i = 0; i < expected.size(); i++) { + assertVariable(expected.get(i), actual.get(i)); + } + } + + private void assertVariable(HttpTemplateVariable expected, HttpTemplateVariable actual) { + assertEquals(expected.getFieldPath(), actual.getFieldPath()); + assertEquals(expected.getStartSegment(), actual.getStartSegment()); + assertEquals(expected.getEndSegment(), actual.getEndSegment()); + assertEquals(expected.hasWildcardPath(), actual.hasWildcardPath()); + } +} diff --git a/vertx-grpc-transcoding/src/test/java/io/vertx/tests/transcoding/PathMatcherTest.java b/vertx-grpc-transcoding/src/test/java/io/vertx/tests/transcoding/PathMatcherTest.java new file mode 100644 index 00000000..986bb9e5 --- /dev/null +++ b/vertx-grpc-transcoding/src/test/java/io/vertx/tests/transcoding/PathMatcherTest.java @@ -0,0 +1,326 @@ +package io.vertx.tests.transcoding; + +import io.vertx.core.http.HttpMethod; +import io.vertx.grpc.transcoding.*; +import io.vertx.grpc.transcoding.impl.PercentEncoding; +import io.vertx.grpc.transcoding.impl.PathMatcherBuilderImpl; +import org.junit.Test; + +import java.util.*; + +import static org.junit.Assert.*; + +public class PathMatcherTest { + + private final PathMatcherBuilder builder = new PathMatcherBuilderImpl(); + private PathMatcher matcher; + private final List storedMethods = new ArrayList<>(); + + private String addPathWithBodyFieldPath(String httpMethod, String httpTemplate, String bodyFieldPath) { + String method = "method_" + storedMethods.size(); + ServiceTranscodingOptions transcodingOptions = ServiceTranscodingOptions.create("selector", HttpMethod.valueOf(httpMethod), httpTemplate, bodyFieldPath, "response", List.of()); + assertTrue(builder.register(transcodingOptions, method)); + storedMethods.add(method); + return method; + } + + private String addPathWithSystemParams(String httpMethod, String httpTemplate, Set systemParams) { + String method = "method_" + storedMethods.size(); + ServiceTranscodingOptions transcodingOptions = ServiceTranscodingOptions.create("selector", HttpMethod.valueOf(httpMethod), httpTemplate, "", "response", List.of()); + assertTrue(builder.register(transcodingOptions, systemParams, method)); + storedMethods.add(method); + return method; + } + + private String addPath(String httpMethod, String httpTemplate) { + return addPathWithBodyFieldPath(httpMethod, httpTemplate, ""); + } + + private String addGetPath(String path) { + return addPath("GET", path); + } + + private void build() { + matcher = builder.build(); + } + + /*private String lookupWithBodyFieldPath(String method, String path, List bindings, String[] bodyFieldPath) { + return matcher.lookup(method, path, "", bindings, bodyFieldPath); + }*/ + + private PathMatcherLookupResult lookup(String method, String path) { + return matcher.lookup(method, path, ""); + } + + /*private String lookupWithParams(String method, String path, String queryParams, + List bindings) { + String[] bodyFieldPath = new String[1]; + return matcher.lookup(method, path, queryParams, bindings, bodyFieldPath); + }*/ + + private String lookupNoBindings(String method, String path) { + return matcher.lookup(method, path); + } + + private void assertVariableList(List expected, List actual) { + assertEquals(expected.size(), actual.size()); + for (int i = 0; i < expected.size(); i++) { + assertVariable(expected.get(i), actual.get(i)); + } + } + + private void assertVariable(HttpVariableBinding expected, HttpVariableBinding actual) { + assertEquals(expected.getFieldPath(), actual.getFieldPath()); + assertEquals(expected.getValue(), actual.getValue()); + } + + private void multiSegmentMatchWithReservedCharactersBase(String expectedComponent) { + String path = addGetPath("/a/{x=*}/{y=**}/c"); + + build(); + + PathMatcherLookupResult result = lookup("GET", "/a/%21%23%24%26%27%28%29%2A%2B%2C%2F%3A%3B%3D%3F%40%5B%5D/%21%23%24%26%27%28%29%2A%2B%2C%2F%3A%3B%3D%3F%40%5B%5D/c"); + + assertEquals(path, result.getMethod()); + assertVariableList( + Arrays.asList( + HttpVariableBinding.create(Collections.singletonList("x"), "!#$&'()*+,/:;=?@[]"), + HttpVariableBinding.create(Collections.singletonList("y"), expectedComponent) + ), result.getVariableBindings()); + } + + @Test + public void testWildCardMatchesRoot() { + String data = addGetPath("/**"); + + build(); + + assertEquals(data, lookupNoBindings("GET", "/")); + assertEquals(data, lookupNoBindings("GET", "/a")); + assertEquals(data, lookupNoBindings("GET", "/a/")); + } + + @Test + public void testWildCardMatches() { + // '*' only matches one path segment, but '**' matches the remaining path. + String pathA = addGetPath("/a/**"); + String pathB = addGetPath("/b/*"); + String pathCD = addGetPath("/c/*/d/**"); + String pathCDE = addGetPath("/c/*/d/e"); + String pathCFDE = addGetPath("/c/f/d/e"); + String root = addGetPath("/"); + + build(); + + assertEquals(pathA, lookupNoBindings("GET", "/a/b")); + assertEquals(pathA, lookupNoBindings("GET", "/a/b/c")); + assertEquals(pathB, lookupNoBindings("GET", "/b/c")); + + assertNull(lookupNoBindings("GET", "b/c/d")); + assertEquals(pathCD, lookupNoBindings("GET", "/c/u/d/v")); + assertEquals(pathCD, lookupNoBindings("GET", "/c/v/d/w/x")); + assertNull(lookupNoBindings("GET", "/c/x/y/d/z")); + assertNull(lookupNoBindings("GET", "/c//v/d/w/x")); + + // Test that more specific match overrides wildcard "**"" match. + assertEquals(pathCDE, lookupNoBindings("GET", "/c/x/d/e")); + // Test that more specific match overrides wildcard "*"" match. + assertEquals(pathCFDE, lookupNoBindings("GET", "/c/f/d/e")); + + assertEquals(root, lookupNoBindings("GET", "/")); + } + + @Test + public void testVariableBindings() { + String pathACDE = addGetPath("/a/{x}/c/d/e"); + String pathABC = addGetPath("/{x=a/*}/b/{y=*}/c"); + String pathABD = addGetPath("/a/{x=b/*}/{y=d/**}"); + String pathAlphaBetaGamma = addGetPath("/alpha/{x=*}/beta/{y=**}/gamma"); + String pathA = addGetPath("/{x=*}/a"); + String pathAB = addGetPath("/{x=**}/a/b"); + String pathAB0 = addGetPath("/a/b/{x=*}"); + String pathABC0 = addGetPath("/a/b/c/{x=**}"); + String pathDEF = addGetPath("/{x=*}/d/e/f/{y=**}"); + + build(); + + PathMatcherLookupResult result = lookup("GET", "/a/book/c/d/e"); + + assertEquals(pathACDE, result.getMethod()); + assertVariableList(Collections.singletonList(HttpVariableBinding.create(Collections.singletonList("x"), "book")), result.getVariableBindings()); + + result = lookup("GET", "/a/hello/b/world/c"); + + assertEquals(pathABC, result.getMethod()); + assertVariableList(Arrays.asList( + HttpVariableBinding.create(Collections.singletonList("x"), "a/hello"), + HttpVariableBinding.create(Collections.singletonList("y"), "world")), + result.getVariableBindings()); + + result = lookup("GET", "/a/b/zoo/d/animal/tiger"); + + assertEquals(pathABD, result.getMethod()); + assertVariableList(Arrays.asList( + HttpVariableBinding.create(Collections.singletonList("x"), "b/zoo"), + HttpVariableBinding.create(Collections.singletonList("y"), "d/animal/tiger")), + result.getVariableBindings()); + + result = lookup("GET", "/alpha/dog/beta/eat/bones/gamma"); + + assertEquals(pathAlphaBetaGamma, result.getMethod()); + assertVariableList(Arrays.asList( + HttpVariableBinding.create(Collections.singletonList("x"), "dog"), + HttpVariableBinding.create(Collections.singletonList("y"), "eat/bones")), + result.getVariableBindings()); + + result = lookup("GET", "/foo/a"); + + assertEquals(pathA, result.getMethod()); + assertVariableList(Collections.singletonList(HttpVariableBinding.create(Collections.singletonList("x"), "foo")), result.getVariableBindings()); + + result = lookup("GET", "/foo/bar/a/b"); + + assertEquals(pathAB, result.getMethod()); + assertVariableList(Collections.singletonList(HttpVariableBinding.create(Collections.singletonList("x"), "foo/bar")), result.getVariableBindings()); + + result = lookup("GET", "/a/b/foo"); + + assertEquals(pathAB0, result.getMethod()); + assertVariableList(Collections.singletonList(HttpVariableBinding.create(Collections.singletonList("x"), "foo")), result.getVariableBindings()); + + result = lookup("GET", "/a/b/c/foo/bar/baz"); + + assertEquals(pathABC0, result.getMethod()); + assertVariableList(Collections.singletonList(HttpVariableBinding.create(Collections.singletonList("x"), "foo/bar/baz")), result.getVariableBindings()); + + result = lookup("GET", "/foo/d/e/f/bar/baz"); + + assertEquals(pathDEF, result.getMethod()); + assertVariableList(Arrays.asList( + HttpVariableBinding.create(Collections.singletonList("x"), "foo"), + HttpVariableBinding.create(Collections.singletonList("y"), "bar/baz")), + result.getVariableBindings()); + } + + @Test + public void testPercentEscapesUnescapedForSingleSegment() { + String path = addGetPath("/a/{x}/c"); + + build(); + + PathMatcherLookupResult result = lookup("GET", "/a/p%20q%2Fr+/c"); + // Also test '+', make sure it is not unescaped + assertEquals(path, result.getMethod()); + assertVariableList(Collections.singletonList(HttpVariableBinding.create(Collections.singletonList("x"), "p q/r+")), result.getVariableBindings()); + } + + @Test + public void testPercentEscapesUnescapedForSingleSegmentAllAsciiChars() { + String getPath = addGetPath("/{x}"); + build(); + + for (int u = 0; u < 2; ++u) { + for (char c = 0; c < 0x7f; ++c) { + String path = "/%" + String.format("%02x", (int) c); + + PathMatcherLookupResult result = lookup("GET", path); + assertEquals(getPath, result.getMethod()); + assertVariableList(Collections.singletonList(HttpVariableBinding.create(Collections.singletonList("x"), String.valueOf(c))), result.getVariableBindings()); + } + } + } + + @Test + public void testPercentEscapesNotUnescapedForMultiSegment1() { + String path = addGetPath("/a/{x=p/*/q/*}/c"); + build(); + + PathMatcherLookupResult result = lookup("GET", "/a/p/foo%20foo/q/bar%2Fbar/c"); + assertEquals(path, result.getMethod()); + // space (%20) is escaped, but slash (%2F) isn't. + assertVariableList(Collections.singletonList(HttpVariableBinding.create(Collections.singletonList("x"), "p/foo foo/q/bar%2Fbar")), result.getVariableBindings()); + } + + @Test + public void testPercentEscapesNotUnescapedForMultiSegment2() { + String path = addGetPath("/a/{x=**}/c"); + build(); + + PathMatcherLookupResult result = lookup("GET", "/a/p/foo%20foo/q/bar%2Fbar+/c"); + // Also test '+', make sure it is not unescaped + assertEquals(path, result.getMethod()); + // space (%20) is unescaped, but slash (%2F) isn't. nor + + assertVariableList(Collections.singletonList(HttpVariableBinding.create(Collections.singletonList("x"), "p/foo foo/q/bar%2Fbar+")), result.getVariableBindings()); + } + + @Test + public void testOnlyUnreservedCharsAreUnescapedForMultiSegmentMatchUnescapeAllExceptReservedImplicit() { + multiSegmentMatchWithReservedCharactersBase("%21%23%24%26%27%28%29%2A%2B%2C%2F%3A%3B%3D%3F%40%5B%5D"); + } + + @Test + public void testOnlyUnreservedCharsAreUnescapedForMultiSegmentMatchUnescapeAllExceptReservedExplicit() { + builder.setUrlUnescapeSpec(PercentEncoding.UrlUnescapeSpec.ALL_CHARACTERS_EXCEPT_RESERVED); + multiSegmentMatchWithReservedCharactersBase("%21%23%24%26%27%28%29%2A%2B%2C%2F%3A%3B%3D%3F%40%5B%5D"); + } + + @Test + public void testOnlyUnreservedCharsAreUnescapedForMultiSegmentMatchUnescapeAllExceptSlash() { + builder.setUrlUnescapeSpec(PercentEncoding.UrlUnescapeSpec.ALL_CHARACTERS_EXCEPT_SLASH); + multiSegmentMatchWithReservedCharactersBase("!#$&'()*+,%2F:;=?@[]"); + } + + @Test + public void testOnlyUnreservedCharsAreUnescapedForMultiSegmentMatchUnescapeAll() { + builder.setUrlUnescapeSpec(PercentEncoding.UrlUnescapeSpec.ALL_CHARACTERS); + multiSegmentMatchWithReservedCharactersBase("!#$&'()*+,/:;=?@[]"); + } + + @Test + public void testCustomVerbIssue() { + String listPerson = addGetPath("/person"); + String getPerson = addGetPath("/person/{id=*}"); + String verb = addGetPath("/{x=**}:verb"); + + build(); + + PathMatcherLookupResult result = lookup("GET", "/person:verb"); + + // with the verb + assertEquals(verb, result.getMethod()); + assertVariableList(Collections.singletonList(HttpVariableBinding.create(Collections.singletonList("x"), "person")), result.getVariableBindings()); + + result = lookup("GET", "/person/jason:verb"); + + assertEquals(verb, result.getMethod()); + assertVariableList(Collections.singletonList(HttpVariableBinding.create(Collections.singletonList("x"), "person/jason")), result.getVariableBindings()); + + // with the verb but with a different prefix + result = lookup("GET", "/animal:verb"); + + assertEquals(verb, result.getMethod()); + assertVariableList(Collections.singletonList(HttpVariableBinding.create(Collections.singletonList("x"), "animal")), result.getVariableBindings()); + + result = lookup("GET", "/animal/cat:verb"); + + assertEquals(verb, result.getMethod()); + assertVariableList(Collections.singletonList(HttpVariableBinding.create(Collections.singletonList("x"), "animal/cat")), result.getVariableBindings()); + + // without a verb + assertEquals(listPerson, lookup("GET", "/person").getMethod()); + assertEquals(getPerson, lookup("GET", "/person/jason").getMethod()); + assertNull(lookup("GET", "/animal")); + assertNull(lookup("GET", "/animal/cat")); + + // with a non-verb + assertNull(lookup("GET", "/person:other")); + + result = lookup("GET", "/person/jason:other"); + + assertEquals(getPerson, result.getMethod()); + assertVariableList(Collections.singletonList(HttpVariableBinding.create((Collections.singletonList("id")), "jason:other")), result.getVariableBindings()); + + assertNull(lookup("GET", "/animal:other")); + assertNull(lookup("GET", "/animal/cat:other")); + } +} diff --git a/vertx-grpc-transcoding/src/test/java/io/vertx/tests/transcoding/PathMatcherUtilityTest.java b/vertx-grpc-transcoding/src/test/java/io/vertx/tests/transcoding/PathMatcherUtilityTest.java new file mode 100644 index 00000000..ebf35d5d --- /dev/null +++ b/vertx-grpc-transcoding/src/test/java/io/vertx/tests/transcoding/PathMatcherUtilityTest.java @@ -0,0 +1,130 @@ +package io.vertx.tests.transcoding; + +import io.vertx.core.http.HttpMethod; +import io.vertx.grpc.transcoding.PathMatcher; +import io.vertx.grpc.transcoding.PathMatcherBuilder; +import io.vertx.grpc.transcoding.ServiceTranscodingOptions; +import io.vertx.grpc.transcoding.impl.PathMatcherBuilderImpl; +import io.vertx.grpc.transcoding.impl.PathMatcherUtility; +import org.junit.Test; + +import java.util.*; + +import static org.junit.Assert.*; + +public class PathMatcherUtilityTest { + + private final String method1 = "method1"; + private final String method2 = "method2"; + + @Test + public void testNeverRegister() { + ServiceTranscodingOptions options = ServiceTranscodingOptions.create("selector", HttpMethod.GET, "/path", "body", "response", Collections.emptyList()); + PathMatcherBuilder pmb = new PathMatcherBuilderImpl(); + + assertTrue(PathMatcherUtility.registerByHttpRule(pmb, options, method1)); + PathMatcher pm = pmb.build(); + + assertNull(pm.lookup("GET", "/any/path")); + } + + @Test + public void testRegisterGet() { + ServiceTranscodingOptions options = ServiceTranscodingOptions.create("selector", HttpMethod.GET, "/path", "body", "response", Collections.emptyList()); + PathMatcherBuilder pmb = new PathMatcherBuilderImpl(); + + assertTrue(PathMatcherUtility.registerByHttpRule(pmb, options, method1)); + + Set queryParams = new HashSet<>(List.of("key")); + assertFalse(PathMatcherUtility.registerByHttpRule(pmb, options, queryParams, method2)); + } + + @Test + public void testRegisterPut() { + ServiceTranscodingOptions options = ServiceTranscodingOptions.create("selector", HttpMethod.PUT, "/path", "body", "response", Collections.emptyList()); + PathMatcherBuilder pmb = new PathMatcherBuilderImpl(); + + assertTrue(PathMatcherUtility.registerByHttpRule(pmb, options, method1)); + + Set queryParams = new HashSet<>(List.of("key")); + assertFalse(PathMatcherUtility.registerByHttpRule(pmb, options, queryParams, method2)); + } + + @Test + public void testRegisterPost() { + ServiceTranscodingOptions options = ServiceTranscodingOptions.create("selector", HttpMethod.POST, "/path", "body", "response", Collections.emptyList()); + PathMatcherBuilder pmb = new PathMatcherBuilderImpl(); + + assertTrue(PathMatcherUtility.registerByHttpRule(pmb, options, method1)); + + Set queryParams = new HashSet<>(List.of("key")); + assertFalse(PathMatcherUtility.registerByHttpRule(pmb, options, queryParams, method2)); + } + + @Test + public void testRegisterDelete() { + ServiceTranscodingOptions options = ServiceTranscodingOptions.create("selector", HttpMethod.DELETE, "/path", "body", "response", Collections.emptyList()); + PathMatcherBuilder pmb = new PathMatcherBuilderImpl(); + + assertTrue(PathMatcherUtility.registerByHttpRule(pmb, options, method1)); + + Set queryParams = new HashSet<>(List.of("key")); + assertFalse(PathMatcherUtility.registerByHttpRule(pmb, options, queryParams, method2)); + } + + @Test + public void testRegisterPatch() { + ServiceTranscodingOptions options = ServiceTranscodingOptions.create("selector", HttpMethod.PATCH, "/path", "body", "response", Collections.emptyList()); + PathMatcherBuilder pmb = new PathMatcherBuilderImpl(); + + assertTrue(PathMatcherUtility.registerByHttpRule(pmb, options, method1)); + + Set queryParams = new HashSet<>(List.of("key")); + assertFalse(PathMatcherUtility.registerByHttpRule(pmb, options, queryParams, method2)); + } + + @Test + public void testRegisterCustom() { + ServiceTranscodingOptions options = ServiceTranscodingOptions.create("selector", HttpMethod.OPTIONS, "/custom_path", "body", "response", Collections.emptyList()); + PathMatcherBuilder pmb = new PathMatcherBuilderImpl(); + + assertTrue(PathMatcherUtility.registerByHttpRule(pmb, options, method1)); + + Set queryParams = new HashSet<>(List.of("key")); + assertFalse(PathMatcherUtility.registerByHttpRule(pmb, options, queryParams, method2)); + } + + @Test + public void testRegisterAdditionalBindings() { + ServiceTranscodingOptions options = ServiceTranscodingOptions.create( + "selector", + HttpMethod.GET, + "/path", + "body", + "response", + Arrays.asList( + ServiceTranscodingOptions.createBinding("selector", HttpMethod.OPTIONS, "/custom_path", "body1", "response"), + ServiceTranscodingOptions.createBinding("selector", HttpMethod.HEAD, "/path", null, "response"), + ServiceTranscodingOptions.createBinding("selector", HttpMethod.PUT, "/put_path", null, "response") + ) + ); + + PathMatcherBuilder pmb = new PathMatcherBuilderImpl(); + + assertTrue(PathMatcherUtility.registerByHttpRule(pmb, options, method1)); + + Set queryParams = new HashSet<>(List.of("key")); + assertFalse(PathMatcherUtility.registerByHttpRule(pmb, options, queryParams, method2)); + } + + @Test + public void testRegisterRootPath() { + ServiceTranscodingOptions options = ServiceTranscodingOptions.create("selector", HttpMethod.GET, "/", "body", "response", Collections.emptyList()); + PathMatcherBuilder pmb = new PathMatcherBuilderImpl(); + + assertTrue(PathMatcherUtility.registerByHttpRule(pmb, options, method1)); + + Set queryParams = new HashSet<>(List.of("key")); + assertFalse(PathMatcherUtility.registerByHttpRule(pmb, options, queryParams, method2)); + } +} diff --git a/vertx-grpc-transcoding/src/test/java/module-info.java b/vertx-grpc-transcoding/src/test/java/module-info.java new file mode 100644 index 00000000..b609f407 --- /dev/null +++ b/vertx-grpc-transcoding/src/test/java/module-info.java @@ -0,0 +1,7 @@ +open module io.vertx.tests.transcoding { + requires io.vertx.testing.unit; + requires junit; + requires io.netty.codec.http; + requires io.vertx.grpc.transcoding; + exports io.vertx.tests.transcoding; +}