Skip to content

Commit

Permalink
Update scala & libs, fix test & compilation
Browse files Browse the repository at this point in the history
  • Loading branch information
igor-vovk committed Nov 16, 2024
1 parent e092507 commit aa069d2
Show file tree
Hide file tree
Showing 18 changed files with 252 additions and 224 deletions.
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

0 comments on commit aa069d2

Please sign in to comment.