= emptyList()): {{.Name}}Stub {
49 | val combinedInterceptor = combineInterceptors(*interceptors.toTypedArray())
50 | return {{.Name}}Stub(client, callOptions, combinedInterceptor)
51 | }
52 |
53 | {{/**
54 | *
55 | * Test service that supports all call types.
56 | *
57 | */}}
58 | interface {{$s.Name}} {
59 | {{range $i, $m := .Methods}}
60 | {{- /**
61 | *
62 | * One requestMore followed by one response.
63 | * The server returns the client payload as-is.
64 | *
65 | */ -}}
66 | suspend fun {{$m.JavaName}}(req: {{$m.FullInputType}}): {{$m.FullOutputType}}
67 | {{end}}
68 | }
69 |
70 | {{/**
71 | *
72 | * Test service that supports all call types.
73 | *
74 | */}}
75 | abstract class {{.Name}}ImplBase : BindableService, {{$s.Name}} {
76 | {{range $i, $m := .Methods}}
77 | {{- /**
78 | *
79 | * One requestMore followed by one response.
80 | * The server returns the client payload as-is.
81 | *
82 | */ -}}
83 | override suspend fun {{$m.JavaName}}(req: {{$m.FullInputType}}): {{$m.FullOutputType}} {
84 | return ServerCalls.{{$m.UnimplementedCall}}({{$m.FieldName}})
85 | }
86 | {{end}}
87 | override fun bindService(): ServerServiceDefinition {
88 | return ServerServiceDefinition(serviceDescriptor) {
89 | {{- range $i, $m := .Methods}}
90 | addMethod({{$m.FieldName}}, ServerCalls.{{$m.Call}}(::{{$m.JavaName}}))
91 | {{- end}}
92 | }
93 | }
94 | }
95 |
96 | {{/**
97 | *
98 | * Test service that supports all call types.
99 | *
100 | */}}
101 | class {{$s.Name}}Stub internal constructor(client: HttpClient, callOptions: CallOptions, interceptors: GrpcInterceptor?)
102 | : AbstractStub<{{$s.Name}}Stub>(client, callOptions, interceptors), {{$s.Name}} {
103 | {{range $i, $m := .Methods}}
104 | {{- /**
105 | *
106 | * One requestMore followed by one response.
107 | * The server returns the client payload as-is.
108 | *
109 | */ -}}
110 | override suspend fun {{$m.JavaName}}(req: {{$m.FullInputType}}): {{$m.FullOutputType}} {
111 | return ClientCalls.{{$m.Call}}(
112 | newCall({{$m.FieldName}}, callOptions), {{$m.CallParams}})
113 | }
114 | {{end}}
115 |
116 | override fun build(client: HttpClient, callOptions: CallOptions, interceptors: GrpcInterceptor?): {{$s.Name}}Stub {
117 | return {{.Name}}Stub(client, callOptions, interceptors)
118 | }
119 | }
120 |
121 | private class {{$s.Name}}DescriptorSupplier : io.grpc.protobuf.ProtoFileDescriptorSupplier {
122 | override fun getFileDescriptor(): com.google.protobuf.Descriptors.FileDescriptor {
123 | return {{$s.JavaPackage}}.{{$s.OuterClassName}}.getDescriptor()
124 | }
125 | }
126 |
127 | val serviceDescriptor: io.grpc.ServiceDescriptor by lazy {
128 | io.grpc.ServiceDescriptor.newBuilder(SERVICE_NAME)
129 | .setSchemaDescriptor({{$s.Name}}DescriptorSupplier())
130 | {{- range $i, $m := .Methods}}
131 | .addMethod({{$m.FieldName}})
132 | {{- end}}
133 | .build()
134 | }
135 | }
136 | {{- end}}
137 | `
138 | )
139 |
--------------------------------------------------------------------------------
/kert-grpc-compiler/src/main/go/go.mod:
--------------------------------------------------------------------------------
1 | module leap.ws/kert-grpc-compiler
2 |
3 | go 1.19
4 |
5 | require google.golang.org/protobuf v1.28.1
6 | require github.com/golang/protobuf v1.5.2
7 |
--------------------------------------------------------------------------------
/kert-grpc-compiler/src/main/go/go.sum:
--------------------------------------------------------------------------------
1 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
2 | github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
3 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
4 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
5 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
6 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
7 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
8 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
9 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
10 | google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
11 | google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
12 | google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
13 | google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
14 |
--------------------------------------------------------------------------------
/kert-grpc-compiler/src/main/go/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "google.golang.org/protobuf/proto"
5 | "io/ioutil"
6 | "leap.ws/kert-grpc-compiler/generator"
7 | "leap.ws/kert-grpc-compiler/util"
8 | "os"
9 | )
10 |
11 | func main() {
12 | // Begin by allocating a generator. The request and response structures are stored there
13 | // so we can do error handling easily - the response structure contains the field to
14 | // report failure.
15 | g := generator.New()
16 |
17 | var data []byte = nil
18 | var err error = nil
19 |
20 | if len(os.Args) > 1 {
21 | filename := os.Args[1]
22 | if data, err = ioutil.ReadFile(filename); err != nil {
23 | util.Error(err, "reading input from file")
24 | }
25 | } else {
26 | if data, err = ioutil.ReadAll(os.Stdin); err != nil {
27 | util.Error(err, "reading input")
28 | }
29 | }
30 |
31 | if err := proto.Unmarshal(data, g.Request); err != nil {
32 | util.Error(err, "parsing input proto")
33 | }
34 |
35 | if len(g.Request.FileToGenerate) == 0 {
36 | util.Fail("no files to generate")
37 | }
38 |
39 | g.CommandLineParameters(g.Request.GetParameter())
40 |
41 | if err = g.WriteInput(data); err != nil {
42 | util.Error(err, "failed to write input data")
43 | }
44 |
45 | if err = g.GenerateAllFiles(); err != nil {
46 | util.Error(err, "failed to generate files")
47 | }
48 |
49 | // Send back the results.
50 | data, err = proto.Marshal(g.Response)
51 | if err != nil {
52 | util.Error(err, "failed to marshal output proto")
53 | }
54 | _, err = os.Stdout.Write(data)
55 | if err != nil {
56 | util.Error(err, "failed to write output proto")
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/kert-grpc-compiler/src/main/go/util/util.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "log"
5 | "os"
6 | "strings"
7 | )
8 |
9 | // Error reports a problem, including an error, and exits the program.
10 | func Error(err error, msgs ...string) {
11 | s := strings.Join(msgs, " ") + ":" + err.Error()
12 | log.Print("protoc-gen-go: error:", s)
13 | os.Exit(1)
14 | }
15 |
16 | // Fail reports a problem and exits the program.
17 | func Fail(msgs ...string) {
18 | s := strings.Join(msgs, " ")
19 | log.Print("protoc-gen-rxjava: error:", s)
20 | os.Exit(1)
21 | }
22 |
--------------------------------------------------------------------------------
/kert-grpc-compiler/src/test/proto/test.proto:
--------------------------------------------------------------------------------
1 | // A simple service definition for testing the protoc plugin.
2 | syntax = "proto3";
3 |
4 | package grpc.testing;
5 |
6 | option java_package = "io.grpc.testing.integration";
7 |
8 | import "google/protobuf/empty.proto";
9 | import "google/protobuf/wrappers.proto";
10 |
11 | message SimpleRequest {
12 | }
13 |
14 | message SimpleResponse {
15 | }
16 |
17 | message StreamingInputCallRequest {
18 | }
19 |
20 | message StreamingInputCallResponse {
21 | }
22 |
23 | message StreamingOutputCallRequest {
24 | }
25 |
26 | message StreamingOutputCallResponse {
27 | }
28 |
29 | // Test service that supports all call types.
30 | service TestService {
31 | // One requestMore followed by one response.
32 | // The server returns the client payload as-is.
33 | rpc UnaryCall(SimpleRequest) returns (SimpleResponse);
34 |
35 | // One requestMore followed by a sequence of responses (streamed download).
36 | // The server returns the payload with client desired type and sizes.
37 | rpc StreamingOutputCall(StreamingOutputCallRequest)
38 | returns (stream StreamingOutputCallResponse);
39 |
40 | // A sequence of requests followed by one response (streamed upload).
41 | // The server returns the aggregated size of client payload as the result.
42 | rpc StreamingInputCall(stream StreamingInputCallRequest)
43 | returns (StreamingInputCallResponse);
44 |
45 | // A sequence of requests with each requestMore served by the server immediately.
46 | // As one requestMore could lead to multiple responses, this interface
47 | // demonstrates the idea of full bidirectionality.
48 | rpc FullBidiCall(stream StreamingOutputCallRequest)
49 | returns (stream StreamingOutputCallResponse);
50 |
51 | // A sequence of requests followed by a sequence of responses.
52 | // The server buffers all the client requests and then serves them in order. A
53 | // stream of responses are returned to the client when the server starts with
54 | // first requestMore.
55 | rpc HalfBidiCall(stream StreamingOutputCallRequest)
56 | returns (stream StreamingOutputCallResponse);
57 | }
58 |
--------------------------------------------------------------------------------
/kert-grpc/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import build.*
2 | import com.google.protobuf.gradle.*
3 |
4 | description = "Kert GRPC support"
5 |
6 | configureLibrary()
7 |
8 | dependencies {
9 | api(project(":kert-http"))
10 | api(libs.protobuf.java)
11 | api(libs.protobuf.kotlin)
12 | api(libs.grpc.protobuf)
13 |
14 | api(libs.javax.annotation.api)
15 |
16 | // generateTestProto needs compiler binary
17 | compileOnly(project(":kert-grpc-compiler"))
18 |
19 | testImplementation(libs.grpc.stub)
20 | testImplementation(libs.grpc.netty)
21 | }
22 |
23 | protobuf {
24 | protoc {
25 | artifact = "com.google.protobuf:protoc:${libs.versions.protobuf.get()}"
26 | }
27 | plugins {
28 | id("grpc-kert") {
29 | val osDetector = extensions.getByType(com.google.gradle.osdetector.OsDetector::class)
30 | val exeSuffix = if(osDetector.os == "windows") ".exe" else ""
31 |
32 | path = "$rootDir/kert-grpc-compiler/build/exe/protoc-gen-grpc-kert${exeSuffix}"
33 | }
34 | // generate java version for performance comparison
35 | id("grpc-java") {
36 | artifact = "io.grpc:protoc-gen-grpc-java:${libs.versions.grpc.get()}"
37 | }
38 | }
39 | generateProtoTasks {
40 | // protos used by library self
41 | ofSourceSet("main").forEach { task ->
42 | task.builtins {
43 | id("kotlin")
44 | }
45 | task.plugins {
46 | id("grpc-kert")
47 | }
48 | }
49 |
50 | // protos used in test (grpc-java is enabled for comparison)
51 | ofSourceSet("test").forEach { task ->
52 | task.builtins {
53 | id("kotlin")
54 | }
55 | task.plugins {
56 | id("grpc-kert")
57 | id("grpc-java")
58 | }
59 | }
60 | }
61 | }
62 |
63 | tasks.named("generateProto") {
64 | dependsOn(":kert-grpc-compiler:buildPlugin")
65 | }
66 |
--------------------------------------------------------------------------------
/kert-grpc/readme.md:
--------------------------------------------------------------------------------
1 | Performance Test
2 | ```shell script
3 | ghz --insecure -c 100 -z 30s --connections 100 \
4 | --proto kert-grpc/src/test/proto/echo.proto \
5 | --call ws.leap.kert.test.Echo.unary \
6 | -d '{"id":1, "value":"hello"}' \
7 | 0.0.0.0:8550
8 | ```
9 |
--------------------------------------------------------------------------------
/kert-grpc/src/main/kotlin/ws/leap/kert/grpc/AbstractStub.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.grpc
2 |
3 | import io.grpc.MethodDescriptor
4 | import io.grpc.Status
5 | import io.vertx.core.buffer.Buffer
6 | import io.vertx.core.http.HttpHeaders
7 | import io.vertx.core.http.HttpVersion
8 | import io.vertx.core.http.impl.headers.HeadersMultiMap
9 | import kotlinx.coroutines.flow.*
10 | import ws.leap.kert.http.HttpClient
11 |
12 | // placeholder, nothing to configure right now
13 | class CallOptions {
14 |
15 | }
16 |
17 | abstract class AbstractStub(
18 | private val client: HttpClient,
19 | protected val callOptions: CallOptions = CallOptions(),
20 | private val interceptors: GrpcInterceptor? = null
21 | ) {
22 | init {
23 | require(client.protocolVersion == HttpVersion.HTTP_2) {
24 | "HTTP client for GRPC must be on HTTP2"
25 | }
26 | }
27 | protected fun newCall(method: MethodDescriptor,
28 | callOptions: CallOptions): GrpcClientCallHandler {
29 | return { requestMessages ->
30 | val handler: GrpcHandler = { m, r -> invokeHttp(m, r) }
31 | val grpcRequest = GrpcRequest(emptyMetadata(), requestMessages)
32 | // TODO bidi streaming stuck when specify GrpcContext, why?
33 | val grpcResponse = //withContext(GrpcContext(method)) {
34 | handle(method, grpcRequest, handler, interceptors)
35 | //}
36 | grpcResponse.messages
37 | }
38 | }
39 |
40 | private suspend fun invokeHttp(method: MethodDescriptor, request: GrpcRequest): GrpcResponse {
41 | val responseDeserializer = GrpcUtils.responseDeserializer(method)
42 |
43 | val httpRequestBody = request.messages.map { msg ->
44 | val buf = GrpcUtils.serializeMessagePacket(msg)
45 | Buffer.buffer(buf)
46 | }
47 |
48 | val httpRequestPath = "/${method.fullMethodName}"
49 | val headers = HeadersMultiMap()
50 | headers.addAll(request.metadata)
51 | headers[HttpHeaders.CONTENT_TYPE] = Constants.contentTypeGrpcProto
52 |
53 | val httpResponse = client.post(httpRequestPath, headers = headers, body = httpRequestBody)
54 | if (httpResponse.statusCode != 200) {
55 | throw IllegalStateException("GRPC call failed, status=${httpResponse.statusCode}")
56 | }
57 |
58 | val responseMessages = GrpcUtils.readMessages(httpResponse.body, responseDeserializer)
59 | val responseMessagesFlow = responseMessages.onCompletion { cause ->
60 | if (cause == null) {
61 | val trailers = httpResponse.trailers()
62 | // fail the flow if grpc-status is missing, it should be either in headers or trailers
63 | val statusCode = httpResponse.headers[Constants.grpcStatus]?.toInt() ?: trailers[Constants.grpcStatus]?.toInt()
64 | ?: throw IllegalStateException("GRPC status is missing, request=$httpRequestPath")
65 | val status = Status.fromCodeValue(statusCode)
66 | if (!status.isOk) {
67 | val message = httpResponse.headers[Constants.grpcMessage] ?: trailers[Constants.grpcMessage] ?: ""
68 | throw status.withDescription(message).asException()
69 | }
70 | }
71 | }
72 |
73 | return GrpcResponse(httpResponse.headers, responseMessagesFlow)
74 | }
75 |
76 | fun intercepted(vararg interceptors: GrpcInterceptor): S {
77 | if (interceptors.isEmpty()) return this as S
78 |
79 | val combinedInterceptor = combineInterceptors(*interceptors)!!
80 | return intercepted(combinedInterceptor)
81 | }
82 |
83 | fun intercepted(interceptor: GrpcInterceptor): S {
84 | // TODO inherit current interceptors or not??
85 | return build(client, callOptions, combineInterceptors(interceptors, interceptor))
86 | }
87 |
88 | /**
89 | * Create a new stub.
90 | */
91 | protected abstract fun build(client: HttpClient, callOptions: CallOptions = CallOptions(), interceptors: GrpcInterceptor? = null): S
92 | }
93 |
--------------------------------------------------------------------------------
/kert-grpc/src/main/kotlin/ws/leap/kert/grpc/ClientCalls.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.grpc
2 |
3 | import kotlinx.coroutines.Deferred
4 | import kotlinx.coroutines.flow.Flow
5 | import kotlinx.coroutines.flow.flowOf
6 | import kotlinx.coroutines.flow.single
7 |
8 | object ClientCalls {
9 |
10 | /**
11 | * Executes a unary call with a response.
12 | */
13 | suspend fun unaryCall(
14 | call: GrpcClientCallHandler,
15 | req: REQ): RESP {
16 | val responses = call(flowOf(req))
17 | return responses.single()
18 | }
19 |
20 | /**
21 | * Executes a server-streaming call with a response [Flow].
22 | */
23 | suspend fun serverStreamingCall(
24 | call: GrpcClientCallHandler,
25 | req: REQ): Flow {
26 | return call(flowOf(req))
27 | }
28 |
29 | /**
30 | * Executes a client-streaming call by sending a [Flow] and returns a [Deferred]
31 | *
32 | * @return requestMore stream observer.
33 | */
34 | suspend fun clientStreamingCall(
35 | call: GrpcClientCallHandler,
36 | req: Flow
37 | ): RESP {
38 | val responses = call(req)
39 | return responses.single()
40 | }
41 |
42 | /**
43 | * Executes a bidi-streaming call.
44 | *
45 | * @return requestMore stream observer.
46 | */
47 | suspend fun bidiStreamingCall(
48 | call: GrpcClientCallHandler,
49 | req: Flow): Flow {
50 | return call(req)
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/kert-grpc/src/main/kotlin/ws/leap/kert/grpc/Constants.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.grpc
2 |
3 | object Constants {
4 | // GRPC message header size (1 byte compression flag + 4 bytes size)
5 | const val messageHeaderSize = 5
6 | const val grpcStatus = "grpc-status"
7 | const val grpcMessage = "grpc-message"
8 | val contentTypeGrpcProto = "application/grpc"
9 | val contentTypeGrpcJson = "application/grpc+json"
10 | val contentTypeGrpcWeb = "application/grpc+web"
11 | }
12 |
--------------------------------------------------------------------------------
/kert-grpc/src/main/kotlin/ws/leap/kert/grpc/GrpcUtils.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.grpc
2 |
3 | import com.google.protobuf.AbstractMessage
4 | import io.grpc.MethodDescriptor
5 | import io.netty.buffer.*
6 | import io.vertx.core.MultiMap
7 | import io.vertx.core.buffer.Buffer
8 | import io.vertx.core.streams.WriteStream
9 | import kotlinx.coroutines.flow.*
10 |
11 | object GrpcUtils {
12 | suspend fun writeMessages(stream: WriteStream, messages: Flow, serializer: (T) -> ByteBuf) {
13 | messages.collect { msg ->
14 | val buf = serializer(msg)
15 |
16 | // stream.writeByte(0) // compressed flag
17 | // stream.writeInt(buf.readableBytes()) // message size
18 | // stream.writeFully(ByteBufUtil.getBytes(buf)) // message bytes
19 | }
20 | }
21 |
22 | suspend fun readMessages(stream: Flow, deserializer: (ByteBuf, Int) -> T): Flow {
23 | // accumulate bytes in the buffer
24 | val accuBuffer = Unpooled.buffer()
25 |
26 | return flow {
27 | stream.collect { data ->
28 | accuBuffer.discardReadBytes()
29 | accuBuffer.writeBytes(data.byteBuf)
30 |
31 | while(true) {
32 | val msg = readMessage(accuBuffer, deserializer) ?: break
33 | emit(msg)
34 | }
35 | }
36 | }
37 | }
38 |
39 | private fun readMessage(buf: ByteBuf, deserializer: (ByteBuf, Int) -> T): T? {
40 | if(buf.readableBytes() < Constants.messageHeaderSize) return null
41 |
42 | val slice = buf.slice() // create a slice so read won't change reader position
43 | val compressedFlag = slice.readUnsignedByte()
44 | val messageSize = slice.readUnsignedInt()
45 |
46 | // there is no complete message in buffer, return null
47 | if (slice.readableBytes() < messageSize) return null
48 |
49 | // move the reader index to consume the GRPC message header
50 | buf.readerIndex(buf.readerIndex() + Constants.messageHeaderSize)
51 |
52 | // TODO message compression is not supported yet
53 | if (compressedFlag == 1.toShort()) throw UnsupportedOperationException("Compression is not supported yet")
54 |
55 | return deserializer(buf, messageSize.toInt())
56 | }
57 |
58 | fun serializeMessagePacket(message: M): ByteBuf {
59 | require(message is AbstractMessage)
60 | val buf = Unpooled.buffer()
61 | buf.writeByte(0)
62 | buf.writeInt(message.serializedSize)
63 | serialize(message, buf)
64 | return buf
65 | }
66 |
67 | private fun serialize(message: AbstractMessage, buf: ByteBuf) {
68 | val out = ByteBufOutputStream(buf)
69 | message.writeTo(out)
70 | }
71 |
72 | fun requestSerializer(method: MethodDescriptor): (ReqT) -> ByteBuf {
73 | return { msg: ReqT ->
74 | val msgStream = method.streamRequest(msg)
75 | val buf = Unpooled.buffer()
76 | buf.writeBytes(msgStream, 1024) // TODO how to get the actual stream size
77 | buf
78 | }
79 | }
80 |
81 | fun responseSerializer(method: MethodDescriptor<*, RespT>): (RespT) -> ByteBuf {
82 | return { msg: RespT ->
83 | val msgStream = method.streamResponse(msg)
84 | val buf = Unpooled.buffer()
85 | // TODO bad performance
86 | buf.writeBytes(msgStream, 1024) // TODO how to get the actual stream size
87 | buf
88 | }
89 | }
90 |
91 | fun requestDeserializer(method: MethodDescriptor): (ByteBuf, Int) -> ReqT {
92 | return { buf: ByteBuf, size: Int ->
93 | val inStream = ByteBufInputStream(buf, size)
94 | method.parseRequest(inStream)
95 | }
96 | }
97 |
98 | fun responseDeserializer(method: MethodDescriptor<*, RespT>): (ByteBuf, Int) -> RespT {
99 | return { buf: ByteBuf, size: Int ->
100 | val inStream = ByteBufInputStream(buf, size)
101 | method.parseResponse(inStream)
102 | }
103 | }
104 | }
105 |
106 | fun emptyMetadata(): MultiMap = MultiMap.caseInsensitiveMultiMap()
107 |
--------------------------------------------------------------------------------
/kert-grpc/src/main/kotlin/ws/leap/kert/grpc/Server.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.grpc
2 |
3 | import io.grpc.Status
4 | import io.vertx.core.buffer.Buffer
5 | import io.vertx.core.http.HttpHeaders
6 | import io.vertx.core.http.HttpMethod
7 | import io.vertx.core.http.HttpVersion
8 | import io.vertx.core.http.impl.headers.HeadersMultiMap
9 | import kotlinx.coroutines.CoroutineExceptionHandler
10 | import kotlinx.coroutines.flow.Flow
11 | import kotlinx.coroutines.flow.map
12 | import kotlinx.coroutines.withContext
13 | import mu.KotlinLogging
14 | import ws.leap.kert.http.*
15 |
16 | private val grpcExceptionLogger = KotlinLogging.logger {}
17 | val defaultGrpcExceptionHandler = CoroutineExceptionHandler { context, exception ->
18 | val routingContext = context[VertxRoutingContext]?.routingContext
19 | ?: throw IllegalStateException("Routing context is not available on coroutine context")
20 |
21 | val method = routingContext.request().path().removePrefix("/")
22 | grpcExceptionLogger.warn("GRPC call failed: method=$method", exception)
23 |
24 | val response = routingContext.response()
25 | if (!response.ended()) {
26 | try {
27 | // grpc-status and grpc-message trailers
28 | val status = Status.fromThrowable(exception)
29 | val message = status.description
30 |
31 | // if headers haven't been sent, set grpc status in header
32 | if (!response.headWritten()) {
33 | response.putHeader(HttpHeaders.CONTENT_TYPE, Constants.contentTypeGrpcProto)
34 | response.putHeader(Constants.grpcStatus, status.code.value().toString())
35 | message?.let { response.putHeader(Constants.grpcMessage, it) }
36 | } else {
37 | // headers have been sent, put grpc status in trailers
38 | response.putTrailer(Constants.grpcStatus, status.code.value().toString())
39 | message?.let { response.putTrailer(Constants.grpcMessage, it) }
40 | }
41 | } finally {
42 | response.end()
43 | }
44 | }
45 | }
46 |
47 | fun HttpServerBuilderDsl.grpc(configure: GrpcServerBuilder.() -> Unit) {
48 | router(defaultGrpcExceptionHandler) {
49 | val builder = GrpcServerBuilder(this)
50 | configure(builder)
51 | builder.build()
52 | }
53 | }
54 |
55 | // https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md
56 | class GrpcServerBuilder(private val httpRouterBuilder: HttpRouterDsl) {
57 | var serverReflection: Boolean = false
58 |
59 | private val registry = ServiceRegistry()
60 | private val interceptors = mutableListOf()
61 |
62 | fun service(service: ServerServiceDefinition): Unit = registry.addService(service)
63 | fun service(service: BindableService): Unit = registry.addService(service)
64 |
65 | fun interceptor(interceptor: GrpcInterceptor) {
66 | interceptors.add(interceptor)
67 | }
68 |
69 | fun build() {
70 | if(serverReflection) {
71 | service(ServerReflectionImpl(registry))
72 | }
73 |
74 | val finalInterceptor = combineInterceptors(*interceptors.toTypedArray())
75 |
76 | for(service in registry.services()) {
77 | httpRouterBuilder.call(HttpMethod.POST, "/${service.serviceDescriptor.name}/:method") { req ->
78 | // get method from url
79 | val methodName = req.pathParams["method"] ?: throw IllegalArgumentException("method is not provided")
80 | val method = registry.lookupMethod("${service.serviceDescriptor.name}/${methodName}")
81 | if (method != null) {
82 | // TODO the context of exceptionHandler doesn't have GrpcContext
83 | withContext(GrpcContext(method.methodDescriptor)) {
84 | handleRequest(req, method, finalInterceptor)
85 | }
86 | } else {
87 | notFound()
88 | }
89 | }
90 | }
91 | }
92 |
93 | private fun notFound(): HttpServerResponse {
94 | return response(
95 | contentType = Constants.contentTypeGrpcProto,
96 | trailers = { HeadersMultiMap().add(Constants.grpcStatus, Status.NOT_FOUND.code.value().toString()) }
97 | )
98 | }
99 |
100 | private suspend fun handleRequest(request: HttpServerRequest, method: ServerMethodDefinition,
101 | interceptors: GrpcInterceptor?): HttpServerResponse {
102 | verifyRequest(request)
103 |
104 | val requestDeserializer = GrpcUtils.requestDeserializer(method.methodDescriptor)
105 | val requestMessages = GrpcUtils.readMessages(request.body, requestDeserializer)
106 | val grpcRequest = GrpcRequest(request.headers, requestMessages)
107 | val grpcResponse = handle(method.methodDescriptor, grpcRequest, method.handler, interceptors)
108 |
109 | val httpBody: Flow = grpcResponse.messages.map { msg ->
110 | val buf = GrpcUtils.serializeMessagePacket(msg)
111 | Buffer.buffer(buf)
112 | }
113 | return response(
114 | headers = grpcResponse.metadata,
115 | body = httpBody,
116 | contentType = Constants.contentTypeGrpcProto,
117 | trailers = { HeadersMultiMap().add(Constants.grpcStatus, Status.OK.code.value().toString()) }
118 | )
119 | }
120 |
121 | private fun verifyRequest(request: HttpServerRequest) {
122 | require(request.version == HttpVersion.HTTP_2) { "GRPC must be HTTP2, current is ${request.version}" }
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/kert-grpc/src/main/kotlin/ws/leap/kert/grpc/ServerCalls.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.grpc
2 |
3 | import io.grpc.MethodDescriptor
4 | import io.grpc.Status
5 | import kotlinx.coroutines.flow.*
6 |
7 | /**
8 | * Utility functions for adapting [GrpcServerCallHandler]s to application service implementation,
9 | * meant to be used by the generated code.
10 | */
11 | object ServerCalls {
12 |
13 | /**
14 | * Creates a `ServerCallHandler` for a unary call method of the service.
15 | *
16 | * @param method an adaptor to the actual method on the service implementation.
17 | */
18 | fun unaryCall(method: suspend (REQ) -> RESP): GrpcServerHandler {
19 | return { _, req ->
20 | val msg = req.messages.single()
21 | GrpcResponse(emptyMetadata(), flowOf(method(msg)))
22 | }
23 | }
24 |
25 | /**
26 | * Creates a `ServerCallHandler` for a server streaming method of the service.
27 | *
28 | * @param method an adaptor to the actual method on the service implementation.
29 | */
30 | fun serverStreamingCall(method: suspend (REQ) -> Flow): GrpcServerHandler {
31 | return { _, req ->
32 | val msg = req.messages.single()
33 | GrpcResponse(emptyMetadata(), method(msg))
34 | }
35 | }
36 |
37 | /**
38 | * Creates a `ServerCallHandler` for a client streaming method of the service.
39 | *
40 | * @param method an adaptor to the actual method on the service implementation.
41 | */
42 | fun clientStreamingCall(method: suspend(Flow) -> RESP): GrpcServerHandler {
43 | return { _, req ->
44 | val resp = method(req.messages)
45 | GrpcResponse(emptyMetadata(), flowOf(resp))
46 | }
47 | }
48 |
49 | fun bidiStreamingCall(method: suspend (Flow) -> Flow): GrpcServerHandler {
50 | return { _, req ->
51 | val resp = method(req.messages)
52 | GrpcResponse(emptyMetadata(), resp)
53 | }
54 | }
55 |
56 | fun unimplementedUnaryCall(
57 | methodDescriptor: MethodDescriptor<*, *>): T {
58 | throw Status.UNIMPLEMENTED
59 | .withDescription("Method ${methodDescriptor.fullMethodName} is unimplemented")
60 | .asRuntimeException()
61 | }
62 |
63 | fun unimplementedStreamingCall(methodDescriptor: MethodDescriptor<*, *>): Flow {
64 | throw Status.UNIMPLEMENTED
65 | .withDescription("Method ${methodDescriptor.fullMethodName} is unimplemented")
66 | .asRuntimeException()
67 | }
68 |
69 | private fun getStatus(t: Throwable): Status {
70 | val status = Status.fromThrowable(t)
71 | return if (status.description == null) {
72 | status.withDescription(t.message)
73 | } else {
74 | status
75 | }
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/kert-grpc/src/main/kotlin/ws/leap/kert/grpc/ServerMethodDefinition.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.grpc
2 |
3 | import io.grpc.MethodDescriptor
4 |
5 | data class ServerMethodDefinition(
6 | /** The `MethodDescriptor` for this method. */
7 | val methodDescriptor: MethodDescriptor,
8 | /** Handler for incoming calls. */
9 | val handler: GrpcHandler
10 | ) {
11 | fun intercepted(interceptor: GrpcInterceptor): ServerMethodDefinition {
12 | return copy(handler = handler.intercepted(interceptor))
13 | }
14 |
15 | fun intercepted(vararg interceptors: GrpcInterceptor): ServerMethodDefinition {
16 | if (interceptors.isEmpty()) return this
17 |
18 | val combinedInterceptor = combineInterceptors(*interceptors)!!
19 | return intercepted(combinedInterceptor)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/kert-grpc/src/main/kotlin/ws/leap/kert/grpc/ServerReflectionImpl.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.grpc
2 |
3 | import com.google.protobuf.Descriptors.*
4 | import grpc.reflection.v1alpha.*
5 | import grpc.reflection.v1alpha.Reflection.*
6 | import io.grpc.Status
7 | import io.grpc.protobuf.ProtoFileDescriptorSupplier
8 | import kotlinx.coroutines.flow.Flow
9 | import kotlinx.coroutines.flow.collect
10 | import kotlinx.coroutines.flow.flow
11 | import java.util.*
12 | import kotlin.collections.set
13 |
14 | class ServerReflectionImpl(private val registry: ServiceRegistry) : ServerReflectionGrpcKt.ServerReflectionImplBase() {
15 | private val serverReflectionIndex: ServerReflectionIndex by lazy {
16 | ServerReflectionIndex(registry.services(), emptyList())
17 |
18 | // TODO handle mutable services (probably don't need it)
19 | // val serverFileDescriptors: MutableSet = HashSet()
20 | // val serverServiceNames: MutableSet = HashSet()
21 | // val serverMutableServices: List = server.getMutableServices()
22 | // for (mutableService in serverMutableServices) {
23 | // val serviceDescriptor = mutableService.serviceDescriptor
24 | // if (serviceDescriptor.schemaDescriptor is ProtoFileDescriptorSupplier) {
25 | // val serviceName = serviceDescriptor.name
26 | // val fileDescriptor = (serviceDescriptor.schemaDescriptor as ProtoFileDescriptorSupplier?)
27 | // .getFileDescriptor()
28 | // serverFileDescriptors.add(fileDescriptor)
29 | // serverServiceNames.add(serviceName)
30 | // }
31 | // }
32 | //
33 | // // Replace the index if the underlying mutable services have changed. Check both the file
34 | // // descriptors and the service names, because one file descriptor can define multiple
35 | // // services.
36 | //
37 | // // Replace the index if the underlying mutable services have changed. Check both the file
38 | // // descriptors and the service names, because one file descriptor can define multiple
39 | // // services.
40 | // val mutableServicesIndex: FileDescriptorIndex = index.getMutableServicesIndex()
41 | // if (mutableServicesIndex.getServiceFileDescriptors() != serverFileDescriptors
42 | // || mutableServicesIndex.getServiceNames() != serverServiceNames
43 | // ) {
44 | // index = ServerReflectionIndex(server.getImmutableServices(), serverMutableServices)
45 | // serverReflectionIndexes.put(server, index)
46 | // }
47 | //
48 | // return index
49 | }
50 |
51 | override suspend fun serverReflectionInfo(req: Flow): Flow {
52 | return flow {
53 | req.collect { msg ->
54 | val resp = when(msg.messageRequestCase) {
55 | ServerReflectionRequest.MessageRequestCase.FILE_BY_FILENAME -> getFileByName(msg)
56 | ServerReflectionRequest.MessageRequestCase.FILE_CONTAINING_SYMBOL -> getFileContainingSymbol(msg)
57 | ServerReflectionRequest.MessageRequestCase.FILE_CONTAINING_EXTENSION -> getFileContainingExtension(msg)
58 | ServerReflectionRequest.MessageRequestCase.ALL_EXTENSION_NUMBERS_OF_TYPE -> getAllExtensions(msg)
59 | ServerReflectionRequest.MessageRequestCase.LIST_SERVICES -> listServices(msg)
60 | else -> throw Status.UNIMPLEMENTED.withDescription("${msg.messageRequestCase} is not implemented").asException()
61 | }
62 |
63 | emit(resp)
64 | }
65 | }
66 | }
67 |
68 | private fun getFileByName(req: ServerReflectionRequest): ServerReflectionResponse {
69 | val name = req.fileByFilename
70 | val fd = serverReflectionIndex.getFileDescriptorByName(name)
71 | ?: throw Status.NOT_FOUND.withDescription("File $name is not found.").asException()
72 | return createServerReflectionResponse(req, fd)
73 | }
74 |
75 | private fun getFileContainingSymbol(req: ServerReflectionRequest): ServerReflectionResponse {
76 | val symbol = req.fileContainingSymbol
77 | val fd = serverReflectionIndex.getFileDescriptorBySymbol(symbol)
78 | ?: throw Status.NOT_FOUND.withDescription("Symbol $symbol is not found.").asException()
79 | return createServerReflectionResponse(req, fd)
80 | }
81 |
82 | private fun getFileContainingExtension(req: ServerReflectionRequest): ServerReflectionResponse {
83 | val extensionRequest = req.fileContainingExtension
84 | val type = extensionRequest.containingType
85 | val extension = extensionRequest.extensionNumber
86 | val fd = serverReflectionIndex.getFileDescriptorByExtensionAndNumber(type, extension)
87 | ?: throw Status.NOT_FOUND.withDescription("Extension $type/$extension is not found.").asException()
88 | return createServerReflectionResponse(req, fd)
89 | }
90 |
91 | private fun getAllExtensions(req: ServerReflectionRequest): ServerReflectionResponse {
92 | val type = req.allExtensionNumbersOfType
93 | val extensions = serverReflectionIndex.getExtensionNumbersOfType(type)
94 | ?: throw Status.NOT_FOUND.withDescription("Type $type is not found.").asException()
95 |
96 | return serverReflectionResponse {
97 | validHost = req.host
98 | originalRequest = req
99 | allExtensionNumbersResponse = extensionNumberResponse {
100 | baseTypeName = type
101 | extensionNumber.addAll(extensions)
102 | }
103 | }
104 | }
105 |
106 | private fun listServices(req: ServerReflectionRequest): ServerReflectionResponse {
107 | return serverReflectionResponse {
108 | validHost = req.host
109 | originalRequest = req
110 | listServicesResponse = listServiceResponse {
111 | service.addAll(serverReflectionIndex.serviceNames.map { serviceName ->
112 | serviceResponse {
113 | name = serviceName
114 | }
115 | })
116 | }
117 | }
118 | }
119 |
120 | private fun createServerReflectionResponse(
121 | request: ServerReflectionRequest, fd: FileDescriptor
122 | ): ServerReflectionResponse {
123 | val fdRBuilder: FileDescriptorResponse.Builder = FileDescriptorResponse.newBuilder()
124 | val seenFiles: MutableSet = HashSet()
125 | val frontier: Queue = ArrayDeque()
126 | seenFiles.add(fd.name)
127 | frontier.add(fd)
128 | while (!frontier.isEmpty()) {
129 | val nextFd = frontier.remove()
130 | fdRBuilder.addFileDescriptorProto(nextFd.toProto().toByteString())
131 | for (dependencyFd in nextFd.dependencies) {
132 | if (!seenFiles.contains(dependencyFd.name)) {
133 | seenFiles.add(dependencyFd.name)
134 | frontier.add(dependencyFd)
135 | }
136 | }
137 | }
138 | return ServerReflectionResponse.newBuilder()
139 | .setValidHost(request.host)
140 | .setOriginalRequest(request)
141 | .setFileDescriptorResponse(fdRBuilder)
142 | .build()
143 | }
144 |
145 |
146 | private class ServerReflectionIndex(
147 | immutableServices: List,
148 | mutableServices: List
149 | ) {
150 | private val immutableServicesIndex: FileDescriptorIndex
151 | private val mutableServicesIndex: FileDescriptorIndex
152 |
153 | init {
154 | immutableServicesIndex = FileDescriptorIndex(immutableServices)
155 | mutableServicesIndex = FileDescriptorIndex(mutableServices)
156 | }
157 |
158 | val serviceNames: Set
159 | get() {
160 | val immutableServiceNames = immutableServicesIndex.getServiceNames()
161 | val mutableServiceNames = mutableServicesIndex.getServiceNames()
162 | val serviceNames: MutableSet = HashSet(immutableServiceNames.size + mutableServiceNames.size)
163 | serviceNames.addAll(immutableServiceNames)
164 | serviceNames.addAll(mutableServiceNames)
165 | return serviceNames
166 | }
167 |
168 | fun getFileDescriptorByName(name: String): FileDescriptor? {
169 | var fd: FileDescriptor? = immutableServicesIndex.getFileDescriptorByName(name)
170 | if (fd == null) {
171 | fd = mutableServicesIndex.getFileDescriptorByName(name)
172 | }
173 | return fd
174 | }
175 |
176 | fun getFileDescriptorBySymbol(symbol: String): FileDescriptor? {
177 | var fd: FileDescriptor? = immutableServicesIndex.getFileDescriptorBySymbol(symbol)
178 | if (fd == null) {
179 | fd = mutableServicesIndex.getFileDescriptorBySymbol(symbol)
180 | }
181 | return fd
182 | }
183 |
184 | fun getFileDescriptorByExtensionAndNumber(type: String, extension: Int): FileDescriptor? {
185 | var fd: FileDescriptor? = immutableServicesIndex.getFileDescriptorByExtensionAndNumber(type, extension)
186 | if (fd == null) {
187 | fd = mutableServicesIndex.getFileDescriptorByExtensionAndNumber(type, extension)
188 | }
189 | return fd
190 | }
191 |
192 | fun getExtensionNumbersOfType(type: String): Set? {
193 | var extensionNumbers = immutableServicesIndex.getExtensionNumbersOfType(type)
194 | if (extensionNumbers == null) {
195 | extensionNumbers = mutableServicesIndex.getExtensionNumbersOfType(type)
196 | }
197 | return extensionNumbers
198 | }
199 | }
200 |
201 | /**
202 | * Provides a set of methods for answering reflection queries for the file descriptors underlying
203 | * a set of services. Used by [ServerReflectionIndex] to separately index immutable and
204 | * mutable services.
205 | */
206 | private class FileDescriptorIndex(services: List) {
207 | private val serviceNames: MutableSet = HashSet()
208 | private val serviceFileDescriptors: MutableSet = HashSet()
209 | private val fileDescriptorsByName: MutableMap = HashMap()
210 | private val fileDescriptorsBySymbol: MutableMap = HashMap()
211 | private val fileDescriptorsByExtensionAndNumber: MutableMap> = HashMap()
212 |
213 | init {
214 | val fileDescriptorsToProcess: Queue = ArrayDeque()
215 | val seenFiles: MutableSet = HashSet()
216 | for (service in services) {
217 | val serviceDescriptor = service.serviceDescriptor
218 | if (serviceDescriptor.schemaDescriptor is ProtoFileDescriptorSupplier) {
219 | val fileDescriptor = (serviceDescriptor.schemaDescriptor as ProtoFileDescriptorSupplier).fileDescriptor
220 | val serviceName = serviceDescriptor.name
221 | require(!serviceNames.contains(serviceName)) { "Service already defined: $serviceName" }
222 | serviceFileDescriptors.add(fileDescriptor)
223 | serviceNames.add(serviceName)
224 | if (!seenFiles.contains(fileDescriptor.name)) {
225 | seenFiles.add(fileDescriptor.name)
226 | fileDescriptorsToProcess.add(fileDescriptor)
227 | }
228 | }
229 | }
230 |
231 | while (!fileDescriptorsToProcess.isEmpty()) {
232 | val currentFd = fileDescriptorsToProcess.remove()
233 | processFileDescriptor(currentFd)
234 | for (dependencyFd in currentFd.dependencies) {
235 | if (!seenFiles.contains(dependencyFd.name)) {
236 | seenFiles.add(dependencyFd.name)
237 | fileDescriptorsToProcess.add(dependencyFd)
238 | }
239 | }
240 | }
241 | }
242 |
243 | /**
244 | * Returns the file descriptors for the indexed services, but not their dependencies. This is
245 | * used to check if the server's mutable services have changed.
246 | */
247 | private fun getServiceFileDescriptors(): Set {
248 | return Collections.unmodifiableSet(serviceFileDescriptors)
249 | }
250 |
251 | fun getServiceNames(): Set {
252 | return Collections.unmodifiableSet(serviceNames)
253 | }
254 |
255 | fun getFileDescriptorByName(name: String): FileDescriptor? {
256 | return fileDescriptorsByName[name]
257 | }
258 |
259 | fun getFileDescriptorBySymbol(symbol: String): FileDescriptor? {
260 | return fileDescriptorsBySymbol[symbol]
261 | }
262 |
263 | fun getFileDescriptorByExtensionAndNumber(type: String, number: Int): FileDescriptor? {
264 | return if (fileDescriptorsByExtensionAndNumber.containsKey(type)) {
265 | fileDescriptorsByExtensionAndNumber[type]!![number]
266 | } else null
267 | }
268 |
269 | fun getExtensionNumbersOfType(type: String): Set? {
270 | return if (fileDescriptorsByExtensionAndNumber.containsKey(type)) {
271 | Collections.unmodifiableSet(fileDescriptorsByExtensionAndNumber[type]!!.keys)
272 | } else null
273 | }
274 |
275 | private fun processFileDescriptor(fd: FileDescriptor) {
276 | val fdName: String = fd.name
277 | require(!fileDescriptorsByName.containsKey(fdName)) { "File name already used: $fdName" }
278 | fileDescriptorsByName[fdName] = fd
279 | for (service in fd.services) {
280 | processService(service, fd)
281 | }
282 | for (type in fd.messageTypes) {
283 | processType(type, fd)
284 | }
285 | for (extension in fd.extensions) {
286 | processExtension(extension, fd)
287 | }
288 | }
289 |
290 | private fun processService(service: ServiceDescriptor, fd: FileDescriptor) {
291 | val serviceName: String = service.fullName
292 | require(!fileDescriptorsBySymbol.containsKey(serviceName)) { "Service already defined: $serviceName" }
293 | fileDescriptorsBySymbol[serviceName] = fd
294 | for (method in service.methods) {
295 | val methodName: String = method.fullName
296 | require(!fileDescriptorsBySymbol.containsKey(methodName)) { "Method already defined: $methodName" }
297 | fileDescriptorsBySymbol[methodName] = fd
298 | }
299 | }
300 |
301 | private fun processType(type: Descriptor, fd: FileDescriptor) {
302 | val typeName: String = type.fullName
303 | require(!fileDescriptorsBySymbol.containsKey(typeName)) { "Type already defined: $typeName" }
304 | fileDescriptorsBySymbol[typeName] = fd
305 | for (extension in type.extensions) {
306 | processExtension(extension, fd)
307 | }
308 | for (nestedType in type.nestedTypes) {
309 | processType(nestedType, fd)
310 | }
311 | }
312 |
313 | private fun processExtension(extension: FieldDescriptor, fd: FileDescriptor) {
314 | val extensionName: String = extension.containingType.fullName
315 | val extensionNumber: Int = extension.number
316 | if (!fileDescriptorsByExtensionAndNumber.containsKey(extensionName)) {
317 | fileDescriptorsByExtensionAndNumber[extensionName] = HashMap()
318 | }
319 | require(!fileDescriptorsByExtensionAndNumber[extensionName]!!.containsKey(extensionNumber)) {
320 | "Extension name and number already defined: $extensionName, $extensionNumber"
321 | }
322 | fileDescriptorsByExtensionAndNumber[extensionName]!![extensionNumber] = fd
323 | }
324 | }
325 | }
326 |
--------------------------------------------------------------------------------
/kert-grpc/src/main/kotlin/ws/leap/kert/grpc/ServerServiceDefinition.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.grpc
2 |
3 | import io.grpc.MethodDescriptor
4 | import io.grpc.ServiceDescriptor
5 | import java.lang.IllegalArgumentException
6 |
7 | interface BindableService {
8 | /**
9 | * Creates [ServerServiceDefinition] object for current instance of service implementation.
10 | *
11 | * @return ServerServiceDefinition object.
12 | */
13 | fun bindService(): ServerServiceDefinition
14 | }
15 |
16 | class ServerServiceMethodsBuilder {
17 | private val methods = mutableMapOf>()
18 |
19 | fun addMethod(method: MethodDescriptor, handler: GrpcHandler) {
20 | methods[method.fullMethodName] = ServerMethodDefinition(method, handler)
21 | }
22 |
23 | fun build(): Map> = methods.toMap()
24 | }
25 |
26 | /** Definition of a service to be exposed via a Server. */
27 | data class ServerServiceDefinition(
28 | val serviceDescriptor: ServiceDescriptor,
29 | private val methods: Map>
30 | ) {
31 | constructor(serviceDescriptor: ServiceDescriptor, configure: ServerServiceMethodsBuilder.() -> Unit) :
32 | this(serviceDescriptor, kotlin.run {
33 | val builder = ServerServiceMethodsBuilder()
34 | configure(builder)
35 | builder.build()
36 | })
37 |
38 | /**
39 | * Gets all the methods of service.
40 | */
41 | fun methods(): Collection> {
42 | return methods.values
43 | }
44 |
45 | /**
46 | * Look up a method by its fully qualified name.
47 | *
48 | * @param methodName the fully qualified name without leading slash. E.g., "com.foo.Foo/Bar"
49 | */
50 | fun method(methodName: String): ServerMethodDefinition<*, *> {
51 | return methods[methodName] ?: throw IllegalArgumentException("Method $methodName is not found")
52 | }
53 |
54 | fun intercepted(interceptor: GrpcInterceptor): ServerServiceDefinition {
55 | val interceptedMethods = methods.mapValues { it.value.intercepted(interceptor) }
56 | return ServerServiceDefinition(serviceDescriptor, interceptedMethods)
57 | }
58 |
59 | fun intercepted(vararg interceptors: GrpcInterceptor): ServerServiceDefinition {
60 | if (interceptors.isEmpty()) return this
61 |
62 | val combinedInterceptor = combineInterceptors(*interceptors)!!
63 | return intercepted(combinedInterceptor)
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/kert-grpc/src/main/kotlin/ws/leap/kert/grpc/ServiceRegistry.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.grpc
2 |
3 | class ServiceRegistry {
4 | private val services = mutableMapOf()
5 | private val methods = mutableMapOf>()
6 |
7 | fun addService(service: ServerServiceDefinition) {
8 | services[service.serviceDescriptor.name] = service
9 | for(method in service.methods()) {
10 | methods[method.methodDescriptor.fullMethodName] = method
11 | }
12 | }
13 |
14 | fun addService(service: BindableService) {
15 | addService(service.bindService())
16 | }
17 |
18 | fun services(): List = services.values.toList()
19 | fun lookupMethod(methodName: String): ServerMethodDefinition<*, *>? = methods[methodName]
20 | }
21 |
--------------------------------------------------------------------------------
/kert-grpc/src/main/kotlin/ws/leap/kert/grpc/Types.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.grpc
2 |
3 | import io.grpc.MethodDescriptor
4 | import io.vertx.core.MultiMap
5 | import kotlinx.coroutines.flow.Flow
6 | import ws.leap.kert.http.Handler
7 | import kotlin.coroutines.AbstractCoroutineContextElement
8 | import kotlin.coroutines.CoroutineContext
9 |
10 | data class GrpcContext(val method: MethodDescriptor<*, *>): AbstractCoroutineContextElement(GrpcContext) {
11 | companion object Key : CoroutineContext.Key
12 | }
13 |
14 | data class GrpcStream(
15 | val metadata: MultiMap,
16 | val messages: Flow,
17 | )
18 |
19 | typealias GrpcRequest = GrpcStream
20 | typealias GrpcResponse = GrpcStream
21 |
22 | typealias GrpcHandler = suspend (method: MethodDescriptor, req: GrpcRequest) -> GrpcResponse
23 | //interface GrpcHandler {
24 | // suspend operator fun invoke(method: MethodDescriptor, req: GrpcRequest): GrpcResponse
25 | //}
26 | typealias GrpcServerHandler = GrpcHandler
27 | typealias GrpcClientHandler = GrpcHandler
28 |
29 | interface GrpcInterceptor {
30 | suspend operator fun invoke(method: MethodDescriptor,
31 | req: GrpcRequest,
32 | next: GrpcHandler): GrpcResponse
33 | }
34 |
35 | //typealias GrpcInterceptor = suspend (method: MethodDescriptor<*, *>,
36 | // req: GrpcRequest<*>,
37 | // next: GrpcHandler<*, *>) -> GrpcResponse<*>
38 |
39 | typealias GrpcCallHandler = Handler, Flow>
40 | typealias GrpcServerCallHandler = GrpcCallHandler
41 | typealias GrpcClientCallHandler = GrpcCallHandler
42 |
43 | fun intercept(handler: GrpcHandler, interceptor: GrpcInterceptor): GrpcHandler {
44 | return { method, req ->
45 | interceptor(method, req, handler)
46 | }
47 | }
48 |
49 | fun GrpcHandler.intercepted(interceptor: GrpcInterceptor): GrpcHandler {
50 | return intercept(this, interceptor)
51 | }
52 |
53 | fun combineInterceptors(vararg interceptors: GrpcInterceptor): GrpcInterceptor? {
54 | if (interceptors.isEmpty()) return null
55 |
56 | return interceptors.reduce { left, right ->
57 | object: GrpcInterceptor {
58 | override suspend fun invoke(method: MethodDescriptor,
59 | req: GrpcRequest,
60 | next: GrpcHandler): GrpcResponse {
61 | return right(method, req) { m, r -> left(m, r, next) }
62 | }
63 | }
64 | }
65 | }
66 |
67 | fun combineInterceptors(current: GrpcInterceptor?, interceptor: GrpcInterceptor): GrpcInterceptor? {
68 | return current?.let { cur ->
69 | object: GrpcInterceptor {
70 | override suspend fun invoke(method: MethodDescriptor,
71 | req: GrpcRequest,
72 | next: GrpcHandler): GrpcResponse {
73 | return interceptor(method, req) { m, r -> cur(m, r, next) }
74 | }
75 | }
76 | } ?: interceptor
77 | }
78 |
79 | internal suspend fun handle(method: MethodDescriptor, req: GrpcRequest, handler: GrpcHandler, interceptor: GrpcInterceptor?): GrpcResponse {
80 | return interceptor?.let { it(method, req, handler) }
81 | ?: handler(method, req)
82 | }
83 |
--------------------------------------------------------------------------------
/kert-grpc/src/main/proto/reflection.proto:
--------------------------------------------------------------------------------
1 | // Copyright 2016 gRPC authors.
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | // Service exported by server reflection
16 |
17 | syntax = "proto3";
18 |
19 | package grpc.reflection.v1alpha;
20 |
21 | service ServerReflection {
22 | // The reflection service is structured as a bidirectional stream, ensuring
23 | // all related requests go to a single server.
24 | rpc ServerReflectionInfo(stream ServerReflectionRequest)
25 | returns (stream ServerReflectionResponse);
26 | }
27 |
28 | // The message sent by the client when calling ServerReflectionInfo method.
29 | message ServerReflectionRequest {
30 | string host = 1;
31 | // To use reflection service, the client should set one of the following
32 | // fields in message_request. The server distinguishes requests by their
33 | // defined field and then handles them using corresponding methods.
34 | oneof message_request {
35 | // Find a proto file by the file name.
36 | string file_by_filename = 3;
37 |
38 | // Find the proto file that declares the given fully-qualified symbol name.
39 | // This field should be a fully-qualified symbol name
40 | // (e.g. .[.] or .).
41 | string file_containing_symbol = 4;
42 |
43 | // Find the proto file which defines an extension extending the given
44 | // message type with the given field number.
45 | ExtensionRequest file_containing_extension = 5;
46 |
47 | // Finds the tag numbers used by all known extensions of the given message
48 | // type, and appends them to ExtensionNumberResponse in an undefined order.
49 | // Its corresponding method is best-effort: it's not guaranteed that the
50 | // reflection service will implement this method, and it's not guaranteed
51 | // that this method will provide all extensions. Returns
52 | // StatusCode::UNIMPLEMENTED if it's not implemented.
53 | // This field should be a fully-qualified type name. The format is
54 | // .
55 | string all_extension_numbers_of_type = 6;
56 |
57 | // List the full names of registered services. The content will not be
58 | // checked.
59 | string list_services = 7;
60 | }
61 | }
62 |
63 | // The type name and extension number sent by the client when requesting
64 | // file_containing_extension.
65 | message ExtensionRequest {
66 | // Fully-qualified type name. The format should be .
67 | string containing_type = 1;
68 | int32 extension_number = 2;
69 | }
70 |
71 | // The message sent by the server to answer ServerReflectionInfo method.
72 | message ServerReflectionResponse {
73 | string valid_host = 1;
74 | ServerReflectionRequest original_request = 2;
75 | // The server set one of the following fields accroding to the message_request
76 | // in the request.
77 | oneof message_response {
78 | // This message is used to answer file_by_filename, file_containing_symbol,
79 | // file_containing_extension requests with transitive dependencies. As
80 | // the repeated label is not allowed in oneof fields, we use a
81 | // FileDescriptorResponse message to encapsulate the repeated fields.
82 | // The reflection service is allowed to avoid sending FileDescriptorProtos
83 | // that were previously sent in response to earlier requests in the stream.
84 | FileDescriptorResponse file_descriptor_response = 4;
85 |
86 | // This message is used to answer all_extension_numbers_of_type requst.
87 | ExtensionNumberResponse all_extension_numbers_response = 5;
88 |
89 | // This message is used to answer list_services request.
90 | ListServiceResponse list_services_response = 6;
91 |
92 | // This message is used when an error occurs.
93 | ErrorResponse error_response = 7;
94 | }
95 | }
96 |
97 | // Serialized FileDescriptorProto messages sent by the server answering
98 | // a file_by_filename, file_containing_symbol, or file_containing_extension
99 | // request.
100 | message FileDescriptorResponse {
101 | // Serialized FileDescriptorProto messages. We avoid taking a dependency on
102 | // descriptor.proto, which uses proto2 only features, by making them opaque
103 | // bytes instead.
104 | repeated bytes file_descriptor_proto = 1;
105 | }
106 |
107 | // A list of extension numbers sent by the server answering
108 | // all_extension_numbers_of_type request.
109 | message ExtensionNumberResponse {
110 | // Full name of the base type, including the package name. The format
111 | // is .
112 | string base_type_name = 1;
113 | repeated int32 extension_number = 2;
114 | }
115 |
116 | // A list of ServiceResponse sent by the server answering list_services request.
117 | message ListServiceResponse {
118 | // The information of each service may be expanded in the future, so we use
119 | // ServiceResponse message to encapsulate it.
120 | repeated ServiceResponse service = 1;
121 | }
122 |
123 | // The information of a single service used by ListServiceResponse to answer
124 | // list_services request.
125 | message ServiceResponse {
126 | // Full name of a registered service, including its package name. The format
127 | // is .
128 | string name = 1;
129 | }
130 |
131 | // The error code and error message sent by the server when an error occurs.
132 | message ErrorResponse {
133 | // This field uses the error codes defined in grpc::StatusCode.
134 | int32 error_code = 1;
135 | string error_message = 2;
136 | }
137 |
--------------------------------------------------------------------------------
/kert-grpc/src/test/kotlin/ws/leap/kert/grpc/EchoServiceImpl.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.grpc
2 |
3 | import ws.leap.kert.test.*
4 | import kotlinx.coroutines.delay
5 | import kotlinx.coroutines.flow.Flow
6 | import kotlinx.coroutines.flow.collect
7 | import kotlinx.coroutines.flow.flow
8 | import kotlinx.coroutines.flow.map
9 | import kotlinx.coroutines.runBlocking
10 | import mu.KotlinLogging
11 | import ws.leap.kert.http.httpServer
12 |
13 | object EchoTest {
14 | val streamSize = 500
15 | val message = "hello".repeat(1024)
16 | }
17 |
18 | private val logger = KotlinLogging.logger {}
19 |
20 | class EchoServiceImpl : EchoGrpcKt.EchoImplBase() {
21 | override suspend fun unary(req: EchoReq): EchoResp {
22 | return echoResp {
23 | id = req.id
24 | value = req.value
25 | }
26 | }
27 |
28 | override suspend fun serverStreaming(req: EchoCountReq): Flow {
29 | return flow {
30 | for(i in 0 until req.count) {
31 | val msg = echoResp {
32 | id = i
33 | value = EchoTest.message
34 | }
35 | emit(msg)
36 | logger.trace { "Server sent id=${msg.id}" }
37 | delay(1)
38 | }
39 | }
40 | }
41 |
42 | override suspend fun clientStreaming(req: Flow): EchoCountResp {
43 | var count = 0
44 | req.collect { msg ->
45 | logger.trace { "Server received id=${msg.id}" }
46 | count++
47 | }
48 |
49 | return echoCountResp { this.count = count }
50 | }
51 |
52 | override suspend fun bidiStreaming(req: Flow): Flow {
53 | return req.map { msg ->
54 | logger.trace { "Server received id=${msg.id}" }
55 | delay(1)
56 | val respMsg = echoResp {
57 | id = msg.id
58 | value = msg.value
59 | }
60 | logger.trace { "Server sent id=${respMsg.id}" }
61 | respMsg
62 | }
63 | }
64 | }
65 |
66 | /*
67 | ghz --insecure -c 100 -z 30s --connections 100 \
68 | --proto kert-grpc/src/test/proto/echo.proto \
69 | --call ws.leap.kert.test.Echo.unary \
70 | -d '{"id":1, "value":"hello"}' \
71 | 0.0.0.0:8551
72 |
73 | With vertx-lang-kotlin-coroutines stream
74 | Summary:
75 | Count: 3248366
76 | Total: 30.00 s
77 | Slowest: 53.73 ms
78 | Fastest: 0.07 ms
79 | Average: 0.83 ms
80 | Requests/sec: 108270.75
81 |
82 | Response time histogram:
83 | 0.074 [1] |
84 | 5.440 [992545] |∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎
85 | 10.805 [6404] |
86 | 16.170 [754] |
87 | 21.536 [158] |
88 | 26.901 [18] |
89 | 32.266 [15] |
90 | 37.632 [1] |
91 | 42.997 [99] |
92 | 48.363 [3] |
93 | 53.728 [2] |
94 |
95 | Latency distribution:
96 | 10 % in 0.31 ms
97 | 25 % in 0.46 ms
98 | 50 % in 0.66 ms
99 | 75 % in 0.91 ms
100 | 90 % in 1.31 ms
101 | 95 % in 1.88 ms
102 | 99 % in 4.78 ms
103 |
104 | Status code distribution:
105 | [Canceled] 2 responses
106 | [OK] 3248321 responses
107 | [Unavailable] 43 responses
108 |
109 | Error distribution:
110 | [43] rpc error: code = Unavailable desc = transport is closing
111 | [2] rpc error: code = Canceled desc = grpc: the client connection is closing
112 |
113 |
114 |
115 | With own stream
116 | Summary:
117 | Count: 3262371
118 | Total: 30.00 s
119 | Slowest: 30.28 ms
120 | Fastest: 0.07 ms
121 | Average: 0.82 ms
122 | Requests/sec: 108734.61
123 |
124 | Response time histogram:
125 | 0.074 [1] |
126 | 3.095 [978766] |∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎
127 | 6.115 [15971] |∎
128 | 9.136 [3717] |
129 | 12.156 [1110] |
130 | 15.177 [245] |
131 | 18.197 [98] |
132 | 21.218 [62] |
133 | 24.238 [13] |
134 | 27.259 [8] |
135 | 30.280 [9] |
136 |
137 | Latency distribution:
138 | 10 % in 0.31 ms
139 | 25 % in 0.46 ms
140 | 50 % in 0.65 ms
141 | 75 % in 0.90 ms
142 | 90 % in 1.30 ms
143 | 95 % in 1.84 ms
144 | 99 % in 4.69 ms
145 |
146 | Status code distribution:
147 | [Canceled] 1 responses
148 | [Unavailable] 15 responses
149 | [OK] 3262355 responses
150 |
151 | Error distribution:
152 | [1] rpc error: code = Canceled desc = grpc: the client connection is closing
153 | [15] rpc error: code = Unavailable desc = transport is closing
154 | */
155 | fun main() = runBlocking {
156 | val server = httpServer(8551) {
157 | grpc {
158 | // enable server reflection
159 | serverReflection = true
160 |
161 | service(EchoServiceImpl())
162 | }
163 | }
164 | server.start()
165 | }
166 |
--------------------------------------------------------------------------------
/kert-grpc/src/test/kotlin/ws/leap/kert/grpc/EchoServiceJavaImpl.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.grpc
2 |
3 | import io.grpc.ServerBuilder
4 | import io.grpc.stub.StreamObserver
5 | import ws.leap.kert.test.*
6 |
7 | class EchoServiceJavaImpl : EchoGrpc.EchoImplBase() {
8 | override fun unary(request: EchoReq, responseObserver: StreamObserver) {
9 | val response = echoResp {
10 | id = request.id
11 | value = request.value
12 | }
13 | responseObserver.onNext(response)
14 | responseObserver.onCompleted()
15 | }
16 |
17 | override fun serverStreaming(request: EchoCountReq, responseObserver: StreamObserver) {
18 | super.serverStreaming(request, responseObserver)
19 | }
20 |
21 | override fun clientStreaming(responseObserver: StreamObserver): StreamObserver {
22 | return super.clientStreaming(responseObserver)
23 | }
24 |
25 | override fun bidiStreaming(responseObserver: StreamObserver): StreamObserver {
26 | return super.bidiStreaming(responseObserver)
27 | }
28 | }
29 |
30 | /*
31 | ghz --insecure -c 100 -z 30s --connections 100 \
32 | --proto kert-grpc/src/test/proto/echo.proto \
33 | --call ws.leap.kert.test.Echo.unary \
34 | -d '{"id":1, "value":"hello"}' \
35 | 0.0.0.0:8550
36 |
37 | Summary:
38 | Count: 2923518
39 | Total: 30.00 s
40 | Slowest: 29.55 ms
41 | Fastest: 0.10 ms
42 | Average: 0.94 ms
43 | Requests/sec: 97443.68
44 |
45 | Response time histogram:
46 | 0.097 [1] |
47 | 3.043 [969170] |∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎
48 | 5.988 [26428] |∎
49 | 8.933 [3386] |
50 | 11.878 [662] |
51 | 14.824 [196] |
52 | 17.769 [115] |
53 | 20.714 [30] |
54 | 23.659 [6] |
55 | 26.605 [3] |
56 | 29.550 [3] |
57 |
58 | Latency distribution:
59 | 10 % in 0.35 ms
60 | 25 % in 0.48 ms
61 | 50 % in 0.69 ms
62 | 75 % in 1.03 ms
63 | 90 % in 1.66 ms
64 | 95 % in 2.40 ms
65 | 99 % in 4.75 ms
66 |
67 | Status code distribution:
68 | [OK] 2923462 responses
69 | [Unavailable] 56 responses
70 |
71 | Error distribution:
72 | [56] rpc error: code = Unavailable desc = transport is closing
73 | */
74 | fun main() {
75 | val server = ServerBuilder
76 | .forPort(8550)
77 | .addService(EchoServiceJavaImpl())
78 | .build()
79 |
80 | server.start()
81 | server.awaitTermination()
82 | }
83 |
--------------------------------------------------------------------------------
/kert-grpc/src/test/kotlin/ws/leap/kert/grpc/Example.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.grpc
2 |
3 | import io.grpc.MethodDescriptor
4 | import io.vertx.core.http.HttpVersion
5 | import kotlinx.coroutines.flow.map
6 | import kotlinx.coroutines.runBlocking
7 | import ws.leap.kert.http.httpClient
8 | import ws.leap.kert.http.httpServer
9 | import ws.leap.kert.http.response
10 | import ws.leap.kert.test.EchoGrpcKt
11 | import ws.leap.kert.test.EchoReq
12 | import ws.leap.kert.test.echoReq
13 |
14 | class Example {
15 | fun server() = runBlocking {
16 | val server = httpServer(8080) {
17 | // server side filter
18 | filter { req, next ->
19 | println("Serving request ${req.path}")
20 | next(req)
21 | }
22 |
23 | // http service
24 | router {
25 | // http request handler
26 | get("/ping") {
27 | response(body = "pong")
28 | }
29 | }
30 |
31 | // grpc service
32 | grpc {
33 | // enable server reflection
34 | serverReflection = true
35 |
36 | // grpc interceptor
37 | interceptor( object : GrpcInterceptor {
38 | override suspend fun invoke(
39 | method: MethodDescriptor,
40 | req: GrpcRequest,
41 | next: GrpcHandler
42 | ): GrpcResponse {
43 | // intercept the request
44 | if (req.metadata["authentication"] == null) throw IllegalArgumentException("Authentication header is missing")
45 |
46 | // intercept each message in the streaming request
47 | val filteredReq = req.copy(messages = req.messages.map {
48 | println(it)
49 | it
50 | })
51 | return next(method, filteredReq)
52 | }
53 | })
54 |
55 | // register service implementation
56 | service(EchoServiceImpl())
57 | }
58 | }
59 |
60 | server.start()
61 | }
62 |
63 | fun client() = runBlocking {
64 | // http request
65 | val client = httpClient {
66 | options {
67 | defaultHost = "localhost"
68 | defaultPort = 8551
69 | protocolVersion = HttpVersion.HTTP_2
70 | }
71 |
72 | // a client side filter to set authorization header in request
73 | filter { req, next ->
74 | req.headers["authorization"] = "my-authorization-header"
75 | next(req)
76 | }
77 | }
78 | client.get("ping")
79 |
80 | // grpc request
81 | val stub = EchoGrpcKt.stub(client)
82 | stub.unary(echoReq { id = 1; value = "hello" })
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/kert-grpc/src/test/kotlin/ws/leap/kert/grpc/GrpcBasicSpec.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.grpc
2 |
3 | import io.kotest.core.spec.DoNotParallelize
4 | import io.kotest.matchers.collections.shouldHaveSize
5 | import io.kotest.matchers.shouldBe
6 | import kotlinx.coroutines.delay
7 | import kotlinx.coroutines.flow.collect
8 | import kotlinx.coroutines.flow.flow
9 | import kotlinx.coroutines.flow.map
10 | import kotlinx.coroutines.flow.toList
11 | import mu.KotlinLogging
12 | import ws.leap.kert.test.*
13 |
14 | private val logger = KotlinLogging.logger {}
15 |
16 | @DoNotParallelize
17 | class GrpcBasicSpec : GrpcSpec() {
18 | override fun configureServer(builder: GrpcServerBuilder) {
19 | builder.service(EchoServiceImpl())
20 | }
21 |
22 | private val stub = EchoGrpcKt.stub(client)
23 |
24 | init {
25 | context("Grpc server/client") {
26 | test("unary") {
27 | val req = echoReq { id = 1; value = EchoTest.message }
28 | val resp = stub.unary(req)
29 | resp.id shouldBe 1
30 | resp.value shouldBe EchoTest.message
31 | }
32 |
33 | test("server stream") {
34 | val req = echoCountReq { count = EchoTest.streamSize }
35 | val resp = stub.serverStreaming(req)
36 | val respMsgs = resp.map { msg ->
37 | logger.trace { "Client received id=${msg.id}" }
38 | msg
39 | }.toList()
40 | respMsgs shouldHaveSize EchoTest.streamSize
41 | }
42 |
43 | test("client stream") {
44 | val req = flow {
45 | for(i in 0 until EchoTest.streamSize) {
46 | val msg = echoReq { id = i; value = EchoTest.message }
47 | emit(msg)
48 | logger.trace { "Client sent id=${msg.id}" }
49 | delay(1)
50 | }
51 | }
52 |
53 | val resp = stub.clientStreaming(req)
54 | resp.count shouldBe EchoTest.streamSize
55 | }
56 |
57 | test("bidi stream") {
58 | val req = flow {
59 | for(i in 0 until EchoTest.streamSize) {
60 | val msg = echoReq { id = i; value = EchoTest.message }
61 | emit(msg)
62 | logger.trace { "Client sent id=${msg.id}" }
63 | delay(1)
64 | }
65 | }
66 |
67 | val resp = stub.bidiStreaming(req)
68 | var count = 0
69 | resp.collect { msg ->
70 | count++
71 | logger.trace { "Client received id=${msg.id}" }
72 | }
73 | count shouldBe EchoTest.streamSize
74 | }
75 | }
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/kert-grpc/src/test/kotlin/ws/leap/kert/grpc/GrpcErrorSpec.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.grpc
2 |
3 | import io.grpc.StatusException
4 | import io.kotest.assertions.throwables.shouldThrow
5 | import io.kotest.core.spec.DoNotParallelize
6 | import kotlinx.coroutines.delay
7 | import kotlinx.coroutines.flow.*
8 | import mu.KotlinLogging
9 | import ws.leap.kert.test.*
10 |
11 | private val logger = KotlinLogging.logger {}
12 |
13 | @DoNotParallelize
14 | class GrpcErrorSpec : GrpcSpec() {
15 | override fun configureServer(builder: GrpcServerBuilder) {
16 | val echoService = object: EchoGrpcKt.EchoImplBase() {
17 | override suspend fun unary(req: EchoReq): EchoResp {
18 | throw RuntimeException("mocked error")
19 | }
20 |
21 | override suspend fun serverStreaming(req: EchoCountReq): Flow {
22 | return flow {
23 | for(i in 0 until req.count / 2) {
24 | val msg = echoResp { id = i; value = EchoTest.message }
25 | emit(msg)
26 | logger.trace { "Server sent id=${msg.id}" }
27 | delay(1)
28 | }
29 |
30 | // throw error in the middle
31 | throw RuntimeException("mocked error")
32 | }
33 | }
34 |
35 | override suspend fun clientStreaming(req: Flow): EchoCountResp {
36 | var count = 0
37 | req.collect { msg ->
38 | logger.trace { "Server received id=${msg.id}" }
39 | count++
40 |
41 | if (count > EchoTest.streamSize / 2) {
42 | // throw error in the middle
43 | throw RuntimeException("mocked error")
44 | }
45 | }
46 |
47 | return echoCountResp { this.count = count }
48 | }
49 |
50 | override suspend fun bidiStreaming(req: Flow): Flow {
51 | var count = 0
52 | return req.map { msg ->
53 | logger.trace { "Server received id=${msg.id}" }
54 | delay(1)
55 | val respMsg = echoResp { id = msg.id; value = msg.value }
56 | logger.trace { "Server sent id=${respMsg.id}" }
57 |
58 | count++
59 | if (count > EchoTest.streamSize / 2) {
60 | // throw error in the middle
61 | throw RuntimeException("mocked error")
62 | }
63 |
64 | respMsg
65 | }
66 | }
67 | }
68 |
69 | builder.service(echoService)
70 | }
71 |
72 | private val stub = EchoGrpcKt.stub(client)
73 |
74 | init {
75 | context("Grpc should capture the errors") {
76 | test("unary") {
77 | val req = echoReq { id = 1; value = EchoTest.message }
78 | shouldThrow {
79 | stub.unary(req)
80 | }
81 | }
82 |
83 | test("server stream") {
84 | val req = echoCountReq { count = EchoTest.streamSize }
85 | val resp = stub.serverStreaming(req)
86 |
87 | shouldThrow {
88 | resp.map { msg ->
89 | logger.trace { "Client received id=${msg.id}" }
90 | msg
91 | }.toList()
92 | }
93 | }
94 |
95 | test("client stream") {
96 | val req = flow {
97 | for(i in 0 until EchoTest.streamSize) {
98 | val msg = echoReq { id = i; value = i.toString() }
99 | emit(msg)
100 | logger.trace { "Client sent id=${msg.id}" }
101 | delay(1)
102 | }
103 | }
104 |
105 | shouldThrow {
106 | stub.clientStreaming(req)
107 | }
108 | }
109 |
110 | test("bidi stream") {
111 | val req = flow {
112 | for(i in 0 until EchoTest.streamSize) {
113 | val msg = echoReq { id = i; value = i.toString() }
114 | emit(msg)
115 | logger.trace { "Client sent id=${msg.id}" }
116 | delay(1)
117 | }
118 | }
119 |
120 | val resp = stub.bidiStreaming(req)
121 | shouldThrow {
122 | resp.collect { msg ->
123 | logger.trace { "Client received id=${msg.id}" }
124 | }
125 | }
126 | }
127 | }
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/kert-grpc/src/test/kotlin/ws/leap/kert/grpc/GrpcInterceptorSpec.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.grpc
2 |
3 | import io.grpc.MethodDescriptor
4 | import io.grpc.StatusException
5 | import io.kotest.assertions.throwables.shouldThrow
6 | import io.kotest.core.spec.DoNotParallelize
7 | import io.kotest.matchers.shouldBe
8 | import kotlinx.coroutines.flow.map
9 | import ws.leap.kert.test.EchoGrpcKt
10 | import ws.leap.kert.test.EchoReq
11 | import ws.leap.kert.test.echoReq
12 |
13 | @DoNotParallelize
14 | class GrpcInterceptorSpec : GrpcSpec() {
15 | override fun configureServer(builder: GrpcServerBuilder) = with(builder) {
16 | interceptor(object: GrpcInterceptor {
17 | override suspend fun invoke(method: MethodDescriptor,
18 | req: GrpcRequest,
19 | next: GrpcHandler): GrpcResponse {
20 | // fail if no authentication header
21 | if (req.metadata["authentication"] == null) throw IllegalArgumentException("Authentication header is missing")
22 |
23 | // fail if message value is "not-good"
24 | val filteredReq = req.copy(messages = req.messages.map { msg ->
25 | if (msg is EchoReq && msg.value == "not-good") throw IllegalArgumentException("Mocked exception")
26 | msg
27 | })
28 | return next(method, filteredReq)
29 | }
30 | })
31 |
32 | service(EchoServiceImpl())
33 | }
34 |
35 | private val stub = EchoGrpcKt.stub(client)
36 | private val stubWithAuth = stub.intercepted(object: GrpcInterceptor {
37 | override suspend fun invoke(method: MethodDescriptor,
38 | req: GrpcRequest,
39 | next: GrpcHandler): GrpcResponse {
40 | req.metadata["authentication"] = "mocked-authentication"
41 | return next(method, req)
42 | }
43 | })
44 |
45 | init {
46 | test("should fail if no authentication") {
47 | shouldThrow {
48 | stub.unary(echoReq { id = 1; value = "good" })
49 | }
50 | }
51 | test("should succeed if message is good") {
52 | val resp = stubWithAuth.unary(echoReq { id = 1; value = "good" })
53 | resp.value shouldBe "good"
54 | }
55 |
56 | test("should fail if message is not-good") {
57 | shouldThrow {
58 | stubWithAuth.unary(echoReq { id = 1; value = "not-good" })
59 | }
60 | }
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/kert-grpc/src/test/kotlin/ws/leap/kert/grpc/GrpcNestedBidiSpec.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.grpc
2 |
3 | import io.kotest.core.spec.DoNotParallelize
4 | import io.kotest.matchers.shouldBe
5 | import kotlinx.coroutines.channels.Channel
6 | import kotlinx.coroutines.flow.Flow
7 | import kotlinx.coroutines.flow.collect
8 | import kotlinx.coroutines.flow.flow
9 | import mu.KotlinLogging
10 | import ws.leap.kert.test.*
11 | import java.util.concurrent.atomic.AtomicInteger
12 |
13 | private val logger = KotlinLogging.logger {}
14 |
15 | /**
16 | * A test to demonstrate feed request messages with response messages from server.
17 | */
18 | @DoNotParallelize
19 | class GrpcNestedBidiSpec : GrpcSpec() {
20 | private val messageNum = 10
21 | private val omittedCount = AtomicInteger()
22 |
23 | override fun configureServer(builder: GrpcServerBuilder) {
24 | val echoService = object: EchoGrpcKt.EchoImplBase() {
25 | override suspend fun bidiStreaming(req: Flow): Flow {
26 | return flow {
27 | req.collect { reqMsg ->
28 | val reqValue = reqMsg.value.toInt()
29 | // if the value is less than 100, double it then send it back
30 | // otherwise omit it
31 | if (reqValue < 100) {
32 | logger.trace { "Server: Message id=${reqMsg.id} value=${reqMsg.value} bounce" }
33 | val respValue = (reqValue * 2).toString()
34 | val respMsg = echoResp { id = reqMsg.id; value = respValue }
35 | emit(respMsg)
36 | } else {
37 | logger.trace { "Server: Message id=${reqMsg.id} value=${reqMsg.value} omitted" }
38 | omittedCount.incrementAndGet()
39 | if(omittedCount.get() == messageNum) {
40 | // all messages are omitted, end the loop
41 | emit(echoResp {
42 | id = -1
43 | value = "end"
44 | })
45 | }
46 | }
47 | }
48 | }
49 | }
50 | }
51 |
52 | builder.service(echoService)
53 | }
54 |
55 | private val stub = EchoGrpcKt.stub(client)
56 |
57 | init {
58 | context("Grpc") {
59 | test("can use response messages as request messages") {
60 | val channel = Channel()
61 | val req = flow {
62 | for(i in 1 .. messageNum) {
63 | // send initial 10 messages
64 | val msg = echoReq { id = i; value = i.toString() }
65 | emit(msg)
66 | logger.trace { "Client sent id=${msg.id} value=${msg.value}" }
67 | }
68 |
69 | // client always bounce the message back to server
70 | for(resp in channel) {
71 | logger.trace { "Client: Message id=${resp.id} value=${resp.value} bounce" }
72 | val msg = echoReq { id = resp.id; value = resp.value }
73 | emit(msg)
74 | }
75 | }
76 |
77 | val resp = stub.bidiStreaming(req)
78 | var count = 0
79 | resp.collect { msg ->
80 | logger.trace { "Client received id=${msg.id}" }
81 | if(msg.id == -1) {
82 | // server indicates end, end the loop
83 | channel.close()
84 | } else {
85 | count++
86 | // put message to channel so it can be sent to server again
87 | channel.send(msg)
88 | }
89 | }
90 | count shouldBe 50 // all messages received, doesn't count the "end" message
91 | }
92 | }
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/kert-grpc/src/test/kotlin/ws/leap/kert/grpc/GrpcSpec.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.grpc
2 |
3 | import io.kotest.core.spec.Spec
4 | import io.kotest.core.spec.style.FunSpec
5 | import io.vertx.core.http.HttpVersion
6 | import kotlinx.coroutines.runBlocking
7 | import ws.leap.kert.http.httpClient
8 | import ws.leap.kert.http.httpServer
9 |
10 | abstract class GrpcSpec : FunSpec() {
11 | protected val port = 8551
12 | protected val client = httpClient {
13 | options {
14 | protocolVersion = HttpVersion.HTTP_2
15 | defaultPort = port
16 | isHttp2ClearTextUpgrade = false
17 | }
18 | }
19 |
20 | protected val server = httpServer(port) {
21 | grpc {
22 | configureServer(this)
23 | }
24 | }
25 | protected abstract fun configureServer(builder: GrpcServerBuilder)
26 |
27 | init {
28 | beforeSpec {
29 | runBlocking {
30 | server.start()
31 | }
32 | }
33 | afterSpec {
34 | runBlocking {
35 | server.stop()
36 | }
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/kert-grpc/src/test/kotlin/ws/leap/kert/grpc/GrpcUnimplementedSpec.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.grpc
2 |
3 | import io.grpc.Status
4 | import io.grpc.StatusException
5 | import io.grpc.StatusRuntimeException
6 | import io.kotest.assertions.throwables.shouldThrow
7 | import io.kotest.core.spec.DoNotParallelize
8 | import io.kotest.matchers.shouldBe
9 | import kotlinx.coroutines.flow.Flow
10 | import kotlinx.coroutines.flow.collect
11 | import kotlinx.coroutines.flow.flow
12 | import ws.leap.kert.test.*
13 |
14 | @DoNotParallelize
15 | class GrpcUnimplementedSpec : GrpcSpec() {
16 | override fun configureServer(builder: GrpcServerBuilder) {
17 | val echoService = object: EchoGrpcKt.EchoImplBase() {
18 | override suspend fun serverStreaming(req: EchoCountReq): Flow {
19 | throw StatusRuntimeException(Status.DATA_LOSS)
20 | }
21 |
22 | override suspend fun clientStreaming(req: Flow): EchoCountResp {
23 | throw StatusRuntimeException(Status.INTERNAL)
24 | }
25 |
26 | override suspend fun bidiStreaming(req: Flow): Flow {
27 | throw StatusException(Status.NOT_FOUND)
28 | }
29 | }
30 | builder.service(echoService)
31 | }
32 |
33 | private val stub = EchoGrpcKt.stub(client)
34 |
35 | init {
36 | context("Grpc") {
37 | test("unary is not implemented") {
38 | val req = echoReq { id = 1; value = EchoTest.message }
39 | val exception = shouldThrow {
40 | stub.unary(req)
41 | }
42 | exception.status.code shouldBe Status.Code.UNIMPLEMENTED
43 | }
44 |
45 | test("server stream should cause DATA_LOSS") {
46 | val req = echoCountReq { count = EchoTest.streamSize }
47 | val exception = shouldThrow {
48 | stub.serverStreaming(req)
49 | .collect {} // collect is required to raise exception from a failed flow
50 | }
51 | exception.status.code shouldBe Status.Code.DATA_LOSS
52 | }
53 |
54 | test("client stream") {
55 | val req = flow {
56 | for(i in 0 until EchoTest.streamSize) {
57 | val msg = echoReq { id = i; value = i.toString() }
58 | emit(msg)
59 | }
60 | }
61 |
62 | val exception = shouldThrow {
63 | stub.clientStreaming(req)
64 | }
65 | exception.status.code shouldBe Status.Code.INTERNAL
66 | }
67 |
68 | test("bidi stream") {
69 | val req = flow {
70 | for(i in 0 until EchoTest.streamSize) {
71 | val msg = echoReq { id = i; value = i.toString() }
72 | emit(msg)
73 | }
74 | }
75 |
76 | val exception = shouldThrow {
77 | stub.bidiStreaming(req)
78 | .collect {} // collect is required to raise exception from a failed flow
79 | }
80 | exception.status.code shouldBe Status.Code.NOT_FOUND
81 | }
82 | }
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/kert-grpc/src/test/kotlin/ws/leap/kert/grpc/ManualTestClient.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.grpc
2 |
3 | import io.vertx.core.http.HttpVersion
4 | import kotlinx.coroutines.runBlocking
5 | import ws.leap.kert.http.httpClient
6 | import ws.leap.kert.test.EchoGrpcKt
7 | import ws.leap.kert.test.echoReq
8 |
9 | /**
10 | * This is for manual test (debugging) to trigger one request.
11 | */
12 | fun main() {
13 | val client = httpClient {
14 | options {
15 | protocolVersion = HttpVersion.HTTP_2
16 | defaultPort = 8551
17 | isHttp2ClearTextUpgrade = false
18 | }
19 | }
20 | val stub = EchoGrpcKt.stub(client)
21 | runBlocking {
22 | val req = echoReq { id = 1; value = EchoTest.message }
23 | val resp = stub.unary(req)
24 | println(resp)
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/kert-grpc/src/test/proto/echo.proto:
--------------------------------------------------------------------------------
1 | syntax = "proto3";
2 | package ws.leap.kert.test;
3 | option java_outer_classname = "EchoProto";
4 | option java_multiple_files = true;
5 |
6 | message EchoReq {
7 | int32 id = 1;
8 | string value = 2;
9 | }
10 |
11 | message EchoCountReq {
12 | int32 count = 1;
13 | }
14 |
15 | message EchoResp {
16 | int32 id = 1;
17 | string value = 2;
18 | }
19 |
20 | message EchoCountResp {
21 | int32 count = 1;
22 | }
23 |
24 | service Echo {
25 | rpc unary(EchoReq) returns (EchoResp);
26 | rpc serverStreaming(EchoCountReq) returns (stream EchoResp);
27 | rpc clientStreaming(stream EchoReq) returns (EchoCountResp);
28 | rpc bidiStreaming(stream EchoReq) returns (stream EchoResp);
29 | }
30 |
--------------------------------------------------------------------------------
/kert-grpc/src/test/resources/logback.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/kert-http/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import build.*
2 |
3 | description = "Kert HTTP support"
4 |
5 | configureLibrary()
6 |
7 | dependencies {
8 | api(libs.bundles.kotlin)
9 |
10 | api(libs.kotlinx.coroutines)
11 | api(libs.kotlinx.coroutines.slf4j)
12 |
13 | api(libs.vertx.web)
14 | api(libs.vertx.lang.kotlin.coroutines)
15 |
16 | testImplementation(libs.vertx.web.client)
17 | }
18 |
--------------------------------------------------------------------------------
/kert-http/src/main/kotlin/ws/leap/kert/http/HttpClient.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.http
2 |
3 | import io.vertx.core.MultiMap
4 | import io.vertx.core.Vertx
5 | import io.vertx.core.http.HttpClientOptions
6 | import io.vertx.core.http.HttpMethod
7 | import io.vertx.core.http.HttpVersion
8 | import java.net.URL
9 |
10 | data class RequestOptions(
11 | val ssl: Boolean,
12 | val host: String,
13 | val port: Int
14 | )
15 |
16 | interface HttpClient {
17 | suspend fun get(uri: String, headers: MultiMap? = null) = call(request(HttpMethod.GET, uri, headers))
18 | suspend fun head(uri: String, headers: MultiMap? = null) = call(request(HttpMethod.HEAD, uri, headers))
19 | suspend fun put(uri: String, body: Any, contentLength: Long? = null, headers: MultiMap? = null) = call(request(HttpMethod.PUT, uri, headers, body, contentLength))
20 | suspend fun post(uri: String, body: Any, contentLength: Long? = null, headers: MultiMap? = null) = call(request(HttpMethod.POST, uri, headers, body, contentLength))
21 | suspend fun delete(uri: String, headers: MultiMap? = null) = call(request(HttpMethod.DELETE, uri, headers))
22 | suspend fun patch(uri: String, body: Any, contentLength: Long? = null, headers: MultiMap? = null) = call(request(HttpMethod.PATCH, uri, headers, body, contentLength))
23 |
24 | suspend fun get(url: URL, headers: MultiMap? = null) = call(request(HttpMethod.GET, url, headers))
25 | suspend fun head(url: URL, headers: MultiMap? = null) = call(request(HttpMethod.HEAD, url, headers))
26 | suspend fun put(url: URL, body: Any, contentLength: Long? = null, headers: MultiMap? = null) = call(request(HttpMethod.PUT, url, headers, body, contentLength))
27 | suspend fun post(url: URL, body: Any, contentLength: Long? = null, headers: MultiMap? = null) = call(request(HttpMethod.POST, url, headers, body, contentLength))
28 | suspend fun delete(url: URL, headers: MultiMap? = null) = call(request(HttpMethod.DELETE, url, headers))
29 | suspend fun patch(url: URL, body: Any, contentLength: Long? = null, headers: MultiMap? = null) = call(request(HttpMethod.PATCH, url, headers, body, contentLength))
30 |
31 | suspend fun call(request: HttpClientRequest): HttpClientResponse
32 |
33 | suspend fun close()
34 |
35 | fun withFilter(filter: HttpClientFilter): HttpClient
36 | fun withFilters(vararg filters: HttpClientFilter): HttpClient
37 | fun withOptions(options: RequestOptions): HttpClient
38 | val protocolVersion: HttpVersion
39 | }
40 |
41 | interface HttpClientBuilderDsl {
42 | fun options(configure: HttpClientOptions.() -> Unit)
43 | fun filter(filter: HttpClientFilter)
44 | }
45 |
46 | class HttpClientBuilder(private val vertx: Vertx): HttpClientBuilderDsl {
47 | private val filters = mutableListOf()
48 | private val options = HttpClientOptions()
49 |
50 | override fun options(configure: HttpClientOptions.() -> Unit) {
51 | configure(options)
52 | }
53 |
54 | override fun filter(filter: HttpClientFilter) {
55 | filters.add(filter)
56 | }
57 |
58 | fun build(): HttpClient {
59 | val filter = combineFilters(*filters.toTypedArray())
60 | val vertxClient = vertx.createHttpClient(options) as io.vertx.core.http.impl.HttpClientImpl
61 | return HttpClientImpl(vertxClient, filter)
62 | }
63 | }
64 |
65 | fun httpClient(vertx: Vertx, configure: (HttpClientBuilderDsl.() -> Unit)? = null): HttpClient {
66 | val builder = HttpClientBuilder(vertx)
67 | configure?.let { it(builder) }
68 | return builder.build()
69 | }
70 |
71 |
72 | fun httpClient(configure: (HttpClientBuilderDsl.() -> Unit)? = null): HttpClient {
73 | return httpClient(Kert.vertx, configure)
74 | }
75 |
--------------------------------------------------------------------------------
/kert-http/src/main/kotlin/ws/leap/kert/http/HttpClientImpl.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.http
2 |
3 | import io.vertx.core.Vertx
4 | import io.vertx.core.http.HttpVersion
5 | import io.vertx.kotlin.coroutines.await
6 | import io.vertx.kotlin.coroutines.dispatcher
7 | import kotlinx.coroutines.CompletableDeferred
8 | import kotlinx.coroutines.CoroutineScope
9 | import kotlinx.coroutines.launch
10 | import mu.KotlinLogging
11 | import kotlin.coroutines.coroutineContext
12 |
13 | private val logger = KotlinLogging.logger {}
14 |
15 | internal class HttpClientImpl (private val underlying: io.vertx.core.http.impl.HttpClientImpl,
16 | private val filters: HttpClientFilter? = null,
17 | private val options: RequestOptions? = null) : HttpClient {
18 | override suspend fun call(request: HttpClientRequest): HttpClientResponse {
19 | return handle(request, ::callHttp, filters)
20 | }
21 |
22 | override suspend fun close() {
23 | underlying.close().await()
24 | }
25 |
26 | override fun withFilter(filter: HttpClientFilter): HttpClient {
27 | return HttpClientImpl(underlying, filter, options)
28 | }
29 |
30 | override fun withFilters(vararg filters: HttpClientFilter): HttpClient {
31 | if(filters.isEmpty()) return this
32 |
33 | val combinedFilter = combineFilters(*filters)!!
34 | return withFilter(combinedFilter)
35 | }
36 |
37 | override fun withOptions(options: RequestOptions): HttpClient {
38 | return HttpClientImpl(underlying, filters, options)
39 | }
40 |
41 | override val protocolVersion: HttpVersion = underlying.options().protocolVersion
42 |
43 | private suspend fun callHttp(request: HttpClientRequest): HttpClientResponse {
44 | val responseDeferred = CompletableDeferred()
45 | val scope = CoroutineScope(coroutineContext)
46 |
47 | underlying.request(requestOptions(request)) { ar ->
48 | logger.debug { "created request" }
49 | if (ar.succeeded()) {
50 | val vertxRequest = ar.result()
51 | val vertxContext = Vertx.currentContext()
52 |
53 | vertxRequest.headers().addAll(request.headers)
54 | vertxRequest.isChunked = request.chunked()
55 |
56 | // start send request body
57 | scope.launch(vertxContext.dispatcher()) {
58 | try {
59 | write(vertxContext, request.body, vertxRequest)
60 | vertxRequest.end().await()
61 | } catch (t: Throwable) {
62 | // send request body failed
63 | responseDeferred.completeExceptionally(t)
64 | }
65 | }
66 |
67 | vertxRequest.response { respResult ->
68 | if(respResult.succeeded()) {
69 | val vertxResponse = respResult.result()
70 | responseDeferred.complete(HttpClientResponse(vertxResponse, vertxContext))
71 | }
72 | }
73 | } else {
74 | // start request failed
75 | responseDeferred.completeExceptionally(ar.cause())
76 | }
77 | }
78 |
79 | return responseDeferred.await()
80 | }
81 |
82 | private fun requestOptions(request: HttpClientRequest): io.vertx.core.http.RequestOptions {
83 | val defaults = request.options ?: options
84 | val options = io.vertx.core.http.RequestOptions()
85 | options.run {
86 | method = request.method
87 | host = defaults?.host
88 | port = defaults?.port
89 | isSsl = defaults?.ssl
90 | uri = request.uri
91 | }
92 | return options
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/kert-http/src/main/kotlin/ws/leap/kert/http/HttpClientRequest.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.http
2 |
3 | import io.vertx.core.MultiMap
4 | import io.vertx.core.buffer.Buffer
5 | import io.vertx.core.http.HttpHeaders
6 | import io.vertx.core.http.HttpMethod
7 | import io.vertx.core.http.impl.headers.HeadersMultiMap
8 | import kotlinx.coroutines.flow.Flow
9 | import kotlinx.coroutines.flow.emptyFlow
10 | import java.net.URL
11 |
12 | data class HttpClientRequest internal constructor(
13 | override val method: HttpMethod,
14 | override val uri: String,
15 | override val headers: MultiMap = HeadersMultiMap(),
16 | override val body: Flow = emptyFlow(),
17 | internal val options: RequestOptions? = null,
18 | ): HttpRequest
19 |
20 | fun request(method: HttpMethod, url: URL, headers: MultiMap? = null, body: Any? = null, contentLength: Long? = null): HttpClientRequest {
21 | val theHeaders = constructHeaders(headers, contentLength, body)
22 |
23 | val requestUri = "${url.file}${url.ref?.map { "#$it" } ?: ""}"
24 | val actualPort = if (url.port != -1) url.port else url.defaultPort
25 | val defaults = RequestOptions(url.protocol == "https", url.host, actualPort)
26 |
27 | return HttpClientRequest(method, requestUri, theHeaders, toFlow(body), defaults)
28 | }
29 |
30 | fun request(method: HttpMethod, uri: String, headers: MultiMap? = null, body: Any? = null, contentLength: Long? = null): HttpClientRequest {
31 | val theHeaders = constructHeaders(headers, contentLength, body)
32 |
33 | return HttpClientRequest(method, uri, headers = theHeaders, body = toFlow(body))
34 | }
35 |
36 | internal fun constructHeaders(headers: MultiMap?, contentLength: Long?, body: Any?): MultiMap {
37 | val theHeaders = headers ?: HeadersMultiMap()
38 | if (contentLength != null) {
39 | theHeaders[HttpHeaders.CONTENT_LENGTH] = contentLength.toString()
40 | } else {
41 | if (body == null) {
42 | theHeaders[HttpHeaders.CONTENT_LENGTH] = "0"
43 | }
44 | }
45 | return theHeaders
46 | }
47 |
--------------------------------------------------------------------------------
/kert-http/src/main/kotlin/ws/leap/kert/http/HttpClientResponse.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.http
2 |
3 | import io.vertx.core.Context
4 | import io.vertx.core.MultiMap
5 | import io.vertx.core.buffer.Buffer
6 | import kotlinx.coroutines.flow.Flow
7 | import io.vertx.core.http.HttpClientResponse as VHttpClientResponse
8 |
9 | class HttpClientResponse (private val underlying: VHttpClientResponse, private val context: Context) : HttpResponse {
10 | override val headers: MultiMap = underlying.headers()
11 | override val trailers: () -> MultiMap = {
12 | underlying.trailers()
13 | }
14 |
15 | override val body: Flow = underlying.asFlow(context)
16 | override val statusCode: Int = underlying.statusCode()
17 | }
18 |
--------------------------------------------------------------------------------
/kert-http/src/main/kotlin/ws/leap/kert/http/HttpRouter.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.http
2 |
3 | import io.vertx.core.Vertx
4 | import io.vertx.core.http.HttpMethod
5 | import io.vertx.ext.web.Router
6 | import io.vertx.kotlin.coroutines.await
7 | import io.vertx.kotlin.coroutines.dispatcher
8 | import kotlinx.coroutines.*
9 | import kotlinx.coroutines.slf4j.MDCContext
10 | import mu.KotlinLogging
11 | import kotlin.coroutines.AbstractCoroutineContextElement
12 | import kotlin.coroutines.CoroutineContext
13 | import io.vertx.ext.web.RoutingContext as VRoutingContext
14 |
15 | data class VertxRoutingContext(
16 | val routingContext: VRoutingContext
17 | ) : AbstractCoroutineContextElement(VertxRoutingContext) {
18 | companion object Key : CoroutineContext.Key
19 | override fun toString(): String = "VertxRoutingContext($routingContext)"
20 | }
21 |
22 | private val httpExceptionLogger = KotlinLogging.logger {}
23 | val defaultHttpExceptionHandler = CoroutineExceptionHandler { context, exception ->
24 | val routingContext = context[VertxRoutingContext]?.routingContext ?: throw IllegalStateException("Routing context is not available on coroutine context")
25 | httpExceptionLogger.warn(exception) { "HTTP call failed, path=${routingContext.request().path()}" }
26 |
27 | val response = routingContext.response()
28 | if (!response.ended()) {
29 | if (!response.headWritten()) {
30 | response.statusCode = 500
31 | response.statusMessage = exception.toString()
32 | response.end()
33 | } else {
34 | // head already sent, reset the connection
35 | response.reset()
36 | // response.close()
37 | }
38 | }
39 | }
40 |
41 | internal data class SubRouterDef(
42 | val path: String,
43 | val exceptionHandler: CoroutineExceptionHandler?,
44 | val configure: HttpRouterBuilder.() -> Unit
45 | )
46 |
47 | internal data class HandlerDef(
48 | val method: HttpMethod,
49 | val path: String,
50 | val handler: HttpServerHandler
51 | )
52 |
53 | open class HttpRouterBuilder(private val vertx: Vertx,
54 | internal val underlying: Router,
55 | private var parentFilter: HttpServerFilter?,
56 | private val exceptionHandler: CoroutineExceptionHandler?): HttpRouterDsl {
57 | private val filters = mutableListOf()
58 | private val subRouters = mutableListOf()
59 | private val handlers = mutableListOf()
60 |
61 | override fun filter(filter: HttpServerFilter) {
62 | filters.add(filter)
63 | }
64 |
65 | override fun subRouter(path: String, exceptionHandler: CoroutineExceptionHandler?, configure: HttpRouterBuilder.() -> Unit) {
66 | subRouters.add(SubRouterDef(path, exceptionHandler, configure))
67 | }
68 |
69 | override fun call(method: HttpMethod, path: String, handler: HttpServerHandler) {
70 | handlers.add(HandlerDef(method, path, handler))
71 | }
72 |
73 | fun build() {
74 | val combinedFilter = combineFilters(*filters.toTypedArray())
75 | val finalFilter = combineFilters(combinedFilter, parentFilter)
76 |
77 | // configure handlers
78 | for(handlerDef in handlers) {
79 | registerCall(handlerDef.method, handlerDef.path, handlerDef.handler, finalFilter)
80 | }
81 |
82 | // configure sub routers
83 | for(subRouter in subRouters) {
84 | val vertxRouter = Router.router(vertx)
85 | val builder = HttpRouterBuilder(vertx, vertxRouter, finalFilter, subRouter.exceptionHandler ?: exceptionHandler)
86 | subRouter.configure(builder)
87 | builder.build()
88 |
89 | underlying.mountSubRouter(subRouter.path, vertxRouter)
90 | }
91 | }
92 |
93 | private fun exceptionHandler(): CoroutineExceptionHandler = exceptionHandler ?: defaultHttpExceptionHandler
94 |
95 | private fun createContext(routingContext: VRoutingContext): CoroutineContext {
96 | val context = Vertx.currentContext()
97 | return context.dispatcher() + VertxRoutingContext(routingContext) + MDCContext() + exceptionHandler()
98 | }
99 |
100 | private fun registerCall(method: HttpMethod, path: String, handler: HttpServerHandler, filter: HttpServerFilter?) {
101 | underlying.route(method, path).handler { routingContext ->
102 | val request = HttpServerRequest(routingContext.request(), routingContext)
103 | val context = Vertx.currentContext()
104 |
105 | CoroutineScope(createContext(routingContext)).launch {
106 | val response = filter?.let { it(request, handler) } ?: handler(request)
107 | val vertxResponse = routingContext.response()
108 |
109 | // copy status code
110 | vertxResponse.statusCode = response.statusCode
111 |
112 | // copy headers
113 | vertxResponse.headers().addAll(response.headers)
114 | vertxResponse.isChunked = response.chunked()
115 |
116 | // write body
117 | write(context, response.body, vertxResponse)
118 |
119 | // write trailers
120 | vertxResponse.trailers().addAll(response.trailers())
121 |
122 | // end response
123 | vertxResponse.end().await()
124 | }
125 | }
126 | }
127 | }
128 |
129 |
130 |
--------------------------------------------------------------------------------
/kert-http/src/main/kotlin/ws/leap/kert/http/HttpRouterDsl.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.http
2 |
3 | import io.vertx.core.http.HttpMethod
4 | import kotlinx.coroutines.CoroutineExceptionHandler
5 |
6 | interface HttpRouterDsl {
7 | fun filter(filter: HttpServerFilter)
8 |
9 | fun subRouter(path: String, exceptionHandler: CoroutineExceptionHandler? = null, configure: HttpRouterBuilder.() -> Unit)
10 |
11 | fun call(method: HttpMethod, path: String, handler: HttpServerHandler)
12 |
13 | fun get(path: String, handler: HttpServerHandler) {
14 | call(HttpMethod.GET, path, handler)
15 | }
16 |
17 | fun head(path: String, handler: HttpServerHandler) {
18 | call(HttpMethod.HEAD, path, handler)
19 | }
20 |
21 | fun post(path: String, handler: HttpServerHandler) {
22 | call(HttpMethod.POST, path, handler)
23 | }
24 |
25 | fun put(path: String, handler: HttpServerHandler) {
26 | call(HttpMethod.PUT, path, handler)
27 | }
28 |
29 | fun delete(path: String, handler: HttpServerHandler) {
30 | call(HttpMethod.DELETE, path, handler)
31 | }
32 |
33 | fun patch(path: String, handler: HttpServerHandler) {
34 | call(HttpMethod.PATCH, path, handler)
35 | }
36 |
37 | fun options(path: String, handler: HttpServerHandler) {
38 | call(HttpMethod.OPTIONS, path, handler)
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/kert-http/src/main/kotlin/ws/leap/kert/http/HttpServer.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.http
2 |
3 | import io.vertx.core.*
4 | import io.vertx.core.http.HttpServer as VHttpServer
5 | import io.vertx.core.http.HttpServerOptions
6 | import io.vertx.ext.web.Router
7 | import io.vertx.kotlin.coroutines.await
8 | import kotlinx.coroutines.CoroutineExceptionHandler
9 |
10 | internal data class RouterDef(
11 | val exceptionHandler: CoroutineExceptionHandler?,
12 | val configure: HttpRouterBuilder.() -> Unit
13 | )
14 |
15 | interface HttpServerBuilderDsl {
16 | fun options(configure: HttpServerOptions.() -> Unit)
17 | fun filter(filter: HttpServerFilter)
18 | fun router(exceptionHandler: CoroutineExceptionHandler? = null, configure: HttpRouterDsl.() -> Unit)
19 | }
20 |
21 | class HttpServerBuilder(private val vertx: Vertx, private val port: Int): HttpServerBuilderDsl {
22 | private val options = HttpServerOptions()
23 | private val filters = mutableListOf()
24 | private val routers = mutableListOf()
25 | var exceptionHandler: CoroutineExceptionHandler = defaultHttpExceptionHandler
26 |
27 | override fun options(configure: HttpServerOptions.() -> Unit) {
28 | configure(options)
29 | }
30 |
31 | override fun filter(filter: HttpServerFilter) {
32 | filters.add(filter)
33 | }
34 |
35 | override fun router(exceptionHandler: CoroutineExceptionHandler?, configure: HttpRouterDsl.() -> Unit) {
36 | routers.add(RouterDef(exceptionHandler, configure))
37 | }
38 |
39 | fun build(): HttpServer {
40 | val filter = combineFilters(*filters.toTypedArray())
41 |
42 | val vertxRouter = Router.router(vertx)
43 | for(router in routers) {
44 | val builder = HttpRouterBuilder(vertx, vertxRouter, filter, router.exceptionHandler ?: exceptionHandler)
45 | router.configure(builder)
46 | builder.build()
47 | }
48 |
49 | return HttpServer(vertx, port, options, vertxRouter)
50 | }
51 | }
52 |
53 | internal class ServerVerticle(private val port: Int, private val options: HttpServerOptions, private val router: Router) : AbstractVerticle() {
54 | private lateinit var server: VHttpServer
55 |
56 | private fun createServer(vertx: Vertx): VHttpServer {
57 | return vertx.createHttpServer(options)
58 | }
59 |
60 | override fun deploymentID(): String {
61 | return "kert-http"
62 | }
63 |
64 | override fun init(vertx: Vertx, context: Context) {
65 | server = createServer(vertx)
66 | server.requestHandler(router)
67 | }
68 |
69 | override fun start(startPromise: Promise) {
70 | server.listen(port) { ar ->
71 | if(ar.succeeded()) startPromise.complete()
72 | else startPromise.fail(ar.cause())
73 | }
74 | }
75 |
76 | override fun stop(stopPromise: Promise) {
77 | server.close { ar ->
78 | if(ar.succeeded()) stopPromise.complete()
79 | else stopPromise.fail(ar.cause())
80 | }
81 | }
82 | }
83 |
84 | class HttpServer(private val vertx: Vertx, private val port: Int, private val options: HttpServerOptions, private val router: Router) {
85 | private var deployId: String? = null
86 |
87 | suspend fun start() {
88 | if(deployId != null) return
89 |
90 | val desiredInstances = VertxOptions.DEFAULT_EVENT_LOOP_POOL_SIZE
91 | val deploymentOptions = DeploymentOptions().setInstances(desiredInstances)
92 | deployId = vertx.deployVerticle({ ServerVerticle(port, options, router) }, deploymentOptions).await()
93 | }
94 |
95 | suspend fun stop() {
96 | deployId?.let {
97 | vertx.undeploy(it).await()
98 | deployId = null
99 | }
100 | }
101 | }
102 |
103 | fun httpServer(vertx: Vertx, port: Int, configure: HttpServerBuilderDsl.() -> Unit): HttpServer {
104 | val builder = HttpServerBuilder(vertx, port)
105 | configure(builder)
106 | return builder.build()
107 | }
108 |
109 | fun httpServer(port: Int, configure: HttpServerBuilderDsl.() -> Unit): HttpServer {
110 | val builder = HttpServerBuilder(Kert.vertx, port)
111 | configure(builder)
112 | return builder.build()
113 | }
114 |
--------------------------------------------------------------------------------
/kert-http/src/main/kotlin/ws/leap/kert/http/HttpServerRequest.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.http
2 |
3 | import io.vertx.core.MultiMap
4 | import io.vertx.core.Vertx
5 | import io.vertx.core.buffer.Buffer
6 | import io.vertx.core.http.HttpMethod
7 | import io.vertx.core.http.HttpServerRequest
8 | import io.vertx.core.http.HttpVersion
9 | import io.vertx.ext.web.RoutingContext
10 | import kotlinx.coroutines.flow.Flow
11 |
12 | class HttpServerRequest(private val underlying: HttpServerRequest, private val routingContext: RoutingContext): HttpRequest {
13 | private val context = Vertx.currentContext() ?: throw IllegalStateException("Request must be created on vertx context")
14 |
15 | override val method: HttpMethod = underlying.method()
16 | override val uri: String = underlying.uri()
17 | override val headers: MultiMap = underlying.headers()
18 | override val body: Flow = underlying.asFlow(context)
19 |
20 | val params: MultiMap = underlying.params()
21 | val path: String = underlying.path()
22 | val pathParams: MutableMap = routingContext.pathParams()
23 | val version: HttpVersion = underlying.version()
24 | }
25 |
--------------------------------------------------------------------------------
/kert-http/src/main/kotlin/ws/leap/kert/http/HttpServerResponse.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.http
2 |
3 | import io.vertx.core.MultiMap
4 | import io.vertx.core.buffer.Buffer
5 | import io.vertx.core.http.HttpHeaders
6 | import io.vertx.core.http.impl.headers.HeadersMultiMap
7 | import kotlinx.coroutines.flow.Flow
8 | import kotlinx.coroutines.flow.emptyFlow
9 | import kotlinx.coroutines.flow.flowOf
10 | import java.lang.IllegalArgumentException
11 |
12 | data class HttpServerResponse internal constructor(
13 | override val statusCode: Int = 200,
14 | override val headers: MultiMap = HeadersMultiMap(),
15 | override val body: Flow = emptyFlow(),
16 | override val trailers: () -> MultiMap = { HeadersMultiMap() }): HttpResponse {
17 | }
18 |
19 | fun response(statusCode: Int = 200, headers: MultiMap? = null) =
20 | HttpServerResponse(statusCode, headers = headers ?: HeadersMultiMap())
21 |
22 | fun response(statusCode: Int = 200,
23 | headers: MultiMap? = null,
24 | body: Any? = null,
25 | contentType: String? = null,
26 | contentLength: Long? = null,
27 | trailers: (() -> MultiMap)? = null): HttpServerResponse {
28 | val theHeaders = constructHeaders(headers, contentLength, body)
29 | contentType?.let { theHeaders[HttpHeaders.CONTENT_TYPE] = it }
30 | val theBody = body?.let { toFlow(it) } ?: emptyFlow()
31 | val theTrailers = trailers ?: { HeadersMultiMap() }
32 | return HttpServerResponse(statusCode, theHeaders, theBody, theTrailers)
33 | }
34 |
35 |
--------------------------------------------------------------------------------
/kert-http/src/main/kotlin/ws/leap/kert/http/Kert.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.http
2 |
3 | import io.vertx.core.Vertx
4 | import io.vertx.core.VertxOptions
5 | import io.vertx.kotlin.coroutines.await
6 |
7 | object Kert {
8 | internal val vertx by lazy {
9 | val options = VertxOptions()
10 | .setEventLoopPoolSize(VertxOptions.DEFAULT_EVENT_LOOP_POOL_SIZE)
11 | Vertx.vertx(options)
12 | }
13 |
14 | suspend fun close() {
15 | vertx.close().await()
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/kert-http/src/main/kotlin/ws/leap/kert/http/Message.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.http
2 |
3 | import io.vertx.core.MultiMap
4 | import io.vertx.core.buffer.Buffer
5 | import io.vertx.core.http.HttpHeaders
6 | import io.vertx.core.http.HttpMethod
7 | import kotlinx.coroutines.flow.*
8 |
9 | interface HttpMessage {
10 | val headers: MultiMap
11 | val body: Flow
12 |
13 | fun chunked(): Boolean = headers[HttpHeaders.TRANSFER_ENCODING] == "chunked" || contentLength() == null
14 | fun contentLength(): Long? = headers[HttpHeaders.CONTENT_LENGTH]?.toLong()
15 |
16 | fun header(name: String): String? = headers[name]
17 |
18 | suspend fun body(): Buffer {
19 | val buf = Buffer.buffer()
20 | body.collect {
21 | buf.appendBuffer(it)
22 | }
23 | return buf
24 | }
25 | }
26 |
27 | interface HttpRequest : HttpMessage {
28 | val method: HttpMethod
29 | val uri: String
30 | }
31 |
32 | interface HttpResponse: HttpMessage {
33 | val statusCode: Int
34 | val trailers: () -> MultiMap
35 | }
36 |
37 |
38 | internal fun toFlow(body: Any?): Flow {
39 | return when(body) {
40 | null -> emptyFlow()
41 | is Flow<*> -> body.map { toBuffer(it!!) }
42 | is ByteArray, is Buffer, is String -> flowOf(toBuffer(body))
43 | else -> throw IllegalArgumentException("Unsupported data type ${body.javaClass.name}")
44 | }
45 | }
46 |
47 | internal fun toBuffer(data: Any): Buffer {
48 | return when(data) {
49 | is ByteArray -> Buffer.buffer(data)
50 | is Buffer -> data
51 | is String -> Buffer.buffer(data)
52 | else -> throw IllegalArgumentException("Unsupported data type ${data.javaClass.name}")
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/kert-http/src/main/kotlin/ws/leap/kert/http/Stream.kt:
--------------------------------------------------------------------------------
1 | package ws.leap.kert.http
2 |
3 | import io.vertx.core.Context
4 | import io.vertx.core.buffer.Buffer
5 | import io.vertx.core.streams.ReadStream
6 | import io.vertx.core.streams.WriteStream
7 | import io.vertx.kotlin.coroutines.await
8 | import kotlinx.coroutines.channels.Channel
9 | import kotlinx.coroutines.flow.Flow
10 | import kotlinx.coroutines.flow.collect
11 | import kotlinx.coroutines.flow.flow
12 | import mu.KotlinLogging
13 |
14 | private val logger = KotlinLogging.logger {}
15 |
16 | fun ReadStream.asFlow(context: Context): Flow