diff --git a/airframe-http-grpc/src/main/scala/wvlet/airframe/http/grpc/Grpc.scala b/airframe-http-grpc/src/main/scala/wvlet/airframe/http/grpc/Grpc.scala new file mode 100644 index 0000000000..862aed22a5 --- /dev/null +++ b/airframe-http-grpc/src/main/scala/wvlet/airframe/http/grpc/Grpc.scala @@ -0,0 +1,20 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package wvlet.airframe.http.grpc + +/** + */ +object Grpc { + def server: GrpcServerConfig = GrpcServerConfig() +} diff --git a/airframe-http-grpc/src/main/scala/wvlet/airframe/http/grpc/GrpcServer.scala b/airframe-http-grpc/src/main/scala/wvlet/airframe/http/grpc/GrpcServer.scala new file mode 100644 index 0000000000..069792fc10 --- /dev/null +++ b/airframe-http-grpc/src/main/scala/wvlet/airframe/http/grpc/GrpcServer.scala @@ -0,0 +1,70 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package wvlet.airframe.http.grpc +import io.grpc.{Server, ServerBuilder} +import wvlet.airframe.{Design, Session} +import wvlet.airframe.http.Router +import wvlet.log.LogSupport +import wvlet.log.io.IOUtil +import scala.language.existentials + +/** + */ +case class GrpcServerConfig( + name: String = "default", + private val serverPort: Option[Int] = None, + router: Router = Router.empty +) extends LogSupport { + lazy val port = serverPort.getOrElse(IOUtil.unusedPort) + + def withName(name: String): GrpcServerConfig = this.copy(name = name) + def withPort(port: Int): GrpcServerConfig = this.copy(serverPort = Some(port)) + def withRouter(router: Router): GrpcServerConfig = this.copy(router = router) + + def newServer(session: Session): GrpcServer = { + val services = GrpcServiceBuilder.buildService(router, session) + debug(s"service:\n${services.map(_.getServiceDescriptor).mkString("\n")}") + val serverBuilder = ServerBuilder.forPort(port) + for (service <- services) { + serverBuilder.addService(service) + } + new GrpcServer(this, serverBuilder.build()) + } + + def design: Design = { + Design.newDesign + .bind[GrpcServerConfig].toInstance(this) + .bind[GrpcServer].toProvider { (config: GrpcServerConfig, session: Session) => config.newServer(session) } + .onStart { _.start } + } +} + +class GrpcServer(grpcServerConfig: GrpcServerConfig, server: Server) extends AutoCloseable with LogSupport { + def port: Int = grpcServerConfig.port + def localAddress: String = s"localhost:${grpcServerConfig.port}" + + def start: Unit = { + info(s"Starting gRPC server ${grpcServerConfig.name} at ${localAddress}") + server.start() + } + + def awaitTermination: Unit = { + server.awaitTermination() + } + + override def close(): Unit = { + info(s"Closing gRPC server ${grpcServerConfig.name} at ${localAddress}") + server.shutdownNow() + } +} diff --git a/airframe-http-grpc/src/main/scala/wvlet/airframe/http/grpc/GrpcServiceBuilder.scala b/airframe-http-grpc/src/main/scala/wvlet/airframe/http/grpc/GrpcServiceBuilder.scala new file mode 100644 index 0000000000..8d37b5d9ee --- /dev/null +++ b/airframe-http-grpc/src/main/scala/wvlet/airframe/http/grpc/GrpcServiceBuilder.scala @@ -0,0 +1,90 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package wvlet.airframe.http.grpc +import java.io.{ByteArrayInputStream, InputStream} + +import io.grpc.MethodDescriptor.Marshaller +import io.grpc.stub.ServerCalls +import io.grpc.{MethodDescriptor, ServerServiceDefinition} +import wvlet.airframe.Session +import wvlet.airframe.codec.{MessageCodec, MessageCodecFactory} +import wvlet.airframe.control.IO +import wvlet.airframe.http.Router +import wvlet.airframe.http.router.Route +import wvlet.airframe.msgpack.spi.MsgPack + +/** + */ +object GrpcServiceBuilder { + + def buildMethodDescriptor(r: Route, codecFactory: MessageCodecFactory): MethodDescriptor[MsgPack, Any] = { + val b = MethodDescriptor.newBuilder[MsgPack, Any]() + // TODO setIdempotent, setSafe, sampling, etc. + b.setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName(s"${r.serviceName}/${r.methodSurface.name}") + .setRequestMarshaller(RPCRequestMarshaller) + .setResponseMarshaller( + new RPCResponseMarshaller[Any]( + codecFactory.of(r.returnTypeSurface).asInstanceOf[MessageCodec[Any]] + ) + ) + .build() + } + + def buildService( + router: Router, + session: Session, + codecFactory: MessageCodecFactory = MessageCodecFactory.defaultFactoryForJSON + ): Seq[ServerServiceDefinition] = { + val services = for ((serviceName, routes) <- router.routes.groupBy(_.serviceName)) yield { + val routeAndMethods = for (route <- routes) yield { + (route, buildMethodDescriptor(route, codecFactory)) + } + + val serviceBuilder = ServerServiceDefinition.builder(serviceName) + + for ((r, m) <- routeAndMethods) { + // TODO Support Client/Server Streams + val controller = session.getInstanceOf(r.controllerSurface) + serviceBuilder.addMethod( + m, + ServerCalls.asyncUnaryCall(new RPCRequestHandler[Any](controller, r.methodSurface, codecFactory)) + ) + } + val serviceDef = serviceBuilder.build() + serviceDef + } + + services.toSeq + } + + object RPCRequestMarshaller extends Marshaller[MsgPack] { + override def stream(value: MsgPack): InputStream = { + new ByteArrayInputStream(value) + } + override def parse(stream: InputStream): MsgPack = { + IO.readFully(stream) + } + } + + class RPCResponseMarshaller[A](codec: MessageCodec[A]) extends Marshaller[A] { + override def stream(value: A): InputStream = { + new ByteArrayInputStream(codec.toMsgPack(value)) + } + override def parse(stream: InputStream): A = { + codec.fromMsgPack(stream.readAllBytes()) + } + } + +} diff --git a/airframe-http-grpc/src/main/scala/wvlet/airframe/http/grpc/RPCRequestHandler.scala b/airframe-http-grpc/src/main/scala/wvlet/airframe/http/grpc/RPCRequestHandler.scala new file mode 100644 index 0000000000..ef757a827f --- /dev/null +++ b/airframe-http-grpc/src/main/scala/wvlet/airframe/http/grpc/RPCRequestHandler.scala @@ -0,0 +1,74 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package wvlet.airframe.http.grpc +import io.grpc.stub.ServerCalls.UnaryMethod +import io.grpc.stub.StreamObserver +import wvlet.airframe.codec.MessageCodecFactory +import wvlet.airframe.codec.PrimitiveCodec.ValueCodec +import wvlet.airframe.http.router.HttpRequestMapper +import wvlet.airframe.msgpack.spi.MsgPack +import wvlet.airframe.msgpack.spi.Value.MapValue +import wvlet.airframe.surface.{CName, MethodSurface} +import wvlet.log.LogSupport + +import scala.util.{Failure, Success, Try} + +/** + * Receives MessagePack Map value for the RPC request, and call the controller method + */ +class RPCRequestHandler[A](controller: Any, methodSurface: MethodSurface, codecFactory: MessageCodecFactory) + extends UnaryMethod[MsgPack, A] + with LogSupport { + + private val argCodecs = methodSurface.args.map(a => codecFactory.of(a.surface)) + + override def invoke(request: MsgPack, responseObserver: StreamObserver[A]): Unit = { + // Build method arguments from MsgPack + val requestValue = ValueCodec.unpack(request) + trace(requestValue) + + val result = Try { + requestValue match { + case m: MapValue => + val mapValue = HttpRequestMapper.toCanonicalKeyNameMap(m) + val args = for ((arg, i) <- methodSurface.args.zipWithIndex) yield { + val argOpt = mapValue.get(CName.toCanonicalName(arg.name)) match { + case Some(paramValue) => + Option(argCodecs(i).fromMsgPack(paramValue.toMsgpack)).orElse { + throw new IllegalArgumentException(s"Failed to parse ${paramValue} for ${arg}") + } + case None => + // If no value is found, use the method parameter's default argument + arg.getMethodArgDefaultValue(controller) + } + argOpt.getOrElse { + throw new IllegalArgumentException(s"No key for ${arg.name} is found in ${m}") + } + } + + trace(s"RPC call ${methodSurface.name}(${args.mkString(", ")})") + methodSurface.call(controller, args: _*) + case _ => + throw new IllegalArgumentException(s"Invalid argument: ${requestValue}") + } + } + result match { + case Success(v) => + responseObserver.onNext(v.asInstanceOf[A]) + case Failure(e) => + responseObserver.onError(e) + } + responseObserver.onCompleted() + } +} diff --git a/airframe-http-grpc/src/main/scala/wvlet/airframe/http/grpc/StringMarshaller.scala b/airframe-http-grpc/src/main/scala/wvlet/airframe/http/grpc/StringMarshaller.scala new file mode 100644 index 0000000000..2cf92f86bc --- /dev/null +++ b/airframe-http-grpc/src/main/scala/wvlet/airframe/http/grpc/StringMarshaller.scala @@ -0,0 +1,45 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package wvlet.airframe.http.grpc +import java.io.{ByteArrayInputStream, InputStream} + +import io.grpc.MethodDescriptor.Marshaller +import wvlet.airframe.codec.{INVALID_DATA, MessageCodecException, MessageContext} +import wvlet.airframe.codec.PrimitiveCodec.StringCodec +import wvlet.airframe.msgpack.spi.MessagePack +import wvlet.log.LogSupport + +/** + * Marshalling String as MessagePack + */ +private[grpc] object StringMarshaller extends Marshaller[String] with LogSupport { + override def stream(value: String): InputStream = { + new ByteArrayInputStream(StringCodec.toMsgPack(value)) + } + override def parse(stream: InputStream): String = { + val unpacker = MessagePack.newUnpacker(stream) + val v = MessageContext() + + StringCodec.unpack(unpacker, v) + if (!v.isNull) { + val s = v.getString + s + } else { + v.getError match { + case Some(e) => throw new RuntimeException(e) + case None => throw new MessageCodecException(INVALID_DATA, StringCodec, "invalid input") + } + } + } +} diff --git a/airframe-http-grpc/src/test/scala/wvlet/airframe/http/grpc/GrpcServiceBuilderTest.scala b/airframe-http-grpc/src/test/scala/wvlet/airframe/http/grpc/GrpcServiceBuilderTest.scala new file mode 100644 index 0000000000..c3968cb827 --- /dev/null +++ b/airframe-http-grpc/src/test/scala/wvlet/airframe/http/grpc/GrpcServiceBuilderTest.scala @@ -0,0 +1,97 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package wvlet.airframe.http.grpc +import io.grpc._ +import io.grpc.stub.{AbstractBlockingStub, ClientCalls} +import wvlet.airframe.Design +import wvlet.airframe.codec.MessageCodecFactory +import wvlet.airframe.http.router.Route +import wvlet.airframe.http.{RPC, Router} +import wvlet.airspec.AirSpec + +/** + */ +object GrpcServiceBuilderTest extends AirSpec { + + @RPC + trait MyApi { + def hello(name: String): String = { + s"Hello ${name}!" + } + + def hello2(name: String, id: Int): String = { + s"Hello ${name}! (id:${id})" + } + } + + private val router = Router.add[MyApi] + debug(router) + + private def getRoute(name: String): Route = { + router.routes.find(_.methodSurface.name == name).getOrElse { + throw new IllegalArgumentException(s"Route is not found :${name}") + } + } + + // TODO: Generate this stub using sbt-airframe + class MyApiStub( + channel: Channel, + callOptions: CallOptions = CallOptions.DEFAULT, + codecFactory: MessageCodecFactory = MessageCodecFactory.defaultFactoryForJSON + ) extends AbstractBlockingStub[MyApiStub](channel, callOptions) { + override def build(channel: Channel, callOptions: CallOptions): MyApiStub = { + new MyApiStub(channel, callOptions) + } + private val codec = codecFactory.of[Map[String, Any]] + private val helloMethodDescriptor = + GrpcServiceBuilder.buildMethodDescriptor(getRoute("hello"), codecFactory) + private val hello2MethodDescriptor = + GrpcServiceBuilder.buildMethodDescriptor(getRoute("hello2"), codecFactory) + + def hello(name: String): String = { + val m = Map("name" -> name) + ClientCalls + .blockingUnaryCall(getChannel, helloMethodDescriptor, getCallOptions, codec.toMsgPack(m)).asInstanceOf[String] + } + def hello2(name: String, id: Int): String = { + val m = Map("name" -> name, "id" -> id) + ClientCalls + .blockingUnaryCall(getChannel, hello2MethodDescriptor, getCallOptions, codec.toMsgPack(m)).asInstanceOf[String] + } + } + + test( + "create gRPC client", + design = { + wvlet.airframe.http.grpc.Grpc.server + .withRouter(router) + .design + .bind[ManagedChannel].toProvider { server: GrpcServer => + ManagedChannelBuilder.forTarget(server.localAddress).usePlaintext().build() + } + .onShutdown { channel => + channel.shutdownNow() + } + } + ) { (server: GrpcServer, channel: ManagedChannel) => + val stub = new MyApiStub(channel) + for (i <- 0 to 100) { + val ret = stub.hello("world") + ret shouldBe "Hello world!" + + val ret2 = stub.hello2("world", i) + ret2 shouldBe s"Hello world! (id:${i})" + } + } +} diff --git a/airframe-http-grpc/src/test/scala/wvlet/airframe/http/grpc/GrpcTest.scala b/airframe-http-grpc/src/test/scala/wvlet/airframe/http/grpc/GrpcTest.scala new file mode 100644 index 0000000000..5cbb1bdf88 --- /dev/null +++ b/airframe-http-grpc/src/test/scala/wvlet/airframe/http/grpc/GrpcTest.scala @@ -0,0 +1,107 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package wvlet.airframe.http.grpc + +import io.grpc.MethodDescriptor.MethodType +import io.grpc._ +import io.grpc.stub.{AbstractBlockingStub, ClientCalls, ServerCalls, StreamObserver} +import wvlet.airframe.Design +import wvlet.airspec.AirSpec +import wvlet.log.LogSupport +import wvlet.log.io.IOUtil + +object MyService extends LogSupport { + def helloMethod: MethodDescriptor[String, String] = + MethodDescriptor + .newBuilder[String, String](StringMarshaller, StringMarshaller) + .setFullMethodName(MethodDescriptor.generateFullMethodName("my-service", "hello")) + .setType(MethodType.UNARY) + .build() + + def helloMethodDef: ServerMethodDefinition[String, String] = { + ServerMethodDefinition.create[String, String]( + helloMethod, + ServerCalls.asyncUnaryCall( + new MethodHandlers() + ) + ) + } + + class MethodHandlers extends ServerCalls.UnaryMethod[String, String] { + override def invoke(request: String, responseObserver: StreamObserver[String]): Unit = { + helloImpl(request, responseObserver) + } + } + + def helloImpl(request: String, responseObserver: StreamObserver[String]): Unit = { + responseObserver.onNext(s"Hello ${request}") + responseObserver.onCompleted() + } + + class MyServiceBlockingStub(channel: Channel, callOptions: CallOptions) + extends AbstractBlockingStub[MyServiceBlockingStub](channel, callOptions) { + override def build(channel: Channel, callOptions: CallOptions): MyServiceBlockingStub = { + new MyServiceBlockingStub(channel, callOptions) + } + + def hello(message: String): String = { + ClientCalls.blockingUnaryCall(getChannel, MyService.helloMethod, getCallOptions, message) + } + } + + def newBlockingStub(channel: Channel): MyServiceBlockingStub = { + new MyServiceBlockingStub(channel, CallOptions.DEFAULT) + } +} + +/** + */ + +object GrpcTest extends AirSpec { + private val service: ServerServiceDefinition = + ServerServiceDefinition + .builder("my-service") + .addMethod[String, String](MyService.helloMethodDef) + .build() + + private val port = IOUtil.randomPort + + override protected def design = + Design.newDesign + .bind[Server].toInstance( + ServerBuilder.forPort(port).addService(service).build() + ).onStart { server => + server.start() + info(s"Starting gRPC server localhost:${port}") + } + .onShutdown { server => + info(s"Shutting down gRPC server localhost:${port}") + server.shutdownNow() + } + .bind[ManagedChannel].toProvider { server: Server => + ManagedChannelBuilder.forTarget(s"localhost:${server.getPort}").usePlaintext().build() + } + .onShutdown { channel => + channel.shutdownNow() + } + + test("run server") { (server: Server, channel: ManagedChannel) => + val client = MyService.newBlockingStub(channel) + for (i <- 0 to 10) { + val ret = client.hello(s"airframe-grpc ${i}") + debug(ret) + ret shouldBe s"Hello airframe-grpc ${i}" + } + } +} diff --git a/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/router/HttpRequestMapper.scala b/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/router/HttpRequestMapper.scala index b1761110ad..1ce2b09d43 100644 --- a/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/router/HttpRequestMapper.scala +++ b/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/router/HttpRequestMapper.scala @@ -244,7 +244,7 @@ object HttpRequestMapper extends LogSupport { /** * Convert MapValue to use CName as keys to support case-insensitive match */ - private def toCanonicalKeyNameMap(m: MapValue): Map[String, Value] = { + private[http] def toCanonicalKeyNameMap(m: MapValue): Map[String, Value] = { m.entries.map { kv => CName.toCanonicalName(kv._1.toString) -> kv._2 }.toMap diff --git a/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/router/Route.scala b/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/router/Route.scala index 22b2c22ab8..792132913a 100644 --- a/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/router/Route.scala +++ b/airframe-http/.jvm/src/main/scala/wvlet/airframe/http/router/Route.scala @@ -28,6 +28,8 @@ import scala.language.higherKinds trait Route { def method: String def methodSurface: MethodSurface + + def serviceName: String def path: String val pathComponents: IndexedSeq[String] = { path @@ -88,6 +90,10 @@ case class ControllerRoute( s"${method} ${path} -> ${methodSurface.name}(${methodSurface.args .map(x => s"${x.name}:${x.surface}").mkString(", ")}): ${methodSurface.returnType}" + override lazy val serviceName: String = { + rpcInterfaceCls.getName.replaceAll("\\$anon\\$", "").replaceAll("\\$", ".") + } + override def returnTypeSurface: Surface = methodSurface.returnType /** diff --git a/build.sbt b/build.sbt index 6f81a9a35d..b253cb12c9 100644 --- a/build.sbt +++ b/build.sbt @@ -20,6 +20,7 @@ val JS_JAVA_TIME_VERSION = "1.0.0" val FINAGLE_VERSION = "20.4.1" val FLUENCY_VERSION = "2.4.1" val SCALAJS_DOM_VERSION = "1.0.0" +val GRPC_VERSION = "1.30.2" val airSpecFramework = new TestFramework("wvlet.airspec.Framework") @@ -154,6 +155,7 @@ lazy val communityBuildProjects: Seq[ProjectReference] = Seq( codecJVM, msgpackJVM, httpJVM, + grpc, jsonJVM, rxJVM, airspecJVM @@ -591,6 +593,21 @@ lazy val httpJVM = http.jvm lazy val httpJS = http.js +lazy val grpc = + project + .in(file("airframe-http-grpc")) + .settings(buildSettings) + .settings( + name := "airframe-http-grpc", + description := "Airframe HTTP gRPC backend", + libraryDependencies ++= Seq( + "io.grpc" % "grpc-netty-shaded" % GRPC_VERSION, + "io.grpc" % "grpc-stub" % GRPC_VERSION, + "org.apache.tomcat" % "annotations-api" % "6.0.53" % Provided, + "org.slf4j" % "slf4j-jdk14" % SLF4J_VERSION % Test + ) + ).dependsOn(httpJVM, airframeMacrosJVMRef, airspecRefJVM % Test) + lazy val finagle = project .in(file("airframe-http-finagle")) diff --git a/docs/airframe-rpc.md b/docs/airframe-rpc.md index bbebe38717..7c8099fbc2 100644 --- a/docs/airframe-rpc.md +++ b/docs/airframe-rpc.md @@ -374,6 +374,46 @@ trait MyAPI { Airframe RPC is built on top of Airframe HTTP framework. See [Airframe HTTP documentation](airframe-http.md) for the other features and advanced configurations. + +## Airframe gRPC + +_(This is an experimental feature available since Airframe 20.8.0)_ + +Airframe gRPC is a gRPC and HTTP2-based implementation of Airframe RPC, which can make thousands of RPC calls per second. With Airframe gRPC: + +- No Protobuf definition is required. You can use plain Scala and case classes to define gRPC service. +- Roadmap + - [x] Create a gRPC server from Airframe RPC router + - [ ] Generate gRPC client stub with sbt-airframe plugin. + - [ ] Support client, server-side, and bidirectional streaming + - [ ] Add a gRPC server proxy with airframe-http-finagle for supporting HTTP1 + +__build.sbt__ + +```scala +"org.wvlet.airframe" %% "airframe-http-grpc" % AIRFRAME_VERSION +``` + +### Starting Airframe gRPC Server + +```scala +import wvlet.airframe.http.Router +import wvlet.airframe.http.grpc.Grpc + +// Create a Router definition in the same manner with Airframe RPC +val router = Router.add[MyApiImpl] + +val grpcServerDesign = Grpc.server + .withRouter(router) + .withPort(8080) + .design + +grpcServerDesign.build[GrpcServer] { server => + // gRPC server (based on Netty) starts at localhost:8080 +} +``` + + ## RPC Internals (_This section describes the internals of Airframe RPC protocol. Just for using Airframe RPC, you can skip this section._)