├── .github ├── release-drafter.yml ├── renovate.json └── workflows │ ├── ci.yml │ ├── release-drafter.yml │ └── release.yml ├── .gitignore ├── .mergify.yml ├── .scalafix.conf ├── .scalafmt.conf ├── README.md ├── akka-http └── src │ ├── main │ └── scala │ │ └── com │ │ └── avast │ │ └── grpc │ │ └── jsonbridge │ │ └── akkahttp │ │ └── AkkaHttp.scala │ └── test │ ├── protobuf │ └── TestServices.proto │ └── scala │ └── com │ └── avast │ └── grpc │ └── jsonbridge │ └── akkahttp │ ├── AkkaHttpTest.scala │ └── TestServiceImpl.scala ├── build.sbt ├── core-scalapb └── src │ ├── main │ └── scala │ │ └── com │ │ └── avast │ │ └── grpc │ │ └── jsonbridge │ │ └── scalapb │ │ ├── ScalaPBReflectionGrpcJsonBridge.scala │ │ └── ScalaPBServiceHandlers.scala │ └── test │ ├── protobuf │ ├── TestServices.proto │ └── com │ │ └── avast │ │ └── grpc │ │ └── jsonbridge │ │ └── scalapbtest │ │ └── TestServices2.proto │ └── scala │ └── com │ └── avast │ └── grpc │ └── jsonbridge │ └── scalapbtest │ ├── ScalaPBReflectionGrpcJsonBridgeTest.scala │ ├── ScalaPBReflectionGrpcJsonBridgeTest2.scala │ ├── TestServiceImpl.scala │ └── TestServiceImpl2.scala ├── core └── src │ ├── main │ ├── java │ │ └── com │ │ │ └── avast │ │ │ └── grpc │ │ │ └── jsonbridge │ │ │ └── JavaGenericHelper.java │ └── scala │ │ └── com │ │ └── avast │ │ └── grpc │ │ └── jsonbridge │ │ ├── BridgeError.scala │ │ ├── BridgeErrorResponse.scala │ │ ├── GrpcJsonBridge.scala │ │ ├── JavaServiceHandlers.scala │ │ └── ReflectionGrpcJsonBridge.scala │ └── test │ ├── protobuf │ ├── TestServices.proto │ └── com │ │ └── avast │ │ └── grpc │ │ └── jsonbridge │ │ └── scalapbtest │ │ └── TestServices2.proto │ └── scala │ └── com │ └── avast │ └── grpc │ └── jsonbridge │ ├── ReflectionGrpcJsonBridgeTest.scala │ ├── ReflectionGrpcJsonBridgeTest2.scala │ ├── TestServiceImpl.scala │ └── TestServiceImpl2.scala ├── http4s └── src │ ├── main │ └── scala │ │ └── com │ │ └── avast │ │ └── grpc │ │ └── jsonbridge │ │ └── http4s │ │ └── Http4s.scala │ └── test │ ├── protobuf │ └── TestServices.proto │ └── scala │ └── com │ └── avast │ └── grpc │ └── jsonbridge │ └── http4s │ ├── Http4sTest.scala │ └── TestServiceImpl.scala ├── project ├── build.properties ├── plugins.sbt └── protoc.sbt └── sbt /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | template: | 4 | # What's Changed 5 | $CHANGES 6 | categories: 7 | - title: 'Breaking' 8 | label: 'type: breaking' 9 | - title: 'New' 10 | label: 'type: feature' 11 | - title: 'Bug Fixes' 12 | label: 'type: bug' 13 | - title: 'Maintenance' 14 | label: 'type: maintenance' 15 | - title: 'Documentation' 16 | label: 'type: docs' 17 | - title: 'Dependency Updates' 18 | label: 'type: dependencies' 19 | 20 | version-resolver: 21 | major: 22 | labels: 23 | - 'type: breaking' 24 | minor: 25 | labels: 26 | - 'type: feature' 27 | patch: 28 | labels: 29 | - 'type: bug' 30 | - 'type: maintenance' 31 | - 'type: docs' 32 | - 'type: dependencies' 33 | - 'type: security' 34 | 35 | exclude-labels: 36 | - 'skip-changelog' 37 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "automerge": true, 3 | "labels": ["type: dependencies"], 4 | "packageRules": [ 5 | { 6 | "matchManagers": [ 7 | "sbt" 8 | ], 9 | "enabled": false 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | jobs: 5 | ci: 6 | runs-on: ubuntu-24.04 7 | strategy: 8 | fail-fast: false 9 | steps: 10 | - uses: actions/checkout@v4 11 | with: 12 | fetch-depth: 100 13 | - name: Fetch tags 14 | run: git fetch --depth=100 origin +refs/tags/*:refs/tags/* 15 | - uses: actions/setup-java@v4 16 | with: 17 | distribution: temurin 18 | java-version: 11 19 | cache: sbt 20 | - name: sbt ci ${{ github.ref }} 21 | run: ./sbt ci 22 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | update_release_draft: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: release-drafter/release-drafter@v6 11 | env: 12 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: [ master ] 5 | release: 6 | types: [ published ] 7 | jobs: 8 | release: 9 | runs-on: ubuntu-24.04 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 100 14 | - name: Fetch tags 15 | run: git fetch --depth=100 origin +refs/tags/*:refs/tags/* 16 | - uses: actions/setup-java@v4 17 | with: 18 | distribution: temurin 19 | java-version: 11 20 | cache: sbt 21 | - name: sbt ci-release ${{ github.ref }} 22 | run: ./sbt ci-release 23 | env: 24 | PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} 25 | PGP_SECRET: ${{ secrets.PGP_SECRET }} 26 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 27 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Thumbnails created by Windows 2 | Thumbs.db 3 | 4 | # Git merging files 5 | *.orig 6 | 7 | # Visual Studio 8 | *.[oO]bj 9 | *.pdb 10 | *.user 11 | *.aps 12 | *.exe 13 | *.pch 14 | *.vspscc 15 | *_i.c 16 | *_p.c 17 | *.ncb 18 | *.suo 19 | *.sdf 20 | *.tlb 21 | *.tlh 22 | *.bak 23 | *.[cC]ache 24 | *.ilk 25 | *.log 26 | [Bb]in 27 | [Dd]ebug*/ 28 | *.lib 29 | *.sbr 30 | [Oo]bj/ 31 | [Rr]elease*/ 32 | _ReSharper*/ 33 | *.resharperoptions 34 | *.Resharper 35 | App_Data 36 | *.Publish.xml 37 | [Tt]est[Rr]esult* 38 | *.opensdf 39 | packages/ 40 | 41 | # IntelliJ IDEA 42 | **/.idea/workspace.xml 43 | **/.idea/tasks.xml 44 | **/.idea/dataSources.ids 45 | **/.idea/dataSources.xml 46 | **/.idea/sqlDataSources.xml 47 | **/.idea/dynamic.xml 48 | **/.idea/uiDesigner.xml 49 | **/.idea/dictionaries/ 50 | **/.idea/** 51 | .idea/workspace.xml 52 | *.ipr 53 | *.iws 54 | *.iml 55 | out/ 56 | # generated by JIRA plugin 57 | atlassian-ide-plugin.xml 58 | 59 | # Maven 60 | target/ 61 | pom.xml.tag 62 | pom.xml.releaseBackup 63 | pom.xml.versionsBackup 64 | pom.xml.next 65 | release.properties 66 | 67 | # Java Flight Recorder 68 | *.jfr 69 | 70 | # Custom TMP directory 71 | CDN/tools/tmp 72 | 73 | #Scala 74 | *.class 75 | *.log 76 | 77 | # sbt specific 78 | .cache 79 | .history 80 | .lib/ 81 | dist/* 82 | target/ 83 | lib_managed/ 84 | src_managed/ 85 | project/boot/ 86 | project/plugins/project/ 87 | 88 | # Scala-IDE specific 89 | .scala_dependencies 90 | .worksheet 91 | 92 | ## Gradle 93 | .gradle 94 | build/ 95 | 96 | # Ignore Gradle GUI config 97 | gradle-app.setting 98 | 99 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 100 | !gradle-wrapper.jar 101 | 102 | **/generated-sources/** 103 | **/test-results/** -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: label scala-steward's PRs 3 | conditions: 4 | - author=scala-steward 5 | actions: 6 | label: 7 | add: ["type: dependencies"] 8 | - name: merge Scala Steward's PRs 9 | conditions: 10 | - base=master 11 | - author=scala-steward 12 | - status-success=ci 13 | actions: 14 | merge: 15 | method: squash 16 | - name: automatic merge for master when CI passes 17 | conditions: 18 | - base=master 19 | - label=mergify-merge 20 | - "#approved-reviews-by>=1" 21 | - status-success=ci 22 | actions: 23 | merge: 24 | method: squash 25 | delete_head_branch: 26 | force: true 27 | -------------------------------------------------------------------------------- /.scalafix.conf: -------------------------------------------------------------------------------- 1 | rules = [ 2 | OrganizeImports 3 | RemoveUnused 4 | NoAutoTupling 5 | LeakingImplicitClassVal 6 | NoValInForComprehension 7 | ProcedureSyntax 8 | DisableSyntax 9 | ExplicitResultTypes 10 | Disable 11 | MissingFinal 12 | ] 13 | 14 | OrganizeImports { 15 | expandRelative = true 16 | groupedImports = Merge 17 | # IntelliJ IDEA's order so that they don't fight each other 18 | groups = [ 19 | "java." 20 | "*" 21 | "scala." 22 | ] 23 | } 24 | 25 | RemoveUnused { 26 | imports = false // handled by OrganizeImports 27 | } 28 | 29 | Disable { 30 | ifSynthetic = [ 31 | "java/io/Serializable" 32 | "scala/Any" 33 | ] 34 | } 35 | 36 | DisableSyntax { 37 | // noAsInstanceOf = true Used extensively in the codebase. TODO: consider reducing and/or local suppression 38 | noDefaultArgs = true 39 | noFinalize = true 40 | noImplicitConversion = true 41 | noImplicitObject = true 42 | noIsInstanceOf = true 43 | // noNulls = true Used extensively in the codebase. TODO: consider reducing and/or local suppression 44 | noReturns = true 45 | noSemicolons = true 46 | noTabs = true 47 | noThrows = true 48 | // noUniversalEquality = true Used extensively in the codebase. TODO: consider reducing and/or local suppression 49 | noValInAbstract = true 50 | noVars = true 51 | noWhileLoops = true 52 | noXml = true 53 | } 54 | 55 | ExplicitResultTypes { 56 | unsafeShortenNames = true 57 | 58 | fatalWarnings = true 59 | 60 | # these apply to non-implicits 61 | memberKind = [Def, Val] 62 | memberVisibility = [Public, Protected] 63 | 64 | # turn to the max... 65 | skipSimpleDefinitions = false 66 | skipLocalImplicits = false 67 | } 68 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = "3.9.7" 2 | runner.dialect = scala213 3 | maxColumn = 140 4 | assumeStandardLibraryStripMargin = true 5 | rewrite.rules = [RedundantParens, PreferCurlyFors] 6 | align.tokens = [] 7 | lineEndings = preserve 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gRPC JSON Bridge 2 | 3 | [![Build Status][Badge-GitHubActions]][Link-GitHubActions] 4 | [![Release Artifacts][Badge-SonatypeReleases]][Link-SonatypeReleases] 5 | [![Snapshot Artifacts][Badge-SonatypeSnapshots]][Link-SonatypeSnapshots] 6 | 7 | This library allows to make a JSON encoded HTTP request to a gRPC service that is implemented in Java or Scala (using ScalaPB). 8 | 9 | It provides an implementation-agnostic module for mapping to your favorite HTTP server (`core`, `core-scalapb`) as well as few implementations for direct usage in some well-known HTTP servers. 10 | 11 | [Standard GPB <-> JSON mapping](https://developers.google.com/protocol-buffers/docs/proto3#json) is used. 12 | 13 | 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`). 14 | 15 | ## Usage 16 | 17 | ```groovy 18 | compile 'com.avast.grpc:grpc-json-bridge-core_2.12:x.x.x' 19 | ``` 20 | ```scala 21 | libraryDependencies += "com.avast.grpc" %% "grpc-json-bridge-core" % "x.x.x" 22 | ``` 23 | 24 | ```proto 25 | syntax = "proto3"; 26 | package com.avast.grpc.jsonbridge.test; 27 | 28 | service TestService { 29 | rpc Add (AddParams) returns (AddResponse) {} 30 | } 31 | 32 | message AddParams { 33 | int32 a = 1; 34 | int32 b = 2; 35 | } 36 | 37 | message AddResponse { 38 | int32 sum = 1; 39 | } 40 | ``` 41 | ```scala 42 | import com.avast.grpc.jsonbridge.ReflectionGrpcJsonBridge 43 | 44 | // for whole server 45 | val grpcServer: io.grpc.Server = ??? 46 | val bridge = ReflectionGrpcJsonBridge.createFromServer[Task](grpcServer) 47 | 48 | // or for selected services 49 | val s1: ServerServiceDefinition = ??? 50 | val s2: ServerServiceDefinition = ??? 51 | val anotherBridge = ReflectionGrpcJsonBridge.createFromServices[Task](s1, s2) 52 | 53 | // call a method manually, with a header specified 54 | val jsonResponse = bridge.invoke("com.avast.grpc.jsonbridge.test.TestService/Add", """ { "a": 1, "b": 2} """, Map("My-Header" -> "value")) 55 | ``` 56 | 57 | This usage supposes that the project uses standard gRPC Java class generation, using [protoc-gen-grpc-java](https://github.com/grpc/grpc-java/tree/master/compiler). 58 | 59 | If the project uses [ScalaPB](https://scalapb.github.io/grpc.html) to generate the classes then following dependency should used instead: 60 | ```groovy 61 | compile 'com.avast.grpc:grpc-json-bridge-core-scalapb_2.12:x.x.x' 62 | ``` 63 | ```scala 64 | libraryDependencies += "com.avast.grpc" %% "grpc-json-bridge-core-scalapb" % "x.x.x" 65 | ``` 66 | And `ScalaPBReflectionGrpcJsonBridge` class must be used to create a new bridge. 67 | 68 | ### http4s 69 | ```groovy 70 | compile 'com.avast.grpc:grpc-json-bridge-http4s_2.12:x.x.x' 71 | ``` 72 | ```scala 73 | libraryDependencies += "com.avast.grpc" %% "grpc-json-bridge-http4s" % "x.x.x" 74 | ``` 75 | ```scala 76 | import com.avast.grpc.jsonbridge.GrpcJsonBridge 77 | import com.avast.grpc.jsonbridge.http4s.{Configuration, Http4s} 78 | import org.http4s.HttpService 79 | 80 | val bridge: GrpcJsonBridge[Task] = ??? 81 | val service: HttpService[Task] = Http4s(Configuration.Default)(bridge) 82 | ``` 83 | 84 | ### akka-http 85 | ```groovy 86 | compile 'com.avast.grpc:grpc-json-bridge-akkahttp_2.12:x.x.x' 87 | ``` 88 | ```scala 89 | libraryDependencies += "com.avast.grpc" %% "grpc-json-bridge-akkahttp" % "x.x.x" 90 | ``` 91 | 92 | ```scala 93 | import com.avast.grpc.jsonbridge.GrpcJsonBridge 94 | import com.avast.grpc.jsonbridge.akkahttp.{AkkaHttp, Configuration} 95 | import akka.http.scaladsl.server.Route 96 | 97 | val bridge: GrpcJsonBridge[Task] = ??? 98 | val route: Route = AkkaHttp(Configuration.Default)(bridge) 99 | ``` 100 | 101 | 102 | ### Calling the bridged service 103 | List all available methods: 104 | ``` 105 | > curl -X GET http://localhost:9999/ 106 | 107 | com.avast.grpc.jsonbridge.test.TestService/Add 108 | ``` 109 | List all methods from particular service: 110 | ``` 111 | > curl -X GET http://localhost:9999/com.avast.grpc.jsonbridge.test.TestService 112 | 113 | com.avast.grpc.jsonbridge.test.TestService/Add 114 | ``` 115 | 116 | Call a method (please note that `POST` and `application/json` must be always specified): 117 | ``` 118 | > curl -X POST -H "Content-Type: application/json" --data ""{\"a\":1, \"b\": 2 }"" http://localhost:9999/com.avast.grpc.jsonbridge.test.TestService/Add 119 | 120 | {"sum":3} 121 | ``` 122 | 123 | [Link-GitHubActions]: https://github.com/avast/grpc-json-bridge/actions?query=workflow%3ARelease+branch%3Amaster "GitHub Actions link" 124 | [Badge-GitHubActions]: https://github.com/avast/grpc-json-bridge/workflows/Release/badge.svg?branch=master "GitHub Actions badge" 125 | 126 | [Link-SonatypeReleases]: https://search.maven.org/artifact/com.avast.grpc/grpc-json-bridge-core_2.13 "Sonatype Releases" 127 | [Link-SonatypeSnapshots]: https://oss.sonatype.org/content/repositories/snapshots/com/avast/grpc/grpc-json-bridge-core_2.13/ "Sonatype Snapshots" 128 | 129 | [Badge-SonatypeReleases]: https://img.shields.io/nexus/r/https/oss.sonatype.org/com.avast.grpc/grpc-json-bridge-core_2.13.svg "Sonatype Releases" 130 | [Badge-SonatypeSnapshots]: https://img.shields.io/nexus/s/https/oss.sonatype.org/com.avast.grpc/grpc-json-bridge-core_2.13.svg "Sonatype Snapshots" 131 | -------------------------------------------------------------------------------- /akka-http/src/main/scala/com/avast/grpc/jsonbridge/akkahttp/AkkaHttp.scala: -------------------------------------------------------------------------------- 1 | package com.avast.grpc.jsonbridge.akkahttp 2 | 3 | import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport 4 | import akka.http.scaladsl.model.StatusCodes.ClientError 5 | import akka.http.scaladsl.model._ 6 | import akka.http.scaladsl.model.headers.`Content-Type` 7 | import akka.http.scaladsl.server.Directives._ 8 | import akka.http.scaladsl.server.{PathMatcher, Route} 9 | import cats.data.NonEmptyList 10 | import cats.effect.Effect 11 | import cats.effect.implicits._ 12 | import com.avast.grpc.jsonbridge.GrpcJsonBridge.GrpcMethodName 13 | import com.avast.grpc.jsonbridge.{BridgeError, BridgeErrorResponse, GrpcJsonBridge} 14 | import com.typesafe.scalalogging.LazyLogging 15 | import io.grpc.Status.Code 16 | import spray.json._ 17 | 18 | import scala.util.control.NonFatal 19 | import scala.util.{Failure, Success} 20 | 21 | object AkkaHttp extends SprayJsonSupport with DefaultJsonProtocol with LazyLogging { 22 | 23 | private implicit val grpcStatusJsonFormat: RootJsonFormat[BridgeErrorResponse] = jsonFormat3(BridgeErrorResponse.apply) 24 | 25 | private[akkahttp] final val JsonContentType: `Content-Type` = `Content-Type` { 26 | ContentType.WithMissingCharset(MediaType.applicationWithOpenCharset("json")) 27 | } 28 | 29 | def apply[F[_]: Effect](configuration: Configuration)(bridge: GrpcJsonBridge[F]): Route = { 30 | 31 | val pathPattern = configuration.pathPrefix 32 | .map { case NonEmptyList(head, tail) => 33 | val rest = if (tail.nonEmpty) { 34 | tail.foldLeft[PathMatcher[Unit]](Neutral)(_ / _) 35 | } else Neutral 36 | 37 | head ~ rest 38 | } 39 | .map(_ / Segment / Segment) 40 | .getOrElse(Segment / Segment) 41 | 42 | logger.info(s"Creating Akka HTTP service proxying gRPC services: ${bridge.servicesNames.mkString("[", ", ", "]")}") 43 | 44 | post { 45 | path(pathPattern) { (serviceName, methodName) => 46 | extractRequest { request => 47 | val headers = request.headers 48 | request.header[`Content-Type`] match { 49 | case Some(`JsonContentType`) => 50 | entity(as[String]) { body => 51 | val methodNameString = GrpcMethodName(serviceName, methodName) 52 | val headersString = mapHeaders(headers) 53 | val methodCall = bridge.invoke(methodNameString, body, headersString).toIO.unsafeToFuture() 54 | onComplete(methodCall) { 55 | case Success(result) => 56 | result match { 57 | case Right(resp) => 58 | logger.trace("Request successful: {}", resp.substring(0, 100)) 59 | respondWithHeader(JsonContentType) { 60 | complete(resp) 61 | } 62 | case Left(er) => 63 | er match { 64 | case BridgeError.GrpcMethodNotFound => 65 | val message = s"Method '${methodNameString.fullName}' not found" 66 | logger.debug(message) 67 | respondWithHeader(JsonContentType) { 68 | complete(StatusCodes.NotFound, BridgeErrorResponse.fromMessage(message)) 69 | } 70 | case er: BridgeError.Json => 71 | val message = "Wrong JSON" 72 | logger.debug(message, er.t) 73 | respondWithHeader(JsonContentType) { 74 | complete(StatusCodes.BadRequest, BridgeErrorResponse.fromException(message, er.t)) 75 | } 76 | case er: BridgeError.Grpc => 77 | val message = "gRPC error" + Option(er.s.getDescription).map(": " + _).getOrElse("") 78 | logger.trace(message, er.s.getCause) 79 | val (s, body) = mapStatus(er.s) 80 | respondWithHeader(JsonContentType) { 81 | complete(s, body) 82 | } 83 | case er: BridgeError.Unknown => 84 | val message = "Unknown error" 85 | logger.warn(message, er.t) 86 | respondWithHeader(JsonContentType) { 87 | complete(StatusCodes.InternalServerError, BridgeErrorResponse.fromException(message, er.t)) 88 | } 89 | } 90 | } 91 | case Failure(NonFatal(er)) => 92 | val message = "Unknown exception" 93 | logger.debug(message, er) 94 | respondWithHeader(JsonContentType) { 95 | complete(StatusCodes.InternalServerError, BridgeErrorResponse.fromException(message, er)) 96 | } 97 | case Failure(e) => throw e // scalafix:ok 98 | } 99 | } 100 | case Some(c) => 101 | val message = s"Content-Type must be '$JsonContentType', it is '$c'" 102 | logger.debug(message) 103 | respondWithHeader(JsonContentType) { 104 | complete(StatusCodes.BadRequest, BridgeErrorResponse.fromMessage(message)) 105 | } 106 | case None => 107 | val message = s"Content-Type must be '$JsonContentType'" 108 | logger.debug(message) 109 | respondWithHeader(JsonContentType) { 110 | complete(StatusCodes.BadRequest, BridgeErrorResponse.fromMessage(message)) 111 | } 112 | } 113 | } 114 | } 115 | } ~ get { 116 | path(Segment) { serviceName => 117 | NonEmptyList.fromList(bridge.methodsNames.filter(_.service == serviceName).toList) match { 118 | case None => 119 | val message = s"Service '$serviceName' not found" 120 | logger.debug(message) 121 | respondWithHeader(JsonContentType) { 122 | complete(StatusCodes.NotFound, BridgeErrorResponse.fromMessage(message)) 123 | } 124 | case Some(methods) => 125 | complete(methods.map(_.fullName).toList.mkString("\n")) 126 | } 127 | } 128 | } ~ get { 129 | path(PathEnd) { 130 | complete(bridge.methodsNames.map(_.fullName).mkString("\n")) 131 | } 132 | } 133 | } 134 | 135 | private def mapHeaders(headers: Seq[HttpHeader]): Map[String, String] = headers.toList.map(h => (h.name(), h.value())).toMap 136 | 137 | // https://github.com/grpc/grpc/blob/master/doc/statuscodes.md 138 | private def mapStatus(s: io.grpc.Status): (StatusCode, BridgeErrorResponse) = { 139 | 140 | val description = BridgeErrorResponse.fromGrpcStatus(s) 141 | 142 | s.getCode match { 143 | case Code.OK => (StatusCodes.OK, description) 144 | case Code.CANCELLED => 145 | (ClientError(499)("Client Closed Request", "The operation was cancelled, typically by the caller."), description) 146 | case Code.UNKNOWN => (StatusCodes.InternalServerError, description) 147 | case Code.INVALID_ARGUMENT => (StatusCodes.BadRequest, description) 148 | case Code.DEADLINE_EXCEEDED => (StatusCodes.GatewayTimeout, description) 149 | case Code.NOT_FOUND => (StatusCodes.NotFound, description) 150 | case Code.ALREADY_EXISTS => (StatusCodes.Conflict, description) 151 | case Code.PERMISSION_DENIED => (StatusCodes.Forbidden, description) 152 | case Code.RESOURCE_EXHAUSTED => (StatusCodes.TooManyRequests, description) 153 | case Code.FAILED_PRECONDITION => (StatusCodes.BadRequest, description) 154 | case Code.ABORTED => (StatusCodes.Conflict, description) 155 | case Code.OUT_OF_RANGE => (StatusCodes.BadRequest, description) 156 | case Code.UNIMPLEMENTED => (StatusCodes.NotImplemented, description) 157 | case Code.INTERNAL => (StatusCodes.InternalServerError, description) 158 | case Code.UNAVAILABLE => (StatusCodes.ServiceUnavailable, description) 159 | case Code.DATA_LOSS => (StatusCodes.InternalServerError, description) 160 | case Code.UNAUTHENTICATED => (StatusCodes.Unauthorized, description) 161 | } 162 | } 163 | } 164 | 165 | final case class Configuration private (pathPrefix: Option[NonEmptyList[String]]) 166 | 167 | object Configuration { 168 | val Default: Configuration = Configuration( 169 | pathPrefix = None 170 | ) 171 | } 172 | -------------------------------------------------------------------------------- /akka-http/src/test/protobuf/TestServices.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package com.avast.grpc.jsonbridge.test; 4 | 5 | service TestService { 6 | rpc Add (AddParams) returns (AddResponse) {} 7 | } 8 | 9 | message AddParams { 10 | int32 a = 1; 11 | int32 b = 2; 12 | } 13 | 14 | message AddResponse { 15 | int32 sum = 1; 16 | } 17 | -------------------------------------------------------------------------------- /akka-http/src/test/scala/com/avast/grpc/jsonbridge/akkahttp/AkkaHttpTest.scala: -------------------------------------------------------------------------------- 1 | package com.avast.grpc.jsonbridge.akkahttp 2 | 3 | import akka.http.scaladsl.model.HttpHeader.ParsingResult.Ok 4 | import akka.http.scaladsl.model._ 5 | import akka.http.scaladsl.model.headers.`Content-Type` 6 | import akka.http.scaladsl.testkit.ScalatestRouteTest 7 | import cats.data.NonEmptyList 8 | import cats.effect.IO 9 | import com.avast.grpc.jsonbridge._ 10 | import io.grpc.ServerServiceDefinition 11 | import org.scalatest.funsuite.AnyFunSuite 12 | 13 | import scala.concurrent.ExecutionContext 14 | import scala.util.Random 15 | 16 | class AkkaHttpTest extends AnyFunSuite with ScalatestRouteTest { 17 | 18 | val ec: ExecutionContext = implicitly[ExecutionContext] 19 | def bridge(ssd: ServerServiceDefinition): GrpcJsonBridge[IO] = 20 | ReflectionGrpcJsonBridge 21 | .createFromServices[IO](ec)(ssd) 22 | .allocated 23 | .unsafeRunSync() 24 | ._1 25 | 26 | test("basic") { 27 | val route = AkkaHttp[IO](Configuration.Default)(bridge(TestServiceImpl.bindService())) 28 | Post("/com.avast.grpc.jsonbridge.test.TestService/Add", """ { "a": 1, "b": 2} """) 29 | .withHeaders(AkkaHttp.JsonContentType) ~> route ~> check { 30 | assertResult(StatusCodes.OK)(status) 31 | assertResult("""{"sum":3}""")(responseAs[String]) 32 | assertResult(Seq(`Content-Type`(ContentType.WithMissingCharset(MediaType.applicationWithOpenCharset("json")))))(headers) 33 | } 34 | } 35 | 36 | test("with path prefix") { 37 | val configuration = Configuration.Default.copy(pathPrefix = Some(NonEmptyList.of("abc", "def"))) 38 | val route = AkkaHttp[IO](configuration)(bridge(TestServiceImpl.bindService())) 39 | Post("/abc/def/com.avast.grpc.jsonbridge.test.TestService/Add", """ { "a": 1, "b": 2} """) 40 | .withHeaders(AkkaHttp.JsonContentType) ~> route ~> check { 41 | assertResult(StatusCodes.OK)(status) 42 | assertResult("""{"sum":3}""")(responseAs[String]) 43 | } 44 | } 45 | 46 | test("bad request after wrong request") { 47 | val route = AkkaHttp[IO](Configuration.Default)(bridge(TestServiceImpl.bindService())) 48 | // empty body 49 | Post("/com.avast.grpc.jsonbridge.test.TestService/Add", "") 50 | .withHeaders(AkkaHttp.JsonContentType) ~> route ~> check { 51 | assertResult(StatusCodes.BadRequest)(status) 52 | } 53 | // no Content-Type header 54 | Post("/com.avast.grpc.jsonbridge.test.TestService/Add", """ { "a": 1, "b": 2} """) ~> route ~> check { 55 | assertResult(StatusCodes.BadRequest)(status) 56 | } 57 | } 58 | 59 | test("propagates user-specified status") { 60 | val route = AkkaHttp(Configuration.Default)(bridge(PermissionDeniedTestServiceImpl.bindService())) 61 | Post(s"/com.avast.grpc.jsonbridge.test.TestService/Add", """ { "a": 1, "b": 2} """) 62 | .withHeaders(AkkaHttp.JsonContentType) ~> route ~> check { 63 | assertResult(status)(StatusCodes.Forbidden) 64 | } 65 | } 66 | 67 | test("provides service description") { 68 | val route = AkkaHttp[IO](Configuration.Default)(bridge(TestServiceImpl.bindService())) 69 | Get("/com.avast.grpc.jsonbridge.test.TestService") ~> route ~> check { 70 | assertResult(StatusCodes.OK)(status) 71 | assertResult("com.avast.grpc.jsonbridge.test.TestService/Add")(responseAs[String]) 72 | } 73 | } 74 | 75 | test("provides services description") { 76 | val route = AkkaHttp[IO](Configuration.Default)(bridge(TestServiceImpl.bindService())) 77 | Get("/") ~> route ~> check { 78 | assertResult(StatusCodes.OK)(status) 79 | assertResult("com.avast.grpc.jsonbridge.test.TestService/Add")(responseAs[String]) 80 | } 81 | } 82 | 83 | test("passes headers") { 84 | val headerValue = Random.alphanumeric.take(10).mkString("") 85 | val route = AkkaHttp[IO](Configuration.Default)(bridge(TestServiceImpl.withInterceptor)) 86 | val Ok(customHeaderToBeSent, _) = HttpHeader.parse(TestServiceImpl.HeaderName, headerValue) 87 | Post("/com.avast.grpc.jsonbridge.test.TestService/Add", """ { "a": 1, "b": 2} """) 88 | .withHeaders(AkkaHttp.JsonContentType, customHeaderToBeSent) ~> route ~> check { 89 | assertResult(StatusCodes.OK)(status) 90 | assertResult("""{"sum":3}""")(responseAs[String]) 91 | assertResult(Seq(`Content-Type`(ContentType.WithMissingCharset(MediaType.applicationWithOpenCharset("json")))))(headers) 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /akka-http/src/test/scala/com/avast/grpc/jsonbridge/akkahttp/TestServiceImpl.scala: -------------------------------------------------------------------------------- 1 | package com.avast.grpc.jsonbridge.akkahttp 2 | 3 | import java.util.concurrent.atomic.AtomicReference 4 | 5 | import com.avast.grpc.jsonbridge.test.TestServices.AddResponse 6 | import com.avast.grpc.jsonbridge.test.{TestServiceGrpc, TestServices} 7 | import io.grpc.stub.StreamObserver 8 | import io.grpc.{ 9 | Context, 10 | Contexts, 11 | Metadata, 12 | ServerCall, 13 | ServerCallHandler, 14 | ServerInterceptor, 15 | ServerInterceptors, 16 | ServerServiceDefinition, 17 | Status, 18 | StatusException 19 | } 20 | 21 | object TestServiceImpl extends TestServiceGrpc.TestServiceImplBase { 22 | val HeaderName: String = "The-Header" 23 | private val contextKey = Context.key[String]("theHeader") 24 | private val metadataKey = Metadata.Key.of(HeaderName, Metadata.ASCII_STRING_MARSHALLER) 25 | val lastContextValue: AtomicReference[String] = new AtomicReference[String]("") 26 | 27 | override def add(request: TestServices.AddParams, responseObserver: StreamObserver[TestServices.AddResponse]): Unit = { 28 | lastContextValue.set(contextKey.get()) 29 | responseObserver.onNext(AddResponse.newBuilder().setSum(request.getA + request.getB).build()) 30 | responseObserver.onCompleted() 31 | } 32 | 33 | def withInterceptor: ServerServiceDefinition = 34 | ServerInterceptors.intercept( 35 | this, 36 | new ServerInterceptor { 37 | override def interceptCall[ReqT, RespT]( 38 | call: ServerCall[ReqT, RespT], 39 | headers: Metadata, 40 | next: ServerCallHandler[ReqT, RespT] 41 | ): ServerCall.Listener[ReqT] = 42 | Contexts.interceptCall(Context.current().withValue(contextKey, headers.get(metadataKey)), call, headers, next) 43 | } 44 | ) 45 | } 46 | 47 | object PermissionDeniedTestServiceImpl extends TestServiceGrpc.TestServiceImplBase { 48 | override def add(request: TestServices.AddParams, responseObserver: StreamObserver[TestServices.AddResponse]): Unit = { 49 | responseObserver.onError(new StatusException(Status.PERMISSION_DENIED)) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import com.typesafe.tools.mima.core._ 2 | 3 | Global / onChangedBuildSource := ReloadOnSourceChanges 4 | 5 | val logger: Logger = ConsoleLogger() 6 | 7 | lazy val ScalaVersions = new { 8 | val V213 = "2.13.12" 9 | val V212 = "2.12.18" 10 | } 11 | 12 | lazy val Versions = new { 13 | val gpb3Version = "3.25.8" 14 | val grpcVersion = "1.68.3" 15 | val circeVersion = "0.14.13" 16 | val http4sVersion = "0.22.2" 17 | val akkaHttp = "10.2.10" 18 | } 19 | 20 | lazy val javaSettings = Seq( 21 | crossPaths := false, 22 | autoScalaLibrary := false 23 | ) 24 | 25 | lazy val commonSettings = Seq( 26 | organization := "com.avast.grpc", 27 | homepage := Some(url("https://github.com/avast/grpc-json-bridge")), 28 | licenses ++= Seq("MIT" -> url(s"https://github.com/avast/grpc-json-bridge/blob/${version.value}/LICENSE")), 29 | developers := List( 30 | Developer( 31 | "jendakol", 32 | "Jenda Kolena", 33 | "jan.kolena@avast.com", 34 | url("https://github.com/jendakol") 35 | ), 36 | Developer( 37 | "augi", 38 | "Michal Augustýn", 39 | "michal.augustyn@avast.com", 40 | url("https://github.com/augi") 41 | ), 42 | Developer( 43 | "sideeffffect", 44 | "Ondra Pelech", 45 | "ondrej.pelech@avast.com", 46 | url("https://github.com/sideeffffect") 47 | ) 48 | ), 49 | ThisBuild / turbo := true, 50 | scalaVersion := ScalaVersions.V213, 51 | crossScalaVersions := Seq(ScalaVersions.V212, ScalaVersions.V213), 52 | scalacOptions --= { 53 | if (!sys.env.contains("CI")) 54 | List("-Xfatal-warnings") // to enable Scalafix 55 | else 56 | List() 57 | }, 58 | description := "Library for exposing gRPC endpoints via HTTP API", 59 | semanticdbEnabled := true, // enable SemanticDB 60 | semanticdbVersion := scalafixSemanticdb.revision, // use Scalafix compatible version 61 | ThisBuild / scalafixScalaBinaryVersion := CrossVersion.binaryScalaVersion(scalaVersion.value), 62 | ThisBuild / scalafixDependencies ++= List( 63 | "com.github.liancheng" %% "organize-imports" % "0.6.0", 64 | "com.github.vovapolu" %% "scaluzzi" % "0.1.23" 65 | ), 66 | libraryDependencies ++= Seq( 67 | "org.scala-lang.modules" %% "scala-collection-compat" % "2.13.0", 68 | "javax.annotation" % "javax.annotation-api" % "1.3.2", 69 | "junit" % "junit" % "4.13.2" % Test, 70 | "org.scalatest" %% "scalatest" % "3.2.17" % Test, 71 | "com.github.sbt" % "junit-interface" % "0.13.3" % Test, // Required by sbt to execute JUnit tests 72 | "ch.qos.logback" % "logback-classic" % "1.5.18" % Test 73 | ), 74 | missinglinkExcludedDependencies ++= List( 75 | moduleFilter(organization = "org.slf4j", name = "slf4j-api") 76 | ), 77 | mimaPreviousArtifacts := previousStableVersion.value.map(organization.value %% moduleName.value % _).toSet, 78 | testOptions += Tests.Argument(TestFrameworks.JUnit), 79 | Test / tpolecatExcludeOptions += ScalacOptions.warnNonUnitStatement 80 | ) ++ 81 | addCommandAlias("check", "; lint; +missinglinkCheck; +mimaReportBinaryIssues; +test") ++ 82 | addCommandAlias( 83 | "lint", 84 | "; scalafmtSbtCheck; scalafmtCheckAll; compile:scalafix --check; test:scalafix --check" 85 | ) ++ 86 | addCommandAlias("fix", "; compile:scalafix; test:scalafix; scalafmtSbt; scalafmtAll") 87 | 88 | lazy val grpcTestGenSettings = inConfig(Test)(sbtprotoc.ProtocPlugin.protobufConfigSettings) ++ Seq( 89 | PB.protocVersion := "3.9.1", 90 | grpcExePath := xsbti.api.SafeLazy.strict { 91 | val exe: File = (baseDirectory in Test).value / ".bin" / grpcExeFileName 92 | if (!exe.exists) { 93 | logger.info("gRPC protoc plugin (for Java) does not exist. Downloading") 94 | // IO.download(grpcExeUrl, exe) 95 | IO.transfer(grpcExeUrl.openStream(), exe) 96 | exe.setExecutable(true) 97 | } else { 98 | logger.debug("gRPC protoc plugin (for Java) exists") 99 | } 100 | exe 101 | }, 102 | PB.protocOptions in Test ++= Seq( 103 | s"--plugin=protoc-gen-java_rpc=${grpcExePath.value.get}", 104 | s"--java_rpc_out=${(sourceManaged in Test).value.getAbsolutePath}" 105 | ), 106 | PB.targets in Test := Seq( 107 | PB.gens.java -> (sourceManaged in Test).value 108 | ) 109 | ) 110 | 111 | lazy val grpcScalaPBTestGenSettings = inConfig(Test)(sbtprotoc.ProtocPlugin.protobufConfigSettings) ++ Seq( 112 | PB.protocVersion := "3.9.1", 113 | PB.targets in Test := Seq( 114 | scalapb.gen() -> (sourceManaged in Test).value 115 | ) 116 | ) 117 | 118 | lazy val root = project 119 | .in(file(".")) 120 | .settings(commonSettings) 121 | .settings( 122 | name := "grpc-json-bridge", 123 | publish / skip := true, // doesn't publish ivy XML files, in contrast to "publishArtifact := false" 124 | mimaReportBinaryIssues := {} 125 | ) 126 | .aggregate(core, http4s, akkaHttp, coreScalaPB) 127 | 128 | lazy val core = (project in file("core")).settings( 129 | commonSettings, 130 | grpcTestGenSettings, 131 | name := "grpc-json-bridge-core", 132 | libraryDependencies ++= Seq( 133 | "com.google.protobuf" % "protobuf-java" % Versions.gpb3Version, 134 | "com.google.protobuf" % "protobuf-java-util" % Versions.gpb3Version, 135 | "io.grpc" % "grpc-core" % Versions.grpcVersion, 136 | "io.grpc" % "grpc-protobuf" % Versions.grpcVersion, 137 | "io.grpc" % "grpc-stub" % Versions.grpcVersion, 138 | "io.grpc" % "grpc-inprocess" % Versions.grpcVersion, 139 | "org.typelevel" %% "cats-core" % "2.13.0", 140 | "org.typelevel" %% "cats-effect" % "2.5.5", 141 | "com.typesafe.scala-logging" %% "scala-logging" % "3.9.5", 142 | "org.slf4j" % "jul-to-slf4j" % "2.0.13", 143 | "org.slf4j" % "jcl-over-slf4j" % "2.0.13", 144 | "io.grpc" % "grpc-services" % Versions.grpcVersion % Test 145 | ) 146 | ) 147 | 148 | lazy val coreScalaPB = (project in file("core-scalapb")) 149 | .settings( 150 | name := "grpc-json-bridge-core-scalapb", 151 | commonSettings, 152 | grpcScalaPBTestGenSettings, 153 | libraryDependencies ++= Seq( 154 | "com.thesamet.scalapb" %% "scalapb-runtime-grpc" % scalapb.compiler.Version.scalapbVersion, 155 | "com.thesamet.scalapb" %% "scalapb-json4s" % "0.12.1", 156 | "junit" % "junit" % "4.13.2" % Test, 157 | "org.scalatest" %% "scalatest" % "3.2.17" % Test, 158 | "com.github.sbt" % "junit-interface" % "0.13.3" % Test, // Required by sbt to execute JUnit tests 159 | "ch.qos.logback" % "logback-classic" % "1.5.18" % Test, 160 | "io.grpc" % "grpc-services" % Versions.grpcVersion % Test, 161 | "com.thesamet.scalapb" %% "scalapb-runtime" % scalapb.compiler.Version.scalapbVersion % "protobuf" 162 | ) 163 | ) 164 | .dependsOn(core) 165 | 166 | lazy val http4s = (project in file("http4s")) 167 | .settings( 168 | commonSettings, 169 | grpcTestGenSettings, 170 | name := "grpc-json-bridge-http4s", 171 | libraryDependencies ++= Seq( 172 | "org.http4s" %% "http4s-dsl" % Versions.http4sVersion, 173 | "org.http4s" %% "http4s-blaze-server" % Versions.http4sVersion, 174 | "org.http4s" %% "http4s-circe" % Versions.http4sVersion, 175 | "io.circe" %% "circe-core" % Versions.circeVersion, 176 | "io.circe" %% "circe-generic" % Versions.circeVersion 177 | ) 178 | ) 179 | .dependsOn(core) 180 | 181 | lazy val akkaHttp = (project in file("akka-http")) 182 | .settings( 183 | commonSettings, 184 | grpcTestGenSettings, 185 | name := "grpc-json-bridge-akkahttp", 186 | libraryDependencies ++= Seq( 187 | "com.typesafe.akka" %% "akka-http" % Versions.akkaHttp, 188 | "com.typesafe.akka" %% "akka-http-spray-json" % Versions.akkaHttp, 189 | "com.typesafe.akka" %% "akka-stream" % "2.6.20", 190 | "com.typesafe.akka" %% "akka-testkit" % "2.6.20" % Test, 191 | "com.typesafe.akka" %% "akka-http-testkit" % Versions.akkaHttp % Test 192 | ) 193 | ) 194 | .dependsOn(core) 195 | 196 | def grpcExeFileName: String = { 197 | val os = if (scala.util.Properties.isMac) { 198 | "osx-x86_64" 199 | } else if (scala.util.Properties.isWin) { 200 | "windows-x86_64" 201 | } else { 202 | "linux-x86_64" 203 | } 204 | s"$grpcArtifactId-${Versions.grpcVersion}-$os.exe" 205 | } 206 | 207 | val grpcArtifactId = "protoc-gen-grpc-java" 208 | val grpcExeUrl = url(s"https://repo1.maven.org/maven2/io/grpc/$grpcArtifactId/${Versions.grpcVersion}/$grpcExeFileName") 209 | val grpcExePath = SettingKey[xsbti.api.Lazy[File]]("grpcExePath") 210 | 211 | addCommandAlias( 212 | "ci", 213 | "; check; +publishLocal" 214 | ) 215 | -------------------------------------------------------------------------------- /core-scalapb/src/main/scala/com/avast/grpc/jsonbridge/scalapb/ScalaPBReflectionGrpcJsonBridge.scala: -------------------------------------------------------------------------------- 1 | package com.avast.grpc.jsonbridge.scalapb 2 | 3 | import com.avast.grpc.jsonbridge.ReflectionGrpcJsonBridge 4 | 5 | object ScalaPBReflectionGrpcJsonBridge extends ReflectionGrpcJsonBridge(ScalaPBServiceHandlers) 6 | -------------------------------------------------------------------------------- /core-scalapb/src/main/scala/com/avast/grpc/jsonbridge/scalapb/ScalaPBServiceHandlers.scala: -------------------------------------------------------------------------------- 1 | package com.avast.grpc.jsonbridge.scalapb 2 | 3 | import java.lang.reflect.{InvocationTargetException, Method} 4 | 5 | import cats.effect.Async 6 | import cats.syntax.all._ 7 | import com.avast.grpc.jsonbridge.GrpcJsonBridge.GrpcMethodName 8 | import com.avast.grpc.jsonbridge.ReflectionGrpcJsonBridge.{HandlerFunc, ServiceHandlers} 9 | import com.avast.grpc.jsonbridge.{BridgeError, JavaGenericHelper, ReflectionGrpcJsonBridge} 10 | import com.fasterxml.jackson.core.JsonProcessingException 11 | import com.typesafe.scalalogging.StrictLogging 12 | import io.grpc._ 13 | import io.grpc.protobuf.ProtoFileDescriptorSupplier 14 | import io.grpc.stub.AbstractStub 15 | import org.json4s.ParserUtil.ParseException 16 | import scalapb.json4s.JsonFormatException 17 | import scalapb.{GeneratedMessage, GeneratedMessageCompanion} 18 | 19 | import scala.concurrent.{ExecutionContext, Future} 20 | import scala.jdk.CollectionConverters._ 21 | import scala.util.control.NonFatal 22 | import scala.util.{Failure, Success} 23 | 24 | private[jsonbridge] object ScalaPBServiceHandlers extends ServiceHandlers with StrictLogging { 25 | def createServiceHandlers[F[_]]( 26 | ec: ExecutionContext 27 | )(inProcessChannel: ManagedChannel)(ssd: ServerServiceDefinition)(implicit F: Async[F]): Map[GrpcMethodName, HandlerFunc[F]] = { 28 | if (ssd.getServiceDescriptor.getName.startsWith("grpc.reflection.") || ssd.getServiceDescriptor.getName.startsWith("grpc.health.")) { 29 | logger.debug("Reflection and health endpoint service cannot be bridged because its implementation is not ScalaPB-based") 30 | Map.empty 31 | } else { 32 | val futureStubCtor = createFutureStubCtor(ssd.getServiceDescriptor, inProcessChannel) 33 | ssd.getMethods.asScala 34 | .filter(ReflectionGrpcJsonBridge.isSupportedMethod) 35 | .map(createHandler(ec)(futureStubCtor)(_)) 36 | .toMap 37 | } 38 | } 39 | 40 | private val printer = new scalapb.json4s.Printer().includingDefaultValueFields 41 | 42 | private val parser = new scalapb.json4s.Parser() 43 | private val parserMethod = parser.getClass.getDeclaredMethods 44 | .find(_.getName == "fromJsonString") 45 | .getOrElse(sys.error(s"Method 'fromJsonString' not found on ${parser.getClass}")) 46 | private def parse(input: String, companion: GeneratedMessageCompanion[_]): Either[Throwable, GeneratedMessage] = 47 | try { 48 | Right(parserMethod.invoke(parser, input, companion).asInstanceOf[GeneratedMessage]) 49 | } catch { 50 | case ie: InvocationTargetException => Left(ie.getCause) 51 | case NonFatal(e) => Left(e) 52 | } 53 | 54 | private def createFutureStubCtor(sd: ServiceDescriptor, inProcessChannel: Channel): () => AbstractStub[_] = { 55 | val serviceCompanionClassNames = getPossibleServiceCompanionClassNames(sd) 56 | val serviceCompanionClass = serviceCompanionClassNames 57 | .map(cn => { 58 | logger.debug(s"Obtaining class of $cn") 59 | try Some(Class.forName(cn)) 60 | catch { 61 | case e: ClassNotFoundException => 62 | logger.trace(s"Class $cn cannot be loaded", e) 63 | None 64 | } 65 | }) 66 | .collectFirst { case Some(c) => 67 | c 68 | } 69 | .getOrElse(sys.error(s"Classes cannot be loaded: ${serviceCompanionClassNames.mkString(", ")}")) 70 | val serviceCompanion = serviceCompanionClass.getDeclaredField("MODULE$").get(null) 71 | val method = serviceCompanionClass.getDeclaredMethod("stub", classOf[Channel]) 72 | () => method.invoke(serviceCompanion, inProcessChannel).asInstanceOf[AbstractStub[_]] 73 | } 74 | 75 | private def getPossibleServiceCompanionClassNames(sd: ServiceDescriptor): Seq[String] = { 76 | val servicePackage = sd.getName.substring(0, sd.getName.lastIndexOf('.')) 77 | val serviceName = sd.getName.substring(sd.getName.lastIndexOf('.') + 1) 78 | val fileNameWithoutExtension = sd.getSchemaDescriptor 79 | .asInstanceOf[ProtoFileDescriptorSupplier] 80 | .getFileDescriptor 81 | .getName 82 | .split('/') 83 | .last 84 | .stripSuffix(".proto") 85 | // we must handle when `flatPackage` is set to `true` - then the filename is included 86 | Seq(servicePackage + "." + fileNameWithoutExtension + "." + serviceName + "Grpc$", servicePackage + "." + serviceName + "Grpc$") 87 | } 88 | 89 | private def createHandler[F[_]]( 90 | ec: ExecutionContext 91 | )(futureStubCtor: () => AbstractStub[_])(method: ServerMethodDefinition[_, _])(implicit F: Async[F]): (GrpcMethodName, HandlerFunc[F]) = { 92 | val requestCompanion = getRequestCompanion(method) 93 | val requestClass = Class.forName(requestCompanion.getClass.getName.stripSuffix("$")) 94 | val scalaMethod = futureStubCtor().getClass.getDeclaredMethod(getScalaMethodName(method), requestClass) 95 | val handler: HandlerFunc[F] = (input: String, headers: Map[String, String]) => 96 | parse(input, requestCompanion) match { 97 | case Left(e: JsonProcessingException) => F.pure(Left(BridgeError.Json(e))) 98 | case Left(e: JsonFormatException) => F.pure(Left(BridgeError.Json(e))) 99 | case Left(e: ParseException) => F.pure(Left(BridgeError.Json(e))) 100 | case Left(e) => F.pure(Left(BridgeError.Unknown(e))) 101 | case Right(request) => 102 | fromScalaFuture(ec) { 103 | F.delay { 104 | executeCore(request, headers, futureStubCtor, scalaMethod)(ec) 105 | } 106 | } 107 | } 108 | val grpcMethodName = GrpcMethodName(method.getMethodDescriptor.getFullMethodName) 109 | (grpcMethodName, handler) 110 | } 111 | 112 | private def executeCore( 113 | request: GeneratedMessage, 114 | headers: Map[String, String], 115 | futureStubCtor: () => AbstractStub[_], 116 | scalaMethod: Method 117 | )(implicit ec: ExecutionContext): Future[Either[BridgeError.Narrow, String]] = { 118 | val metadata = { 119 | val md = new Metadata() 120 | headers.foreach { case (k, v) => md.put(Metadata.Key.of(k, Metadata.ASCII_STRING_MARSHALLER), v) } 121 | md 122 | } 123 | val stubWithMetadata = JavaGenericHelper.attachHeaders(futureStubCtor(), metadata) 124 | scalaMethod 125 | .invoke(stubWithMetadata, request.asInstanceOf[Object]) 126 | .asInstanceOf[scala.concurrent.Future[GeneratedMessage]] 127 | .map(gm => printer.print(gm)) 128 | .map(Right(_): Either[BridgeError.Narrow, String]) 129 | .recover { 130 | case e: StatusException => 131 | Left(BridgeError.Grpc(e.getStatus)) 132 | case e: StatusRuntimeException => 133 | Left(BridgeError.Grpc(e.getStatus)) 134 | case NonFatal(ex) => 135 | Left(BridgeError.Unknown(ex)) 136 | } 137 | } 138 | 139 | private def getScalaMethodName(method: ServerMethodDefinition[_, _]): String = { 140 | val Seq(_, methodName) = method.getMethodDescriptor.getFullMethodName.split('/').toSeq 141 | methodName.substring(0, 1).toLowerCase + methodName.substring(1) 142 | } 143 | 144 | private def getRequestCompanion(method: ServerMethodDefinition[_, _]): GeneratedMessageCompanion[_] = { 145 | val requestMarshaller = method.getMethodDescriptor.getRequestMarshaller match { 146 | case marshaller: scalapb.grpc.Marshaller[_] => marshaller 147 | case typeMappedMarshaller: scalapb.grpc.TypeMappedMarshaller[_, _] => typeMappedMarshaller 148 | case x => 149 | logger.warn( 150 | "Marshaller of unexpected class '{}', we can't be sure it contains the field `companion: GeneratedMessageCompanion[_]`", 151 | x.getClass 152 | ) 153 | x 154 | } 155 | val companionField = requestMarshaller.getClass.getDeclaredField("companion") 156 | companionField.setAccessible(true) 157 | companionField.get(requestMarshaller).asInstanceOf[GeneratedMessageCompanion[_]] 158 | } 159 | 160 | private def fromScalaFuture[F[_], A](ec: ExecutionContext)(fsf: F[Future[A]])(implicit F: Async[F]): F[A] = 161 | fsf.flatMap { sf => 162 | F.async { cb => 163 | sf.onComplete { 164 | case Success(r) => cb(Right(r)) 165 | case Failure(e) => cb(Left(BridgeError.Unknown(e))) 166 | }(ec) 167 | } 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /core-scalapb/src/test/protobuf/TestServices.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package com.avast.grpc.jsonbridge.scalapbtest; 4 | 5 | service TestService { 6 | rpc Add (AddParams) returns (AddResponse) {} 7 | } 8 | 9 | message AddParams { 10 | int32 a = 1; 11 | int32 b = 2; 12 | } 13 | 14 | message AddResponse { 15 | int32 sum = 1; 16 | } 17 | -------------------------------------------------------------------------------- /core-scalapb/src/test/protobuf/com/avast/grpc/jsonbridge/scalapbtest/TestServices2.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package com.avast.grpc.jsonbridge.scalapbtest; 4 | 5 | service TestService2 { 6 | rpc Add2 (AddParams2) returns (AddResponse2) {} 7 | } 8 | 9 | message AddParams2 { 10 | int32 a = 1; 11 | int32 b = 2; 12 | } 13 | 14 | message AddResponse2 { 15 | int32 sum = 1; 16 | } 17 | -------------------------------------------------------------------------------- /core-scalapb/src/test/scala/com/avast/grpc/jsonbridge/scalapbtest/ScalaPBReflectionGrpcJsonBridgeTest.scala: -------------------------------------------------------------------------------- 1 | package com.avast.grpc.jsonbridge.scalapbtest 2 | 3 | import cats.effect.IO 4 | import com.avast.grpc.jsonbridge.GrpcJsonBridge.GrpcMethodName 5 | import com.avast.grpc.jsonbridge.scalapb.ScalaPBReflectionGrpcJsonBridge 6 | import com.avast.grpc.jsonbridge.scalapbtest.TestServices.TestServiceGrpc 7 | import com.avast.grpc.jsonbridge.{BridgeError, GrpcJsonBridge} 8 | import io.grpc.inprocess.InProcessServerBuilder 9 | import io.grpc.protobuf.services.{HealthStatusManager, ProtoReflectionService} 10 | import org.scalatest.matchers.should.Matchers 11 | import org.scalatest.{Outcome, flatspec} 12 | 13 | import scala.concurrent.ExecutionContext.Implicits.global 14 | 15 | class ScalaPBReflectionGrpcJsonBridgeTest extends flatspec.FixtureAnyFlatSpec with Matchers { 16 | 17 | case class FixtureParam(bridge: GrpcJsonBridge[IO]) 18 | 19 | override protected def withFixture(test: OneArgTest): Outcome = { 20 | val channelName = InProcessServerBuilder.generateName 21 | val server = InProcessServerBuilder 22 | .forName(channelName) 23 | .addService(TestServiceGrpc.bindService(new TestServiceImpl, global)) 24 | .addService(ProtoReflectionService.newInstance()) 25 | .addService(new HealthStatusManager().getHealthService) 26 | .build 27 | val (bridge, close) = ScalaPBReflectionGrpcJsonBridge.createFromServer[IO](global)(server).allocated.unsafeRunSync() 28 | try { 29 | test(FixtureParam(bridge)) 30 | } finally { 31 | server.shutdownNow() 32 | close.unsafeRunSync() 33 | } 34 | } 35 | 36 | it must "successfully call the invoke method" in { f => 37 | val Right(response) = 38 | f.bridge 39 | .invoke(GrpcMethodName("com.avast.grpc.jsonbridge.scalapbtest.TestService/Add"), """ { "a": 1, "b": 2} """, Map.empty) 40 | .unsafeRunSync() 41 | response shouldBe """{"sum":3}""" 42 | } 43 | 44 | it must "return expected status code for missing method" in { f => 45 | val Left(status) = f.bridge.invoke(GrpcMethodName("ble/bla"), "{}", Map.empty).unsafeRunSync() 46 | status shouldBe BridgeError.GrpcMethodNotFound 47 | } 48 | 49 | it must "return BridgeError.Json for wrongly named field" in { f => 50 | val Left(status) = f.bridge 51 | .invoke(GrpcMethodName("com.avast.grpc.jsonbridge.scalapbtest.TestService/Add"), """ { "x": 1, "b": 2} """, Map.empty) 52 | .unsafeRunSync() 53 | status should matchPattern { case BridgeError.Json(_) => } 54 | } 55 | 56 | it must "return expected status code for malformed JSON" in { f => 57 | val Left(status) = 58 | f.bridge.invoke(GrpcMethodName("com.avast.grpc.jsonbridge.scalapbtest.TestService/Add"), "{ble}", Map.empty).unsafeRunSync() 59 | status should matchPattern { case BridgeError.Json(_) => } 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /core-scalapb/src/test/scala/com/avast/grpc/jsonbridge/scalapbtest/ScalaPBReflectionGrpcJsonBridgeTest2.scala: -------------------------------------------------------------------------------- 1 | package com.avast.grpc.jsonbridge.scalapbtest 2 | 3 | import cats.effect.IO 4 | import com.avast.grpc.jsonbridge.GrpcJsonBridge.GrpcMethodName 5 | import com.avast.grpc.jsonbridge.scalapb.ScalaPBReflectionGrpcJsonBridge 6 | import com.avast.grpc.jsonbridge.scalapbtest.TestServices2.TestService2Grpc 7 | import com.avast.grpc.jsonbridge.{BridgeError, GrpcJsonBridge} 8 | import io.grpc.inprocess.InProcessServerBuilder 9 | import io.grpc.protobuf.services.{HealthStatusManager, ProtoReflectionService} 10 | import org.scalatest.matchers.should.Matchers 11 | import org.scalatest.{Outcome, flatspec} 12 | 13 | import scala.concurrent.ExecutionContext.Implicits.global 14 | 15 | class ScalaPBReflectionGrpcJsonBridgeTest2 extends flatspec.FixtureAnyFlatSpec with Matchers { 16 | 17 | case class FixtureParam(bridge: GrpcJsonBridge[IO]) 18 | 19 | override protected def withFixture(test: OneArgTest): Outcome = { 20 | val channelName = InProcessServerBuilder.generateName 21 | val server = InProcessServerBuilder 22 | .forName(channelName) 23 | .addService(TestService2Grpc.bindService(new TestServiceImpl2, global)) 24 | .addService(ProtoReflectionService.newInstance()) 25 | .addService(new HealthStatusManager().getHealthService) 26 | .build 27 | val (bridge, close) = ScalaPBReflectionGrpcJsonBridge.createFromServer[IO](global)(server).allocated.unsafeRunSync() 28 | try { 29 | test(FixtureParam(bridge)) 30 | } finally { 31 | server.shutdownNow() 32 | close.unsafeRunSync() 33 | } 34 | } 35 | 36 | it must "successfully call the invoke method" in { f => 37 | val Right(response) = 38 | f.bridge 39 | .invoke(GrpcMethodName("com.avast.grpc.jsonbridge.scalapbtest.TestService2/Add2"), """ { "a": 1, "b": 2} """, Map.empty) 40 | .unsafeRunSync() 41 | response shouldBe """{"sum":3}""" 42 | } 43 | 44 | it must "return expected status code for missing method" in { f => 45 | val Left(status) = f.bridge.invoke(GrpcMethodName("ble/bla"), "{}", Map.empty).unsafeRunSync() 46 | status shouldBe BridgeError.GrpcMethodNotFound 47 | } 48 | 49 | it must "return expected status code for malformed JSON" in { f => 50 | val Left(status) = 51 | f.bridge.invoke(GrpcMethodName("com.avast.grpc.jsonbridge.scalapbtest.TestService2/Add2"), "{ble}", Map.empty).unsafeRunSync() 52 | status should matchPattern { case BridgeError.Json(_) => } 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /core-scalapb/src/test/scala/com/avast/grpc/jsonbridge/scalapbtest/TestServiceImpl.scala: -------------------------------------------------------------------------------- 1 | package com.avast.grpc.jsonbridge.scalapbtest 2 | 3 | import com.avast.grpc.jsonbridge.scalapbtest.TestServices.{AddParams, AddResponse, TestServiceGrpc} 4 | 5 | import scala.concurrent.Future 6 | 7 | class TestServiceImpl extends TestServiceGrpc.TestService { 8 | def add(request: AddParams): Future[AddResponse] = Future.successful(AddResponse(request.a + request.b)) 9 | } 10 | -------------------------------------------------------------------------------- /core-scalapb/src/test/scala/com/avast/grpc/jsonbridge/scalapbtest/TestServiceImpl2.scala: -------------------------------------------------------------------------------- 1 | package com.avast.grpc.jsonbridge.scalapbtest 2 | 3 | import com.avast.grpc.jsonbridge.scalapbtest.TestServices2.{AddParams2, AddResponse2, TestService2Grpc} 4 | 5 | import scala.concurrent.Future 6 | 7 | class TestServiceImpl2 extends TestService2Grpc.TestService2 { 8 | def add2(request: AddParams2): Future[AddResponse2] = Future.successful(AddResponse2(request.a + request.b)) 9 | } 10 | -------------------------------------------------------------------------------- /core/src/main/java/com/avast/grpc/jsonbridge/JavaGenericHelper.java: -------------------------------------------------------------------------------- 1 | package com.avast.grpc.jsonbridge; 2 | 3 | import io.grpc.Metadata; 4 | import io.grpc.stub.AbstractStub; 5 | 6 | import static io.grpc.stub.MetadataUtils.newAttachHeadersInterceptor; 7 | 8 | @SuppressWarnings("unchecked") 9 | public class JavaGenericHelper { 10 | private JavaGenericHelper() { 11 | } 12 | 13 | /* 14 | * It's problematic to call self-bounded generic method from Scala, 15 | * in this case the attachHeaders method has this generic: > 16 | */ 17 | public static AbstractStub attachHeaders(AbstractStub stub, Metadata extraHeaders) { 18 | return stub.withInterceptors(newAttachHeadersInterceptor(extraHeaders)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /core/src/main/scala/com/avast/grpc/jsonbridge/BridgeError.scala: -------------------------------------------------------------------------------- 1 | package com.avast.grpc.jsonbridge 2 | 3 | import io.grpc.Status 4 | 5 | sealed trait BridgeError extends Exception with Product with Serializable 6 | object BridgeError { 7 | final case object GrpcMethodNotFound extends BridgeError 8 | 9 | sealed trait Narrow extends BridgeError 10 | final case class Json(t: Throwable) extends Narrow 11 | final case class Grpc(s: Status) extends Narrow 12 | final case class Unknown(t: Throwable) extends Narrow 13 | } 14 | -------------------------------------------------------------------------------- /core/src/main/scala/com/avast/grpc/jsonbridge/BridgeErrorResponse.scala: -------------------------------------------------------------------------------- 1 | package com.avast.grpc.jsonbridge 2 | 3 | import io.grpc.Status 4 | 5 | final case class BridgeErrorResponse(description: Option[String], exception: Option[String], message: Option[String]) 6 | 7 | object BridgeErrorResponse { 8 | 9 | def fromGrpcStatus(s: Status): BridgeErrorResponse = 10 | BridgeErrorResponse( 11 | Option(s.getDescription), 12 | Option(s.getCause).flatMap(e => Option(e.getClass.getCanonicalName)), 13 | Option(s.getCause).flatMap(e => Option(e.getMessage)) 14 | ) 15 | 16 | def fromMessage(message: String): BridgeErrorResponse = { 17 | BridgeErrorResponse(Some(message), None, None) 18 | } 19 | 20 | def fromException(message: String, ex: Throwable): BridgeErrorResponse = { 21 | BridgeErrorResponse(Some(message), Some(ex.getClass.getCanonicalName), Option(ex.getMessage)) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /core/src/main/scala/com/avast/grpc/jsonbridge/GrpcJsonBridge.scala: -------------------------------------------------------------------------------- 1 | package com.avast.grpc.jsonbridge 2 | 3 | import com.avast.grpc.jsonbridge.GrpcJsonBridge.GrpcMethodName 4 | 5 | trait GrpcJsonBridge[F[_]] { 6 | def invoke(methodName: GrpcMethodName, body: String, headers: Map[String, String]): F[Either[BridgeError, String]] 7 | def methodHandlers: Map[GrpcMethodName, (String, Map[String, String]) => F[Either[BridgeError.Narrow, String]]] 8 | def methodsNames: Seq[GrpcMethodName] 9 | def servicesNames: Seq[String] 10 | } 11 | 12 | object GrpcJsonBridge { 13 | /* 14 | * Represents gRPC method name - it consists of service name (it includes package) and method name. 15 | */ 16 | final case class GrpcMethodName(service: String, method: String) { 17 | val fullName: String = service + "/" + method 18 | } 19 | object GrpcMethodName { 20 | def apply(fullMethodName: String): GrpcMethodName = { 21 | val Seq(s, m) = fullMethodName.split('/').toSeq 22 | GrpcMethodName(s, m) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /core/src/main/scala/com/avast/grpc/jsonbridge/JavaServiceHandlers.scala: -------------------------------------------------------------------------------- 1 | package com.avast.grpc.jsonbridge 2 | 3 | import java.lang.reflect.Method 4 | 5 | import cats.effect.Async 6 | import cats.syntax.all._ 7 | import com.avast.grpc.jsonbridge.GrpcJsonBridge.GrpcMethodName 8 | import com.avast.grpc.jsonbridge.ReflectionGrpcJsonBridge.{HandlerFunc, ServiceHandlers} 9 | import com.google.common.util.concurrent.{FutureCallback, Futures, ListenableFuture} 10 | import com.google.protobuf.util.JsonFormat 11 | import com.google.protobuf.{InvalidProtocolBufferException, Message, MessageOrBuilder} 12 | import com.typesafe.scalalogging.StrictLogging 13 | import io.grpc.MethodDescriptor.PrototypeMarshaller 14 | import io.grpc._ 15 | import io.grpc.stub.AbstractStub 16 | 17 | import scala.concurrent.ExecutionContext 18 | import scala.jdk.CollectionConverters._ 19 | import scala.util.control.NonFatal 20 | 21 | private[jsonbridge] object JavaServiceHandlers extends ServiceHandlers with StrictLogging { 22 | private val parser: JsonFormat.Parser = JsonFormat.parser() 23 | private val printer: JsonFormat.Printer = { 24 | JsonFormat.printer().includingDefaultValueFields().omittingInsignificantWhitespace() 25 | } 26 | 27 | def createServiceHandlers[F[_]]( 28 | ec: ExecutionContext 29 | )(inProcessChannel: ManagedChannel)(ssd: ServerServiceDefinition)(implicit F: Async[F]): Map[GrpcMethodName, HandlerFunc[F]] = { 30 | val futureStubCtor = createFutureStubCtor(ssd.getServiceDescriptor, inProcessChannel) 31 | ssd.getMethods.asScala 32 | .filter(ReflectionGrpcJsonBridge.isSupportedMethod) 33 | .map(createHandler(ec)(futureStubCtor)(_)) 34 | .toMap 35 | } 36 | 37 | private def createFutureStubCtor(sd: ServiceDescriptor, inProcessChannel: Channel): () => AbstractStub[_] = { 38 | val serviceClassName = sd.getSchemaDescriptor.getClass.getName.split("\\$").head 39 | logger.debug(s"Creating instance of $serviceClassName") 40 | val method = Class.forName(serviceClassName).getDeclaredMethod("newFutureStub", classOf[Channel]) 41 | () => method.invoke(null, inProcessChannel).asInstanceOf[AbstractStub[_]] 42 | } 43 | 44 | private def createHandler[F[_]]( 45 | ec: ExecutionContext 46 | )(futureStubCtor: () => AbstractStub[_])(method: ServerMethodDefinition[_, _])(implicit F: Async[F]): (GrpcMethodName, HandlerFunc[F]) = { 47 | val requestMessagePrototype = getRequestMessagePrototype(method) 48 | val javaMethod = futureStubCtor().getClass 49 | .getDeclaredMethod(getJavaMethodName(method), requestMessagePrototype.getClass) 50 | val grpcMethodName = GrpcMethodName(method.getMethodDescriptor.getFullMethodName) 51 | val methodHandler = coreHandler(requestMessagePrototype, executeRequest[F](ec)(futureStubCtor, javaMethod)) 52 | (grpcMethodName, methodHandler) 53 | } 54 | 55 | private def getRequestMessagePrototype(method: ServerMethodDefinition[_, _]): Message = { 56 | val requestMarshaller = method.getMethodDescriptor.getRequestMarshaller.asInstanceOf[PrototypeMarshaller[_]] 57 | requestMarshaller.getMessagePrototype.asInstanceOf[Message] 58 | } 59 | 60 | private def getJavaMethodName(method: ServerMethodDefinition[_, _]): String = { 61 | val Seq(_, methodName) = method.getMethodDescriptor.getFullMethodName.split('/').toSeq 62 | methodName.substring(0, 1).toLowerCase + methodName.substring(1) 63 | } 64 | 65 | private def coreHandler[F[_]](requestMessagePrototype: Message, execute: (Message, Map[String, String]) => F[MessageOrBuilder])(implicit 66 | F: Async[F] 67 | ): HandlerFunc[F] = { (json, headers) => 68 | { 69 | parseRequest(json, requestMessagePrototype) match { 70 | case Right(req) => 71 | execute(req, headers) 72 | .map(printer.print) 73 | .map(Right(_): Either[BridgeError.Narrow, String]) 74 | .recover { 75 | case e: StatusException => 76 | Left(BridgeError.Grpc(e.getStatus)) 77 | case e: StatusRuntimeException => 78 | Left(BridgeError.Grpc(e.getStatus)) 79 | case NonFatal(ex) => 80 | Left(BridgeError.Unknown(ex)) 81 | } 82 | case Left(ex) => 83 | F.pure { 84 | Left(BridgeError.Json(ex)) 85 | } 86 | } 87 | } 88 | } 89 | 90 | private def executeRequest[F[_]]( 91 | ec: ExecutionContext 92 | )(futureStubCtor: () => AbstractStub[_], javaMethod: Method)(req: Message, headers: Map[String, String])(implicit 93 | F: Async[F] 94 | ): F[MessageOrBuilder] = { 95 | val metaData = { 96 | val md = new Metadata() 97 | headers.foreach { case (k, v) => md.put(Metadata.Key.of(k, Metadata.ASCII_STRING_MARSHALLER), v) } 98 | md 99 | } 100 | val stubWithHeaders = JavaGenericHelper.attachHeaders(futureStubCtor(), metaData) 101 | fromListenableFuture(ec)(F.delay { 102 | javaMethod.invoke(stubWithHeaders, req).asInstanceOf[ListenableFuture[MessageOrBuilder]] 103 | }) 104 | } 105 | 106 | private def parseRequest(json: String, requestMessage: Message): Either[InvalidProtocolBufferException, Message] = 107 | Either.catchOnly[InvalidProtocolBufferException] { 108 | val requestBuilder = requestMessage.newBuilderForType() 109 | parser.merge(json, requestBuilder) 110 | requestBuilder.build() 111 | } 112 | 113 | private def fromListenableFuture[F[_], A](ec: ExecutionContext)(flf: F[ListenableFuture[A]])(implicit F: Async[F]): F[A] = 114 | flf.flatMap { lf => 115 | F.async { cb => 116 | Futures.addCallback( 117 | lf, 118 | new FutureCallback[A] { 119 | def onFailure(t: Throwable): Unit = cb(Left(t)) 120 | def onSuccess(result: A): Unit = cb(Right(result)) 121 | }, 122 | ec.execute(_) 123 | ) 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /core/src/main/scala/com/avast/grpc/jsonbridge/ReflectionGrpcJsonBridge.scala: -------------------------------------------------------------------------------- 1 | package com.avast.grpc.jsonbridge 2 | 3 | import cats.effect._ 4 | import cats.syntax.all._ 5 | import com.avast.grpc.jsonbridge.GrpcJsonBridge.GrpcMethodName 6 | import com.avast.grpc.jsonbridge.ReflectionGrpcJsonBridge.{HandlerFunc, ServiceHandlers} 7 | import com.typesafe.scalalogging.StrictLogging 8 | import io.grpc.MethodDescriptor.MethodType 9 | import io.grpc._ 10 | import io.grpc.inprocess.{InProcessChannelBuilder, InProcessServerBuilder} 11 | 12 | import scala.concurrent.ExecutionContext 13 | import scala.jdk.CollectionConverters._ 14 | 15 | object ReflectionGrpcJsonBridge extends ReflectionGrpcJsonBridge(JavaServiceHandlers) { 16 | // JSON body and headers to a response (fail status or JSON response) 17 | type HandlerFunc[F[_]] = (String, Map[String, String]) => F[Either[BridgeError.Narrow, String]] 18 | 19 | trait ServiceHandlers { 20 | def createServiceHandlers[F[_]](ec: ExecutionContext)(inProcessChannel: ManagedChannel)(ssd: ServerServiceDefinition)(implicit 21 | F: Async[F] 22 | ): Map[GrpcMethodName, HandlerFunc[F]] 23 | } 24 | 25 | def isSupportedMethod(d: ServerMethodDefinition[_, _]): Boolean = d.getMethodDescriptor.getType == MethodType.UNARY 26 | } 27 | 28 | private[jsonbridge] class ReflectionGrpcJsonBridge(serviceHandlers: ServiceHandlers) extends StrictLogging { 29 | 30 | def createFromServer[F[_]](ec: ExecutionContext)(grpcServer: io.grpc.Server)(implicit F: Async[F]): Resource[F, GrpcJsonBridge[F]] = { 31 | createFromServices(ec)(grpcServer.getImmutableServices.asScala.toList: _*) 32 | } 33 | 34 | def createFromServices[F[_]]( 35 | ec: ExecutionContext 36 | )(services: ServerServiceDefinition*)(implicit F: Async[F]): Resource[F, GrpcJsonBridge[F]] = { 37 | for { 38 | inProcessServiceName <- Resource.eval(F.delay { s"ReflectionGrpcJsonBridge-${System.nanoTime()}" }) 39 | inProcessServer <- createInProcessServer(ec)(inProcessServiceName, services) 40 | inProcessChannel <- createInProcessChannel(ec)(inProcessServiceName) 41 | handlersPerMethod = 42 | inProcessServer.getImmutableServices.asScala 43 | .flatMap(serviceHandlers.createServiceHandlers(ec)(inProcessChannel)(_)) 44 | .toMap 45 | bridge = createFromHandlers(handlersPerMethod) 46 | } yield bridge 47 | } 48 | 49 | def createFromHandlers[F[_]](handlersPerMethod: Map[GrpcMethodName, HandlerFunc[F]])(implicit F: Async[F]): GrpcJsonBridge[F] = { 50 | new GrpcJsonBridge[F] { 51 | 52 | override def invoke( 53 | methodName: GrpcJsonBridge.GrpcMethodName, 54 | body: String, 55 | headers: Map[String, String] 56 | ): F[Either[BridgeError, String]] = 57 | handlersPerMethod.get(methodName) match { 58 | case Some(handler) => handler(body, headers).map(x => x: Either[BridgeError, String]) 59 | case None => F.pure(Left(BridgeError.GrpcMethodNotFound)) 60 | } 61 | 62 | override def methodHandlers: Map[GrpcMethodName, HandlerFunc[F]] = 63 | handlersPerMethod 64 | 65 | override val methodsNames: Seq[GrpcJsonBridge.GrpcMethodName] = handlersPerMethod.keys.toSeq 66 | override val servicesNames: Seq[String] = methodsNames.map(_.service).distinct 67 | } 68 | } 69 | 70 | private def createInProcessServer[F[_]]( 71 | ec: ExecutionContext 72 | )(inProcessServiceName: String, services: Seq[ServerServiceDefinition])(implicit F: Sync[F]): Resource[F, Server] = 73 | Resource { 74 | F.delay { 75 | val b = InProcessServerBuilder 76 | .forName(inProcessServiceName) 77 | .maxInboundMessageSize(Int.MaxValue) 78 | .executor(ec.execute(_)) 79 | services.foreach(b.addService) 80 | val s = b.build().start() 81 | (s, F.delay { s.shutdown().awaitTermination() }) 82 | } 83 | } 84 | 85 | private def createInProcessChannel[F[_]]( 86 | ec: ExecutionContext 87 | )(inProcessServiceName: String)(implicit F: Sync[F]): Resource[F, ManagedChannel] = 88 | Resource[F, ManagedChannel] { 89 | F.delay { 90 | val c = InProcessChannelBuilder 91 | .forName(inProcessServiceName) 92 | .maxInboundMessageSize(Int.MaxValue) 93 | .executor(ec.execute(_)) 94 | .build() 95 | (c, F.delay { val _ = c.shutdown() }) 96 | } 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /core/src/test/protobuf/TestServices.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package com.avast.grpc.jsonbridge.test; 4 | 5 | service TestService { 6 | rpc Add (AddParams) returns (AddResponse) {} 7 | } 8 | 9 | message AddParams { 10 | int32 a = 1; 11 | int32 b = 2; 12 | } 13 | 14 | message AddResponse { 15 | int32 sum = 1; 16 | } 17 | -------------------------------------------------------------------------------- /core/src/test/protobuf/com/avast/grpc/jsonbridge/scalapbtest/TestServices2.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package com.avast.grpc.jsonbridge.test; 4 | 5 | service TestService2 { 6 | rpc Add2 (AddParams2) returns (AddResponse2) {} 7 | } 8 | 9 | message AddParams2 { 10 | int32 a = 1; 11 | int32 b = 2; 12 | } 13 | 14 | message AddResponse2 { 15 | int32 sum = 1; 16 | } 17 | -------------------------------------------------------------------------------- /core/src/test/scala/com/avast/grpc/jsonbridge/ReflectionGrpcJsonBridgeTest.scala: -------------------------------------------------------------------------------- 1 | package com.avast.grpc.jsonbridge 2 | 3 | import cats.effect.IO 4 | import com.avast.grpc.jsonbridge.GrpcJsonBridge.GrpcMethodName 5 | import io.grpc.inprocess.InProcessServerBuilder 6 | import io.grpc.protobuf.services.{HealthStatusManager, ProtoReflectionService} 7 | import org.scalatest.matchers.should.Matchers 8 | import org.scalatest.{Outcome, flatspec} 9 | 10 | import scala.concurrent.ExecutionContext.Implicits.global 11 | 12 | class ReflectionGrpcJsonBridgeTest extends flatspec.FixtureAnyFlatSpec with Matchers { 13 | 14 | case class FixtureParam(bridge: GrpcJsonBridge[IO]) 15 | 16 | override protected def withFixture(test: OneArgTest): Outcome = { 17 | val channelName = InProcessServerBuilder.generateName 18 | val server = InProcessServerBuilder 19 | .forName(channelName) 20 | .addService(new TestServiceImpl()) 21 | .addService(ProtoReflectionService.newInstance()) 22 | .addService(new HealthStatusManager().getHealthService) 23 | .build 24 | val (bridge, close) = ReflectionGrpcJsonBridge.createFromServer[IO](global)(server).allocated.unsafeRunSync() 25 | try { 26 | test(FixtureParam(bridge)) 27 | } finally { 28 | server.shutdownNow() 29 | close.unsafeRunSync() 30 | } 31 | } 32 | 33 | it must "successfully call the invoke method" in { f => 34 | val Right(response) = 35 | f.bridge 36 | .invoke(GrpcMethodName("com.avast.grpc.jsonbridge.test.TestService/Add"), """ { "a": 1, "b": 2} """, Map.empty) 37 | .unsafeRunSync() 38 | response shouldBe """{"sum":3}""" 39 | } 40 | 41 | it must "return expected status code for missing method" in { f => 42 | val Left(status) = f.bridge.invoke(GrpcMethodName("ble/bla"), "{}", Map.empty).unsafeRunSync() 43 | status shouldBe BridgeError.GrpcMethodNotFound 44 | } 45 | 46 | it must "return BridgeError.Json for wrongly named field" in { f => 47 | val Left(status) = f.bridge 48 | .invoke(GrpcMethodName("com.avast.grpc.jsonbridge.test.TestService/Add"), """ { "x": 1, "b": 2} """, Map.empty) 49 | .unsafeRunSync() 50 | status should matchPattern { case BridgeError.Json(_) => } 51 | } 52 | 53 | it must "return expected status code for malformed JSON" in { f => 54 | val Left(status) = 55 | f.bridge.invoke(GrpcMethodName("com.avast.grpc.jsonbridge.test.TestService/Add"), "{ble}", Map.empty).unsafeRunSync() 56 | status should matchPattern { case BridgeError.Json(_) => } 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /core/src/test/scala/com/avast/grpc/jsonbridge/ReflectionGrpcJsonBridgeTest2.scala: -------------------------------------------------------------------------------- 1 | package com.avast.grpc.jsonbridge 2 | 3 | import cats.effect.IO 4 | import com.avast.grpc.jsonbridge.GrpcJsonBridge.GrpcMethodName 5 | import io.grpc.inprocess.InProcessServerBuilder 6 | import io.grpc.protobuf.services.{HealthStatusManager, ProtoReflectionService} 7 | import org.scalatest.matchers.should.Matchers 8 | import org.scalatest.{flatspec, _} 9 | 10 | import scala.concurrent.ExecutionContext.Implicits.global 11 | 12 | class ReflectionGrpcJsonBridgeTest2 extends flatspec.FixtureAnyFlatSpec with Matchers { 13 | 14 | case class FixtureParam(bridge: GrpcJsonBridge[IO]) 15 | 16 | override protected def withFixture(test: OneArgTest): Outcome = { 17 | val channelName = InProcessServerBuilder.generateName 18 | val server = InProcessServerBuilder 19 | .forName(channelName) 20 | .addService(new TestServiceImpl2()) 21 | .addService(ProtoReflectionService.newInstance()) 22 | .addService(new HealthStatusManager().getHealthService) 23 | .build 24 | val (bridge, close) = ReflectionGrpcJsonBridge.createFromServer[IO](global)(server).allocated.unsafeRunSync() 25 | try { 26 | test(FixtureParam(bridge)) 27 | } finally { 28 | server.shutdownNow() 29 | close.unsafeRunSync() 30 | } 31 | } 32 | 33 | it must "successfully call the invoke method" in { f => 34 | val Right(response) = 35 | f.bridge 36 | .invoke(GrpcMethodName("com.avast.grpc.jsonbridge.test.TestService2/Add2"), """ { "a": 1, "b": 2} """, Map.empty) 37 | .unsafeRunSync() 38 | response shouldBe """{"sum":3}""" 39 | } 40 | 41 | it must "return expected status code for missing method" in { f => 42 | val Left(status) = f.bridge.invoke(GrpcMethodName("ble/bla"), "{}", Map.empty).unsafeRunSync() 43 | status shouldBe BridgeError.GrpcMethodNotFound 44 | } 45 | 46 | it must "return expected status code for malformed JSON" in { f => 47 | val Left(status) = 48 | f.bridge.invoke(GrpcMethodName("com.avast.grpc.jsonbridge.test.TestService2/Add2"), "{ble}", Map.empty).unsafeRunSync() 49 | status should matchPattern { case BridgeError.Json(_) => } 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /core/src/test/scala/com/avast/grpc/jsonbridge/TestServiceImpl.scala: -------------------------------------------------------------------------------- 1 | package com.avast.grpc.jsonbridge 2 | 3 | import com.avast.grpc.jsonbridge.test.TestServices.AddResponse 4 | import com.avast.grpc.jsonbridge.test.{TestServiceGrpc, TestServices} 5 | import io.grpc.stub.StreamObserver 6 | 7 | class TestServiceImpl extends TestServiceGrpc.TestServiceImplBase { 8 | override def add(request: TestServices.AddParams, responseObserver: StreamObserver[TestServices.AddResponse]): Unit = { 9 | responseObserver.onNext(AddResponse.newBuilder().setSum(request.getA + request.getB).build()) 10 | responseObserver.onCompleted() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /core/src/test/scala/com/avast/grpc/jsonbridge/TestServiceImpl2.scala: -------------------------------------------------------------------------------- 1 | package com.avast.grpc.jsonbridge 2 | 3 | import com.avast.grpc.jsonbridge.test.TestServices2.AddResponse2 4 | import com.avast.grpc.jsonbridge.test.{TestService2Grpc, TestServices2} 5 | import io.grpc.stub.StreamObserver 6 | 7 | class TestServiceImpl2 extends TestService2Grpc.TestService2ImplBase { 8 | override def add2(request: TestServices2.AddParams2, responseObserver: StreamObserver[TestServices2.AddResponse2]): Unit = { 9 | responseObserver.onNext(AddResponse2.newBuilder().setSum(request.getA + request.getB).build()) 10 | responseObserver.onCompleted() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /http4s/src/main/scala/com/avast/grpc/jsonbridge/http4s/Http4s.scala: -------------------------------------------------------------------------------- 1 | package com.avast.grpc.jsonbridge.http4s 2 | 3 | import cats._ 4 | import cats.data.NonEmptyList 5 | import cats.effect._ 6 | import cats.syntax.all._ 7 | import com.avast.grpc.jsonbridge.GrpcJsonBridge.GrpcMethodName 8 | import com.avast.grpc.jsonbridge.{BridgeError, BridgeErrorResponse, GrpcJsonBridge} 9 | import com.typesafe.scalalogging.LazyLogging 10 | import io.circe.generic.auto._ 11 | import io.grpc.Status.Code 12 | import io.grpc.{Status => GrpcStatus} 13 | import org.http4s._ 14 | import org.http4s.circe.jsonEncoderOf 15 | import org.http4s.dsl.Http4sDsl 16 | import org.http4s.dsl.impl.EntityResponseGenerator 17 | import org.http4s.headers.{`Content-Type`, `WWW-Authenticate`} 18 | import org.http4s.server.middleware.{CORS, CORSConfig} 19 | 20 | import scala.annotation.nowarn 21 | 22 | object Http4s extends LazyLogging { 23 | 24 | def apply[F[_]: Sync](configuration: Configuration)(bridge: GrpcJsonBridge[F]): HttpRoutes[F] = { 25 | implicit val h: Http4sDsl[F] = Http4sDsl[F] 26 | import h._ 27 | 28 | val pathPrefix = configuration.pathPrefix 29 | .map(_.foldLeft[Path](Root)(_ / Path.Segment(_))) 30 | .getOrElse(Root) 31 | 32 | logger.info(s"Creating HTTP4S service proxying gRPC services: ${bridge.servicesNames.mkString("[", ", ", "]")}") 33 | 34 | val http4sService = HttpRoutes.of[F] { 35 | case _ @GET -> `pathPrefix` / serviceName if serviceName.nonEmpty => 36 | NonEmptyList.fromList(bridge.methodsNames.filter(_.service == serviceName).toList) match { 37 | case None => 38 | val message = s"Service '$serviceName' not found" 39 | logger.debug(message) 40 | NotFound(BridgeErrorResponse.fromMessage(message)) 41 | case Some(methods) => 42 | Ok { methods.map(_.fullName).toList.mkString("\n") } 43 | } 44 | 45 | case _ @GET -> `pathPrefix` => 46 | Ok { bridge.methodsNames.map(_.fullName).mkString("\n") } 47 | 48 | case request @ POST -> `pathPrefix` / serviceName / methodName => 49 | val headers = request.headers 50 | headers.get[`Content-Type`] match { 51 | case Some(`Content-Type`(MediaType.application.json, _)) => 52 | request 53 | .as[String] 54 | .flatMap { body => 55 | val methodNameString = GrpcMethodName(serviceName, methodName) 56 | val headersString = mapHeaders(headers) 57 | bridge.invoke(methodNameString, body, headersString).flatMap { 58 | case Right(resp) => 59 | logger.trace("Request successful: {}", resp.substring(0, 100)) 60 | Ok(resp, `Content-Type`(MediaType.application.json)) 61 | case Left(er) => 62 | er match { 63 | case BridgeError.GrpcMethodNotFound => 64 | val message = s"Method '${methodNameString.fullName}' not found" 65 | logger.debug(message) 66 | NotFound(BridgeErrorResponse.fromMessage(message)) 67 | case er: BridgeError.Json => 68 | val message = "Wrong JSON" 69 | logger.debug(message, er.t) 70 | BadRequest(BridgeErrorResponse.fromException(message, er.t)) 71 | case er: BridgeError.Grpc => 72 | val message = "gRPC error" + Option(er.s.getDescription).map(": " + _).getOrElse("") 73 | logger.trace(message, er.s.getCause) 74 | mapStatus(er.s, configuration) 75 | case er: BridgeError.Unknown => 76 | val message = "Unknown error" 77 | logger.warn(message, er.t) 78 | InternalServerError(BridgeErrorResponse.fromException(message, er.t)) 79 | } 80 | } 81 | } 82 | case Some(`Content-Type`(mediaType, _)) => 83 | val message = s"Content-Type must be '${MediaType.application.json}', it is '$mediaType'" 84 | logger.debug(message) 85 | BadRequest(BridgeErrorResponse.fromMessage(message)) 86 | case None => 87 | val message = s"Content-Type must be '${MediaType.application.json}', it is not specified" 88 | logger.debug(message) 89 | BadRequest(BridgeErrorResponse.fromMessage(message)) 90 | } 91 | } 92 | 93 | configuration.corsConfig match { 94 | case Some(c) => CORS(http4sService, c) 95 | case None => http4sService 96 | } 97 | } 98 | 99 | private def mapHeaders(headers: Headers): Map[String, String] = headers.headers.map(h => (h.name.toString, h.value)).toMap 100 | 101 | private def mapStatus[F[_]: Sync](s: GrpcStatus, configuration: Configuration)(implicit h: Http4sDsl[F]): F[Response[F]] = { 102 | import h._ 103 | val ClientClosedRequest = Status(499, "Client Closed Request") 104 | final case class ClientClosedRequestOps(status: ClientClosedRequest.type) extends EntityResponseGenerator[F, F] { 105 | val liftG: F ~> F = h.liftG 106 | } 107 | 108 | val description = BridgeErrorResponse.fromGrpcStatus(s) 109 | 110 | // https://github.com/grpc/grpc/blob/master/doc/statuscodes.md 111 | s.getCode match { 112 | case Code.OK => Ok(description) 113 | case Code.CANCELLED => ClientClosedRequestOps(ClientClosedRequest)(description) 114 | case Code.UNKNOWN => InternalServerError(description) 115 | case Code.INVALID_ARGUMENT => BadRequest(description) 116 | case Code.DEADLINE_EXCEEDED => GatewayTimeout(description) 117 | case Code.NOT_FOUND => NotFound(description) 118 | case Code.ALREADY_EXISTS => Conflict(description) 119 | case Code.PERMISSION_DENIED => Forbidden(description) 120 | case Code.RESOURCE_EXHAUSTED => TooManyRequests(description) 121 | case Code.FAILED_PRECONDITION => BadRequest(description) 122 | case Code.ABORTED => Conflict(description) 123 | case Code.OUT_OF_RANGE => BadRequest(description) 124 | case Code.UNIMPLEMENTED => NotImplemented(description) 125 | case Code.INTERNAL => InternalServerError(description) 126 | case Code.UNAVAILABLE => ServiceUnavailable(description) 127 | case Code.DATA_LOSS => InternalServerError(description) 128 | case Code.UNAUTHENTICATED => Unauthorized(configuration.wwwAuthenticate) 129 | } 130 | } 131 | 132 | @nowarn 133 | private implicit def grpcStatusJsonEntityEncoder[F[_]: Applicative]: EntityEncoder[F, BridgeErrorResponse] = 134 | jsonEncoderOf[F, BridgeErrorResponse] 135 | } 136 | 137 | final case class Configuration( 138 | pathPrefix: Option[NonEmptyList[String]], 139 | authChallenges: NonEmptyList[Challenge], 140 | corsConfig: Option[CORSConfig] 141 | ) { 142 | private[http4s] val wwwAuthenticate: `WWW-Authenticate` = `WWW-Authenticate`(authChallenges) 143 | } 144 | 145 | object Configuration { 146 | val Default: Configuration = Configuration( 147 | pathPrefix = None, 148 | authChallenges = NonEmptyList.one(Challenge("Bearer", "")), 149 | corsConfig = None 150 | ) 151 | } 152 | -------------------------------------------------------------------------------- /http4s/src/test/protobuf/TestServices.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package com.avast.grpc.jsonbridge.test; 4 | 5 | service TestService { 6 | rpc Add (AddParams) returns (AddResponse) {} 7 | } 8 | 9 | message AddParams { 10 | int32 a = 1; 11 | int32 b = 2; 12 | } 13 | 14 | message AddResponse { 15 | int32 sum = 1; 16 | } 17 | -------------------------------------------------------------------------------- /http4s/src/test/scala/com/avast/grpc/jsonbridge/http4s/Http4sTest.scala: -------------------------------------------------------------------------------- 1 | package com.avast.grpc.jsonbridge.http4s 2 | 3 | import cats.data.NonEmptyList 4 | import cats.effect.IO 5 | import com.avast.grpc.jsonbridge._ 6 | import io.grpc.ServerServiceDefinition 7 | import org.http4s.headers.{`Content-Length`, `Content-Type`} 8 | import org.http4s.{Charset, Header, Headers, MediaType, Method, Request, Uri} 9 | import org.scalatest.concurrent.ScalaFutures 10 | import org.scalatest.funsuite.AnyFunSuite 11 | import org.typelevel.ci.CIString 12 | 13 | import scala.concurrent.ExecutionContext 14 | import scala.concurrent.ExecutionContext.Implicits.global 15 | import scala.util.Random 16 | 17 | class Http4sTest extends AnyFunSuite with ScalaFutures { 18 | 19 | val ec: ExecutionContext = implicitly[ExecutionContext] 20 | def bridge(ssd: ServerServiceDefinition): GrpcJsonBridge[IO] = 21 | ReflectionGrpcJsonBridge 22 | .createFromServices[IO](ec)(ssd) 23 | .allocated 24 | .unsafeRunSync() 25 | ._1 26 | 27 | test("basic") { 28 | val service = Http4s(Configuration.Default)(bridge(TestServiceImpl.bindService())) 29 | 30 | val Some(response) = service 31 | .apply( 32 | Request[IO]( 33 | method = Method.POST, 34 | uri = Uri.fromString("/com.avast.grpc.jsonbridge.test.TestService/Add").getOrElse(fail()) 35 | ).withEntity(""" { "a": 1, "b": 2} """) 36 | .withContentType(`Content-Type`(MediaType.application.json, Charset.`UTF-8`)) 37 | ) 38 | .value 39 | .unsafeRunSync() 40 | 41 | assertResult(org.http4s.Status.Ok)(response.status) 42 | 43 | assertResult("""{"sum":3}""")(response.as[String].unsafeRunSync()) 44 | 45 | assertResult(Headers(`Content-Type`(MediaType.application.json), `Content-Length`(9)))(response.headers) 46 | } 47 | 48 | test("path prefix") { 49 | val configuration = Configuration.Default.copy(pathPrefix = Some(NonEmptyList.of("abc", "def"))) 50 | val service = Http4s(configuration)(bridge(TestServiceImpl.bindService())) 51 | val Some(response) = service 52 | .apply( 53 | Request[IO](method = Method.POST, uri = Uri.fromString("/abc/def/com.avast.grpc.jsonbridge.test.TestService/Add").getOrElse(fail())) 54 | .withEntity(""" { "a": 1, "b": 2} """) 55 | .withContentType(`Content-Type`(MediaType.application.json)) 56 | ) 57 | .value 58 | .unsafeRunSync() 59 | 60 | assertResult(org.http4s.Status.Ok)(response.status) 61 | 62 | assertResult("""{"sum":3}""")(response.as[String].unsafeRunSync()) 63 | 64 | assertResult(Headers(`Content-Type`(MediaType.application.json), `Content-Length`(9)))(response.headers) 65 | } 66 | 67 | test("bad request after wrong request") { 68 | val service = Http4s(Configuration.Default)(bridge(TestServiceImpl.bindService())) 69 | 70 | { // empty body 71 | val Some(response) = service 72 | .apply( 73 | Request[IO](method = Method.POST, uri = Uri.fromString("/com.avast.grpc.jsonbridge.test.TestService/Add").getOrElse(fail())) 74 | .withEntity("") 75 | .withContentType(`Content-Type`(MediaType.application.json)) 76 | ) 77 | .value 78 | .unsafeRunSync() 79 | 80 | assertResult(org.http4s.Status.BadRequest)(response.status) 81 | assertResult("Bad Request")(response.status.reason) 82 | } 83 | 84 | { 85 | val Some(response) = service 86 | .apply( 87 | Request[IO](method = Method.POST, uri = Uri.fromString("/com.avast.grpc.jsonbridge.test.TestService/Add").getOrElse(fail())) 88 | .withEntity(""" { "a": 1, "b": 2} """) 89 | ) 90 | .value 91 | .unsafeRunSync() 92 | 93 | assertResult(org.http4s.Status.BadRequest)(response.status) 94 | } 95 | } 96 | 97 | test("propagate user-specified status") { 98 | val service = Http4s(Configuration.Default)(bridge(PermissionDeniedTestServiceImpl.bindService())) 99 | 100 | val Some(response) = service 101 | .apply( 102 | Request[IO](method = Method.POST, uri = Uri.fromString("/com.avast.grpc.jsonbridge.test.TestService/Add").getOrElse(fail())) 103 | .withEntity(""" { "a": 1, "b": 2} """) 104 | .withContentType(`Content-Type`(MediaType.application.json)) 105 | ) 106 | .value 107 | .unsafeRunSync() 108 | 109 | assertResult(org.http4s.Status.Forbidden)(response.status) 110 | assertResult("Forbidden")(response.status.reason) 111 | } 112 | 113 | test("provides service info") { 114 | val service = Http4s(Configuration.Default)(bridge(TestServiceImpl.bindService())) 115 | 116 | val Some(response) = service 117 | .apply( 118 | Request[IO](method = Method.GET, uri = Uri.fromString("/com.avast.grpc.jsonbridge.test.TestService").getOrElse(fail())) 119 | ) 120 | .value 121 | .unsafeRunSync() 122 | 123 | assertResult(org.http4s.Status.Ok)(response.status) 124 | 125 | assertResult("com.avast.grpc.jsonbridge.test.TestService/Add")(response.as[String].unsafeRunSync()) 126 | } 127 | 128 | test("provides services info") { 129 | val service = Http4s(Configuration.Default)(bridge(TestServiceImpl.bindService())) 130 | 131 | val Some(response) = service 132 | .apply( 133 | Request[IO](method = Method.GET, uri = Uri.fromString("/").getOrElse(fail())) 134 | ) 135 | .value 136 | .unsafeRunSync() 137 | 138 | assertResult(org.http4s.Status.Ok)(response.status) 139 | 140 | assertResult("com.avast.grpc.jsonbridge.test.TestService/Add")(response.as[String].unsafeRunSync()) 141 | } 142 | 143 | test("passes user headers") { 144 | val service = Http4s(Configuration.Default)(bridge(TestServiceImpl.withInterceptor)) 145 | 146 | val headerValue = Random.alphanumeric.take(10).mkString("") 147 | 148 | val Some(response) = service 149 | .apply( 150 | Request[IO]( 151 | method = Method.POST, 152 | uri = Uri.fromString("/com.avast.grpc.jsonbridge.test.TestService/Add").getOrElse(fail()), 153 | headers = Headers(Header.Raw(CIString(TestServiceImpl.HeaderName), headerValue)) 154 | ).withEntity(""" { "a": 1, "b": 2} """) 155 | .withContentType(`Content-Type`(MediaType.application.json)) 156 | ) 157 | .value 158 | .unsafeRunSync() 159 | 160 | assertResult(org.http4s.Status.Ok)(response.status) 161 | assertResult(headerValue)(TestServiceImpl.lastContextValue.get()) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /http4s/src/test/scala/com/avast/grpc/jsonbridge/http4s/TestServiceImpl.scala: -------------------------------------------------------------------------------- 1 | package com.avast.grpc.jsonbridge.http4s 2 | 3 | import java.util.concurrent.atomic.AtomicReference 4 | 5 | import com.avast.grpc.jsonbridge.test.TestServices.AddResponse 6 | import com.avast.grpc.jsonbridge.test.{TestServiceGrpc, TestServices} 7 | import io.grpc.stub.StreamObserver 8 | import io.grpc.{ 9 | Context, 10 | Contexts, 11 | Metadata, 12 | ServerCall, 13 | ServerCallHandler, 14 | ServerInterceptor, 15 | ServerInterceptors, 16 | ServerServiceDefinition, 17 | Status, 18 | StatusException 19 | } 20 | 21 | object TestServiceImpl extends TestServiceGrpc.TestServiceImplBase { 22 | val HeaderName: String = "The-Header" 23 | private val contextKey = Context.key[String]("theHeader") 24 | private val metadataKey = Metadata.Key.of(HeaderName, Metadata.ASCII_STRING_MARSHALLER) 25 | val lastContextValue: AtomicReference[String] = new AtomicReference[String]("") 26 | 27 | override def add(request: TestServices.AddParams, responseObserver: StreamObserver[TestServices.AddResponse]): Unit = { 28 | lastContextValue.set(contextKey.get()) 29 | responseObserver.onNext(AddResponse.newBuilder().setSum(request.getA + request.getB).build()) 30 | responseObserver.onCompleted() 31 | } 32 | 33 | def withInterceptor: ServerServiceDefinition = 34 | ServerInterceptors.intercept( 35 | this, 36 | new ServerInterceptor { 37 | override def interceptCall[ReqT, RespT]( 38 | call: ServerCall[ReqT, RespT], 39 | headers: Metadata, 40 | next: ServerCallHandler[ReqT, RespT] 41 | ): ServerCall.Listener[ReqT] = 42 | Contexts.interceptCall(Context.current().withValue(contextKey, headers.get(metadataKey)), call, headers, next) 43 | } 44 | ) 45 | } 46 | 47 | object PermissionDeniedTestServiceImpl extends TestServiceGrpc.TestServiceImplBase { 48 | override def add(request: TestServices.AddParams, responseObserver: StreamObserver[TestServices.AddResponse]): Unit = { 49 | responseObserver.onError(new StatusException(Status.PERMISSION_DENIED)) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.9.9 -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.3") 2 | addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.12.1") 3 | addSbtPlugin("ch.epfl.scala" % "sbt-missinglink" % "0.3.6") 4 | addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "1.1.4") 5 | addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.4.4") 6 | addSbtPlugin("com.timushev.sbt" % "sbt-rewarn" % "0.1.3") 7 | addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.11.1") 8 | -------------------------------------------------------------------------------- /project/protoc.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.thesamet" % "sbt-protoc" % "1.0.7") 2 | 3 | libraryDependencies += "com.thesamet.scalapb" %% "compilerplugin" % "0.11.17" 4 | -------------------------------------------------------------------------------- /sbt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # A more capable sbt runner, coincidentally also called sbt. 4 | # Author: Paul Phillips 5 | # https://github.com/paulp/sbt-extras 6 | # 7 | # Generated from http://www.opensource.org/licenses/bsd-license.php 8 | # Copyright (c) 2011, Paul Phillips. All rights reserved. 9 | # 10 | # Redistribution and use in source and binary forms, with or without 11 | # modification, are permitted provided that the following conditions are 12 | # met: 13 | # 14 | # * Redistributions of source code must retain the above copyright 15 | # notice, this list of conditions and the following disclaimer. 16 | # * Redistributions in binary form must reproduce the above copyright 17 | # notice, this list of conditions and the following disclaimer in the 18 | # documentation and/or other materials provided with the distribution. 19 | # * Neither the name of the author nor the names of its contributors 20 | # may be used to endorse or promote products derived from this software 21 | # without specific prior written permission. 22 | # 23 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 24 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 25 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 26 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 27 | # HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 28 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 29 | # TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 30 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 31 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 32 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 33 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 34 | 35 | set -o pipefail 36 | 37 | declare -r sbt_release_version="1.9.4" 38 | declare -r sbt_unreleased_version="1.9.4" 39 | 40 | declare -r latest_213="2.13.12" 41 | declare -r latest_212="2.12.18" 42 | declare -r latest_211="2.11.12" 43 | declare -r latest_210="2.10.7" 44 | declare -r latest_29="2.9.3" 45 | declare -r latest_28="2.8.2" 46 | 47 | declare -r buildProps="project/build.properties" 48 | 49 | declare -r sbt_launch_ivy_release_repo="https://repo.typesafe.com/typesafe/ivy-releases" 50 | declare -r sbt_launch_ivy_snapshot_repo="https://repo.scala-sbt.org/scalasbt/ivy-snapshots" 51 | declare -r sbt_launch_mvn_release_repo="https://repo1.maven.org/maven2" 52 | declare -r sbt_launch_mvn_snapshot_repo="https://repo.scala-sbt.org/scalasbt/maven-snapshots" 53 | 54 | declare -r default_jvm_opts_common="-Xms512m -Xss2m -XX:MaxInlineLevel=18" 55 | declare -r noshare_opts="-Dsbt.global.base=project/.sbtboot -Dsbt.boot.directory=project/.boot -Dsbt.ivy.home=project/.ivy -Dsbt.coursier.home=project/.coursier" 56 | 57 | declare sbt_jar sbt_dir sbt_create sbt_version sbt_script sbt_new 58 | declare sbt_explicit_version 59 | declare verbose noshare batch trace_level 60 | 61 | declare java_cmd="java" 62 | declare sbt_launch_dir="$HOME/.sbt/launchers" 63 | declare sbt_launch_repo 64 | 65 | # pull -J and -D options to give to java. 66 | declare -a java_args scalac_args sbt_commands residual_args 67 | 68 | # args to jvm/sbt via files or environment variables 69 | declare -a extra_jvm_opts extra_sbt_opts 70 | 71 | echoerr() { echo >&2 "$@"; } 72 | vlog() { [[ -n "$verbose" ]] && echoerr "$@"; } 73 | die() { 74 | echo "Aborting: $*" 75 | exit 1 76 | } 77 | 78 | setTrapExit() { 79 | # save stty and trap exit, to ensure echo is re-enabled if we are interrupted. 80 | SBT_STTY="$(stty -g 2>/dev/null)" 81 | export SBT_STTY 82 | 83 | # restore stty settings (echo in particular) 84 | onSbtRunnerExit() { 85 | [ -t 0 ] || return 86 | vlog "" 87 | vlog "restoring stty: $SBT_STTY" 88 | stty "$SBT_STTY" 89 | } 90 | 91 | vlog "saving stty: $SBT_STTY" 92 | trap onSbtRunnerExit EXIT 93 | } 94 | 95 | # this seems to cover the bases on OSX, and someone will 96 | # have to tell me about the others. 97 | get_script_path() { 98 | local path="$1" 99 | [[ -L "$path" ]] || { 100 | echo "$path" 101 | return 102 | } 103 | 104 | local -r target="$(readlink "$path")" 105 | if [[ "${target:0:1}" == "/" ]]; then 106 | echo "$target" 107 | else 108 | echo "${path%/*}/$target" 109 | fi 110 | } 111 | 112 | script_path="$(get_script_path "${BASH_SOURCE[0]}")" 113 | declare -r script_path 114 | script_name="${script_path##*/}" 115 | declare -r script_name 116 | 117 | init_default_option_file() { 118 | local overriding_var="${!1}" 119 | local default_file="$2" 120 | if [[ ! -r "$default_file" && "$overriding_var" =~ ^@(.*)$ ]]; then 121 | local envvar_file="${BASH_REMATCH[1]}" 122 | if [[ -r "$envvar_file" ]]; then 123 | default_file="$envvar_file" 124 | fi 125 | fi 126 | echo "$default_file" 127 | } 128 | 129 | sbt_opts_file="$(init_default_option_file SBT_OPTS .sbtopts)" 130 | sbtx_opts_file="$(init_default_option_file SBTX_OPTS .sbtxopts)" 131 | jvm_opts_file="$(init_default_option_file JVM_OPTS .jvmopts)" 132 | 133 | build_props_sbt() { 134 | [[ -r "$buildProps" ]] && 135 | grep '^sbt\.version' "$buildProps" | tr '=\r' ' ' | awk '{ print $2; }' 136 | } 137 | 138 | set_sbt_version() { 139 | sbt_version="${sbt_explicit_version:-$(build_props_sbt)}" 140 | [[ -n "$sbt_version" ]] || sbt_version=$sbt_release_version 141 | export sbt_version 142 | } 143 | 144 | url_base() { 145 | local version="$1" 146 | 147 | case "$version" in 148 | 0.7.*) echo "https://storage.googleapis.com/google-code-archive-downloads/v2/code.google.com/simple-build-tool" ;; 149 | 0.10.*) echo "$sbt_launch_ivy_release_repo" ;; 150 | 0.11.[12]) echo "$sbt_launch_ivy_release_repo" ;; 151 | 0.*-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) # ie "*-yyyymmdd-hhMMss" 152 | echo "$sbt_launch_ivy_snapshot_repo" ;; 153 | 0.*) echo "$sbt_launch_ivy_release_repo" ;; 154 | *-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]T[0-9][0-9][0-9][0-9][0-9][0-9]) # ie "*-yyyymmddThhMMss" 155 | echo "$sbt_launch_mvn_snapshot_repo" ;; 156 | *) echo "$sbt_launch_mvn_release_repo" ;; 157 | esac 158 | } 159 | 160 | make_url() { 161 | local version="$1" 162 | 163 | local base="${sbt_launch_repo:-$(url_base "$version")}" 164 | 165 | case "$version" in 166 | 0.7.*) echo "$base/sbt-launch-0.7.7.jar" ;; 167 | 0.10.*) echo "$base/org.scala-tools.sbt/sbt-launch/$version/sbt-launch.jar" ;; 168 | 0.11.[12]) echo "$base/org.scala-tools.sbt/sbt-launch/$version/sbt-launch.jar" ;; 169 | 0.*) echo "$base/org.scala-sbt/sbt-launch/$version/sbt-launch.jar" ;; 170 | *) echo "$base/org/scala-sbt/sbt-launch/$version/sbt-launch-${version}.jar" ;; 171 | esac 172 | } 173 | 174 | addJava() { 175 | vlog "[addJava] arg = '$1'" 176 | java_args+=("$1") 177 | } 178 | addSbt() { 179 | vlog "[addSbt] arg = '$1'" 180 | sbt_commands+=("$1") 181 | } 182 | addScalac() { 183 | vlog "[addScalac] arg = '$1'" 184 | scalac_args+=("$1") 185 | } 186 | addResidual() { 187 | vlog "[residual] arg = '$1'" 188 | residual_args+=("$1") 189 | } 190 | 191 | addResolver() { addSbt "set resolvers += $1"; } 192 | 193 | addDebugger() { addJava "-Xdebug" && addJava "-Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=$1"; } 194 | 195 | setThisBuild() { 196 | vlog "[addBuild] args = '$*'" 197 | local key="$1" && shift 198 | addSbt "set $key in ThisBuild := $*" 199 | } 200 | setScalaVersion() { 201 | [[ "$1" == *"-SNAPSHOT" ]] && addResolver 'Resolver.sonatypeRepo("snapshots")' 202 | addSbt "++ $1" 203 | } 204 | setJavaHome() { 205 | java_cmd="$1/bin/java" 206 | setThisBuild javaHome "_root_.scala.Some(file(\"$1\"))" 207 | export JAVA_HOME="$1" 208 | export JDK_HOME="$1" 209 | export PATH="$JAVA_HOME/bin:$PATH" 210 | } 211 | 212 | getJavaVersion() { 213 | local -r str=$("$1" -version 2>&1 | grep -E -e '(java|openjdk) version' | awk '{ print $3 }' | tr -d '"') 214 | 215 | # java -version on java8 says 1.8.x 216 | # but on 9 and 10 it's 9.x.y and 10.x.y. 217 | if [[ "$str" =~ ^1\.([0-9]+)(\..*)?$ ]]; then 218 | echo "${BASH_REMATCH[1]}" 219 | # Fixes https://github.com/dwijnand/sbt-extras/issues/326 220 | elif [[ "$str" =~ ^([0-9]+)(\..*)?(-ea)?$ ]]; then 221 | echo "${BASH_REMATCH[1]}" 222 | elif [[ -n "$str" ]]; then 223 | echoerr "Can't parse java version from: $str" 224 | fi 225 | } 226 | 227 | checkJava() { 228 | # Warn if there is a Java version mismatch between PATH and JAVA_HOME/JDK_HOME 229 | 230 | [[ -n "$JAVA_HOME" && -e "$JAVA_HOME/bin/java" ]] && java="$JAVA_HOME/bin/java" 231 | [[ -n "$JDK_HOME" && -e "$JDK_HOME/lib/tools.jar" ]] && java="$JDK_HOME/bin/java" 232 | 233 | if [[ -n "$java" ]]; then 234 | pathJavaVersion=$(getJavaVersion java) 235 | homeJavaVersion=$(getJavaVersion "$java") 236 | if [[ "$pathJavaVersion" != "$homeJavaVersion" ]]; then 237 | echoerr "Warning: Java version mismatch between PATH and JAVA_HOME/JDK_HOME, sbt will use the one in PATH" 238 | echoerr " Either: fix your PATH, remove JAVA_HOME/JDK_HOME or use -java-home" 239 | echoerr " java version from PATH: $pathJavaVersion" 240 | echoerr " java version from JAVA_HOME/JDK_HOME: $homeJavaVersion" 241 | fi 242 | fi 243 | } 244 | 245 | java_version() { 246 | local -r version=$(getJavaVersion "$java_cmd") 247 | vlog "Detected Java version: $version" 248 | echo "$version" 249 | } 250 | 251 | is_apple_silicon() { [[ "$(uname -s)" == "Darwin" && "$(uname -m)" == "arm64" ]]; } 252 | 253 | # MaxPermSize critical on pre-8 JVMs but incurs noisy warning on 8+ 254 | default_jvm_opts() { 255 | local -r v="$(java_version)" 256 | if [[ $v -ge 17 ]]; then 257 | echo "$default_jvm_opts_common" 258 | elif [[ $v -ge 10 ]]; then 259 | if is_apple_silicon; then 260 | # As of Dec 2020, JVM for Apple Silicon (M1) doesn't support JVMCI 261 | echo "$default_jvm_opts_common" 262 | else 263 | echo "$default_jvm_opts_common -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler" 264 | fi 265 | elif [[ $v -ge 8 ]]; then 266 | echo "$default_jvm_opts_common" 267 | else 268 | echo "-XX:MaxPermSize=384m $default_jvm_opts_common" 269 | fi 270 | } 271 | 272 | execRunner() { 273 | # print the arguments one to a line, quoting any containing spaces 274 | vlog "# Executing command line:" && { 275 | for arg; do 276 | if [[ -n "$arg" ]]; then 277 | if printf "%s\n" "$arg" | grep -q ' '; then 278 | printf >&2 "\"%s\"\n" "$arg" 279 | else 280 | printf >&2 "%s\n" "$arg" 281 | fi 282 | fi 283 | done 284 | vlog "" 285 | } 286 | 287 | setTrapExit 288 | 289 | if [[ -n "$batch" ]]; then 290 | "$@" /dev/null 2>&1; then 312 | curl --fail --silent --location "$url" --output "$jar" 313 | elif command -v wget >/dev/null 2>&1; then 314 | wget -q -O "$jar" "$url" 315 | fi 316 | } && [[ -r "$jar" ]] 317 | } 318 | 319 | acquire_sbt_jar() { 320 | { 321 | sbt_jar="$(jar_file "$sbt_version")" 322 | [[ -r "$sbt_jar" ]] 323 | } || { 324 | sbt_jar="$HOME/.ivy2/local/org.scala-sbt/sbt-launch/$sbt_version/jars/sbt-launch.jar" 325 | [[ -r "$sbt_jar" ]] 326 | } || { 327 | sbt_jar="$(jar_file "$sbt_version")" 328 | jar_url="$(make_url "$sbt_version")" 329 | 330 | echoerr "Downloading sbt launcher for ${sbt_version}:" 331 | echoerr " From ${jar_url}" 332 | echoerr " To ${sbt_jar}" 333 | 334 | download_url "${jar_url}" "${sbt_jar}" 335 | 336 | case "${sbt_version}" in 337 | 0.*) 338 | vlog "SBT versions < 1.0 do not have published MD5 checksums, skipping check" 339 | echo "" 340 | ;; 341 | *) verify_sbt_jar "${sbt_jar}" ;; 342 | esac 343 | } 344 | } 345 | 346 | verify_sbt_jar() { 347 | local jar="${1}" 348 | local md5="${jar}.md5" 349 | md5url="$(make_url "${sbt_version}").md5" 350 | 351 | echoerr "Downloading sbt launcher ${sbt_version} md5 hash:" 352 | echoerr " From ${md5url}" 353 | echoerr " To ${md5}" 354 | 355 | download_url "${md5url}" "${md5}" >/dev/null 2>&1 356 | 357 | if command -v md5sum >/dev/null 2>&1; then 358 | if echo "$(cat "${md5}") ${jar}" | md5sum -c -; then 359 | rm -rf "${md5}" 360 | return 0 361 | else 362 | echoerr "Checksum does not match" 363 | return 1 364 | fi 365 | elif command -v md5 >/dev/null 2>&1; then 366 | if [ "$(md5 -q "${jar}")" == "$(cat "${md5}")" ]; then 367 | rm -rf "${md5}" 368 | return 0 369 | else 370 | echoerr "Checksum does not match" 371 | return 1 372 | fi 373 | elif command -v openssl >/dev/null 2>&1; then 374 | if [ "$(openssl md5 -r "${jar}" | awk '{print $1}')" == "$(cat "${md5}")" ]; then 375 | rm -rf "${md5}" 376 | return 0 377 | else 378 | echoerr "Checksum does not match" 379 | return 1 380 | fi 381 | else 382 | echoerr "Could not find an MD5 command" 383 | return 1 384 | fi 385 | } 386 | 387 | usage() { 388 | set_sbt_version 389 | cat < display stack traces with a max of frames (default: -1, traces suppressed) 402 | -debug-inc enable debugging log for the incremental compiler 403 | -no-colors disable ANSI color codes 404 | -sbt-create start sbt even if current directory contains no sbt project 405 | -sbt-dir path to global settings/plugins directory (default: ~/.sbt/) 406 | -sbt-boot path to shared boot directory (default: ~/.sbt/boot in 0.11+) 407 | -ivy path to local Ivy repository (default: ~/.ivy2) 408 | -no-share use all local caches; no sharing 409 | -offline put sbt in offline mode 410 | -jvm-debug Turn on JVM debugging, open at the given port. 411 | -batch Disable interactive mode 412 | -prompt Set the sbt prompt; in expr, 's' is the State and 'e' is Extracted 413 | -script Run the specified file as a scala script 414 | 415 | # sbt version (default: sbt.version from $buildProps if present, otherwise $sbt_release_version) 416 | -sbt-version use the specified version of sbt (default: $sbt_release_version) 417 | -sbt-force-latest force the use of the latest release of sbt: $sbt_release_version 418 | -sbt-dev use the latest pre-release version of sbt: $sbt_unreleased_version 419 | -sbt-jar use the specified jar as the sbt launcher 420 | -sbt-launch-dir directory to hold sbt launchers (default: $sbt_launch_dir) 421 | -sbt-launch-repo repo url for downloading sbt launcher jar (default: $(url_base "$sbt_version")) 422 | 423 | # scala version (default: as chosen by sbt) 424 | -28 use $latest_28 425 | -29 use $latest_29 426 | -210 use $latest_210 427 | -211 use $latest_211 428 | -212 use $latest_212 429 | -213 use $latest_213 430 | -scala-home use the scala build at the specified directory 431 | -scala-version use the specified version of scala 432 | -binary-version use the specified scala version when searching for dependencies 433 | 434 | # java version (default: java from PATH, currently $(java -version 2>&1 | grep version)) 435 | -java-home alternate JAVA_HOME 436 | 437 | # passing options to the jvm - note it does NOT use JAVA_OPTS due to pollution 438 | # The default set is used if JVM_OPTS is unset and no -jvm-opts file is found 439 | $(default_jvm_opts) 440 | JVM_OPTS environment variable holding either the jvm args directly, or 441 | the reference to a file containing jvm args if given path is prepended by '@' (e.g. '@/etc/jvmopts') 442 | Note: "@"-file is overridden by local '.jvmopts' or '-jvm-opts' argument. 443 | -jvm-opts file containing jvm args (if not given, .jvmopts in project root is used if present) 444 | -Dkey=val pass -Dkey=val directly to the jvm 445 | -J-X pass option -X directly to the jvm (-J is stripped) 446 | 447 | # passing options to sbt, OR to this runner 448 | SBT_OPTS environment variable holding either the sbt args directly, or 449 | the reference to a file containing sbt args if given path is prepended by '@' (e.g. '@/etc/sbtopts') 450 | Note: "@"-file is overridden by local '.sbtopts' or '-sbt-opts' argument. 451 | -sbt-opts file containing sbt args (if not given, .sbtopts in project root is used if present) 452 | -S-X add -X to sbt's scalacOptions (-S is stripped) 453 | 454 | # passing options exclusively to this runner 455 | SBTX_OPTS environment variable holding either the sbt-extras args directly, or 456 | the reference to a file containing sbt-extras args if given path is prepended by '@' (e.g. '@/etc/sbtxopts') 457 | Note: "@"-file is overridden by local '.sbtxopts' or '-sbtx-opts' argument. 458 | -sbtx-opts file containing sbt-extras args (if not given, .sbtxopts in project root is used if present) 459 | EOM 460 | exit 0 461 | } 462 | 463 | process_args() { 464 | require_arg() { 465 | local type="$1" 466 | local opt="$2" 467 | local arg="$3" 468 | 469 | if [[ -z "$arg" ]] || [[ "${arg:0:1}" == "-" ]]; then 470 | die "$opt requires <$type> argument" 471 | fi 472 | } 473 | while [[ $# -gt 0 ]]; do 474 | case "$1" in 475 | -h | -help) usage ;; 476 | -v) verbose=true && shift ;; 477 | -d) addSbt "--debug" && shift ;; 478 | -w) addSbt "--warn" && shift ;; 479 | -q) addSbt "--error" && shift ;; 480 | -x) shift ;; # currently unused 481 | -trace) require_arg integer "$1" "$2" && trace_level="$2" && shift 2 ;; 482 | -debug-inc) addJava "-Dxsbt.inc.debug=true" && shift ;; 483 | 484 | -no-colors) addJava "-Dsbt.log.noformat=true" && addJava "-Dsbt.color=false" && shift ;; 485 | -sbt-create) sbt_create=true && shift ;; 486 | -sbt-dir) require_arg path "$1" "$2" && sbt_dir="$2" && shift 2 ;; 487 | -sbt-boot) require_arg path "$1" "$2" && addJava "-Dsbt.boot.directory=$2" && shift 2 ;; 488 | -ivy) require_arg path "$1" "$2" && addJava "-Dsbt.ivy.home=$2" && shift 2 ;; 489 | -no-share) noshare=true && shift ;; 490 | -offline) addSbt "set offline in Global := true" && shift ;; 491 | -jvm-debug) require_arg port "$1" "$2" && addDebugger "$2" && shift 2 ;; 492 | -batch) batch=true && shift ;; 493 | -prompt) require_arg "expr" "$1" "$2" && setThisBuild shellPrompt "(s => { val e = Project.extract(s) ; $2 })" && shift 2 ;; 494 | -script) require_arg file "$1" "$2" && sbt_script="$2" && addJava "-Dsbt.main.class=sbt.ScriptMain" && shift 2 ;; 495 | 496 | -sbt-version) require_arg version "$1" "$2" && sbt_explicit_version="$2" && shift 2 ;; 497 | -sbt-force-latest) sbt_explicit_version="$sbt_release_version" && shift ;; 498 | -sbt-dev) sbt_explicit_version="$sbt_unreleased_version" && shift ;; 499 | -sbt-jar) require_arg path "$1" "$2" && sbt_jar="$2" && shift 2 ;; 500 | -sbt-launch-dir) require_arg path "$1" "$2" && sbt_launch_dir="$2" && shift 2 ;; 501 | -sbt-launch-repo) require_arg path "$1" "$2" && sbt_launch_repo="$2" && shift 2 ;; 502 | 503 | -28) setScalaVersion "$latest_28" && shift ;; 504 | -29) setScalaVersion "$latest_29" && shift ;; 505 | -210) setScalaVersion "$latest_210" && shift ;; 506 | -211) setScalaVersion "$latest_211" && shift ;; 507 | -212) setScalaVersion "$latest_212" && shift ;; 508 | -213) setScalaVersion "$latest_213" && shift ;; 509 | 510 | -scala-version) require_arg version "$1" "$2" && setScalaVersion "$2" && shift 2 ;; 511 | -binary-version) require_arg version "$1" "$2" && setThisBuild scalaBinaryVersion "\"$2\"" && shift 2 ;; 512 | -scala-home) require_arg path "$1" "$2" && setThisBuild scalaHome "_root_.scala.Some(file(\"$2\"))" && shift 2 ;; 513 | -java-home) require_arg path "$1" "$2" && setJavaHome "$2" && shift 2 ;; 514 | -sbt-opts) require_arg path "$1" "$2" && sbt_opts_file="$2" && shift 2 ;; 515 | -sbtx-opts) require_arg path "$1" "$2" && sbtx_opts_file="$2" && shift 2 ;; 516 | -jvm-opts) require_arg path "$1" "$2" && jvm_opts_file="$2" && shift 2 ;; 517 | 518 | -D*) addJava "$1" && shift ;; 519 | -J*) addJava "${1:2}" && shift ;; 520 | -S*) addScalac "${1:2}" && shift ;; 521 | 522 | new) sbt_new=true && : ${sbt_explicit_version:=$sbt_release_version} && addResidual "$1" && shift ;; 523 | 524 | *) addResidual "$1" && shift ;; 525 | esac 526 | done 527 | } 528 | 529 | # process the direct command line arguments 530 | process_args "$@" 531 | 532 | # skip #-styled comments and blank lines 533 | readConfigFile() { 534 | local end=false 535 | until $end; do 536 | read -r || end=true 537 | [[ $REPLY =~ ^# ]] || [[ -z $REPLY ]] || echo "$REPLY" 538 | done <"$1" 539 | } 540 | 541 | # if there are file/environment sbt_opts, process again so we 542 | # can supply args to this runner 543 | if [[ -r "$sbt_opts_file" ]]; then 544 | vlog "Using sbt options defined in file $sbt_opts_file" 545 | while read -r opt; do extra_sbt_opts+=("$opt"); done < <(readConfigFile "$sbt_opts_file") 546 | elif [[ -n "$SBT_OPTS" && ! ("$SBT_OPTS" =~ ^@.*) ]]; then 547 | vlog "Using sbt options defined in variable \$SBT_OPTS" 548 | IFS=" " read -r -a extra_sbt_opts <<<"$SBT_OPTS" 549 | else 550 | vlog "No extra sbt options have been defined" 551 | fi 552 | 553 | # if there are file/environment sbtx_opts, process again so we 554 | # can supply args to this runner 555 | if [[ -r "$sbtx_opts_file" ]]; then 556 | vlog "Using sbt options defined in file $sbtx_opts_file" 557 | while read -r opt; do extra_sbt_opts+=("$opt"); done < <(readConfigFile "$sbtx_opts_file") 558 | elif [[ -n "$SBTX_OPTS" && ! ("$SBTX_OPTS" =~ ^@.*) ]]; then 559 | vlog "Using sbt options defined in variable \$SBTX_OPTS" 560 | IFS=" " read -r -a extra_sbt_opts <<<"$SBTX_OPTS" 561 | else 562 | vlog "No extra sbt options have been defined" 563 | fi 564 | 565 | [[ -n "${extra_sbt_opts[*]}" ]] && process_args "${extra_sbt_opts[@]}" 566 | 567 | # reset "$@" to the residual args 568 | set -- "${residual_args[@]}" 569 | argumentCount=$# 570 | 571 | # set sbt version 572 | set_sbt_version 573 | 574 | checkJava 575 | 576 | # only exists in 0.12+ 577 | setTraceLevel() { 578 | case "$sbt_version" in 579 | "0.7."* | "0.10."* | "0.11."*) echoerr "Cannot set trace level in sbt version $sbt_version" ;; 580 | *) setThisBuild traceLevel "$trace_level" ;; 581 | esac 582 | } 583 | 584 | # set scalacOptions if we were given any -S opts 585 | [[ ${#scalac_args[@]} -eq 0 ]] || addSbt "set scalacOptions in ThisBuild += \"${scalac_args[*]}\"" 586 | 587 | [[ -n "$sbt_explicit_version" && -z "$sbt_new" ]] && addJava "-Dsbt.version=$sbt_explicit_version" 588 | vlog "Detected sbt version $sbt_version" 589 | 590 | if [[ -n "$sbt_script" ]]; then 591 | residual_args=("$sbt_script" "${residual_args[@]}") 592 | else 593 | # no args - alert them there's stuff in here 594 | ((argumentCount > 0)) || { 595 | vlog "Starting $script_name: invoke with -help for other options" 596 | residual_args=(shell) 597 | } 598 | fi 599 | 600 | # verify this is an sbt dir, -create was given or user attempts to run a scala script 601 | [[ -r ./build.sbt || -d ./project || -n "$sbt_create" || -n "$sbt_script" || -n "$sbt_new" ]] || { 602 | cat <