Skip to content

Update Scala & libs, fix tests & compilation #690

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,6 @@ gradle-app.setting
!gradle-wrapper.jar

**/generated-sources/**
**/test-results/**
**/test-results/**

.bsp
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ It provides an implementation-agnostic module for mapping to your favorite HTTP

[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 _tagless final_ (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`).

## Usage

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
package com.avast.grpc.jsonbridge.akkahttp

import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport
import akka.http.scaladsl.model.StatusCodes.ClientError
import akka.http.scaladsl.marshalling.{Marshaller, ToEntityMarshaller, ToResponseMarshallable}
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 cats.effect.implicits._
import cats.effect.Sync
import cats.implicits._
import com.avast.grpc.jsonbridge.GrpcJsonBridge.GrpcMethodName
import com.avast.grpc.jsonbridge.{BridgeError, BridgeErrorResponse, GrpcJsonBridge}
import com.typesafe.scalalogging.LazyLogging
Expand All @@ -22,11 +22,10 @@ object AkkaHttp extends SprayJsonSupport with DefaultJsonProtocol with LazyLoggi

private implicit val grpcStatusJsonFormat: RootJsonFormat[BridgeErrorResponse] = jsonFormat3(BridgeErrorResponse.apply)

private[akkahttp] final val JsonContentType: `Content-Type` = `Content-Type` {
ContentType.WithMissingCharset(MediaType.applicationWithOpenCharset("json"))
}
private val jsonStringMarshaller: ToEntityMarshaller[String] =
Marshaller.stringMarshaller(MediaTypes.`application/json`)

def apply[F[_]: Effect](configuration: Configuration)(bridge: GrpcJsonBridge[F]): Route = {
def apply[F[_]: Sync: LiftToFuture](configuration: Configuration)(bridge: GrpcJsonBridge[F]): Route = {

val pathPattern = configuration.pathPrefix
.map { case NonEmptyList(head, tail) =>
Expand All @@ -44,71 +43,61 @@ object AkkaHttp extends SprayJsonSupport with DefaultJsonProtocol with LazyLoggi
post {
path(pathPattern) { (serviceName, methodName) =>
extractRequest { request =>
val headers = request.headers
request.header[`Content-Type`] match {
case Some(`JsonContentType`) =>
case Some(ct) if ct.contentType.mediaType == MediaTypes.`application/json` =>
entity(as[String]) { body =>
val methodNameString = GrpcMethodName(serviceName, methodName)
val headersString = mapHeaders(headers)
val methodCall = bridge.invoke(methodNameString, body, headersString).toIO.unsafeToFuture()
val headersString = mapHeaders(request.headers)
val methodCall = LiftToFuture[F].liftF {
bridge
.invoke(methodNameString, body, headersString)
.flatMap(Sync[F].fromEither)
}

onComplete(methodCall) {
case Success(result) =>
result match {
case Right(resp) =>
logger.trace("Request successful: {}", resp.substring(0, 100))
respondWithHeader(JsonContentType) {
complete(resp)
}
case Left(er) =>
er match {
case BridgeError.GrpcMethodNotFound =>
val message = s"Method '${methodNameString.fullName}' not found"
logger.debug(message)
respondWithHeader(JsonContentType) {
complete(StatusCodes.NotFound, BridgeErrorResponse.fromMessage(message))
}
case er: BridgeError.Json =>
val message = "Wrong JSON"
logger.debug(message, er.t)
respondWithHeader(JsonContentType) {
complete(StatusCodes.BadRequest, BridgeErrorResponse.fromException(message, er.t))
}
case er: BridgeError.Grpc =>
val message = "gRPC error" + Option(er.s.getDescription).map(": " + _).getOrElse("")
logger.trace(message, er.s.getCause)
val (s, body) = mapStatus(er.s)
respondWithHeader(JsonContentType) {
complete(s, body)
}
case er: BridgeError.Unknown =>
val message = "Unknown error"
logger.warn(message, er.t)
respondWithHeader(JsonContentType) {
complete(StatusCodes.InternalServerError, BridgeErrorResponse.fromException(message, er.t))
}
}
}
case Failure(NonFatal(er)) =>
case Success(resp) =>
logger.trace("Request successful: {}", resp.substring(0, 100))

complete(ToResponseMarshallable(resp)(jsonStringMarshaller))
case Failure(BridgeError.GrpcMethodNotFound) =>
val message = s"Method '${methodNameString.fullName}' not found"
logger.debug(message)

complete(StatusCodes.NotFound, BridgeErrorResponse.fromMessage(message))
case Failure(er: BridgeError.Json) =>
val message = "Wrong JSON"
logger.debug(message, er.t)

complete(StatusCodes.BadRequest, BridgeErrorResponse.fromException(message, er.t))
case Failure(er: BridgeError.Grpc) =>
val message = "gRPC error" + Option(er.s.getDescription).map(": " + _).getOrElse("")
logger.trace(message, er.s.getCause)
val (s, body) = mapStatus(er.s)

complete(s, body)
case Failure(er: BridgeError.Unknown) =>
val message = "Unknown error"
logger.warn(message, er.t)

complete(StatusCodes.InternalServerError, BridgeErrorResponse.fromException(message, er.t))
case Failure(NonFatal(ex)) =>
val message = "Unknown exception"
logger.debug(message, er)
respondWithHeader(JsonContentType) {
complete(StatusCodes.InternalServerError, BridgeErrorResponse.fromException(message, er))
}
logger.debug(message, ex)

complete(StatusCodes.InternalServerError, BridgeErrorResponse.fromException(message, ex))
case Failure(e) => throw e // scalafix:ok
}
}
case Some(c) =>
val message = s"Content-Type must be '$JsonContentType', it is '$c'"
val message = s"Content-Type must be 'application/json', it is '$c'"
logger.debug(message)
respondWithHeader(JsonContentType) {
complete(StatusCodes.BadRequest, BridgeErrorResponse.fromMessage(message))
}

complete(StatusCodes.BadRequest, BridgeErrorResponse.fromMessage(message))
case None =>
val message = s"Content-Type must be '$JsonContentType'"
val message = "Content-Type must be 'application/json'"
logger.debug(message)
respondWithHeader(JsonContentType) {
complete(StatusCodes.BadRequest, BridgeErrorResponse.fromMessage(message))
}

complete(StatusCodes.BadRequest, BridgeErrorResponse.fromMessage(message))
}
}
}
Expand All @@ -118,9 +107,8 @@ object AkkaHttp extends SprayJsonSupport with DefaultJsonProtocol with LazyLoggi
case None =>
val message = s"Service '$serviceName' not found"
logger.debug(message)
respondWithHeader(JsonContentType) {
complete(StatusCodes.NotFound, BridgeErrorResponse.fromMessage(message))
}

complete(StatusCodes.NotFound, BridgeErrorResponse.fromMessage(message))
case Some(methods) =>
complete(methods.map(_.fullName).toList.mkString("\n"))
}
Expand All @@ -142,7 +130,7 @@ object AkkaHttp extends SprayJsonSupport with DefaultJsonProtocol with LazyLoggi
s.getCode match {
case Code.OK => (StatusCodes.OK, description)
case Code.CANCELLED =>
(ClientError(499)("Client Closed Request", "The operation was cancelled, typically by the caller."), description)
(StatusCodes.custom(499, "Client Closed Request", "The operation was cancelled, typically by the caller."), description)
case Code.UNKNOWN => (StatusCodes.InternalServerError, description)
case Code.INVALID_ARGUMENT => (StatusCodes.BadRequest, description)
case Code.DEADLINE_EXCEEDED => (StatusCodes.GatewayTimeout, description)
Expand All @@ -162,7 +150,7 @@ object AkkaHttp extends SprayJsonSupport with DefaultJsonProtocol with LazyLoggi
}
}

final case class Configuration private (pathPrefix: Option[NonEmptyList[String]])
final case class Configuration(pathPrefix: Option[NonEmptyList[String]])

object Configuration {
val Default: Configuration = Configuration(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.avast.grpc.jsonbridge.akkahttp

import cats.effect.IO
import cats.effect.unsafe.IORuntime

import scala.concurrent.Future

trait LiftToFuture[F[_]] {
def liftF[A](f: F[A]): Future[A]
}

object LiftToFuture {
def apply[F[_]](implicit f: LiftToFuture[F]): LiftToFuture[F] = f

implicit def liftToFutureForIO(implicit runtime: IORuntime): LiftToFuture[IO] = new LiftToFuture[IO] {
override def liftF[A](f: IO[A]): Future[A] = f.unsafeToFuture()
}
}
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
package com.avast.grpc.jsonbridge.akkahttp

import akka.http.scaladsl.model.HttpHeader.ParsingResult.Ok
import akka.http.scaladsl.model._
import akka.http.scaladsl.model.headers.`Content-Type`
import akka.http.scaladsl.model.headers.{RawHeader, `Content-Type`}
import akka.http.scaladsl.testkit.ScalatestRouteTest
import cats.data.NonEmptyList
import cats.effect.IO
import cats.effect.unsafe.implicits.global
import cats.implicits._
import com.avast.grpc.jsonbridge._
import io.grpc.ServerServiceDefinition
import org.scalatest.funsuite.AnyFunSuite

import scala.concurrent.ExecutionContext
import scala.concurrent.ExecutionContext.Implicits.{global => ec}
import scala.util.Random

class AkkaHttpTest extends AnyFunSuite with ScalatestRouteTest {

val ec: ExecutionContext = implicitly[ExecutionContext]
def bridge(ssd: ServerServiceDefinition): GrpcJsonBridge[IO] =
ReflectionGrpcJsonBridge
.createFromServices[IO](ec)(ssd)
Expand All @@ -25,19 +25,22 @@ class AkkaHttpTest extends AnyFunSuite with ScalatestRouteTest {

test("basic") {
val route = AkkaHttp[IO](Configuration.Default)(bridge(TestServiceImpl.bindService()))
Post("/com.avast.grpc.jsonbridge.test.TestService/Add", """ { "a": 1, "b": 2} """)
.withHeaders(AkkaHttp.JsonContentType) ~> route ~> check {
val entity = HttpEntity(ContentTypes.`application/json`, """ { "a": 1, "b": 2} """)
Post("/com.avast.grpc.jsonbridge.test.TestService/Add", entity) ~> route ~> check {
assertResult(StatusCodes.OK)(status)
assertResult("""{"sum":3}""")(responseAs[String])
assertResult(Seq(`Content-Type`(ContentType.WithMissingCharset(MediaType.applicationWithOpenCharset("json")))))(headers)
assertResult(MediaTypes.`application/json`.some)(
header[`Content-Type`].map(_.contentType.mediaType)
)
}
}

test("with path prefix") {
val configuration = Configuration.Default.copy(pathPrefix = Some(NonEmptyList.of("abc", "def")))
val route = AkkaHttp[IO](configuration)(bridge(TestServiceImpl.bindService()))
Post("/abc/def/com.avast.grpc.jsonbridge.test.TestService/Add", """ { "a": 1, "b": 2} """)
.withHeaders(AkkaHttp.JsonContentType) ~> route ~> check {

val entity = HttpEntity(ContentTypes.`application/json`, """ { "a": 1, "b": 2} """)
Post("/abc/def/com.avast.grpc.jsonbridge.test.TestService/Add", entity) ~> route ~> check {
assertResult(StatusCodes.OK)(status)
assertResult("""{"sum":3}""")(responseAs[String])
}
Expand All @@ -46,20 +49,21 @@ class AkkaHttpTest extends AnyFunSuite with ScalatestRouteTest {
test("bad request after wrong request") {
val route = AkkaHttp[IO](Configuration.Default)(bridge(TestServiceImpl.bindService()))
// empty body
Post("/com.avast.grpc.jsonbridge.test.TestService/Add", "")
.withHeaders(AkkaHttp.JsonContentType) ~> route ~> check {
val entity = HttpEntity(ContentTypes.`application/json`, "")
Post("/com.avast.grpc.jsonbridge.test.TestService/Add", entity) ~> route ~> check {
assertResult(StatusCodes.BadRequest)(status)
}
// no Content-Type header
Post("/com.avast.grpc.jsonbridge.test.TestService/Add", """ { "a": 1, "b": 2} """) ~> route ~> check {
val entity2 = HttpEntity(""" { "a": 1, "b": 2} """)
Post("/com.avast.grpc.jsonbridge.test.TestService/Add", entity2) ~> route ~> check {
assertResult(StatusCodes.BadRequest)(status)
}
}

test("propagates user-specified status") {
val route = AkkaHttp(Configuration.Default)(bridge(PermissionDeniedTestServiceImpl.bindService()))
Post(s"/com.avast.grpc.jsonbridge.test.TestService/Add", """ { "a": 1, "b": 2} """)
.withHeaders(AkkaHttp.JsonContentType) ~> route ~> check {
val entity = HttpEntity(ContentTypes.`application/json`, """ { "a": 1, "b": 2} """)
Post(s"/com.avast.grpc.jsonbridge.test.TestService/Add", entity) ~> route ~> check {
assertResult(status)(StatusCodes.Forbidden)
}
}
Expand All @@ -83,12 +87,15 @@ class AkkaHttpTest extends AnyFunSuite with ScalatestRouteTest {
test("passes headers") {
val headerValue = Random.alphanumeric.take(10).mkString("")
val route = AkkaHttp[IO](Configuration.Default)(bridge(TestServiceImpl.withInterceptor))
val Ok(customHeaderToBeSent, _) = HttpHeader.parse(TestServiceImpl.HeaderName, headerValue)
Post("/com.avast.grpc.jsonbridge.test.TestService/Add", """ { "a": 1, "b": 2} """)
.withHeaders(AkkaHttp.JsonContentType, customHeaderToBeSent) ~> route ~> check {
val customHeaderToBeSent = RawHeader(TestServiceImpl.HeaderName, headerValue)
val entity = HttpEntity(ContentTypes.`application/json`, """ { "a": 1, "b": 2} """)
Post("/com.avast.grpc.jsonbridge.test.TestService/Add", entity)
.withHeaders(customHeaderToBeSent) ~> route ~> check {
assertResult(StatusCodes.OK)(status)
assertResult("""{"sum":3}""")(responseAs[String])
assertResult(Seq(`Content-Type`(ContentType.WithMissingCharset(MediaType.applicationWithOpenCharset("json")))))(headers)
assertResult(MediaTypes.`application/json`.some)(
header[`Content-Type`].map(_.contentType.mediaType)
)
}
}
}
Loading