From 70749bf2a5b2da8c7b6619c8cf103e2e8005b97e Mon Sep 17 00:00:00 2001 From: WayneWang12 Date: Wed, 29 Jun 2022 00:22:23 +0800 Subject: [PATCH] feat: support grpc http transcoding --- .../templates/ScalaServer/Handler.scala.txt | 7 +- project/Dependencies.scala | 3 + .../protobuf/google/api/annotations.proto | 32 + .../src/main/protobuf/google/api/http.proto | 377 ++++++++ .../main/protobuf/google/api/httpbody.proto | 78 ++ .../src/main/scala/akka/grpc/HttpApi.scala | 830 ++++++++++++++++++ .../src/main/scala/akka/grpc/Options.scala | 71 ++ 7 files changed, 1397 insertions(+), 1 deletion(-) create mode 100644 runtime/src/main/protobuf/google/api/annotations.proto create mode 100644 runtime/src/main/protobuf/google/api/http.proto create mode 100644 runtime/src/main/protobuf/google/api/httpbody.proto create mode 100644 runtime/src/main/scala/akka/grpc/HttpApi.scala create mode 100644 runtime/src/main/scala/akka/grpc/Options.scala diff --git a/codegen/src/main/twirl/templates/ScalaServer/Handler.scala.txt b/codegen/src/main/twirl/templates/ScalaServer/Handler.scala.txt index 47632536c..8ae1e8710 100644 --- a/codegen/src/main/twirl/templates/ScalaServer/Handler.scala.txt +++ b/codegen/src/main/twirl/templates/ScalaServer/Handler.scala.txt @@ -128,12 +128,17 @@ object @{serviceName}Handler { .recoverWith(GrpcExceptionHandler.from(eHandler(system.classicSystem))(system, writer)) ).getOrElse(unsupportedMediaType) - Function.unlift((req: model.HttpRequest) => req.uri.path match { + + + val httpHandler = akka.grpc.HttpApi.serve(@{service.name}.descriptor, handle _) + + val grpcHandler = Function.unlift((req: model.HttpRequest) => req.uri.path match { case model.Uri.Path.Slash(model.Uri.Path.Segment(`prefix`, model.Uri.Path.Slash(model.Uri.Path.Segment(method, model.Uri.Path.Empty)))) => Some(handle(spi.onRequest(prefix, method, req), method)) case _ => None }) + httpHandler orElse grpcHandler } } } diff --git a/project/Dependencies.scala b/project/Dependencies.scala index a9861036d..8e48d424e 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -79,6 +79,7 @@ object Dependencies { object Protobuf { val protobufJava = "com.google.protobuf" % "protobuf-java" % Versions.googleProtobuf + val protobufJavaUtil = "com.google.protobuf" % "protobuf-java-util" % Versions.googleProtobuf val googleCommonProtos = "com.google.protobuf" % "protobuf-java" % Versions.googleProtobuf % "protobuf" } @@ -100,6 +101,8 @@ object Dependencies { val runtime = l ++= Seq( Compile.scalapbRuntime, Protobuf.protobufJava, // or else scalapb pulls older version in transitively + Protobuf.protobufJavaUtil, + Protobuf.googleCommonProtos, Compile.grpcCore, Compile.grpcStub % "provided", // comes from the generators Compile.grpcNettyShaded, diff --git a/runtime/src/main/protobuf/google/api/annotations.proto b/runtime/src/main/protobuf/google/api/annotations.proto new file mode 100644 index 000000000..f7bcab135 --- /dev/null +++ b/runtime/src/main/protobuf/google/api/annotations.proto @@ -0,0 +1,32 @@ +// Copyright (c) 2015, Google Inc. +// +// 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 csharp_namespace = "Google.Protobuf"; +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/runtime/src/main/protobuf/google/api/http.proto b/runtime/src/main/protobuf/google/api/http.proto new file mode 100644 index 000000000..d554871be --- /dev/null +++ b/runtime/src/main/protobuf/google/api/http.proto @@ -0,0 +1,377 @@ +// Copyright 2019 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 csharp_namespace = "Google.Protobuf"; +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 | gRPC +// -----|----- +// `GET /v1/messages/123456` | `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 | gRPC +// -----|----- +// `GET /v1/messages/123456?revision=2&sub.subfield=foo` | +// `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 | gRPC +// -----|----- +// `PATCH /v1/messages/123456 { "text": "Hi!" }` | `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 | gRPC +// -----|----- +// `PATCH /v1/messages/123456 { "text": "Hi!" }` | `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 | gRPC +// -----|----- +// `GET /v1/messages/123456` | `GetMessage(message_id: "123456")` +// `GET /v1/users/me/messages/123456` | `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. +// +// Example: +// +// http: +// rules: +// # Selects a gRPC method and applies HttpRule to it. +// - 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/runtime/src/main/protobuf/google/api/httpbody.proto b/runtime/src/main/protobuf/google/api/httpbody.proto new file mode 100644 index 000000000..45c1e76b1 --- /dev/null +++ b/runtime/src/main/protobuf/google/api/httpbody.proto @@ -0,0 +1,78 @@ +// Copyright 2019 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/protobuf/any.proto"; + +option cc_enable_arenas = true; +option go_package = "google.golang.org/genproto/googleapis/api/httpbody;httpbody"; +option java_multiple_files = true; +option java_outer_classname = "HttpBodyProto"; +option java_package = "com.google.api"; +option objc_class_prefix = "GAPI"; + +// Message that represents an arbitrary HTTP body. It should only be used for +// payload formats that can't be represented as JSON, such as raw binary or +// an HTML page. +// +// +// This message can be used both in streaming and non-streaming API methods in +// the request as well as the response. +// +// It can be used as a top-level request field, which is convenient if one +// wants to extract parameters from either the URL or HTTP template into the +// request fields and also want access to the raw HTTP body. +// +// Example: +// +// message GetResourceRequest { +// // A unique request id. +// string request_id = 1; +// +// // The raw HTTP body is bound to this field. +// google.api.HttpBody http_body = 2; +// } +// +// service ResourceService { +// rpc GetResource(GetResourceRequest) returns (google.api.HttpBody); +// rpc UpdateResource(google.api.HttpBody) returns +// (google.protobuf.Empty); +// } +// +// Example with streaming methods: +// +// service CaldavService { +// rpc GetCalendar(stream google.api.HttpBody) +// returns (stream google.api.HttpBody); +// rpc UpdateCalendar(stream google.api.HttpBody) +// returns (stream google.api.HttpBody); +// } +// +// Use of this type only changes how the request and response bodies are +// handled, all other features will continue to work unchanged. +message HttpBody { + // The HTTP Content-Type header value specifying the content type of the body. + string content_type = 1; + + // The HTTP request/response body as raw binary. + bytes data = 2; + + // Application specific response metadata. Must be set in the first response + // for streaming APIs. + repeated google.protobuf.Any extensions = 3; +} diff --git a/runtime/src/main/scala/akka/grpc/HttpApi.scala b/runtime/src/main/scala/akka/grpc/HttpApi.scala new file mode 100644 index 000000000..ac74d3efe --- /dev/null +++ b/runtime/src/main/scala/akka/grpc/HttpApi.scala @@ -0,0 +1,830 @@ +/* + * Copyright (C) 2018-2021 Lightbend Inc. + */ + +package akka.grpc + +import akka.grpc.internal.{ Codecs, GrpcProtocolNative, Identity } +import akka.grpc.scaladsl.headers.`Message-Accept-Encoding` +import akka.{ ConfigurationException, NotUsed } +import akka.http.scaladsl.marshalling.Marshal +import akka.http.scaladsl.marshalling.sse.EventStreamMarshalling +import akka.http.scaladsl.model.Uri.Path +import akka.http.scaladsl.model.headers.Accept +import akka.http.scaladsl.model.sse.ServerSentEvent +import akka.http.scaladsl.model.{ + ContentType, + ContentTypes, + ErrorInfo, + HttpEntity, + HttpHeader, + HttpMethod, + HttpMethods, + HttpProtocols, + HttpRequest, + HttpResponse, + IllegalRequestException, + IllegalResponseException, + MediaTypes, + RequestEntityAcceptance, + StatusCodes, + Uri +} +import akka.http.scaladsl.unmarshalling.Unmarshal +import akka.parboiled2.util.Base64 +import akka.stream.Materializer +import akka.stream.scaladsl.{ Keep, Sink, Source } +import akka.util.ByteString +import com.google.api.annotations.AnnotationsProto +import com.google.api.http.HttpRule +import com.google.protobuf.any.{ Any => ProtobufAny } +import com.google.protobuf.Descriptors.FieldDescriptor.JavaType +import com.google.protobuf.Descriptors.{ + Descriptor, + EnumValueDescriptor, + FieldDescriptor, + FileDescriptor, + MethodDescriptor +} +import com.google.protobuf.util.JsonFormat + +import java.lang.{ + Boolean => JBoolean, + Double => JDouble, + Float => JFloat, + Integer => JInteger, + Long => JLong, + Short => JShort +} +import com.google.protobuf.{ + DynamicMessage, + ListValue, + MessageOrBuilder, + Struct, + Value, + ByteString => ProtobufByteString +} + +import java.net.URLDecoder +import java.util.regex.{ Matcher, Pattern } +import scala.annotation.tailrec +import scala.concurrent.{ ExecutionContext, Future } +import scala.util.matching.Regex +import scala.util.parsing.combinator.Parsers +import scala.util.parsing.input.{ CharSequenceReader, Positional } +import scala.collection.JavaConverters._ +import scala.util.{ Failure, Success } +import scala.util.control.NonFatal + +object HttpApi { + + final val ParseShort: String => Option[JShort] = + s => + try Option(JShort.valueOf(s)) + catch { + case _: NumberFormatException => None + } + + final val ParseInt: String => Option[JInteger] = + s => + try Option(JInteger.valueOf(s)) + catch { + case _: NumberFormatException => None + } + + final val ParseLong: String => Option[JLong] = + s => + try Option(JLong.valueOf(s)) + catch { + case _: NumberFormatException => None + } + + final val ParseFloat: String => Option[JFloat] = + s => + try Option(JFloat.valueOf(s)) + catch { + case _: NumberFormatException => None + } + + final val ParseDouble: String => Option[JDouble] = + s => + try Option(JDouble.valueOf(s)) + catch { + case _: NumberFormatException => None + } + + final val ParseString: String => Option[String] = + s => Option(s) + + private[this] final val someJTrue = Some(JBoolean.TRUE) + private[this] final val someJFalse = Some(JBoolean.FALSE) + + final val ParseBoolean: String => Option[JBoolean] = + _.toLowerCase match { + case "true" => someJTrue + case "false" => someJFalse + case _ => None + } + + // Reads a rfc2045 encoded Base64 string + final val ParseBytes: String => Option[ProtobufByteString] = + s => Some(ProtobufByteString.copyFrom(Base64.rfc2045.decode(s))) // Make cheaper? Protobuf has a Base64 decoder? + + final def suitableParserFor(field: FieldDescriptor)(whenIllegal: String => Nothing): String => Option[Any] = + field.getJavaType match { + case JavaType.BOOLEAN => ParseBoolean + case JavaType.BYTE_STRING => ParseBytes + case JavaType.DOUBLE => ParseDouble + case JavaType.ENUM => whenIllegal("Enum path parameters not supported!") + case JavaType.FLOAT => ParseFloat + case JavaType.INT => ParseInt + case JavaType.LONG => ParseLong + case JavaType.MESSAGE => whenIllegal("Message path parameters not supported!") + case JavaType.STRING => ParseString + } + + // We use this to indicate problems with the configuration of the routes + private final val configError: String => Nothing = s => throw new ConfigurationException("HTTP API Config: " + s) + + // We use this to signal to the requestor that there's something wrong with the request + private final val requestError: String => Nothing = s => throw IllegalRequestException(StatusCodes.BadRequest, s) + + // This is used to support the "*" custom pattern + private final val ANY_METHOD = HttpMethod.custom( + name = "ANY", + safe = false, + idempotent = false, + requestEntityAcceptance = RequestEntityAcceptance.Tolerated) + + private final val NEWLINE_BYTES = ByteString('\n') + private final val NoMatch = PartialFunction.empty[HttpRequest, Future[HttpResponse]] + + def serve(fileDescriptor: FileDescriptor, handler: (HttpRequest, String) => Future[HttpResponse])( + implicit mat: Materializer, + ec: ExecutionContext) = { + val handlers = for { + service <- fileDescriptor.getServices.asScala + method <- service.getMethods.asScala + rules = getRules(method) + binding <- rules + } yield { + new HttpHandler(method, binding, req => handler(req, method.getName)) + } + handlers.foldLeft(NoMatch) { + case (NoMatch, first) => first + case (previous, current) => current.orElse(previous) // Last goes first + } + } + + private def getRules(methDesc: MethodDescriptor) = { + AnnotationsProto.http.get(Options.convertMethodOptions(methDesc)) match { + case Some(rule) => + rule +: rule.additionalBindings + case None => + Seq.empty + } + } + + final class HttpHandler(methDesc: MethodDescriptor, rule: HttpRule, grpcHandler: HttpRequest => Future[HttpResponse])( + implicit ec: ExecutionContext, + mat: Materializer) + extends PartialFunction[HttpRequest, Future[HttpResponse]] { + + // For descriptive purposes so it's clear what these types do + private type PathParameterEffect = (FieldDescriptor, Option[Any]) => Unit + private type ExtractPathParameters = (Matcher, PathParameterEffect) => Unit + + // Question: Do we need to handle conversion from JSON names? + private[this] final def lookupFieldByName(desc: Descriptor, selector: String): FieldDescriptor = + desc.findFieldByName( + selector + ) // TODO potentially start supporting path-like selectors with maximum nesting level? + + private[this] final def parsePathExtractor( + pattern: String): (PathTemplateParser.ParsedTemplate, ExtractPathParameters) = { + val template = PathTemplateParser.parse(pattern) + val pathFieldParsers = template.fields.iterator + .map { + case tv @ PathTemplateParser.TemplateVariable(fieldName :: Nil, _) => + lookupFieldByName(methDesc.getInputType, fieldName) match { + case null => + configError( + s"Unknown field name [$fieldName] in type [${methDesc.getInputType.getFullName}] reference in path template for method [${methDesc.getFullName}]") + case field => + if (field.isRepeated) + configError(s"Repeated parameters [${field.getFullName}] are not allowed as path variables") + else if (field.isMapField) + configError(s"Map parameters [${field.getFullName}] are not allowed as path variables") + else (tv, field, suitableParserFor(field)(configError)) + } + case multi => + // todo implement field paths properly + configError("Multiple fields in field path not yet implemented: " + multi.fieldPath.mkString(".")) + } + .zipWithIndex + .toList + + ( + template, + (matcher, effect) => { + pathFieldParsers.foreach { + case ((_, field, parser), idx) => + val rawValue = matcher.group(idx + 1) + // When encoding, we need to be careful to only encode / if it's a single segment variable. But when + // decoding, it doesn't matter, we decode %2F if it's there regardless. + val decoded = URLDecoder.decode(rawValue, "utf-8") + val value = parser(decoded) + effect(field, value) + } + }) + } + + // This method validates the configuration and returns values obtained by parsing the configuration + private[this] final def extractAndValidate(): ( + HttpMethod, + PathTemplateParser.ParsedTemplate, + ExtractPathParameters, + Descriptor, + Option[FieldDescriptor]) = { + // Validate selector + if (rule.selector != "" && rule.selector != methDesc.getFullName) + configError(s"Rule selector [${rule.selector}] must be empty or [${methDesc.getFullName}]") + + // Validate pattern + val (mp, pattern) = { + import HttpRule.Pattern.{ Custom, Delete, Empty, Get, Patch, Post, Put } + import akka.http.scaladsl.model.HttpMethods.{ DELETE, GET, PATCH, POST, PUT } + + rule.pattern match { + case Empty => configError(s"Pattern missing for rule [$rule]!") // TODO improve error message + case Get(pattern) => (GET, pattern) + case Put(pattern) => (PUT, pattern) + case Post(pattern) => (POST, pattern) + case Delete(pattern) => (DELETE, pattern) + case Patch(pattern) => (PATCH, pattern) + case Custom(chp) => + if (chp.kind == "*") + ( + ANY_METHOD, + chp.path + ) // FIXME is "path" the same as "pattern" for the other kinds? Is an empty kind valid? + else configError(s"Only Custom patterns with [*] kind supported but [${chp.kind}] found!") + } + } + val (template, extractor) = parsePathExtractor(pattern) + + // Validate body value + val bd = + rule.body match { + case "" => methDesc.getInputType + case "*" => + if (!mp.isEntityAccepted) + configError(s"Body configured to [*] but HTTP Method [$mp] does not have a request body.") + else + methDesc.getInputType + case fieldName => + val field = lookupFieldByName(methDesc.getInputType, fieldName) + if (field == null) + configError(s"Body configured to [$fieldName] but that field does not exist on input type.") + else if (field.isRepeated) + configError(s"Body configured to [$fieldName] but that field is a repeated field.") + else if (!mp.isEntityAccepted) + configError(s"Body configured to [$fieldName] but HTTP Method $mp does not have a request body.") + else + field.getMessageType + } + + // Validate response body value + val rd = + rule.responseBody match { + case "" => None + case fieldName => + lookupFieldByName(methDesc.getOutputType, fieldName) match { + case null => + configError( + s"Response body field [$fieldName] does not exist on type [${methDesc.getOutputType.getFullName}]") + case field => Some(field) + } + } + + if (rule.additionalBindings.exists(_.additionalBindings.nonEmpty)) + configError(s"Only one level of additionalBindings supported, but [$rule] has more than one!") + + (mp, template, extractor, bd, rd) + } + + private[this] final val (methodPattern, pathTemplate, pathExtractor, bodyDescriptor, responseBodyDescriptor) = + extractAndValidate() + + @tailrec private[this] final def lookupFieldByPath(desc: Descriptor, selector: String): FieldDescriptor = + Names.splitNext(selector) match { + case ("", "") => null + case (fieldName, "") => lookupFieldByName(desc, fieldName) + case (fieldName, next) => + val field = lookupFieldByName(desc, fieldName) + if (field == null) null + else if (field.getMessageType == null) null + else lookupFieldByPath(field.getMessageType, next) + } + + private val jsonParser = + JsonFormat.parser.usingTypeRegistry(JsonFormat.TypeRegistry.newBuilder().add(bodyDescriptor).build) + + private[this] final val jsonPrinter = JsonFormat.printer + .usingTypeRegistry(JsonFormat.TypeRegistry.newBuilder.add(methDesc.getOutputType).build()) + .includingDefaultValueFields() + .omittingInsignificantWhitespace() + + // Making this a method so we can ensure it's used the same way + final def matches(path: Uri.Path): Boolean = + pathTemplate.regex.pattern + .matcher(path.toString()) + .matches() // FIXME path.toString is costly, and using Regexes are too, switch to using a generated parser instead + + private[this] final def parseRequestParametersInto( + query: Map[String, List[String]], + inputBuilder: DynamicMessage.Builder): Unit = + query.foreach { + case (selector, values) => + if (values.nonEmpty) { + lookupFieldByPath(methDesc.getInputType, selector) match { + case null => requestError("Query parameter [$selector] refers to non-existant field") + case field if field.getJavaType == FieldDescriptor.JavaType.MESSAGE => + requestError( + "Query parameter [$selector] refers to a message type" + ) // FIXME validate assumption that this is prohibited + case field if !field.isRepeated && values.size > 1 => + requestError("Multiple values sent for non-repeated field by query parameter [$selector]") + case field => // FIXME verify that we can set nested fields from the inputBuilder type + val x = suitableParserFor(field)(requestError) + if (field.isRepeated) { + values.foreach { v => + inputBuilder.addRepeatedField( + field, + x(v).getOrElse(requestError("Malformed Query parameter [$selector]"))) + } + } else + inputBuilder.setField( + field, + x(values.head).getOrElse(requestError("Malformed Query parameter [$selector]"))) + } + } // Ignore empty values + } + + private[this] final def parsePathParametersInto(matcher: Matcher, inputBuilder: DynamicMessage.Builder): Unit = + pathExtractor( + matcher, + (field, value) => + inputBuilder.setField(field, value.getOrElse(requestError("Path contains value of wrong type!")))) + + final private[this] val isHttpBodyResponse = methDesc.getOutputType.getFullName == "google.api.HttpBody" + + def extractContentTypeFromHttpBody(entityMessage: MessageOrBuilder): ContentType = + entityMessage.getField(entityMessage.getDescriptorForType.findFieldByName("content_type")) match { + case null | "" => ContentTypes.NoContentType + case string: String => + ContentType + .parse(string) + .fold( + list => + throw new IllegalResponseException( + list.headOption.getOrElse(ErrorInfo.fromCompoundString("Unknown error"))), + identity) + } + + def extractDataFromHttpBody(entityMessage: MessageOrBuilder): ByteString = + ByteString.fromArrayUnsafe( + entityMessage + .getField(entityMessage.getDescriptorForType.findFieldByName("data")) + .asInstanceOf[ProtobufByteString] + .toByteArray) + + // FIXME Devise other way of supporting responseBody, this is waaay too costly and unproven + // This method converts an arbitrary type to something which can be represented as JSON. + private[this] final def responseBody( + jType: JavaType, + value: AnyRef, + repeated: Boolean): com.google.protobuf.Value = { + val result = + if (repeated) { + Value.newBuilder.setListValue( + ListValue.newBuilder.addAllValues( + value.asInstanceOf[java.lang.Iterable[AnyRef]].asScala.map(v => responseBody(jType, v, false)).asJava)) + } else { + val b = Value.newBuilder + jType match { + case JavaType.BOOLEAN => b.setBoolValue(value.asInstanceOf[JBoolean]) + case JavaType.BYTE_STRING => b.setStringValueBytes(value.asInstanceOf[ProtobufByteString]) + case JavaType.DOUBLE => b.setNumberValue(value.asInstanceOf[JDouble]) + case JavaType.ENUM => + b.setStringValue( + value.asInstanceOf[EnumValueDescriptor].getName + ) // Switch to getNumber if enabling printingEnumsAsInts in the JSON Printer + case JavaType.FLOAT => b.setNumberValue(value.asInstanceOf[JFloat].toDouble) + case JavaType.INT => b.setNumberValue(value.asInstanceOf[JInteger].toDouble) + case JavaType.LONG => b.setNumberValue(value.asInstanceOf[JLong].toDouble) + case JavaType.MESSAGE => + val sb = Struct.newBuilder + value + .asInstanceOf[MessageOrBuilder] + .getAllFields + .forEach((k, v) => + sb.putFields( + k.getJsonName, + responseBody(k.getJavaType, v, k.isRepeated) + ) //Switch to getName if enabling preservingProtoFieldNames in the JSON Printer + ) + b.setStructValue(sb) + case JavaType.STRING => b.setStringValue(value.asInstanceOf[String]) + } + } + result.build() + } + + private final val IdentityHeader = new `Message-Accept-Encoding`("identity") + private final val grpcWriter = GrpcProtocolNative.newWriter(Identity) + + private[this] final def updateRequest(req: HttpRequest, message: DynamicMessage): HttpRequest = { + HttpRequest( + method = HttpMethods.POST, + uri = Uri(path = Path / methDesc.getService.getFullName / methDesc.getName), + headers = req.headers :+ IdentityHeader, + entity = HttpEntity.Chunked( + ContentTypes.`application/grpc+proto`, + Source.single( + grpcWriter.encodeFrame(GrpcProtocol.DataFrame(ByteString.fromArrayUnsafe(message.toByteArray))))), + protocol = HttpProtocols.`HTTP/2.0`) + } + + final def transformRequest(req: HttpRequest, matcher: Matcher): Future[HttpRequest] = + if (rule.body.nonEmpty && req.entity.contentType != ContentTypes.`application/json`) { + Future.failed(IllegalRequestException(StatusCodes.BadRequest, "Content-type must be application/json!")) + } else { + val inputBuilder = DynamicMessage.newBuilder(methDesc.getInputType) + rule.body match { + case "" => // Iff empty body rule, then only query parameters + req.discardEntityBytes() + parseRequestParametersInto(req.uri.query().toMultiMap, inputBuilder) + parsePathParametersInto(matcher, inputBuilder) + Future.successful(updateRequest(req, inputBuilder.build)) + case "*" => // Iff * body rule, then no query parameters, and only fields not mapped in path variables + Unmarshal(req.entity) + .to[String] + .map(str => { + jsonParser.merge(str, inputBuilder) + parsePathParametersInto(matcher, inputBuilder) + updateRequest(req, inputBuilder.build) + }) + case fieldName => // Iff fieldName body rule, then all parameters not mapped in path variables + Unmarshal(req.entity) + .to[String] + .map(str => { + val subField = lookupFieldByName(methDesc.getInputType, fieldName) + val subInputBuilder = DynamicMessage.newBuilder(subField.getMessageType) + jsonParser.merge(str, subInputBuilder) + parseRequestParametersInto(req.uri.query().toMultiMap, inputBuilder) + parsePathParametersInto(matcher, inputBuilder) + inputBuilder.setField(subField, subInputBuilder.build()) + updateRequest(req, inputBuilder.build) + }) + } + } + + private[this] final def parseResponseBody(pbAny: ProtobufAny): MessageOrBuilder = { + val bytes = ReplySerializer.serialize(pbAny) + val message = DynamicMessage.parseFrom(methDesc.getOutputType, bytes.iterator.asInputStream) + responseBodyDescriptor.fold(message: MessageOrBuilder) { field => + message.getField(field) match { + case m: MessageOrBuilder if !field.isRepeated => m // No need to wrap this + case value => responseBody(field.getJavaType, value, field.isRepeated) + } + } + } + + final def transformResponse( + grpcRequest: HttpRequest, + futureResponse: Future[(List[HttpHeader], Source[ProtobufAny, NotUsed])]): Future[HttpResponse] = { + def extractContentTypeFromHttpBody(entityMessage: MessageOrBuilder): ContentType = + entityMessage.getField(entityMessage.getDescriptorForType.findFieldByName("content_type")) match { + case null | "" => ContentTypes.NoContentType + case string: String => + ContentType + .parse(string) + .fold( + list => + throw new IllegalResponseException( + list.headOption.getOrElse(ErrorInfo.fromCompoundString("Unknown error"))), + identity) + } + + def extractDataFromHttpBody(entityMessage: MessageOrBuilder): ByteString = + ByteString.fromArrayUnsafe( + entityMessage + .getField(entityMessage.getDescriptorForType.findFieldByName("data")) + .asInstanceOf[ProtobufByteString] + .toByteArray) + + if (methDesc.isServerStreaming) { + val sseAccepted = + grpcRequest + .header[Accept] + .exists(_.mediaRanges.exists(_.value.startsWith(MediaTypes.`text/event-stream`.toString))) + + futureResponse.flatMap { + case (headers, data) => + if (sseAccepted) { + import EventStreamMarshalling._ + Marshal(data.map(parseResponseBody).map { em => + ServerSentEvent(jsonPrinter.print(em)) + }).to[HttpResponse].map(response => response.withHeaders(headers)) + } else if (isHttpBodyResponse) { + Future.successful( + HttpResponse( + entity = HttpEntity.Chunked( + headers + .find(_.lowercaseName() == "content-type") + .flatMap(ct => ContentType.parse(ct.value()).toOption) + .getOrElse(ContentTypes.`application/octet-stream`), + data.map(em => HttpEntity.Chunk(extractDataFromHttpBody(parseResponseBody(em))))), + headers = headers.filterNot(_.lowercaseName() == "content-type"))) + } else { + Future.successful( + HttpResponse( + entity = HttpEntity.Chunked( + ContentTypes.`application/json`, + data + .map(parseResponseBody) + .map(em => HttpEntity.Chunk(ByteString(jsonPrinter.print(em)) ++ NEWLINE_BYTES))), + headers = headers)) + } + } + + } else { + for { + (headers, data) <- futureResponse + protobuf <- data.runWith(Sink.head) + } yield { + val entityMessage = parseResponseBody(protobuf) + HttpResponse( + entity = if (isHttpBodyResponse) { + HttpEntity(extractContentTypeFromHttpBody(entityMessage), extractDataFromHttpBody(entityMessage)) + } else { + HttpEntity(ContentTypes.`application/json`, ByteString(jsonPrinter.print(entityMessage))) + }, + headers = headers) + } + } + } + + final val AnyTypeUrlHostName = "type.googleapis.com/" + + final val expectedReplyTypeUrl: String = AnyTypeUrlHostName + methDesc.getOutputType.getFullName + + private[this] final def processRequest(req: HttpRequest, matcher: Matcher): Future[HttpResponse] = { + transformRequest(req, matcher) + .transformWith { + case Success(request) => + val response = grpcHandler(request).map { resp => + val headers = resp.headers + val grpcReader = GrpcProtocolNative.newReader(Codecs.detect(resp).get) + val body = resp.entity.dataBytes.viaMat(grpcReader.dataFrameDecoder)(Keep.none).map { payload => + ProtobufAny(typeUrl = expectedReplyTypeUrl, value = ProtobufByteString.copyFrom(payload.asByteBuffer)) + } + headers.toList -> body + } + + transformResponse(request, response) + case Failure(_) => + requestError("Malformed request") + } + .recover { + case ire: IllegalRequestException => HttpResponse(ire.status.intValue, entity = ire.status.reason) + case NonFatal(error) => HttpResponse(StatusCodes.InternalServerError, entity = error.getMessage) + } + } + + override def isDefinedAt(req: HttpRequest): Boolean = { + (methodPattern == ANY_METHOD || req.method == methodPattern) && matches(req.uri.path) + } + + override def apply(req: HttpRequest): Future[HttpResponse] = { + assert((methodPattern == ANY_METHOD || req.method == methodPattern)) + val matcher = pathTemplate.regex.pattern.matcher(req.uri.path.toString()) + assert(matcher.matches()) + processRequest(req, matcher) + } + } + + private final object ReplySerializer extends ProtobufSerializer[ProtobufAny] { + override final def serialize(reply: ProtobufAny): ByteString = + if (reply.value.isEmpty) ByteString.empty + else ByteString.fromArrayUnsafe(reply.value.toByteArray) + + override final def deserialize(bytes: ByteString): ProtobufAny = + throw new UnsupportedOperationException("operation not supported") + } + + private object Names { + final def splitPrev(name: String): (String, String) = { + val dot = name.lastIndexOf('.') + if (dot >= 0) { + (name.substring(0, dot), name.substring(dot + 1)) + } else { + ("", name) + } + } + + final def splitNext(name: String): (String, String) = { + val dot = name.indexOf('.') + if (dot >= 0) { + (name.substring(0, dot), name.substring(dot + 1)) + } else { + (name, "") + } + } + } + + private object PathTemplateParser extends Parsers { + + override type Elem = Char + + final class ParsedTemplate(path: String, template: Template) { + val regex: Regex = { + def doToRegex(builder: StringBuilder, segments: List[Segment], matchSlash: Boolean): StringBuilder = + segments match { + case Nil => builder // Do nothing + case head :: tail => + if (matchSlash) { + builder.append('/') + } + + head match { + case LiteralSegment(literal) => + builder.append(Pattern.quote(literal)) + case SingleSegmentMatcher => + builder.append("[^/:]*") + case MultiSegmentMatcher() => + builder.append(".*") + case VariableSegment(_, None) => + builder.append("([^/:]*)") + case VariableSegment(_, Some(template)) => + builder.append('(') + doToRegex(builder, template, matchSlash = false) + builder.append(')') + } + + doToRegex(builder, tail, matchSlash = true) + } + + val builder = doToRegex(new StringBuilder, template.segments, matchSlash = true) + + template.verb + .foldLeft(builder) { (builder, verb) => + builder.append(':').append(Pattern.quote(verb)) + } + .toString() + .r + } + + val fields: List[TemplateVariable] = { + var found = Set.empty[List[String]] + template.segments.collect { + case v @ VariableSegment(fieldPath, _) if found(fieldPath) => + throw PathTemplateParseException("Duplicate path in template", path, v.pos.column + 1) + case VariableSegment(fieldPath, segments) => + found += fieldPath + TemplateVariable( + fieldPath, + segments.exists(_ match { + case ((_: MultiSegmentMatcher) :: _) | (_ :: _ :: _) => true + case _ => false + })) + } + } + } + + final case class TemplateVariable(fieldPath: List[String], multi: Boolean) + + final case class PathTemplateParseException(msg: String, path: String, column: Int) + extends RuntimeException( + s"$msg at ${if (column >= path.length) "end of input" else s"character $column"} of '$path'") { + + def prettyPrint: String = { + val caret = + if (column >= path.length) "" + else "\n" + path.take(column - 1).map { case '\t' => '\t'; case _ => ' ' } + "^" + + s"$msg at ${if (column >= path.length) "end of input" else s"character $column"}:${'\n'}$path$caret" + } + } + + final def parse(path: String): ParsedTemplate = + template(new CharSequenceReader(path)) match { + case Success(template, _) => + new ParsedTemplate(path, validate(path, template)) + case NoSuccess(msg, next) => + throw PathTemplateParseException(msg, path, next.pos.column) + } + + private final def validate(path: String, template: Template): Template = { + def flattenSegments(segments: Segments, allowVariables: Boolean): Segments = + segments.flatMap { + case variable: VariableSegment if !allowVariables => + throw PathTemplateParseException("Variable segments may not be nested", path, variable.pos.column) + case VariableSegment(_, Some(nested)) => flattenSegments(nested, false) + case other => List(other) + } + + // Flatten, verifying that there are no nested variables + val flattened = flattenSegments(template.segments, true) + + // Verify there are no ** matchers that aren't the last matcher + flattened.dropRight(1).foreach { + case m @ MultiSegmentMatcher() => + throw PathTemplateParseException( + "Multi segment matchers (**) may only be in the last position of the template", + path, + m.pos.column) + case _ => + } + template + } + + // AST for syntax described here: + // https://cloud.google.com/endpoints/docs/grpc-service-config/reference/rpc/google.api#google.api.HttpRule.description.subsection + // Note that there are additional rules (eg variables cannot contain nested variables) that this AST doesn't enforce, + // these are validated elsewhere. + private final case class Template(segments: Segments, verb: Option[Verb]) + + private type Segments = List[Segment] + private type Verb = String + + private sealed trait Segment + + private final case class LiteralSegment(literal: Literal) extends Segment + + private final case class VariableSegment(fieldPath: FieldPath, template: Option[Segments]) + extends Segment + with Positional + + private type FieldPath = List[Ident] + + private case object SingleSegmentMatcher extends Segment + + private final case class MultiSegmentMatcher() extends Segment with Positional + + private type Literal = String + private type Ident = String + + private final val NotLiteral = Set('*', '{', '}', '/', ':', '\n') + + // Matches ident syntax from https://developers.google.com/protocol-buffers/docs/reference/proto3-spec + private final val ident: Parser[Ident] = rep1( + acceptIf(ch => (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z'))(e => + s"Expected identifier first letter, but got '$e'"), + acceptIf(ch => (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_')(_ => + "identifier part")) ^^ (_.mkString) + + // There is no description of this in the spec. It's not a URL segment, since the spec explicitly says that the value + // must be URL encoded when expressed as a URL. Since all segments are delimited by a / character or a colon, and a + // literal may only be a full segment, we could assume it's any non slash or colon character, but that would mean + // syntax errors in variables for example would end up being parsed as literals, which wouldn't give nice error + // messages at all. So we'll be a little more strict, and not allow *, { or } in any literals. + private final val literal: Parser[Literal] = + rep(acceptIf(ch => !NotLiteral(ch))(_ => "literal part")) ^^ (_.mkString) + + private final val fieldPath: Parser[FieldPath] = rep1(ident, '.' ~> ident) + + private final val literalSegment: Parser[LiteralSegment] = literal ^^ LiteralSegment + + // After we see an open curly, we commit to erroring if we fail to parse the remainder. + private final def variable: Parser[VariableSegment] = + positioned( + '{' ~> commit( + fieldPath ~ ('=' ~> segments).? <~ '}'.withFailureMessage("Unclosed variable or unexpected character") ^^ { + case fieldPath ~ maybeTemplate => VariableSegment(fieldPath, maybeTemplate) + })) + + private final val singleSegmentMatcher: Parser[SingleSegmentMatcher.type] = '*' ^^ (_ => SingleSegmentMatcher) + private final val multiSegmentMatcher: Parser[MultiSegmentMatcher] = positioned( + '*' ~ '*' ^^ (_ => MultiSegmentMatcher())) + private final val segment: Parser[Segment] = commit( + multiSegmentMatcher | singleSegmentMatcher | variable | literalSegment) + + private final val verb: Parser[Verb] = ':' ~> literal + private final val segments: Parser[Segments] = rep1(segment, '/' ~> segment) + private final val endOfInput: Parser[None.type] = Parser { in => + if (!in.atEnd) { + Error("Expected '/', ':', path literal character, or end of input", in) + } else { + Success(None, in) + } + } + + private final val template: Parser[Template] = '/'.withFailureMessage("Template must start with a slash") ~> + segments ~ verb.? <~ endOfInput ^^ { + case segments ~ maybeVerb => Template(segments, maybeVerb) + } + } + +} diff --git a/runtime/src/main/scala/akka/grpc/Options.scala b/runtime/src/main/scala/akka/grpc/Options.scala new file mode 100644 index 000000000..433bd1e22 --- /dev/null +++ b/runtime/src/main/scala/akka/grpc/Options.scala @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2018-2021 Lightbend Inc. + */ + +package akka.grpc + +import com.google.protobuf.descriptor.{ MethodOptions => spbMethodOptions } +import com.google.protobuf.Descriptors.MethodDescriptor +import com.google.protobuf.descriptor.MethodOptions.IdempotencyLevel +import com.google.protobuf.descriptor.MethodOptions.IdempotencyLevel.{ + IDEMPOTENCY_UNKNOWN, + IDEMPOTENT, + NO_SIDE_EFFECTS, + Unrecognized +} + +import scala.collection.JavaConverters._ + +private[grpc] object Options { + + private def fromValue(__value: _root_.scala.Int): IdempotencyLevel = __value match { + case 0 => IDEMPOTENCY_UNKNOWN + case 1 => NO_SIDE_EFFECTS + case 2 => IDEMPOTENT + case __other => Unrecognized(__other) + } + private def fromJavaValue( + pbJavaSource: com.google.protobuf.DescriptorProtos.MethodOptions.IdempotencyLevel): IdempotencyLevel = fromValue( + pbJavaSource.getNumber) + + private def fromJavaProto( + javaPbSource: com.google.protobuf.DescriptorProtos.MethodOptions): com.google.protobuf.descriptor.MethodOptions = + com.google.protobuf.descriptor.MethodOptions( + deprecated = if (javaPbSource.hasDeprecated) Some(javaPbSource.getDeprecated.booleanValue) else _root_.scala.None, + idempotencyLevel = + if (javaPbSource.hasIdempotencyLevel) Some(fromJavaValue(javaPbSource.getIdempotencyLevel)) + else _root_.scala.None, + uninterpretedOption = javaPbSource.getUninterpretedOptionList.asScala.iterator.map(fromJavaProto(_)).toSeq) + + private def fromJavaProto(javaPbSource: com.google.protobuf.DescriptorProtos.UninterpretedOption.NamePart) + : com.google.protobuf.descriptor.UninterpretedOption.NamePart = com.google.protobuf.descriptor.UninterpretedOption + .NamePart(namePart = javaPbSource.getNamePart, isExtension = javaPbSource.getIsExtension.booleanValue) + + private def fromJavaProto(javaPbSource: com.google.protobuf.DescriptorProtos.UninterpretedOption) + : com.google.protobuf.descriptor.UninterpretedOption = com.google.protobuf.descriptor.UninterpretedOption( + name = javaPbSource.getNameList.asScala.iterator.map(fromJavaProto(_)).toSeq, + identifierValue = if (javaPbSource.hasIdentifierValue) Some(javaPbSource.getIdentifierValue) else _root_.scala.None, + positiveIntValue = + if (javaPbSource.hasPositiveIntValue) Some(javaPbSource.getPositiveIntValue.longValue) else _root_.scala.None, + negativeIntValue = + if (javaPbSource.hasNegativeIntValue) Some(javaPbSource.getNegativeIntValue.longValue) else _root_.scala.None, + doubleValue = if (javaPbSource.hasDoubleValue) Some(javaPbSource.getDoubleValue.doubleValue) else _root_.scala.None, + stringValue = if (javaPbSource.hasStringValue) Some(javaPbSource.getStringValue) else _root_.scala.None, + aggregateValue = if (javaPbSource.hasAggregateValue) Some(javaPbSource.getAggregateValue) else _root_.scala.None) + + /** + * ScalaPB doesn't do this conversion for us unfortunately. + * By doing it, we can use HttpProto.entityKey.get() to read the entity key nicely. + */ + final def convertMethodOptions(method: MethodDescriptor): spbMethodOptions = + fromJavaProto(method.toProto.getOptions) + .withUnknownFields(scalapb.UnknownFieldSet(method.getOptions.getUnknownFields.asMap.asScala.map { + case (idx, f) => + idx.toInt -> scalapb.UnknownFieldSet.Field( + varint = f.getVarintList.iterator.asScala.map(_.toLong).toSeq, + fixed64 = f.getFixed64List.iterator.asScala.map(_.toLong).toSeq, + fixed32 = f.getFixed32List.iterator.asScala.map(_.toInt).toSeq, + lengthDelimited = f.getLengthDelimitedList.asScala.toSeq) + }.toMap)) + +}