Skip to content

Commit

Permalink
Reflection based implementation (#32)
Browse files Browse the repository at this point in the history
* GrpcJsonBridge for multiple services 
* Reflection based implementation (macros removed)
* ExecutionContext used in public API
* Monix dependency removed
  • Loading branch information
augi authored and jendakol committed May 22, 2019
1 parent faaa337 commit 1c61764
Show file tree
Hide file tree
Showing 25 changed files with 618 additions and 1,171 deletions.
160 changes: 67 additions & 93 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,131 +3,105 @@
[![Build Status](https://travis-ci.org/avast/grpc-json-bridge.svg?branch=master)](https://travis-ci.org/avast/grpc-json-bridge)
[![Download](https://api.bintray.com/packages/avast/maven/grpc-json-bridge/images/download.svg) ](https://bintray.com/avast/maven/grpc-json-bridge/_latestVersion)

This library makes possible to receive a JSON encoded request to a gRPC service. It provides an implementation-agnostic module for mapping to
your favorite HTTP server as well as few implementations for direct usage in some well-known HTTP servers.
For requests/responses mapping a [standard GPB <-> JSON mapping](https://developers.google.com/protocol-buffers/docs/proto3#json) is used.
This library allows to make a JSON encoded request to a gRPC service. It provides an implementation-agnostic module for mapping to your favorite HTTP server (`core`) as well as few implementations for direct usage in some well-known HTTP servers.

It uses Scala macros for creating mapping between runtime-provided service and method names to pregenerated Java gRPC classes. In case you
don't want to use _plain Java API_ you can easily use it together with [Cactus](https://github.com/avast/cactus).
[Standard GPB <-> JSON mapping](https://developers.google.com/protocol-buffers/docs/proto3#json) is used.

The API is _finally tagless_ (read more e.g. [here](https://www.beyondthelines.net/programming/introduction-to-tagless-final/))
meaning it can use whatever [`F[_]: cats.effect.Effect`](https://typelevel.org/cats-effect/typeclasses/effect.html)
(e.g. `cats.effect.IO`, `monix.eval.Task`).
The API is _finally tagless_ (read more e.g. [here](https://www.beyondthelines.net/programming/introduction-to-tagless-final/)) meaning it can use whatever [`F[_]: cats.effect.Effect`](https://typelevel.org/cats-effect/typeclasses/effect.html) (e.g. `cats.effect.IO`, `monix.eval.Task`).

There are several modules:
1. core - for basic implementation-agnostic usage
1. [http4s](http4s) - integration with [http4s](https://http4s.org/) webserver
1. [akka-http](akka-http) - integration with [Akka Http](https://doc.akka.io/docs/akka-http/current/server-side/index.html) webserver
## Usage

The created [`GrpcJsonBridge`](core/src/main/scala/com/avast/grpc/jsonbridge/GrpcJsonBridge.scala) exposes not only the methods itself but
also provides their list to make possible to implement an _info_ endpoint (which is already implemented in server-agnostic implementations).

Recommended URL pattern for exposing the service (and the one used in provided implementations) is `/$SERVICENAME/$METHOD` name and the http
method is obviously `POST`. The _info_ endpoint is supposed to be exposed on the `/$SERVICENAME` URL and available with `GET` request.

The bridge passes all received headers from JSON request to the gRPC service so it's not any problem to use it e.g. authentication.

## Core module

### Dependency

#### Gradle
```groovy
compile 'com.avast.grpc:grpc-json-bridge-core_2.12:x.x.x'
```

#### SBT
```scala
libraryDependencies += "com.avast.grpc" %% "grpc-json-bridge-core" % "x.x.x"
```

### Usage

Having a proto like
```proto
option java_package = "com.avast.grpc.jsonbridge.test";
message TestApi {
message GetRequest {
repeated string names = 1; // REQUIRED
}
message GetResponse {
map<string, int32> results = 1; // REQUIRED
}
}
syntax = "proto3";
package com.avast.grpc.jsonbridge.test;
service TestApiService {
rpc Get (TestApi.GetRequest) returns (TestApi.GetResponse) {}
service TestService {
rpc Add (AddParams) returns (AddResponse) {}
}
```
you can create [`GrpcJsonBridge`](core/src/main/scala/com/avast/grpc/jsonbridge/GrpcJsonBridge.scala) instance by
```scala
import com.avast.grpc.jsonbridge._ // this does the magic!
import scala.concurrent.ExecutionContextExecutorService
import com.avast.grpc.jsonbridge.test.TestApi
import com.avast.grpc.jsonbridge.test.TestApi.{GetRequest, GetResponse}
import com.avast.grpc.jsonbridge.test.TestApiServiceGrpc.{TestApiServiceFutureStub, TestApiServiceImplBase}
import io.grpc.stub.StreamObserver

implicit val executor: ExecutionContextExecutorService = ???
message AddParams {
int32 a = 1;
int32 b = 2;
}
val service = new TestApiServiceImplBase {
override def get(request: GetRequest, responseObserver: StreamObserver[TestApi.GetResponse]): Unit = {
responseObserver.onNext(GetResponse.newBuilder().putResults("name", 42).build())
responseObserver.onCompleted()
}
}
val bridge = service.createGrpcJsonBridge[Task, TestApiServiceFutureStub]() // this does the magic!
message AddResponse {
int32 sum = 1;
}
```
or you can even go with the [Cactus](https://github.com/avast/cactus) and let it map the GPB messages to your case classes:
```scala
import com.avast.grpc.jsonbridge._ // import for the grpc-json-bridge mapping
import com.avast.cactus.grpc._
import com.avast.cactus.grpc.server._ // import for the cactus mapping
import com.avast.grpc.jsonbridge.ReflectionGrpcJsonBridge

import com.avast.grpc.jsonbridge.test.TestApiServiceGrpc.{TestApiServiceFutureStub, TestApiServiceImplBase}
import io.grpc.Status
// for whole server
val grpcServer: io.grpc.Server = ???
val bridge = new ReflectionGrpcJsonBridge[Task](grpcServer)

import scala.concurrent.{ExecutionContextExecutorService, Future}
// or for selected services
val s1: ServerServiceDefinition = ???
val s2: ServerServiceDefinition = ???
val anotherBridge = new ReflectionGrpcJsonBridge[Task](s1, s2)

implicit val executor: ExecutionContextExecutorService = ???
// call a method manually, with a header specified
val jsonResponse = bridge.invoke("com.avast.grpc.jsonbridge.test.TestService/Add", """ { "a": 1, "b": 2} """, Map("My-Header" -> "value"))
```

case class MyRequest(names: Seq[String])
### http4s
```groovy
compile 'com.avast.grpc:grpc-json-bridge-http4s_2.12:x.x.x'
```
```scala
libraryDependencies += "com.avast.grpc" %% "grpc-json-bridge-http4s" % "x.x.x"
```
```scala
import com.avast.grpc.jsonbridge.GrpcJsonBridge
import com.avast.grpc.jsonbrige.http4s.{Configuration, Http4s}
import org.http4s.HttpService

case class MyResponse(results: Map[String, Int])
val bridge: GrpcJsonBridge[Task] = ???
val service: HttpService[Task] = Http4s(Configuration.Default)(bridge)
```

trait MyApi extends GrpcService[Task] {
def get(request: MyRequest): Task[Either[Status, MyResponse]]
}
### akka-http
```groovy
compile 'com.avast.grpc:grpc-json-bridge-akkahttp_2.12:x.x.x'
```
```scala
libraryDependencies += "com.avast.grpc" %% "grpc-json-bridge-akkahttp" % "x.x.x"
```

val service = new MyApi {
override def get(request: MyRequest): Task[Either[Status, MyResponse]] = Task {
Right {
MyResponse {
Map(
"name" -> 42
)
}
}
}
}.mappedToService[TestApiServiceImplBase]() // cactus mapping
```scala
import com.avast.grpc.jsonbridge.GrpcJsonBridge
import com.avast.grpc.jsonbridge.akkahttp.{AkkaHttp, Configuration}
import akka.http.scaladsl.server.Route

val bridge = service.createGrpcJsonBridge[Task, TestApiServiceFutureStub]()
val bridge: GrpcJsonBridge[Task] = ???
val route: Route = AkkaHttp(Configuration.Default)(bridge)
```

### Calling the bridged service

You can use e.g. cURL command to call the `Get` method
### Calling the bridged service
List all available methods:
```
curl -X POST -H "Content-Type: application/json" --data " { \"names\": [\"abc\",\"def\"] } " http://localhost:9999/com.avast.grpc.jsonbridge.test.TestApiServiceGrpc/Get
> curl -X GET http://localhost:9999/
com.avast.grpc.jsonbridge.test.TestService/Add
```
or get info about exposed service:
List all methods from particular service:
```
curl -X GET http://localhost:9999/com.avast.grpc.jsonbridge.test.TestApiServiceGrpc
> curl -X GET http://localhost:9999/com.avast.grpc.jsonbridge.test.TestService
com.avast.grpc.jsonbridge.test.TestService/Add
```
or list available services:

Call a method (please note that `POST` and `application/json` must be always specified):
```
curl -X GET http://localhost:9999/
> curl -X POST -H "Content-Type: application/json" --data ""{\"a\":1, \"b\": 2 }"" http://localhost:9999/com.avast.grpc.jsonbridge.test.TestService/Add
{"sum":3}
```
33 changes: 0 additions & 33 deletions akka-http/README.md

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
package com.avast.grpc.jsonbridge.akkahttp

import cats.effect.implicits._
import akka.http.scaladsl.model._
import akka.http.scaladsl.model.headers.`Content-Type`
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.{PathMatcher, Route}
import cats.data.NonEmptyList
import cats.effect.Effect
import com.avast.grpc.jsonbridge.GrpcJsonBridge
import com.avast.grpc.jsonbridge.GrpcJsonBridge.GrpcHeader
import io.grpc.BindableService
import com.avast.grpc.jsonbridge.GrpcJsonBridge.GrpcMethodName
import io.grpc.Status.Code
import monix.eval.Task
import monix.execution.Scheduler

import scala.concurrent.ExecutionContext
import scala.language.higherKinds
Expand All @@ -24,11 +22,7 @@ object AkkaHttp {
ContentType.WithMissingCharset(MediaType.applicationWithOpenCharset("json"))
}

def apply[F[_]: Effect](configuration: Configuration)(bridges: GrpcJsonBridge[F, _ <: BindableService]*)(
implicit ec: ExecutionContext): Route = {
implicit val sch: Scheduler = Scheduler(ec)

val bridgesMap = bridges.map(s => (s.serviceName, s): (String, GrpcJsonBridge[F, _])).toMap
def apply[F[_]: Effect](configuration: Configuration)(bridge: GrpcJsonBridge[F])(implicit ec: ExecutionContext): Route = {

val pathPattern = configuration.pathPrefix
.map {
Expand All @@ -47,25 +41,19 @@ object AkkaHttp {
extractRequest { req =>
req.header[`Content-Type`] match {
case Some(`JsonContentType`) =>
bridgesMap.get(serviceName) match {
case Some(service) =>
entity(as[String]) { json =>
val methodCall =
Task.fromEffect {
service.invokeGrpcMethod(methodName, json, mapHeaders(req.headers))
}.runToFuture

onComplete(methodCall) {
case Success(Right(r)) =>
respondWithHeader(JsonContentType) {
complete(r)
}
case Success(Left(status)) => complete(mapStatus(status))
case Failure(NonFatal(_)) => complete(StatusCodes.InternalServerError)
entity(as[String]) { json =>
val methodCall = bridge
.invoke(GrpcMethodName(serviceName, methodName), json, mapHeaders(req.headers))
.toIO
.unsafeToFuture()
onComplete(methodCall) {
case Success(Right(r)) =>
respondWithHeader(JsonContentType) {
complete(r)
}
}

case None => complete(StatusCodes.NotFound, s"Service '$serviceName' not found")
case Success(Left(status)) => complete(mapStatus(status))
case Failure(NonFatal(_)) => complete(StatusCodes.InternalServerError)
}
}

case _ =>
Expand All @@ -75,25 +63,21 @@ object AkkaHttp {
}
} ~ get {
path(Segment) { serviceName =>
bridgesMap.get(serviceName) match {
case Some(service) =>
complete(service.methodsNames.mkString("\n"))

case None => complete(StatusCodes.NotFound, s"Service '$serviceName' not found")
NonEmptyList.fromList(bridge.methodsNames.filter(_.service == serviceName).toList) match {
case None =>
complete(StatusCodes.NotFound, s"Service '$serviceName' not found")
case Some(methods) =>
complete(methods.map(_.fullName).toList.mkString("\n"))
}
}
} ~ get {
path(PathEnd) {
complete(bridgesMap.values.flatMap(s => s.methodsNames).mkString("\n"))
complete(bridge.methodsNames.map(_.fullName).mkString("\n"))
}
}
}

private def mapHeaders(headers: Seq[HttpHeader]): Seq[GrpcHeader] = {
headers.map { h =>
GrpcHeader(h.name(), h.value())
}
}
private def mapHeaders(headers: Seq[HttpHeader]): Map[String, String] = headers.toList.map(h => (h.name(), h.value())).toMap

private def mapStatus(s: io.grpc.Status): StatusCode = s.getCode match {
case Code.NOT_FOUND => StatusCodes.NotFound
Expand Down
16 changes: 16 additions & 0 deletions akka-http/src/test/protobuf/TestServices.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
syntax = "proto3";

package com.avast.grpc.jsonbridge.test;

service TestService {
rpc Add (AddParams) returns (AddResponse) {}
}

message AddParams {
int32 a = 1;
int32 b = 2;
}

message AddResponse {
int32 sum = 1;
}
29 changes: 0 additions & 29 deletions akka-http/src/test/protobuf/test_api.proto

This file was deleted.

Loading

0 comments on commit 1c61764

Please sign in to comment.