From e955b2477b4faab5296265b6d6790bc2032b5fa0 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Tue, 21 Oct 2025 16:16:09 +0200 Subject: [PATCH 1/6] grpc: Implement GrpcMetadata --- cinterop-c/include/kgrpc.h | 6 + cinterop-c/src/kgrpc.cpp | 13 + .../client/internal/suspendClientCalls.kt | 8 +- .../grpc/client/internal/NativeClientCall.kt | 43 ++-- .../kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt | 26 +- .../rpc/grpc/test/proto/GrpcProtoTest.kt | 33 +++ .../rpc/grpc/test/proto/MetadataTest.kt | 222 ++++++++++++++++++ .../kotlinx/rpc/grpc/GrpcMetadata.jvm.kt | 56 +++++ .../kotlinx/rpc/grpc/GrpcMetadata.native.kt | 204 +++++++++++++++- .../kotlin/kotlinx/rpc/grpc/internal/utils.kt | 16 ++ .../server/internal/suspendServerCalls.kt | 34 ++- .../grpc/server/internal/NativeServerCall.kt | 30 ++- .../grpc/server/internal/serverCallTags.kt | 12 +- 13 files changed, 644 insertions(+), 59 deletions(-) create mode 100644 grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/MetadataTest.kt diff --git a/cinterop-c/include/kgrpc.h b/cinterop-c/include/kgrpc.h index 8b26aefc4..4a024f1e2 100644 --- a/cinterop-c/include/kgrpc.h +++ b/cinterop-c/include/kgrpc.h @@ -89,6 +89,12 @@ void kgrpc_server_set_batch_method_allocator( kgrpc_batch_call_allocator allocator ); +/** + * Append an grpc_metadata entry to the given grpc_metadata_array. + * + * @return false if the array has not enough capacity, true otherwise. + */ +bool kgrpc_metadata_array_append(grpc_metadata_array *array, grpc_slice key, grpc_slice value); #ifdef __cplusplus } diff --git a/cinterop-c/src/kgrpc.cpp b/cinterop-c/src/kgrpc.cpp index a1a979f37..04104218f 100644 --- a/cinterop-c/src/kgrpc.cpp +++ b/cinterop-c/src/kgrpc.cpp @@ -3,6 +3,7 @@ #include #include "src/core/lib/iomgr/iomgr.h" #include "src/core/server/server.h" +#include "grpc/support/string_util.h" extern "C" { @@ -53,6 +54,18 @@ void kgrpc_server_set_batch_method_allocator( }); } +bool kgrpc_metadata_array_append(grpc_metadata_array *array, grpc_slice key, grpc_slice value) { + if (array->capacity - array->count <= 0) { + return false; + } + grpc_metadata entry = { + .key = key, + .value = value + }; + array->metadata[array->count++] = entry; + return true; +} + } diff --git a/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/internal/suspendClientCalls.kt b/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/internal/suspendClientCalls.kt index bfb6ef30e..87635893d 100644 --- a/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/internal/suspendClientCalls.kt +++ b/grpc/grpc-client/src/commonMain/kotlin/kotlinx/rpc/grpc/client/internal/suspendClientCalls.kt @@ -17,17 +17,17 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.single import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.rpc.grpc.client.ClientCallScope -import kotlinx.rpc.grpc.client.GrpcClient import kotlinx.rpc.grpc.GrpcMetadata import kotlinx.rpc.grpc.Status import kotlinx.rpc.grpc.StatusCode import kotlinx.rpc.grpc.StatusException -import kotlinx.rpc.grpc.internal.CallbackFuture +import kotlinx.rpc.grpc.client.ClientCallScope +import kotlinx.rpc.grpc.client.GrpcClient import kotlinx.rpc.grpc.descriptor.MethodDescriptor import kotlinx.rpc.grpc.descriptor.MethodType -import kotlinx.rpc.grpc.internal.Ready import kotlinx.rpc.grpc.descriptor.methodType +import kotlinx.rpc.grpc.internal.CallbackFuture +import kotlinx.rpc.grpc.internal.Ready import kotlinx.rpc.grpc.internal.singleOrStatus import kotlinx.rpc.grpc.statusCode import kotlinx.rpc.internal.utils.InternalRpcApi diff --git a/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/internal/NativeClientCall.kt b/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/internal/NativeClientCall.kt index 6232084b1..bf32a27e0 100644 --- a/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/internal/NativeClientCall.kt +++ b/grpc/grpc-client/src/nativeMain/kotlin/kotlinx/rpc/grpc/client/internal/NativeClientCall.kt @@ -26,9 +26,10 @@ import kotlinx.coroutines.CompletableJob import kotlinx.rpc.grpc.GrpcMetadata import kotlinx.rpc.grpc.Status import kotlinx.rpc.grpc.StatusCode +import kotlinx.rpc.grpc.descriptor.MethodDescriptor import kotlinx.rpc.grpc.internal.BatchResult import kotlinx.rpc.grpc.internal.CompletionQueue -import kotlinx.rpc.grpc.descriptor.MethodDescriptor +import kotlinx.rpc.grpc.internal.destroyEntries import kotlinx.rpc.grpc.internal.internalError import kotlinx.rpc.grpc.internal.toByteArray import kotlinx.rpc.grpc.internal.toGrpcByteBuffer @@ -182,7 +183,7 @@ internal class NativeClientCall( if (!success) return // send and receive initial headers to/from the server - sendAndReceiveInitialMetadata() + sendAndReceiveInitialMetadata(headers) } /** @@ -251,13 +252,16 @@ internal class NativeClientCall( val statusCode = arena.alloc() val statusDetails = arena.alloc() val errorStr = arena.alloc>() + + val trailingMetadata = arena.alloc() + grpc_metadata_array_init(trailingMetadata.ptr) + val op = arena.alloc { op = GRPC_OP_RECV_STATUS_ON_CLIENT data.recv_status_on_client.status = statusCode.ptr data.recv_status_on_client.status_details = statusDetails.ptr data.recv_status_on_client.error_string = errorStr.ptr - // TODO: trailing metadata - data.recv_status_on_client.trailing_metadata = null + data.recv_status_on_client.trailing_metadata = trailingMetadata.ptr } when (val callResult = cq.runBatch(this@NativeClientCall.raw, op.ptr, 1u)) { @@ -266,11 +270,13 @@ internal class NativeClientCall( val details = statusDetails.toByteArray().toKString() val kStatusCode = statusCode.value.toKotlin() val status = Status(kStatusCode, details, null) - val trailers = GrpcMetadata() + val trailers = GrpcMetadata(trailingMetadata) // cleanup grpc_slice_unref(statusDetails.readValue()) if (errorStr.value != null) gpr_free(errorStr.value) + // the entries are owned by the call object, so we must only destroy the array + grpc_metadata_array_destroy(trailingMetadata.readValue()) arena.clear() // set close info and try to close the call. @@ -296,29 +302,37 @@ internal class NativeClientCall( } } - private fun sendAndReceiveInitialMetadata() { + private fun sendAndReceiveInitialMetadata(headers: GrpcMetadata) { // sending and receiving initial metadata val arena = Arena() val opsNum = 2uL val ops = arena.allocArray(opsNum.convert()) + // turn given headers into a grpc_metadata_array. + val sendInitialMetadata: grpc_metadata_array = with(headers) { + arena.allocRawGrpcMetadata() + } + // send initial meta data to server - // TODO: initial metadata ops[0].op = GRPC_OP_SEND_INITIAL_METADATA - ops[0].data.send_initial_metadata.count = 0u + ops[0].data.send_initial_metadata.count = sendInitialMetadata.count + ops[0].data.send_initial_metadata.metadata = sendInitialMetadata.metadata - val meta = arena.alloc() - // TODO: make metadata array an object (for lifecycle management) - grpc_metadata_array_init(meta.ptr) + val recvInitialMetadata = arena.alloc() + grpc_metadata_array_init(recvInitialMetadata.ptr) ops[1].op = GRPC_OP_RECV_INITIAL_METADATA - ops[1].data.recv_initial_metadata.recv_initial_metadata = meta.ptr + ops[1].data.recv_initial_metadata.recv_initial_metadata = recvInitialMetadata.ptr runBatch(ops, opsNum, cleanup = { - grpc_metadata_array_destroy(meta.ptr) + // we must not destroy the array itself, as it is cleared when clearing the arena. + sendInitialMetadata.destroyEntries() + // the entries are owned by the call object, so we must only destroy the array + grpc_metadata_array_destroy(recvInitialMetadata.readValue()) arena.clear() }) { + val headers = GrpcMetadata(recvInitialMetadata) safeUserCode("Failed to call onHeaders.") { - listener?.onHeaders(GrpcMetadata()) + listener?.onHeaders(headers) } } } @@ -447,4 +461,3 @@ internal class NativeClientCall( } } - diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt index d5f5749e3..c9bb67a99 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt @@ -5,6 +5,26 @@ package kotlinx.rpc.grpc @Suppress("RedundantConstructorKeyword") -public expect class GrpcMetadata constructor() { - public fun merge(trailers: GrpcMetadata) -} +public expect class GrpcMetadata constructor() + +public expect operator fun GrpcMetadata.get(key: String): String? +public expect fun GrpcMetadata.getBinary(key: String): ByteArray? +public expect fun GrpcMetadata.getAll(key: String): List +public expect fun GrpcMetadata.getAllBinary(key: String): List + +public expect fun GrpcMetadata.keys(): Set +public expect operator fun GrpcMetadata.contains(key: String): Boolean + +public expect fun GrpcMetadata.append(key: String, value: String) +public expect fun GrpcMetadata.appendBinary(key: String, value: ByteArray) + +public expect fun GrpcMetadata.remove(key: String, value: String): Boolean +public expect fun GrpcMetadata.removeBinary(key: String, value: ByteArray): Boolean +public expect fun GrpcMetadata.removeAll(key: String): List +public expect fun GrpcMetadata.removeAllBinary(key: String): List + +public expect fun GrpcMetadata.merge(other: GrpcMetadata) +public operator fun GrpcMetadata.plusAssign(other: GrpcMetadata): Unit = merge(other) + +public fun GrpcMetadata.copy(): GrpcMetadata = GrpcMetadata().also { it.merge(this) } +public operator fun GrpcMetadata.plus(other: GrpcMetadata): GrpcMetadata = copy().apply { merge(other) } diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcProtoTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcProtoTest.kt index 6cd3ea810..231d4cdaf 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcProtoTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/GrpcProtoTest.kt @@ -4,14 +4,17 @@ package kotlinx.rpc.grpc.test.proto +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.test.runTest import kotlinx.rpc.RpcServer +import kotlinx.rpc.grpc.client.ClientCallScope import kotlinx.rpc.grpc.client.ClientCredentials import kotlinx.rpc.grpc.client.ClientInterceptor import kotlinx.rpc.grpc.client.GrpcClient import kotlinx.rpc.grpc.server.GrpcServer +import kotlinx.rpc.grpc.server.ServerCallScope import kotlinx.rpc.grpc.server.ServerCredentials import kotlinx.rpc.grpc.server.ServerInterceptor @@ -58,4 +61,34 @@ abstract class GrpcProtoTest { companion object { const val PORT = 8080 } + + internal fun serverInterceptor( + block: ServerCallScope.(Flow) -> Flow, + ): List { + return listOf(object : ServerInterceptor { + @Suppress("UNCHECKED_CAST") + override fun ServerCallScope.intercept( + request: Flow, + ): Flow { + with(this as ServerCallScope) { + return block(request as Flow) as Flow + } + } + }) + } + + internal fun clientInterceptor( + block: ClientCallScope.(Flow) -> Flow, + ): List { + return listOf(object : ClientInterceptor { + @Suppress("UNCHECKED_CAST") + override fun ClientCallScope.intercept( + request: Flow, + ): Flow { + with(this as ClientCallScope) { + return block(request as Flow) as Flow + } + } + }) + } } diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/MetadataTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/MetadataTest.kt new file mode 100644 index 000000000..550b863ff --- /dev/null +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/MetadataTest.kt @@ -0,0 +1,222 @@ +/* + * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.rpc.grpc.test.proto + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import kotlinx.io.Buffer +import kotlinx.io.readByteArray +import kotlinx.rpc.RpcServer +import kotlinx.rpc.grpc.GrpcMetadata +import kotlinx.rpc.grpc.append +import kotlinx.rpc.grpc.appendBinary +import kotlinx.rpc.grpc.client.GrpcClient +import kotlinx.rpc.grpc.contains +import kotlinx.rpc.grpc.get +import kotlinx.rpc.grpc.getAll +import kotlinx.rpc.grpc.getAllBinary +import kotlinx.rpc.grpc.getBinary +import kotlinx.rpc.grpc.keys +import kotlinx.rpc.grpc.plus +import kotlinx.rpc.grpc.remove +import kotlinx.rpc.grpc.removeAll +import kotlinx.rpc.grpc.removeAllBinary +import kotlinx.rpc.grpc.removeBinary +import kotlinx.rpc.grpc.test.EchoRequest +import kotlinx.rpc.grpc.test.EchoRequestInternal +import kotlinx.rpc.grpc.test.EchoService +import kotlinx.rpc.grpc.test.EchoServiceImpl +import kotlinx.rpc.grpc.test.invoke +import kotlinx.rpc.protobuf.input.stream.asInputStream +import kotlinx.rpc.protobuf.input.stream.asSource +import kotlinx.rpc.registerService +import kotlinx.rpc.withService +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class MetadataTest : GrpcProtoTest() { + + override fun RpcServer.registerServices() { + registerService { EchoServiceImpl() } + } + + @Test + fun `test send and receive headers and trailers`() = runTest { + var clientHeaders: GrpcMetadata? = null + val responseHeadersDef = CompletableDeferred() + val responseTrailersDef = CompletableDeferred() + + // helper function to append metadata to GrpcMetadata + fun GrpcMetadata.appendMetadata() { + append("my-key", "my-value") + appendBinary("my-key-bin", byteArrayOf(1, 2, 3)) + appendBinary("my-key-bin", byteArrayOf(4, 5, 6)) + append("my-multi-key", "my-value1") + val protoMsg = EchoRequest { message = "My Proto Header" } + appendBinary("my-proto-bin", EchoRequestInternal.CODEC.encode(protoMsg).asSource().readByteArray()) + append("my-multi-key", "my-value2") + append("my-multi-key", "my-value3") + } + + runGrpcTest( + clientInterceptors = clientInterceptor { + requestHeaders.appendMetadata() + onHeaders { headers -> responseHeadersDef.complete(headers) } + onClose { _, trailers -> responseTrailersDef.complete(trailers) } + proceed(it) + }, + serverInterceptors = serverInterceptor { + responseHeaders.appendMetadata() + responseTrailers.appendMetadata() + clientHeaders = this.requestHeaders + proceed(it) + } + ) { unaryEcho(it) } + + // helper function to assert GrpcMetadata (headers and trailers) + fun GrpcMetadata.assertValues() { + // if user-agent is set, we expect 4 keys, otherwise 3 keys + var expectedSize = 4 + // calculate expected size based on potentially present default headers + if ("user-agent" in this) expectedSize++ + if ("content-type" in this) expectedSize++ + if ("grpc-accept-encoding" in this) expectedSize++ + if ("grpc-encoding" in this) expectedSize++ + assertEquals(expectedSize, keys().size) + assertTrue { contains("my-key") } + assertTrue { contains("my-key-bin") } + assertTrue { contains("my-multi-key") } + assertTrue { contains("my-proto-bin") } + assertEquals("my-value", get("my-key")) + assertContentEquals(byteArrayOf(4, 5, 6), getBinary("my-key-bin")) + assertContentEquals(byteArrayOf(1, 2, 3), getAllBinary("my-key-bin")[0]) + assertEquals(listOf("my-value1", "my-value2", "my-value3"), getAll("my-multi-key")) + assertEquals("my-value3", get("my-multi-key")) + val proto = Buffer().apply { + write(getBinary("my-proto-bin")!!) + } + val decodedProto = EchoRequestInternal.CODEC.decode(proto.asInputStream()) + assertEquals("My Proto Header", decodedProto.message) + } + + + clientHeaders!!.assertValues() + responseHeadersDef.await().assertValues() + responseTrailersDef.await().assertValues() + } + + @Test + fun `test invalid keys`() { + val metadata = GrpcMetadata() + assertFailsWith { metadata["invalid-key-bin"] } + assertFailsWith { metadata.getBinary("invalid-key") } + assertFailsWith { metadata.getAll("invalid-key-bin") } + assertFailsWith { metadata.getAllBinary("invalid-key") } + assertFailsWith { metadata.append("invalid-key-bin", "value") } + assertFailsWith { metadata.appendBinary("invalid-key", byteArrayOf(1)) } + assertFailsWith { metadata.remove("invalid-key-bin", "value") } + assertFailsWith { metadata.removeBinary("invalid-key", byteArrayOf(1)) } + assertFailsWith { metadata.removeAll("invalid-key-bin") } + assertFailsWith { metadata.removeAllBinary("invalid-key") } + // space in key is not allowed + assertFailsWith { metadata.append(" my-key", "my-value") } + // not alphanumeric key with .-_ + assertFailsWith { metadata.append("my>key", "my-value") } + } + + @Test + fun `test remove`() { + GrpcMetadata().apply { + append("my-key", "my-value") + assertTrue(remove("my-key", "my-value")) + assertEquals(0, keys().size) + } + + GrpcMetadata().apply { + append("my-key", "my-value") + append("my-key", "my-value2") + assertEquals(2, removeAll("my-key").size) + assertEquals(0, keys().size) + } + + GrpcMetadata().apply { + append("my-key", "my-value") + append("my-key", "my-value2") + assertEquals("my-value2", get("my-key")) + remove("my-key", "my-value2") + assertEquals("my-value", get("my-key")) + assertEquals(1, keys().size) + remove("my-key", "my-value") + assertEquals(null, get("my-key")) + assertEquals(0, keys().size) + } + + GrpcMetadata().apply { + val arr1 = byteArrayOf(1, 2, 3) + val arr2 = byteArrayOf(4, 5, 6) + appendBinary("my-key-bin", arr1) + appendBinary("my-key-bin", arr2) + assertEquals(arr2, getBinary("my-key-bin")) + assertEquals(1, keys().size) + removeBinary("my-key-bin", arr2) + removeBinary("my-key-bin", arr1) + assertEquals(null, getBinary("my-key-bin")) + assertEquals(0, keys().size) + } + } + + @Test + fun `test ascii replacement`() { + GrpcMetadata().apply { + append("my-key", "my-value你") + assertEquals("my-value?", get("my-key")) + } + } + + @Test + fun `test merge`() { + val md1 = GrpcMetadata().apply { + append("my-key", "my-value-1") + appendBinary("my-key-bin", byteArrayOf(1, 2, 3)) + append("my-key-1", "my-value-1") + appendBinary("my-key-1-bin", byteArrayOf(1, 2, 3)) + append("my-key-common", "my-value-common") + } + + val md2 = GrpcMetadata().apply { + append("my-key", "my-value-2") + appendBinary("my-key-bin", byteArrayOf(4, 5, 6)) + append("my-key-2", "my-value-2") + appendBinary("my-key-2-bin", byteArrayOf(4, 5, 6)) + append("my-key-common", "my-value-common") + } + + val mdPlus = md1 + md2 + + mdPlus.run { + assertEquals(7, keys().size) + assertEquals(listOf("my-value-1"), getAll("my-key-1")) + assertEquals(listOf("my-value-2"), getAll("my-key-2")) + assertEquals(listOf("my-value-1", "my-value-2"), getAll("my-key")) + assertEquals(listOf("my-value-common", "my-value-common"), getAll("my-key-common")) + + assertContentEquals(byteArrayOf(1, 2, 3), getAllBinary("my-key-bin")[0]) + assertContentEquals(byteArrayOf(4, 5, 6), getAllBinary("my-key-bin")[1]) + assertContentEquals(byteArrayOf(1, 2, 3), getAllBinary("my-key-1-bin")[0]) + assertContentEquals(byteArrayOf(4, 5, 6), getAllBinary("my-key-2-bin")[0]) + } + } + + + private suspend fun unaryEcho(grpcClient: GrpcClient) { + val service = grpcClient.withService() + val response = service.UnaryEcho(EchoRequest { message = "Echo" }) + assertEquals("Echo", response.message) + } + +} \ No newline at end of file diff --git a/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.jvm.kt b/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.jvm.kt index fbb286a8c..3f3b343e7 100644 --- a/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.jvm.kt +++ b/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.jvm.kt @@ -4,7 +4,63 @@ package kotlinx.rpc.grpc +import io.grpc.Metadata import kotlinx.rpc.internal.utils.InternalRpcApi @InternalRpcApi public actual typealias GrpcMetadata = io.grpc.Metadata + +public actual operator fun GrpcMetadata.get(key: String): String? { + return get(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER)) +} + +public actual fun GrpcMetadata.getBinary(key: String): ByteArray? { + return get(Metadata.Key.of(key, Metadata.BINARY_BYTE_MARSHALLER)) +} + +public actual fun GrpcMetadata.getAll(key: String): List { + return getAll(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER))?.toList() ?: emptyList() +} + +public actual fun GrpcMetadata.getAllBinary(key: String): List { + return getAll(Metadata.Key.of(key, Metadata.BINARY_BYTE_MARSHALLER))?.toList() ?: emptyList() +} + +public actual operator fun GrpcMetadata.contains(key: String): Boolean { + val javaKey = if (key.endsWith(Metadata.BINARY_HEADER_SUFFIX)) + Metadata.Key.of(key, Metadata.BINARY_BYTE_MARSHALLER) else + Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER) + return containsKey(javaKey) +} + +public actual fun GrpcMetadata.keys(): Set { + return this.keys() +} + +public actual fun GrpcMetadata.append(key: String, value: String) { + return put(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER), value) +} + +public actual fun GrpcMetadata.appendBinary(key: String, value: ByteArray) { + return put(Metadata.Key.of(key, Metadata.BINARY_BYTE_MARSHALLER), value) +} + +public actual fun GrpcMetadata.remove(key: String, value: String): Boolean { + return remove(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER), value) +} + +public actual fun GrpcMetadata.removeBinary(key: String, value: ByteArray): Boolean { + return remove(Metadata.Key.of(key, Metadata.BINARY_BYTE_MARSHALLER), value) +} + +public actual fun GrpcMetadata.removeAll(key: String): List { + return removeAll(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER))?.toList() ?: emptyList() +} + +public actual fun GrpcMetadata.removeAllBinary(key: String): List { + return removeAll(Metadata.Key.of(key, Metadata.BINARY_BYTE_MARSHALLER))?.toList() ?: emptyList() +} + +public actual fun GrpcMetadata.merge(other: GrpcMetadata) { + this.merge(other) +} \ No newline at end of file diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.native.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.native.kt index 91d154c77..b576c8332 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.native.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.native.kt @@ -2,9 +2,211 @@ * Copyright 2023-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. */ +@file:OptIn(ExperimentalForeignApi::class, ExperimentalNativeApi::class) + package kotlinx.rpc.grpc +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.NativePlacement +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.alloc +import kotlinx.cinterop.allocArray +import kotlinx.cinterop.convert +import kotlinx.cinterop.get +import kotlinx.cinterop.ptr +import kotlinx.cinterop.usePinned +import kotlinx.rpc.grpc.internal.toByteArray +import kotlinx.rpc.internal.utils.InternalRpcApi +import libkgrpc.grpc_metadata +import libkgrpc.grpc_metadata_array +import libkgrpc.grpc_metadata_array_init +import libkgrpc.grpc_slice_from_copied_buffer +import libkgrpc.grpc_slice_from_copied_string +import libkgrpc.grpc_slice_ref +import libkgrpc.grpc_slice_unref +import libkgrpc.kgrpc_metadata_array_append +import kotlin.experimental.ExperimentalNativeApi + +private value class GrpcKey private constructor(val name: String) { + val isBinary get() = name.endsWith("-bin") + + companion object { + fun binary(name: String): GrpcKey { + val key = GrpcKey(validateName(name.lowercase())) + require(key.isBinary) { "Binary header is named ${key.name}. It must end with '-bin'" } + return key + } + + fun string(name: String): GrpcKey { + val key = GrpcKey(validateName(name.lowercase())) + require(!key.isBinary) { "String header is named ${key.name}. It must not end with '-bin'" } + return key + } + } +} + @Suppress(names = ["RedundantConstructorKeyword"]) public actual class GrpcMetadata actual constructor() { - public actual fun merge(trailers: GrpcMetadata) {} + internal val map: LinkedHashMap> = linkedMapOf() + + public constructor(raw: grpc_metadata_array) : this() { + for (i in 0 until raw.count.toInt()) { + val metadata = raw.metadata?.get(i) + if (metadata != null) { + val key = metadata.key.toByteArray().toAsciiString() + val value = metadata.value.toByteArray() + map.getOrPut(key) { mutableListOf() }.add(value) + } + } + } + + @InternalRpcApi + public fun NativePlacement.allocRawGrpcMetadata(): grpc_metadata_array { + val raw = alloc() + grpc_metadata_array_init(raw.ptr) + + // the sum of all values + val entryCount = map.entries.sumOf { it.value.size } + + raw.count = 0u + raw.capacity = entryCount.convert() + raw.metadata = allocArray(entryCount) + + map.entries.forEach { (key, values) -> + val keySlice = grpc_slice_from_copied_string(key) + + for (entry in values) { + val size = entry.size.toULong() + val valSlice = entry.usePinned { pinned -> + grpc_slice_from_copied_buffer(pinned.addressOf(0), size) + } + // we create a fresh reference for each entry + val keySliceRef = grpc_slice_ref(keySlice) + + check(kgrpc_metadata_array_append(raw.ptr, keySliceRef, valSlice)) { + "Failed to append metadata to array" + } + } + + // we unref/drop the original keySlice, as it isn't used anymore + grpc_slice_unref(keySlice) + } + + return raw + } +} + +public actual operator fun GrpcMetadata.get(key: String): String? { + return map[GrpcKey.string(key).name]?.lastOrNull()?.toAsciiString() +} + +public actual fun GrpcMetadata.getBinary(key: String): ByteArray? { + return map[GrpcKey.binary(key).name]?.lastOrNull() +} + +public actual fun GrpcMetadata.getAll(key: String): List { + return map[GrpcKey.string(key).name]?.map { it.toAsciiString() } ?: emptyList() +} + +public actual fun GrpcMetadata.getAllBinary(key: String): List { + return map[GrpcKey.binary(key).name]?.map { it } ?: emptyList() +} + +public actual operator fun GrpcMetadata.contains(key: String): Boolean { + return map.containsKey(key.lowercase()) +} + +public actual fun GrpcMetadata.keys(): Set { + return map.entries.filter { it.value.isNotEmpty() }.mapTo(mutableSetOf()) { it.key } +} + +public actual fun GrpcMetadata.append(key: String, value: String) { + val k = GrpcKey.string(key) // non-bin key + map.getOrPut(k.name) { mutableListOf() }.add(value.toAsciiBytes()) +} + +public actual fun GrpcMetadata.appendBinary(key: String, value: ByteArray) { + val k = GrpcKey.binary(key) + map.getOrPut(k.name) { mutableListOf() }.add(value) +} + +public actual fun GrpcMetadata.remove(key: String, value: String): Boolean { + val index = getAll(key).indexOf(value) + if (index == -1) return false + map[GrpcKey.string(key).name]!!.removeAt(index) + return true +} + +public actual fun GrpcMetadata.removeBinary(key: String, value: ByteArray): Boolean { + val index = getAllBinary(key).indexOf(value) + if (index == -1) return false + map[GrpcKey.binary(key).name]!!.removeAt(index) + return true +} + +public actual fun GrpcMetadata.removeAll(key: String): List { + return map.remove(GrpcKey.string(key).name)?.map { it.toAsciiString() } ?: emptyList() +} + +public actual fun GrpcMetadata.removeAllBinary(key: String): List { + return map.remove(GrpcKey.binary(key).name) ?: emptyList() } + +public actual fun GrpcMetadata.merge(other: GrpcMetadata) { + for ((key, values) in other.map) { + map.getOrPut(key) { mutableListOf() }.addAll(values) + } +} + +/** + * Converts the ByteArray to a string containing only ASCII characters. + * For bytes within the ASCII range (0x00 to 0x7F), the corresponding character is used. + * For bytes outside this range, the replacement character '�' (`\uFFFD`) is used. + * + * @return A string representation of the ByteArray, + * where non-ASCII bytes are replaced with '�' (`\uFFFD`). + */ +private fun ByteArray.toAsciiString(): String { + return buildString(size) { + for (b in this@toAsciiString) { + val ub = b.toInt() and 0xFF + append(if (ub in 0..0x7F) ub.toChar() else '\uFFFD') + } + } +} + +/** + * Converts the string to a byte array encoded in US-ASCII. + * Characters outside the ASCII range are replaced with the '?' character. + * + * @return a byte array representing the ASCII-encoded version of the string + */ +private fun String.toAsciiBytes(): ByteArray { + // encode as US_ASCII bytes, replacing non-ASCII chars with '?' + return ByteArray(length) { idx -> + val c = this[idx] + if (c.code in 0..0x7F) c.code.toByte() else '?'.code.toByte() + } +} + +@OptIn(ObsoleteNativeApi::class) +private val VALID_KEY_CHARS by lazy { + BitSet(0x7f).apply { + set('-'.code) + set('_'.code) + set('.'.code) + set('0'.code..'9'.code) + set('a'.code..'z'.code) + } +} + +@OptIn(ObsoleteNativeApi::class) +private fun GrpcKey.Companion.validateName(name: String): String { + require(!name.startsWith("grpc-")) { "Header is named $name. It must not start with 'grpc-' as it is reserved for internal use." } + + for (char in name) { + require(VALID_KEY_CHARS[char.code]) { "Header is named $name. It contains illegal character $char." } + } + + return name +} \ No newline at end of file diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/utils.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/utils.kt index ce01cddfa..fa4d76b41 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/utils.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/internal/utils.kt @@ -14,6 +14,7 @@ import kotlinx.cinterop.addressOf import kotlinx.cinterop.alloc import kotlinx.cinterop.allocArray import kotlinx.cinterop.convert +import kotlinx.cinterop.get import kotlinx.cinterop.memScoped import kotlinx.cinterop.plus import kotlinx.cinterop.ptr @@ -35,6 +36,8 @@ import libkgrpc.grpc_byte_buffer_reader import libkgrpc.grpc_byte_buffer_reader_destroy import libkgrpc.grpc_byte_buffer_reader_init import libkgrpc.grpc_byte_buffer_reader_next +import libkgrpc.grpc_metadata +import libkgrpc.grpc_metadata_array import libkgrpc.grpc_raw_byte_buffer_create import libkgrpc.grpc_slice import libkgrpc.grpc_slice_from_copied_buffer @@ -225,4 +228,17 @@ public fun StatusCode.toRaw(): grpc_status_code = when (this) { StatusCode.UNAVAILABLE -> grpc_status_code.GRPC_STATUS_UNAVAILABLE StatusCode.DATA_LOSS -> grpc_status_code.GRPC_STATUS_DATA_LOSS StatusCode.UNAUTHENTICATED -> grpc_status_code.GRPC_STATUS_UNAUTHENTICATED +} + +@InternalRpcApi +public fun grpc_metadata.destroy() { + grpc_slice_unref(key.readValue()) + grpc_slice_unref(value.readValue()) +} + +@InternalRpcApi +public fun grpc_metadata_array.destroyEntries() { + for (i in 0 until count.convert()) { + metadata?.get(i)?.destroy() + } } \ No newline at end of file diff --git a/grpc/grpc-server/src/commonMain/kotlin/kotlinx/rpc/grpc/server/internal/suspendServerCalls.kt b/grpc/grpc-server/src/commonMain/kotlin/kotlinx/rpc/grpc/server/internal/suspendServerCalls.kt index 4ddda2a13..85333614d 100644 --- a/grpc/grpc-server/src/commonMain/kotlin/kotlinx/rpc/grpc/server/internal/suspendServerCalls.kt +++ b/grpc/grpc-server/src/commonMain/kotlin/kotlinx/rpc/grpc/server/internal/suspendServerCalls.kt @@ -17,18 +17,19 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.rpc.grpc.GrpcMetadata -import kotlinx.rpc.grpc.server.ServerCallScope -import kotlinx.rpc.grpc.server.ServerInterceptor import kotlinx.rpc.grpc.Status import kotlinx.rpc.grpc.StatusCode import kotlinx.rpc.grpc.StatusException import kotlinx.rpc.grpc.StatusRuntimeException -import kotlinx.rpc.grpc.internal.CallbackFuture import kotlinx.rpc.grpc.descriptor.MethodDescriptor import kotlinx.rpc.grpc.descriptor.MethodType -import kotlinx.rpc.grpc.internal.Ready import kotlinx.rpc.grpc.descriptor.methodType +import kotlinx.rpc.grpc.internal.CallbackFuture +import kotlinx.rpc.grpc.internal.Ready import kotlinx.rpc.grpc.internal.singleOrStatusFlow +import kotlinx.rpc.grpc.merge +import kotlinx.rpc.grpc.server.ServerCallScope +import kotlinx.rpc.grpc.server.ServerInterceptor import kotlinx.rpc.internal.utils.InternalRpcApi import kotlin.reflect.KType import kotlin.reflect.typeOf @@ -185,7 +186,7 @@ private fun CoroutineScope.serverCallListenerImpl( // once we have a response message, check if we've sent headers yet - if not, do so if (headersSent.value.compareAndSet(expect = false, update = true)) { mutex.withLock { - handler.sendHeaders(GrpcMetadata()) + handler.sendHeaders(serverCallScope.responseHeaders) } } ready.suspendUntilReady() @@ -198,7 +199,7 @@ private fun CoroutineScope.serverCallListenerImpl( // no elements or threw an exception, then we wouldn't have sent them if (failure == null && headersSent.value.compareAndSet(expect = false, update = true)) { mutex.withLock { - handler.sendHeaders(GrpcMetadata()) + handler.sendHeaders(serverCallScope.responseHeaders) } } @@ -210,21 +211,14 @@ private fun CoroutineScope.serverCallListenerImpl( else -> Status(StatusCode.UNKNOWN, cause = failure) } - val trailers = failure?.let { - when (it) { - is StatusException -> { - it.getTrailers() - } - - is StatusRuntimeException -> { - it.getTrailers() - } + val trailers = serverCallScope.responseTrailers - else -> { - null - } - } - } ?: GrpcMetadata() + // we merge the failure trailers with the user-defined trailers + when (failure) { + is StatusException -> failure.getTrailers() + is StatusRuntimeException -> failure.getTrailers() + else -> null + }?.let { trailers.merge(it) } mutex.withLock { handler.close(closeStatus, trailers) diff --git a/grpc/grpc-server/src/nativeMain/kotlin/kotlinx/rpc/grpc/server/internal/NativeServerCall.kt b/grpc/grpc-server/src/nativeMain/kotlin/kotlinx/rpc/grpc/server/internal/NativeServerCall.kt index c48e38c4c..9fc17e1b9 100644 --- a/grpc/grpc-server/src/nativeMain/kotlin/kotlinx/rpc/grpc/server/internal/NativeServerCall.kt +++ b/grpc/grpc-server/src/nativeMain/kotlin/kotlinx/rpc/grpc/server/internal/NativeServerCall.kt @@ -25,12 +25,13 @@ import kotlinx.rpc.grpc.GrpcMetadata import kotlinx.rpc.grpc.Status import kotlinx.rpc.grpc.StatusCode import kotlinx.rpc.grpc.StatusException -import kotlinx.rpc.grpc.internal.BatchResult -import kotlinx.rpc.grpc.internal.CompletionQueue import kotlinx.rpc.grpc.descriptor.MethodDescriptor import kotlinx.rpc.grpc.descriptor.MethodType -import kotlinx.rpc.grpc.internal.internalError import kotlinx.rpc.grpc.descriptor.methodType +import kotlinx.rpc.grpc.internal.BatchResult +import kotlinx.rpc.grpc.internal.CompletionQueue +import kotlinx.rpc.grpc.internal.destroyEntries +import kotlinx.rpc.grpc.internal.internalError import kotlinx.rpc.grpc.internal.toGrpcByteBuffer import kotlinx.rpc.grpc.internal.toGrpcSlice import kotlinx.rpc.grpc.internal.toKotlin @@ -262,15 +263,22 @@ internal class NativeServerCall( override fun sendHeaders(headers: GrpcMetadata) { check(initialized) { internalError("Call not initialized") } val arena = Arena() - // TODO: Implement header metadata operation + + val initialMetadata = with(headers) { + arena.allocRawGrpcMetadata() + } + val op = arena.alloc { op = GRPC_OP_SEND_INITIAL_METADATA - data.send_initial_metadata.count = 0u - data.send_initial_metadata.metadata = null + data.send_initial_metadata.count = initialMetadata.count + data.send_initial_metadata.metadata = initialMetadata.metadata } sentInitialMetadata = true - runBatch(op.ptr, 1u, cleanup = { arena.clear() }) { + runBatch(op.ptr, 1u, cleanup = { + initialMetadata.destroyEntries() + arena.clear() + }) { // nothing to do here } } @@ -304,6 +312,9 @@ internal class NativeServerCall( check(initialized) { internalError("Call not initialized") } val arena = Arena() + val trailingMetadata = with(trailers) { + arena.allocRawGrpcMetadata() + } val details = status.getDescription()?.toGrpcSlice() val detailsPtr = details?.getPointer(arena) @@ -315,8 +326,8 @@ internal class NativeServerCall( ops[0].op = GRPC_OP_SEND_STATUS_FROM_SERVER ops[0].data.send_status_from_server.status = status.statusCode.toRaw() ops[0].data.send_status_from_server.status_details = detailsPtr - ops[0].data.send_status_from_server.trailing_metadata_count = 0u - ops[0].data.send_status_from_server.trailing_metadata = null + ops[0].data.send_status_from_server.trailing_metadata_count = trailingMetadata.count + ops[0].data.send_status_from_server.trailing_metadata = trailingMetadata.metadata if (!sentInitialMetadata) { // if we haven't sent GRPC_OP_SEND_INITIAL_METADATA yet, @@ -328,6 +339,7 @@ internal class NativeServerCall( runBatch(ops, nOps, cleanup = { if (details != null) grpc_slice_unref(details) + trailingMetadata.destroyEntries() arena.clear() }) { closed = true diff --git a/grpc/grpc-server/src/nativeMain/kotlin/kotlinx/rpc/grpc/server/internal/serverCallTags.kt b/grpc/grpc-server/src/nativeMain/kotlin/kotlinx/rpc/grpc/server/internal/serverCallTags.kt index c6294c187..7dc5921b1 100644 --- a/grpc/grpc-server/src/nativeMain/kotlin/kotlinx/rpc/grpc/server/internal/serverCallTags.kt +++ b/grpc/grpc-server/src/nativeMain/kotlin/kotlinx/rpc/grpc/server/internal/serverCallTags.kt @@ -16,9 +16,9 @@ import kotlinx.cinterop.cValue import kotlinx.cinterop.ptr import kotlinx.cinterop.value import kotlinx.rpc.grpc.GrpcMetadata +import kotlinx.rpc.grpc.descriptor.MethodDescriptor import kotlinx.rpc.grpc.internal.CallbackTag import kotlinx.rpc.grpc.internal.CompletionQueue -import kotlinx.rpc.grpc.descriptor.MethodDescriptor import kotlinx.rpc.grpc.internal.toByteArray import kotlinx.rpc.grpc.server.HandlerRegistry import libkgrpc.gpr_timespec @@ -65,10 +65,9 @@ internal class RegisteredServerCallTag( // create a NativeServerCall to control the underlying core call. // ownership of the core call is transferred to the NativeServerCall. val call = NativeServerCall(rawCall.value!!, cq, method.getMethodDescriptor()) - // TODO: Turn metadata into a kotlin GrpcTrailers. - val trailers = GrpcMetadata() + val headers = GrpcMetadata(rawRequestMetadata) // start the actual call. - val listener = method.getServerCallHandler().startCall(call, trailers) + val listener = method.getServerCallHandler().startCall(call, headers) call.setListener(listener) } finally { // at this point, all return values have been transformed into kotlin ones, @@ -144,9 +143,8 @@ internal class LookupServerCallTag( cq, definition.getMethodDescriptor() as MethodDescriptor ) - // TODO: Turn metadata into a kotlin GrpcTrailers. - val metadata = GrpcMetadata() - val listener = callHandler.startCall(call, metadata) + val headers = GrpcMetadata(rawRequestMetadata) + val listener = callHandler.startCall(call, headers) call.setListener(listener) } } From 3c64ac1fcabfdcda7c4cc99beff6e2c240a65471 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Wed, 22 Oct 2025 11:40:32 +0200 Subject: [PATCH 2/6] grpc: Add documentation and more tests --- .../kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt | 184 ++++++++++++++++ .../rpc/grpc/test/proto/MetadataTest.kt | 201 ++++++++++++++++++ 2 files changed, 385 insertions(+) diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt index c9bb67a99..ce38ef924 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt @@ -4,27 +4,211 @@ package kotlinx.rpc.grpc +/** + * Provides access to read and write metadata values to be exchanged during a gRPC call. + * + * Metadata is an ordered map with case-insensitive keys that are ASCII strings. Each key can be + * associated with multiple values. Values can be either strings (for standard keys) or binary data + * (for keys ending with "-bin" suffix). + * + * ## Key Requirements + * + * Keys must contain only the following ASCII characters: + * - Digits: `0-9` + * - Lowercase letters: `a-z` (uppercase letters are normalized to lowercase) + * - Special characters: `-`, `_`, `.` + * + * Keys must not contain spaces or other special characters. Invalid keys will cause an + * [IllegalArgumentException] to be thrown. Binary keys must additionally end with the `-bin` suffix. + * + * ## Value Requirements + * + * ASCII string values must contain only: + * - ASCII visible characters (0x21-0x7E) + * - Space (0x20), but not at the beginning or end of the string + * + * Non-ASCII characters in values are replaced with `?` during encoding. + * + * ## Thread Safety + * + * This class is not thread-safe. Modifications made by one thread may not be visible to another + * thread concurrently reading the metadata. + * + * ## Example usage + * ```kotlin + * val metadata = GrpcMetadata().apply { + * append("custom-header", "value1") + * append("custom-header", "value2") + * appendBinary("custom-header-bin", byteArrayOf(1, 2, 3)) + *} + * + * val firstValue = metadata["custom-header"] // returns "value2" (last added) + * val allValues = metadata.getAll("custom-header") // returns ["value1", "value2"] + * ``` + */ @Suppress("RedundantConstructorKeyword") public expect class GrpcMetadata constructor() +/** + * Returns the last metadata entry added with the given [key], or `null` if there are no entries. + * + * @param key the name of the metadata entry to retrieve (case-insensitive). Must not end with `-bin`. + * @return the last value associated with the key, or `null` if no values exist + * @throws IllegalArgumentException if the key ends with `-bin` + */ public expect operator fun GrpcMetadata.get(key: String): String? + +/** + * Returns the last binary metadata entry added with the given [key], or `null` if there are no entries. + * + * Binary keys must end with the "-bin" suffix according to gRPC specification. + * + * @param key the name of the binary metadata entry to retrieve (case-insensitive). Must end with `-bin`. + * @return the last binary value associated with the key, or `null` if no values exist + * @throws IllegalArgumentException if the key does not end with `-bin` + */ public expect fun GrpcMetadata.getBinary(key: String): ByteArray? + +/** + * Returns all metadata entries with the given [key], in the order they were added. + * + * @param key the name of the metadata entries to retrieve (case-insensitive). Must not end with `-bin`. + * @return a list of all values associated with the key, or an empty list if no values exist + * @throws IllegalArgumentException if the key ends with `-bin` + */ public expect fun GrpcMetadata.getAll(key: String): List + +/** + * Returns all binary metadata entries with the given [key], in the order they were added. + * + * Binary keys must end with the "-bin" suffix according to gRPC specification. + * + * @param key the name of the binary metadata entries to retrieve (case-insensitive). Must end with `-bin`. + * @return a list of all binary values associated with the key, or an empty list if no values exist + * @throws IllegalArgumentException if the key does not end with `-bin` + */ public expect fun GrpcMetadata.getAllBinary(key: String): List +/** + * Returns an immutable set of all keys present in this metadata. + * + * The returned set is a snapshot of the keys at the time of the call and will not reflect + * subsequent modifications to the metadata. + * + * @return an immutable set of all keys + */ public expect fun GrpcMetadata.keys(): Set + +/** + * Returns `true` if this metadata contains one or more values for the specified [key]. + * + * @param key the key whose presence is to be tested (case-insensitive) + * @return `true` if this metadata contains the key, `false` otherwise + */ public expect operator fun GrpcMetadata.contains(key: String): Boolean +/** + * Appends a metadata entry with the given [key] and [value]. + * + * If the key already has values, the new value is added to the end of the list. + * Duplicate values for the same key are permitted. + * + * @param key the name of the metadata entry (case-insensitive). Must contain only digits (0-9), + * lowercase letters (a-z), and special characters (`-`, `_`, `.`). Must not end with `-bin`. + * @param value the ASCII string value to add. Non-ASCII characters will be replaced with `?`. + * @throws IllegalArgumentException if the key contains invalid characters or ends with `-bin` + */ public expect fun GrpcMetadata.append(key: String, value: String) + +/** + * Appends a binary metadata entry with the given [key] and [value]. + * + * Binary keys must end with the "-bin" suffix according to gRPC specification. + * If the key already has values, the new value is added to the end of the list. + * Duplicate values for the same key are permitted. + * + * @param key the name of the binary metadata entry (case-insensitive). Must contain only digits (0-9), + * lowercase letters (a-z), and special characters (`-`, `_`, `.`). Must end with `-bin`. + * @param value the binary value to add + * @throws IllegalArgumentException if the key contains invalid characters or does not end with `-bin` + */ public expect fun GrpcMetadata.appendBinary(key: String, value: ByteArray) +/** + * Removes the first occurrence of the specified [value] for the given [key]. + * + * @param key the name of the metadata entry (case-insensitive). Must not end with `-bin`. + * @param value the value to remove + * @return `true` if the value was found and removed, `false` if the value was not present + * @throws IllegalArgumentException if the key ends with `-bin` + */ public expect fun GrpcMetadata.remove(key: String, value: String): Boolean + +/** + * Removes the first occurrence of the specified binary [value] for the given [key]. + * + * Binary keys must end with the "-bin" suffix according to gRPC specification. + * + * @param key the name of the binary metadata entry (case-insensitive). Must end with `-bin`. + * @param value the binary value to remove + * @return `true` if the value was found and removed, `false` if the value was not present + * @throws IllegalArgumentException if the key does not end with `-bin` + */ public expect fun GrpcMetadata.removeBinary(key: String, value: ByteArray): Boolean + +/** + * Removes all values for the given [key] and returns them. + * + * @param key the name of the metadata entries to remove (case-insensitive). Must not end with `-bin`. + * @return a list of all values that were removed, or an empty list if there were no values + * @throws IllegalArgumentException if the key ends with `-bin` + */ public expect fun GrpcMetadata.removeAll(key: String): List + +/** + * Removes all binary values for the given [key] and returns them. + * + * Binary keys must end with the "-bin" suffix according to gRPC specification. + * + * @param key the name of the binary metadata entries to remove (case-insensitive). Must end with `-bin`. + * @return a list of all binary values that were removed, or an empty list if there were no values + * @throws IllegalArgumentException if the key does not end with `-bin` + */ public expect fun GrpcMetadata.removeAllBinary(key: String): List +/** + * Merges all entries from [other] metadata into this metadata. + * + * This is a purely additive operation. All entries from [other] are appended to this metadata, + * preserving the order and allowing duplicate values for the same key. + * + * @param other the metadata to merge into this metadata + */ public expect fun GrpcMetadata.merge(other: GrpcMetadata) + +/** + * Merges all entries from [other] metadata into this metadata using the `+=` operator. + * + * This is an alias for [merge] that allows idiomatic Kotlin usage. + * + * @param other the metadata to merge into this metadata + * @see merge + */ public operator fun GrpcMetadata.plusAssign(other: GrpcMetadata): Unit = merge(other) +/** + * Creates a copy of this metadata containing all entries. + * + * @return a new [GrpcMetadata] instance with the same entries as this metadata + */ public fun GrpcMetadata.copy(): GrpcMetadata = GrpcMetadata().also { it.merge(this) } + +/** + * Creates a new metadata instance containing all entries from this metadata and [other]. + * + * This operation does not modify either the current or [other] metadata. + * + * @param other the metadata to merge with this metadata + * @return a new [GrpcMetadata] instance containing entries from both metadata objects + */ public operator fun GrpcMetadata.plus(other: GrpcMetadata): GrpcMetadata = copy().apply { merge(other) } diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/MetadataTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/MetadataTest.kt index 550b863ff..7d8414297 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/MetadataTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/MetadataTest.kt @@ -14,6 +14,7 @@ import kotlinx.rpc.grpc.append import kotlinx.rpc.grpc.appendBinary import kotlinx.rpc.grpc.client.GrpcClient import kotlinx.rpc.grpc.contains +import kotlinx.rpc.grpc.copy import kotlinx.rpc.grpc.get import kotlinx.rpc.grpc.getAll import kotlinx.rpc.grpc.getAllBinary @@ -212,6 +213,206 @@ class MetadataTest : GrpcProtoTest() { } } + @Test + fun `test plusAssign operator`() { + var md1 = GrpcMetadata().apply { + append("my-key", "my-value-1") + } + + val md2 = GrpcMetadata().apply { + append("my-key", "my-value-2") + } + + md1 += md2 + + assertEquals(listOf("my-value-1", "my-value-2"), md1.getAll("my-key")) + assertEquals(listOf("my-value-2"), md2.getAll("my-key")) + } + + @Test + fun `test copy`() { + val original = GrpcMetadata().apply { + append("my-key", "my-value") + appendBinary("my-key-bin", byteArrayOf(1, 2, 3)) + } + + val copy = original.copy() + + assertEquals(original.get("my-key"), copy.get("my-key")) + assertContentEquals(original.getBinary("my-key-bin"), copy.getBinary("my-key-bin")) + + // Verify they are independent + copy.append("my-key", "my-value-2") + assertEquals("my-value", original.get("my-key")) + assertEquals("my-value-2", copy.get("my-key")) + } + + @Test + fun `test empty metadata`() { + val metadata = GrpcMetadata() + assertEquals(0, metadata.keys().size) + assertEquals(null, metadata.get("non-existent")) + assertEquals(null, metadata.getBinary("non-existent-bin")) + assertEquals(emptyList(), metadata.getAll("non-existent")) + assertEquals(emptyList(), metadata.getAllBinary("non-existent-bin")) + assertEquals(false, metadata.contains("non-existent")) + } + + @Test + fun `test get returns last value`() { + val metadata = GrpcMetadata().apply { + append("my-key", "first") + append("my-key", "second") + append("my-key", "third") + } + + assertEquals("third", metadata.get("my-key")) + assertEquals(listOf("first", "second", "third"), metadata.getAll("my-key")) + } + + @Test + fun `test getBinary returns last value`() { + val metadata = GrpcMetadata().apply { + appendBinary("my-key-bin", byteArrayOf(1, 2, 3)) + appendBinary("my-key-bin", byteArrayOf(4, 5, 6)) + appendBinary("my-key-bin", byteArrayOf(7, 8, 9)) + } + + assertContentEquals(byteArrayOf(7, 8, 9), metadata.getBinary("my-key-bin")) + assertEquals(3, metadata.getAllBinary("my-key-bin").size) + assertContentEquals(byteArrayOf(1, 2, 3), metadata.getAllBinary("my-key-bin")[0]) + assertContentEquals(byteArrayOf(4, 5, 6), metadata.getAllBinary("my-key-bin")[1]) + assertContentEquals(byteArrayOf(7, 8, 9), metadata.getAllBinary("my-key-bin")[2]) + } + + @Test + fun `test case insensitivity`() { + val metadata = GrpcMetadata().apply { + append("My-Key", "my-value") + appendBinary("My-Key-bin", byteArrayOf(1, 2, 3)) + } + + assertEquals("my-value", metadata.get("my-key")) + assertEquals("my-value", metadata.get("MY-KEY")) + assertEquals("my-value", metadata.get("My-Key")) + assertContentEquals(byteArrayOf(1, 2, 3), metadata.getBinary("my-key-bin")) + assertContentEquals(byteArrayOf(1, 2, 3), metadata.getBinary("MY-KEY-bin")) + assertTrue(metadata.contains("my-key")) + assertTrue(metadata.contains("MY-KEY")) + } + + @Test + fun `test remove returns false for non-existent value`() { + val metadata = GrpcMetadata().apply { + append("my-key", "my-value") + } + + assertEquals(false, metadata.remove("my-key", "other-value")) + assertEquals(false, metadata.remove("non-existent", "value")) + assertEquals(true, metadata.remove("my-key", "my-value")) + } + + @Test + fun `test removeBinary returns false for non-existent value`() { + val value = byteArrayOf(1, 2, 3) + val metadata = GrpcMetadata().apply { + appendBinary("my-key-bin", value) + } + + assertEquals(false, metadata.removeBinary("my-key-bin", byteArrayOf(4, 5, 6))) + assertEquals(false, metadata.removeBinary("non-existent-bin", value)) + assertEquals(true, metadata.removeBinary("my-key-bin", value)) + } + + @Test + fun `test removeAll returns empty list for non-existent key`() { + val metadata = GrpcMetadata() + assertEquals(emptyList(), metadata.removeAll("non-existent")) + } + + @Test + fun `test removeAllBinary returns empty list for non-existent key`() { + val metadata = GrpcMetadata() + assertEquals(emptyList(), metadata.removeAllBinary("non-existent-bin")) + } + + @Test + fun `test removeAll removes all values and returns them`() { + val metadata = GrpcMetadata().apply { + append("my-key", "value1") + append("my-key", "value2") + append("my-key", "value3") + } + + val removed = metadata.removeAll("my-key") + assertEquals(listOf("value1", "value2", "value3"), removed) + assertEquals(null, metadata.get("my-key")) + assertEquals(false, metadata.contains("my-key")) + } + + @Test + fun `test removeAllBinary removes all values and returns them`() { + val metadata = GrpcMetadata().apply { + appendBinary("my-key-bin", byteArrayOf(1, 2, 3)) + appendBinary("my-key-bin", byteArrayOf(4, 5, 6)) + } + + val removed = metadata.removeAllBinary("my-key-bin") + assertEquals(2, removed.size) + assertContentEquals(byteArrayOf(1, 2, 3), removed[0]) + assertContentEquals(byteArrayOf(4, 5, 6), removed[1]) + assertEquals(null, metadata.getBinary("my-key-bin")) + assertEquals(false, metadata.contains("my-key-bin")) + } + + @Test + fun `test duplicate values allowed`() { + val metadata = GrpcMetadata().apply { + append("my-key", "value") + append("my-key", "value") + append("my-key", "value") + } + + assertEquals(listOf("value", "value", "value"), metadata.getAll("my-key")) + assertEquals(true, metadata.remove("my-key", "value")) + assertEquals(listOf("value", "value"), metadata.getAll("my-key")) + } + + @Test + fun `test keys returns snapshot`() { + val metadata = GrpcMetadata().apply { + append("key1", "value1") + append("key2", "value2") + } + + val keys = metadata.keys() + assertEquals(2, keys.size) + + metadata.append("key3", "value3") + // Keys set should still be 2 since it's a snapshot + assertEquals(2, keys.size) + } + + @Test + fun `test plus operator does not modify originals`() { + val md1 = GrpcMetadata().apply { + append("key1", "value1") + } + + val md2 = GrpcMetadata().apply { + append("key2", "value2") + } + + val md3 = md1 + md2 + + assertEquals(listOf("value1"), md1.getAll("key1")) + assertEquals(false, md1.contains("key2")) + assertEquals(listOf("value2"), md2.getAll("key2")) + assertEquals(false, md2.contains("key1")) + assertEquals(listOf("value1"), md3.getAll("key1")) + assertEquals(listOf("value2"), md3.getAll("key2")) + } + private suspend fun unaryEcho(grpcClient: GrpcClient) { val service = grpcClient.withService() From cb957f2547b19b67ebc0664980e3a4d316506f43 Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Mon, 27 Oct 2025 09:59:44 +0100 Subject: [PATCH 3/6] grpc: Address PR comments --- .../kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt | 44 +++++++++++++------ .../kotlinx/rpc/grpc/GrpcMetadata.native.kt | 34 +++++++------- 2 files changed, 47 insertions(+), 31 deletions(-) diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt index ce38ef924..85b15dde1 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt @@ -52,9 +52,10 @@ public expect class GrpcMetadata constructor() /** * Returns the last metadata entry added with the given [key], or `null` if there are no entries. * - * @param key the name of the metadata entry to retrieve (case-insensitive). Must not end with `-bin`. + * @param key the name of the metadata entry (case-insensitive). Must contain only digits (0-9), + * lowercase letters (a-z), and special characters (`-`, `_`, `.`). Must not end with `-bin`. * @return the last value associated with the key, or `null` if no values exist - * @throws IllegalArgumentException if the key ends with `-bin` + * @throws IllegalArgumentException if the key ends with `-bin` or contains invalid characters */ public expect operator fun GrpcMetadata.get(key: String): String? @@ -63,18 +64,22 @@ public expect operator fun GrpcMetadata.get(key: String): String? * * Binary keys must end with the "-bin" suffix according to gRPC specification. * - * @param key the name of the binary metadata entry to retrieve (case-insensitive). Must end with `-bin`. + * @param key the name of the metadata entry (case-insensitive). + * Must contain only digits (0-9), lowercase letters (a-z), and special characters (`-`, `_`, `.`). + * Must end with `-bin`. * @return the last binary value associated with the key, or `null` if no values exist - * @throws IllegalArgumentException if the key does not end with `-bin` + * @throws IllegalArgumentException if the key does not end with `-bin` or contains invalid characters */ public expect fun GrpcMetadata.getBinary(key: String): ByteArray? /** * Returns all metadata entries with the given [key], in the order they were added. * - * @param key the name of the metadata entries to retrieve (case-insensitive). Must not end with `-bin`. + * @param key the name of the metadata entry (case-insensitive). + * Must contain only digits (0-9), lowercase letters (a-z), and special characters (`-`, `_`, `.`). + * Must not end with `-bin`. * @return a list of all values associated with the key, or an empty list if no values exist - * @throws IllegalArgumentException if the key ends with `-bin` + * @throws IllegalArgumentException if the key ends with `-bin` or contains invalid characters */ public expect fun GrpcMetadata.getAll(key: String): List @@ -83,9 +88,11 @@ public expect fun GrpcMetadata.getAll(key: String): List * * Binary keys must end with the "-bin" suffix according to gRPC specification. * - * @param key the name of the binary metadata entries to retrieve (case-insensitive). Must end with `-bin`. + * @param key the name of the metadata entry (case-insensitive). + * Must contain only digits (0-9), lowercase letters (a-z), and special characters (`-`, `_`, `.`). + * Must end with `-bin`. * @return a list of all binary values associated with the key, or an empty list if no values exist - * @throws IllegalArgumentException if the key does not end with `-bin` + * @throws IllegalArgumentException if the key does not end with `-bin` or contains invalid characters */ public expect fun GrpcMetadata.getAllBinary(key: String): List @@ -102,7 +109,8 @@ public expect fun GrpcMetadata.keys(): Set /** * Returns `true` if this metadata contains one or more values for the specified [key]. * - * @param key the key whose presence is to be tested (case-insensitive) + * @param key the key whose presence is to be tested (case-insensitive). + * Must contain only digits (0-9), lowercase letters (a-z), and special characters (`-`, `_`, `.`). * @return `true` if this metadata contains the key, `false` otherwise */ public expect operator fun GrpcMetadata.contains(key: String): Boolean @@ -137,10 +145,12 @@ public expect fun GrpcMetadata.appendBinary(key: String, value: ByteArray) /** * Removes the first occurrence of the specified [value] for the given [key]. * - * @param key the name of the metadata entry (case-insensitive). Must not end with `-bin`. + * @param key the name of the metadata entry (case-insensitive). + * Must contain only digits (0-9), lowercase letters (a-z), and special characters (`-`, `_`, `.`). + * Must not end with `-bin`. * @param value the value to remove * @return `true` if the value was found and removed, `false` if the value was not present - * @throws IllegalArgumentException if the key ends with `-bin` + * @throws IllegalArgumentException if the key ends with `-bin` or contains invalid characters */ public expect fun GrpcMetadata.remove(key: String, value: String): Boolean @@ -149,7 +159,9 @@ public expect fun GrpcMetadata.remove(key: String, value: String): Boolean * * Binary keys must end with the "-bin" suffix according to gRPC specification. * - * @param key the name of the binary metadata entry (case-insensitive). Must end with `-bin`. + * @param key the name of the binary metadata entry (case-insensitive). + * Must contain only digits (0-9), lowercase letters (a-z), and special characters (`-`, `_`, `.`). + * Must end with `-bin`. * @param value the binary value to remove * @return `true` if the value was found and removed, `false` if the value was not present * @throws IllegalArgumentException if the key does not end with `-bin` @@ -159,7 +171,9 @@ public expect fun GrpcMetadata.removeBinary(key: String, value: ByteArray): Bool /** * Removes all values for the given [key] and returns them. * - * @param key the name of the metadata entries to remove (case-insensitive). Must not end with `-bin`. + * @param key the name of the metadata entries to remove (case-insensitive). + * Must contain only digits (0-9), lowercase letters (a-z), and special characters (`-`, `_`, `.`). + * Must not end with `-bin`. * @return a list of all values that were removed, or an empty list if there were no values * @throws IllegalArgumentException if the key ends with `-bin` */ @@ -170,7 +184,9 @@ public expect fun GrpcMetadata.removeAll(key: String): List * * Binary keys must end with the "-bin" suffix according to gRPC specification. * - * @param key the name of the binary metadata entries to remove (case-insensitive). Must end with `-bin`. + * @param key the name of the binary metadata entries to remove (case-insensitive). + * Must contain only digits (0-9), lowercase letters (a-z), and special characters (`-`, `_`, `.`). + * Must end with `-bin`. * @return a list of all binary values that were removed, or an empty list if there were no values * @throws IllegalArgumentException if the key does not end with `-bin` */ diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.native.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.native.kt index b576c8332..9a764beaf 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.native.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.native.kt @@ -27,18 +27,18 @@ import libkgrpc.grpc_slice_unref import libkgrpc.kgrpc_metadata_array_append import kotlin.experimental.ExperimentalNativeApi -private value class GrpcKey private constructor(val name: String) { +private value class GrpcMetadataKey private constructor(val name: String) { val isBinary get() = name.endsWith("-bin") - companion object { - fun binary(name: String): GrpcKey { - val key = GrpcKey(validateName(name.lowercase())) + companion object Companion { + fun binary(name: String): GrpcMetadataKey { + val key = GrpcMetadataKey(validateName(name.lowercase())) require(key.isBinary) { "Binary header is named ${key.name}. It must end with '-bin'" } return key } - fun string(name: String): GrpcKey { - val key = GrpcKey(validateName(name.lowercase())) + fun string(name: String): GrpcMetadataKey { + val key = GrpcMetadataKey(validateName(name.lowercase())) require(!key.isBinary) { "String header is named ${key.name}. It must not end with '-bin'" } return key } @@ -97,19 +97,19 @@ public actual class GrpcMetadata actual constructor() { } public actual operator fun GrpcMetadata.get(key: String): String? { - return map[GrpcKey.string(key).name]?.lastOrNull()?.toAsciiString() + return map[GrpcMetadataKey.string(key).name]?.lastOrNull()?.toAsciiString() } public actual fun GrpcMetadata.getBinary(key: String): ByteArray? { - return map[GrpcKey.binary(key).name]?.lastOrNull() + return map[GrpcMetadataKey.binary(key).name]?.lastOrNull() } public actual fun GrpcMetadata.getAll(key: String): List { - return map[GrpcKey.string(key).name]?.map { it.toAsciiString() } ?: emptyList() + return map[GrpcMetadataKey.string(key).name]?.map { it.toAsciiString() } ?: emptyList() } public actual fun GrpcMetadata.getAllBinary(key: String): List { - return map[GrpcKey.binary(key).name]?.map { it } ?: emptyList() + return map[GrpcMetadataKey.binary(key).name]?.map { it } ?: emptyList() } public actual operator fun GrpcMetadata.contains(key: String): Boolean { @@ -121,35 +121,35 @@ public actual fun GrpcMetadata.keys(): Set { } public actual fun GrpcMetadata.append(key: String, value: String) { - val k = GrpcKey.string(key) // non-bin key + val k = GrpcMetadataKey.string(key) // non-bin key map.getOrPut(k.name) { mutableListOf() }.add(value.toAsciiBytes()) } public actual fun GrpcMetadata.appendBinary(key: String, value: ByteArray) { - val k = GrpcKey.binary(key) + val k = GrpcMetadataKey.binary(key) map.getOrPut(k.name) { mutableListOf() }.add(value) } public actual fun GrpcMetadata.remove(key: String, value: String): Boolean { val index = getAll(key).indexOf(value) if (index == -1) return false - map[GrpcKey.string(key).name]!!.removeAt(index) + map[GrpcMetadataKey.string(key).name]!!.removeAt(index) return true } public actual fun GrpcMetadata.removeBinary(key: String, value: ByteArray): Boolean { val index = getAllBinary(key).indexOf(value) if (index == -1) return false - map[GrpcKey.binary(key).name]!!.removeAt(index) + map[GrpcMetadataKey.binary(key).name]!!.removeAt(index) return true } public actual fun GrpcMetadata.removeAll(key: String): List { - return map.remove(GrpcKey.string(key).name)?.map { it.toAsciiString() } ?: emptyList() + return map.remove(GrpcMetadataKey.string(key).name)?.map { it.toAsciiString() } ?: emptyList() } public actual fun GrpcMetadata.removeAllBinary(key: String): List { - return map.remove(GrpcKey.binary(key).name) ?: emptyList() + return map.remove(GrpcMetadataKey.binary(key).name) ?: emptyList() } public actual fun GrpcMetadata.merge(other: GrpcMetadata) { @@ -201,7 +201,7 @@ private val VALID_KEY_CHARS by lazy { } @OptIn(ObsoleteNativeApi::class) -private fun GrpcKey.Companion.validateName(name: String): String { +private fun GrpcMetadataKey.Companion.validateName(name: String): String { require(!name.startsWith("grpc-")) { "Header is named $name. It must not start with 'grpc-' as it is reserved for internal use." } for (char in name) { From 6cc258d8e339a56d4fb4fae47785e617d1e5961a Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Mon, 27 Oct 2025 17:05:41 +0100 Subject: [PATCH 4/6] grpc: Extend Metadata API --- .../kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt | 17 ++ .../kotlinx/rpc/grpc/GrpcMetadata.jvm.kt | 80 +++++++++ .../kotlinx/rpc/grpc/GrpcMetadata.native.kt | 154 ++++++++++++++---- 3 files changed, 223 insertions(+), 28 deletions(-) diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt index 85b15dde1..9c6de717c 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt @@ -4,6 +4,8 @@ package kotlinx.rpc.grpc +import kotlinx.rpc.grpc.codec.MessageCodec + /** * Provides access to read and write metadata values to be exchanged during a gRPC call. * @@ -49,6 +51,9 @@ package kotlinx.rpc.grpc @Suppress("RedundantConstructorKeyword") public expect class GrpcMetadata constructor() + +public expect class GrpcMetadataKey internal constructor(name: String, codec: MessageCodec) {} + /** * Returns the last metadata entry added with the given [key], or `null` if there are no entries. * @@ -59,6 +64,8 @@ public expect class GrpcMetadata constructor() */ public expect operator fun GrpcMetadata.get(key: String): String? +public expect fun GrpcMetadata.get(key: GrpcMetadataKey): T? + /** * Returns the last binary metadata entry added with the given [key], or `null` if there are no entries. * @@ -72,6 +79,8 @@ public expect operator fun GrpcMetadata.get(key: String): String? */ public expect fun GrpcMetadata.getBinary(key: String): ByteArray? +public expect fun GrpcMetadata.getBinary(key: GrpcMetadataKey): T? + /** * Returns all metadata entries with the given [key], in the order they were added. * @@ -82,6 +91,7 @@ public expect fun GrpcMetadata.getBinary(key: String): ByteArray? * @throws IllegalArgumentException if the key ends with `-bin` or contains invalid characters */ public expect fun GrpcMetadata.getAll(key: String): List +public expect fun GrpcMetadata.getAll(key: GrpcMetadataKey): List /** * Returns all binary metadata entries with the given [key], in the order they were added. @@ -95,6 +105,7 @@ public expect fun GrpcMetadata.getAll(key: String): List * @throws IllegalArgumentException if the key does not end with `-bin` or contains invalid characters */ public expect fun GrpcMetadata.getAllBinary(key: String): List +public expect fun GrpcMetadata.getAllBinary(key: GrpcMetadataKey): List /** * Returns an immutable set of all keys present in this metadata. @@ -127,6 +138,7 @@ public expect operator fun GrpcMetadata.contains(key: String): Boolean * @throws IllegalArgumentException if the key contains invalid characters or ends with `-bin` */ public expect fun GrpcMetadata.append(key: String, value: String) +public expect fun GrpcMetadata.append(key: GrpcMetadataKey, value: T) /** * Appends a binary metadata entry with the given [key] and [value]. @@ -141,6 +153,7 @@ public expect fun GrpcMetadata.append(key: String, value: String) * @throws IllegalArgumentException if the key contains invalid characters or does not end with `-bin` */ public expect fun GrpcMetadata.appendBinary(key: String, value: ByteArray) +public expect fun GrpcMetadata.appendBinary(key: GrpcMetadataKey, value: T) /** * Removes the first occurrence of the specified [value] for the given [key]. @@ -153,6 +166,7 @@ public expect fun GrpcMetadata.appendBinary(key: String, value: ByteArray) * @throws IllegalArgumentException if the key ends with `-bin` or contains invalid characters */ public expect fun GrpcMetadata.remove(key: String, value: String): Boolean +public expect fun GrpcMetadata.remove(key: GrpcMetadataKey, value: T): Boolean /** * Removes the first occurrence of the specified binary [value] for the given [key]. @@ -167,6 +181,7 @@ public expect fun GrpcMetadata.remove(key: String, value: String): Boolean * @throws IllegalArgumentException if the key does not end with `-bin` */ public expect fun GrpcMetadata.removeBinary(key: String, value: ByteArray): Boolean +public expect fun GrpcMetadata.removeBinary(key: GrpcMetadataKey, value: T): Boolean /** * Removes all values for the given [key] and returns them. @@ -178,6 +193,7 @@ public expect fun GrpcMetadata.removeBinary(key: String, value: ByteArray): Bool * @throws IllegalArgumentException if the key ends with `-bin` */ public expect fun GrpcMetadata.removeAll(key: String): List +public expect fun GrpcMetadata.removeAll(key: GrpcMetadataKey): List /** * Removes all binary values for the given [key] and returns them. @@ -191,6 +207,7 @@ public expect fun GrpcMetadata.removeAll(key: String): List * @throws IllegalArgumentException if the key does not end with `-bin` */ public expect fun GrpcMetadata.removeAllBinary(key: String): List +public expect fun GrpcMetadata.removeAllBinary(key: GrpcMetadataKey): List /** * Merges all entries from [other] metadata into this metadata. diff --git a/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.jvm.kt b/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.jvm.kt index 3f3b343e7..00180e1e6 100644 --- a/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.jvm.kt +++ b/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.jvm.kt @@ -5,27 +5,83 @@ package kotlinx.rpc.grpc import io.grpc.Metadata +import kotlinx.io.Buffer +import kotlinx.io.asInputStream +import kotlinx.rpc.grpc.codec.MessageCodec import kotlinx.rpc.internal.utils.InternalRpcApi @InternalRpcApi public actual typealias GrpcMetadata = io.grpc.Metadata +public actual class GrpcMetadataKey internal actual constructor( + private val name: String, + private val codec: MessageCodec, +) { + + internal fun encode(value: T): ByteArray = codec.encode(value).readBytes() + internal fun decode(value: ByteArray): T = Buffer().let { buffer -> + buffer.write(value) + codec.decode(buffer.asInputStream()) + } + + internal fun toAsciiKey(): Metadata.Key = Metadata.Key.of(name, AsciiMarshaller(this)) + internal fun toBinaryKey(): Metadata.Key = Metadata.Key.of(name, BinaryMarshaller(this)) +} + +@JvmInline +private value class AsciiMarshaller(val key: GrpcMetadataKey) : Metadata.AsciiMarshaller { + override fun toAsciiString(value: T): String { + return key.encode(value).decodeToString() + } + + override fun parseAsciiString(serialized: String?): T? { + return key.decode(serialized!!.encodeToByteArray()) + } +} + +@JvmInline +private value class BinaryMarshaller(val key: GrpcMetadataKey) : Metadata.BinaryMarshaller { + override fun toBytes(value: T): ByteArray { + return key.encode(value) + } + + override fun parseBytes(serialized: ByteArray): T { + return key.decode(serialized) + } +} + public actual operator fun GrpcMetadata.get(key: String): String? { return get(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER)) } +public actual fun GrpcMetadata.get(key: GrpcMetadataKey): T? { + return get(key.toAsciiKey()) +} + public actual fun GrpcMetadata.getBinary(key: String): ByteArray? { return get(Metadata.Key.of(key, Metadata.BINARY_BYTE_MARSHALLER)) } +public actual fun GrpcMetadata.getBinary(key: GrpcMetadataKey): T? { + return get(key.toBinaryKey()) +} + public actual fun GrpcMetadata.getAll(key: String): List { return getAll(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER))?.toList() ?: emptyList() } +public actual fun GrpcMetadata.getAll(key: GrpcMetadataKey): List { + return getAll(key.toAsciiKey())?.toList() ?: emptyList() +} + public actual fun GrpcMetadata.getAllBinary(key: String): List { return getAll(Metadata.Key.of(key, Metadata.BINARY_BYTE_MARSHALLER))?.toList() ?: emptyList() } +public actual fun GrpcMetadata.getAllBinary(key: GrpcMetadataKey): List { + return getAll(key.toBinaryKey())?.toList() ?: emptyList() +} + public actual operator fun GrpcMetadata.contains(key: String): Boolean { val javaKey = if (key.endsWith(Metadata.BINARY_HEADER_SUFFIX)) Metadata.Key.of(key, Metadata.BINARY_BYTE_MARSHALLER) else @@ -41,26 +97,50 @@ public actual fun GrpcMetadata.append(key: String, value: String) { return put(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER), value) } +public actual fun GrpcMetadata.append(key: GrpcMetadataKey, value: T) { + return put(key.toAsciiKey(), value) +} + public actual fun GrpcMetadata.appendBinary(key: String, value: ByteArray) { return put(Metadata.Key.of(key, Metadata.BINARY_BYTE_MARSHALLER), value) } +public actual fun GrpcMetadata.appendBinary(key: GrpcMetadataKey, value: T) { + return put(key.toBinaryKey(), value) +} + public actual fun GrpcMetadata.remove(key: String, value: String): Boolean { return remove(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER), value) } +public actual fun GrpcMetadata.remove(key: GrpcMetadataKey, value: T): Boolean { + return remove(key.toAsciiKey(), value) +} + public actual fun GrpcMetadata.removeBinary(key: String, value: ByteArray): Boolean { return remove(Metadata.Key.of(key, Metadata.BINARY_BYTE_MARSHALLER), value) } +public actual fun GrpcMetadata.removeBinary(key: GrpcMetadataKey, value: T): Boolean { + return remove(key.toBinaryKey(), value) +} + public actual fun GrpcMetadata.removeAll(key: String): List { return removeAll(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER))?.toList() ?: emptyList() } +public actual fun GrpcMetadata.removeAll(key: GrpcMetadataKey): List { + return removeAll(key.toAsciiKey())?.toList() ?: emptyList() +} + public actual fun GrpcMetadata.removeAllBinary(key: String): List { return removeAll(Metadata.Key.of(key, Metadata.BINARY_BYTE_MARSHALLER))?.toList() ?: emptyList() } +public actual fun GrpcMetadata.removeAllBinary(key: GrpcMetadataKey): List { + return removeAll(key.toBinaryKey())?.toList() ?: emptyList() +} + public actual fun GrpcMetadata.merge(other: GrpcMetadata) { this.merge(other) } \ No newline at end of file diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.native.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.native.kt index 9a764beaf..2c9590382 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.native.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.native.kt @@ -15,8 +15,14 @@ import kotlinx.cinterop.convert import kotlinx.cinterop.get import kotlinx.cinterop.ptr import kotlinx.cinterop.usePinned +import kotlinx.io.Buffer +import kotlinx.io.Source +import kotlinx.io.readByteArray +import kotlinx.rpc.grpc.codec.MessageCodec +import kotlinx.rpc.grpc.codec.SourcedMessageCodec import kotlinx.rpc.grpc.internal.toByteArray import kotlinx.rpc.internal.utils.InternalRpcApi +import kotlinx.rpc.protobuf.input.stream.asInputStream import libkgrpc.grpc_metadata import libkgrpc.grpc_metadata_array import libkgrpc.grpc_metadata_array_init @@ -27,22 +33,31 @@ import libkgrpc.grpc_slice_unref import libkgrpc.kgrpc_metadata_array_append import kotlin.experimental.ExperimentalNativeApi -private value class GrpcMetadataKey private constructor(val name: String) { - val isBinary get() = name.endsWith("-bin") +public actual class GrpcMetadataKey actual constructor(public var name: String, public val codec: MessageCodec) { - companion object Companion { - fun binary(name: String): GrpcMetadataKey { - val key = GrpcMetadataKey(validateName(name.lowercase())) - require(key.isBinary) { "Binary header is named ${key.name}. It must end with '-bin'" } - return key - } + init { + name = name.lowercase() + } - fun string(name: String): GrpcMetadataKey { - val key = GrpcMetadataKey(validateName(name.lowercase())) - require(!key.isBinary) { "String header is named ${key.name}. It must not end with '-bin'" } - return key - } + internal val isBinary get() = name.endsWith("-bin") + + internal fun encode(value: T): ByteArray = codec.encode(value).buffer.readByteArray() + internal fun decode(value: ByteArray): T = Buffer().let { buffer -> + buffer.write(value) + codec.decode(buffer.asInputStream()) + } + + internal fun validateForString() { + validateName() + require(!isBinary) { "String header is named ${name}. It must not end with '-bin'" } } + + internal fun validateForBinary() { + validateName() + require(isBinary) { "Binary header is named ${name}. It must end with '-bin'" } + } + + internal companion object } @Suppress(names = ["RedundantConstructorKeyword"]) @@ -97,19 +112,47 @@ public actual class GrpcMetadata actual constructor() { } public actual operator fun GrpcMetadata.get(key: String): String? { - return map[GrpcMetadataKey.string(key).name]?.lastOrNull()?.toAsciiString() + return get(key.toAsciiKey()) +} + +public actual fun GrpcMetadata.get(key: GrpcMetadataKey): T? { + key.validateForString() + return map[key.name]?.lastOrNull()?.let { + key.decode(it) + } } public actual fun GrpcMetadata.getBinary(key: String): ByteArray? { - return map[GrpcMetadataKey.binary(key).name]?.lastOrNull() + val key = key.toBinaryKey() + key.validateForBinary() + return map[key.name]?.lastOrNull() +} + +public actual fun GrpcMetadata.getBinary(key: GrpcMetadataKey): T? { + key.validateForBinary() + return map[key.name]?.lastOrNull()?.let { + key.decode(it) + } } public actual fun GrpcMetadata.getAll(key: String): List { - return map[GrpcMetadataKey.string(key).name]?.map { it.toAsciiString() } ?: emptyList() + return getAll(key.toAsciiKey()) +} + +public actual fun GrpcMetadata.getAll(key: GrpcMetadataKey): List { + key.validateForString() + return map[key.name]?.map { key.decode(it) } ?: emptyList() } public actual fun GrpcMetadata.getAllBinary(key: String): List { - return map[GrpcMetadataKey.binary(key).name]?.map { it } ?: emptyList() + val key = key.toBinaryKey() + key.validateForBinary() + return map[key.name] ?: emptyList() +} + +public actual fun GrpcMetadata.getAllBinary(key: GrpcMetadataKey): List { + key.validateForBinary() + return map[key.name]?.map { key.decode(it) } ?: emptyList() } public actual operator fun GrpcMetadata.contains(key: String): Boolean { @@ -121,35 +164,70 @@ public actual fun GrpcMetadata.keys(): Set { } public actual fun GrpcMetadata.append(key: String, value: String) { - val k = GrpcMetadataKey.string(key) // non-bin key - map.getOrPut(k.name) { mutableListOf() }.add(value.toAsciiBytes()) + append(key.toAsciiKey(), value) +} + +public actual fun GrpcMetadata.append(key: GrpcMetadataKey, value: T) { + key.validateForString() + map.getOrPut(key.name) { mutableListOf() }.add(key.encode(value)) } public actual fun GrpcMetadata.appendBinary(key: String, value: ByteArray) { - val k = GrpcMetadataKey.binary(key) - map.getOrPut(k.name) { mutableListOf() }.add(value) + val key = key.toBinaryKey() + key.validateForBinary() + map.getOrPut(key.name) { mutableListOf() }.add(value) +} + +public actual fun GrpcMetadata.appendBinary(key: GrpcMetadataKey, value: T) { + key.validateForBinary() + map.getOrPut(key.name) { mutableListOf() }.add(key.encode(value)) } public actual fun GrpcMetadata.remove(key: String, value: String): Boolean { + return remove(key.toAsciiKey(), value) +} + +public actual fun GrpcMetadata.remove(key: GrpcMetadataKey, value: T): Boolean { + key.validateForString() val index = getAll(key).indexOf(value) if (index == -1) return false - map[GrpcMetadataKey.string(key).name]!!.removeAt(index) + map[key.name]!!.removeAt(index) return true } public actual fun GrpcMetadata.removeBinary(key: String, value: ByteArray): Boolean { + val keyObj = key.toBinaryKey() + keyObj.validateForBinary() val index = getAllBinary(key).indexOf(value) if (index == -1) return false - map[GrpcMetadataKey.binary(key).name]!!.removeAt(index) + map[keyObj.name]!!.removeAt(index) + return true +} + +public actual fun GrpcMetadata.removeBinary(key: GrpcMetadataKey, value: T): Boolean { + key.validateForBinary() + val index = getAllBinary(key).indexOf(value) + if (index == -1) return false + map[key.name]!!.removeAt(index) return true } public actual fun GrpcMetadata.removeAll(key: String): List { - return map.remove(GrpcMetadataKey.string(key).name)?.map { it.toAsciiString() } ?: emptyList() + return removeAll(key.toAsciiKey()) +} + +public actual fun GrpcMetadata.removeAll(key: GrpcMetadataKey): List { + key.validateForString() + return map.remove(key.name)?.map { key.decode(it) } ?: emptyList() } public actual fun GrpcMetadata.removeAllBinary(key: String): List { - return map.remove(GrpcMetadataKey.binary(key).name) ?: emptyList() + return removeAllBinary(key.toBinaryKey()) +} + +public actual fun GrpcMetadata.removeAllBinary(key: GrpcMetadataKey): List { + key.validateForBinary() + return map.remove(key.name)?.map { key.decode(it) } ?: emptyList() } public actual fun GrpcMetadata.merge(other: GrpcMetadata) { @@ -201,12 +279,32 @@ private val VALID_KEY_CHARS by lazy { } @OptIn(ObsoleteNativeApi::class) -private fun GrpcMetadataKey.Companion.validateName(name: String): String { +private fun GrpcMetadataKey.validateName() { require(!name.startsWith("grpc-")) { "Header is named $name. It must not start with 'grpc-' as it is reserved for internal use." } for (char in name) { require(VALID_KEY_CHARS[char.code]) { "Header is named $name. It contains illegal character $char." } } +} + +private val AsciiCodec = object : SourcedMessageCodec { + override fun encodeToSource(value: String): Source = Buffer().apply { + write(value.toAsciiBytes()) + } + + override fun decodeFromSource(stream: Source): String = stream.use { buffer -> + buffer.readByteArray().toAsciiString() + } +} + +private val BinaryCodec = object : SourcedMessageCodec { + override fun encodeToSource(value: ByteArray): Source = Buffer().apply { + write(value) + } + + override fun decodeFromSource(stream: Source): ByteArray = stream.readByteArray() +} + +private fun String.toAsciiKey() = GrpcMetadataKey(this, AsciiCodec) +private fun String.toBinaryKey() = GrpcMetadataKey(this, BinaryCodec) - return name -} \ No newline at end of file From a0b07683212225bb130d82efc73aeccd88b8f0ef Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Tue, 28 Oct 2025 09:50:55 +0100 Subject: [PATCH 5/6] grpc: Extend Metadata API and docs --- .../kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt | 135 +++- .../rpc/grpc/test/proto/MetadataTest.kt | 696 ++++++++++++++++++ 2 files changed, 828 insertions(+), 3 deletions(-) diff --git a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt index 9c6de717c..9ed2f1bc7 100644 --- a/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt +++ b/grpc/grpc-core/src/commonMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.kt @@ -51,8 +51,24 @@ import kotlinx.rpc.grpc.codec.MessageCodec @Suppress("RedundantConstructorKeyword") public expect class GrpcMetadata constructor() - -public expect class GrpcMetadataKey internal constructor(name: String, codec: MessageCodec) {} +/** + * A typed key for metadata entries that uses a [MessageCodec] to encode and decode values. + * + * Typed keys provide type-safe access to metadata values with automatic serialization and + * deserialization using the provided codec. The key name follows the same requirements as + * string-based keys (case-insensitive, ASCII characters only). + * + * For non-binary methods ([get], [getAll], [append], [remove], [removeAll]), the codec must + * encode values as ASCII strings and assume an ASCII string from the passed stream. + * For binary methods ([getBinary], [getAllBinary], [appendBinary], [removeBinary], [removeAllBinary]), + * the codec encodes values as raw bytes. + * + * @param T the type of values associated with this key + * @param name the key name (case-insensitive). Must contain only digits (0-9), lowercase + * letters (a-z), and special characters (`-`, `_`, `.`). Binary keys must end with `-bin`. + * @param codec the codec used to encode and decode values of type [T] + */ +public expect class GrpcMetadataKey public constructor(name: String, codec: MessageCodec) {} /** * Returns the last metadata entry added with the given [key], or `null` if there are no entries. @@ -64,7 +80,18 @@ public expect class GrpcMetadataKey internal constructor(name: String, codec: */ public expect operator fun GrpcMetadata.get(key: String): String? -public expect fun GrpcMetadata.get(key: GrpcMetadataKey): T? +/** + * Returns the last metadata entry added with the given typed [key], or `null` if there are no entries. + * + * The value is decoded using the codec associated with the key. + * The codec must encode values as ASCII strings (not raw bytes). + * + * @param T the type of value associated with the key + * @param key the typed metadata key. The key name must not end with `-bin`. + * @return the last value associated with the key, decoded using the key's codec, or `null` if no values exist + * @throws IllegalArgumentException if the key name ends with `-bin` or contains invalid characters + */ +public expect operator fun GrpcMetadata.get(key: GrpcMetadataKey): T? /** * Returns the last binary metadata entry added with the given [key], or `null` if there are no entries. @@ -79,6 +106,17 @@ public expect fun GrpcMetadata.get(key: GrpcMetadataKey): T? */ public expect fun GrpcMetadata.getBinary(key: String): ByteArray? +/** + * Returns the last binary metadata entry added with the given typed [key], or `null` if there are no entries. + * + * Binary keys must end with the "-bin" suffix according to gRPC specification. The value is + * decoded using the codec associated with the key, which encodes values as raw bytes. + * + * @param T the type of value associated with the key + * @param key the typed metadata key. The key name must end with `-bin`. + * @return the last binary value associated with the key, decoded using the key's codec, or `null` if no values exist + * @throws IllegalArgumentException if the key name does not end with `-bin` or contains invalid characters + */ public expect fun GrpcMetadata.getBinary(key: GrpcMetadataKey): T? /** @@ -91,6 +129,18 @@ public expect fun GrpcMetadata.getBinary(key: GrpcMetadataKey): T? * @throws IllegalArgumentException if the key ends with `-bin` or contains invalid characters */ public expect fun GrpcMetadata.getAll(key: String): List + +/** + * Returns all metadata entries with the given typed [key], in the order they were added. + * + * Each value is decoded using the codec associated with the key. The codec must encode values + * as ASCII strings (not raw bytes). + * + * @param T the type of values associated with the key + * @param key the typed metadata key. The key name must not end with `-bin`. + * @return a list of all values associated with the key, decoded using the key's codec, or an empty list if no values exist + * @throws IllegalArgumentException if the key name ends with `-bin` or contains invalid characters + */ public expect fun GrpcMetadata.getAll(key: GrpcMetadataKey): List /** @@ -105,6 +155,18 @@ public expect fun GrpcMetadata.getAll(key: GrpcMetadataKey): List * @throws IllegalArgumentException if the key does not end with `-bin` or contains invalid characters */ public expect fun GrpcMetadata.getAllBinary(key: String): List + +/** + * Returns all binary metadata entries with the given typed [key], in the order they were added. + * + * Binary keys must end with the "-bin" suffix according to gRPC specification. Each value is + * decoded using the codec associated with the key, which encodes values as raw bytes. + * + * @param T the type of values associated with the key + * @param key the typed metadata key. The key name must end with `-bin`. + * @return a list of all binary values associated with the key, decoded using the key's codec, or an empty list if no values exist + * @throws IllegalArgumentException if the key name does not end with `-bin` or contains invalid characters + */ public expect fun GrpcMetadata.getAllBinary(key: GrpcMetadataKey): List /** @@ -138,6 +200,19 @@ public expect operator fun GrpcMetadata.contains(key: String): Boolean * @throws IllegalArgumentException if the key contains invalid characters or ends with `-bin` */ public expect fun GrpcMetadata.append(key: String, value: String) + +/** + * Appends a metadata entry with the given typed [key] and [value]. + * + * The value is encoded using the codec associated with the key. The codec must encode values + * as ASCII strings (not raw bytes). If the key already has values, the new value is added to + * the end of the list. Duplicate values for the same key are permitted. + * + * @param T the type of value associated with the key + * @param key the typed metadata key. The key name must not end with `-bin`. + * @param value the value to add, which will be encoded using the key's codec + * @throws IllegalArgumentException if the key name contains invalid characters or ends with `-bin` + */ public expect fun GrpcMetadata.append(key: GrpcMetadataKey, value: T) /** @@ -153,6 +228,20 @@ public expect fun GrpcMetadata.append(key: GrpcMetadataKey, value: T) * @throws IllegalArgumentException if the key contains invalid characters or does not end with `-bin` */ public expect fun GrpcMetadata.appendBinary(key: String, value: ByteArray) + +/** + * Appends a binary metadata entry with the given typed [key] and [value]. + * + * Binary keys must end with the "-bin" suffix according to gRPC specification. The value is + * encoded using the codec associated with the key, which encodes values as raw bytes. If the + * key already has values, the new value is added to the end of the list. Duplicate values for + * the same key are permitted. + * + * @param T the type of value associated with the key + * @param key the typed metadata key. The key name must end with `-bin`. + * @param value the value to add, which will be encoded using the key's codec + * @throws IllegalArgumentException if the key name contains invalid characters or does not end with `-bin` + */ public expect fun GrpcMetadata.appendBinary(key: GrpcMetadataKey, value: T) /** @@ -166,12 +255,26 @@ public expect fun GrpcMetadata.appendBinary(key: GrpcMetadataKey, value: * @throws IllegalArgumentException if the key ends with `-bin` or contains invalid characters */ public expect fun GrpcMetadata.remove(key: String, value: String): Boolean + +/** + * Removes the first occurrence of the specified [value] for the given typed [key]. + * + * The value is compared using the decoded form (after decoding with the key's codec). + * The codec must encode values as ASCII strings (not raw bytes). + * + * @param T the type of value associated with the key + * @param key the typed metadata key. The key name must not end with `-bin`. + * @param value the value to remove + * @return `true` if the value was found and removed, `false` if the value was not present + * @throws IllegalArgumentException if the key name ends with `-bin` or contains invalid characters + */ public expect fun GrpcMetadata.remove(key: GrpcMetadataKey, value: T): Boolean /** * Removes the first occurrence of the specified binary [value] for the given [key]. * * Binary keys must end with the "-bin" suffix according to gRPC specification. + * The value is compared by reference, not by content. * * @param key the name of the binary metadata entry (case-insensitive). * Must contain only digits (0-9), lowercase letters (a-z), and special characters (`-`, `_`, `.`). @@ -181,6 +284,20 @@ public expect fun GrpcMetadata.remove(key: GrpcMetadataKey, value: T): Bo * @throws IllegalArgumentException if the key does not end with `-bin` */ public expect fun GrpcMetadata.removeBinary(key: String, value: ByteArray): Boolean + +/** + * Removes the first occurrence of the specified binary [value] for the given typed [key]. + * + * Binary keys must end with the "-bin" suffix according to gRPC specification. The value is + * compared using the decoded form (after decoding with the key's codec), which encodes values + * as raw bytes. + * + * @param T the type of value associated with the key + * @param key the typed metadata key. The key name must end with `-bin`. + * @param value the binary value to remove + * @return `true` if the value was found and removed, `false` if the value was not present + * @throws IllegalArgumentException if the key name does not end with `-bin` or contains invalid characters + */ public expect fun GrpcMetadata.removeBinary(key: GrpcMetadataKey, value: T): Boolean /** @@ -193,6 +310,18 @@ public expect fun GrpcMetadata.removeBinary(key: GrpcMetadataKey, value: * @throws IllegalArgumentException if the key ends with `-bin` */ public expect fun GrpcMetadata.removeAll(key: String): List + +/** + * Removes all values for the given typed [key] and returns them. + * + * Each value is decoded using the codec associated with the key. The codec must encode values + * as ASCII strings (not raw bytes). + * + * @param T the type of values associated with the key + * @param key the typed metadata key. The key name must not end with `-bin`. + * @return a list of all values that were removed, decoded using the key's codec, or an empty list if there were no values + * @throws IllegalArgumentException if the key name ends with `-bin` or contains invalid characters + */ public expect fun GrpcMetadata.removeAll(key: GrpcMetadataKey): List /** diff --git a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/MetadataTest.kt b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/MetadataTest.kt index 7d8414297..3bdd16873 100644 --- a/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/MetadataTest.kt +++ b/grpc/grpc-core/src/commonTest/kotlin/kotlinx/rpc/grpc/test/proto/MetadataTest.kt @@ -8,11 +8,15 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.test.runTest import kotlinx.io.Buffer import kotlinx.io.readByteArray +import kotlinx.io.readString +import kotlinx.io.writeString import kotlinx.rpc.RpcServer import kotlinx.rpc.grpc.GrpcMetadata +import kotlinx.rpc.grpc.GrpcMetadataKey import kotlinx.rpc.grpc.append import kotlinx.rpc.grpc.appendBinary import kotlinx.rpc.grpc.client.GrpcClient +import kotlinx.rpc.grpc.codec.MessageCodec import kotlinx.rpc.grpc.contains import kotlinx.rpc.grpc.copy import kotlinx.rpc.grpc.get @@ -25,11 +29,15 @@ import kotlinx.rpc.grpc.remove import kotlinx.rpc.grpc.removeAll import kotlinx.rpc.grpc.removeAllBinary import kotlinx.rpc.grpc.removeBinary +import kotlinx.rpc.grpc.test.AllPrimitives +import kotlinx.rpc.grpc.test.AllPrimitivesInternal import kotlinx.rpc.grpc.test.EchoRequest import kotlinx.rpc.grpc.test.EchoRequestInternal import kotlinx.rpc.grpc.test.EchoService import kotlinx.rpc.grpc.test.EchoServiceImpl import kotlinx.rpc.grpc.test.invoke +import kotlinx.rpc.internal.utils.ExperimentalRpcApi +import kotlinx.rpc.protobuf.input.stream.InputStream import kotlinx.rpc.protobuf.input.stream.asInputStream import kotlinx.rpc.protobuf.input.stream.asSource import kotlinx.rpc.registerService @@ -38,6 +46,7 @@ import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertTrue class MetadataTest : GrpcProtoTest() { @@ -420,4 +429,691 @@ class MetadataTest : GrpcProtoTest() { assertEquals("Echo", response.message) } + // Helper codecs and types for testing typed keys + + // Custom test type for ASCII encoding (non-binary methods) + data class TestUser(val name: String, val age: Int) + + // ASCII codec - encodes to/from ASCII string (for non-binary methods) + @OptIn(ExperimentalRpcApi::class) + private object TestUserAsciiCodec : MessageCodec { + override fun encode(value: TestUser): InputStream { + // Encode as ASCII string in format "name:age" + val asciiString = "${value.name}:${value.age}" + return Buffer().apply { writeString(asciiString) }.asInputStream() + } + + override fun decode(stream: InputStream): TestUser { + // Decode from ASCII string + val asciiString = Buffer().apply { transferFrom(stream.asSource()) }.readString() + val parts = asciiString.split(":") + return TestUser(parts[0], parts[1].toInt()) + } + } + + // Tests for typed key methods + // Testing ASCII methods: get, getAll, append, remove, removeAll + + @Test + fun `test typed key get returns last value`() { + val key = GrpcMetadataKey("my-key", TestUserAsciiCodec) + val metadata = GrpcMetadata().apply { + append(key, TestUser("Alice", 30)) + append(key, TestUser("Bob", 25)) + append(key, TestUser("Charlie", 35)) + } + + assertEquals(TestUser("Charlie", 35), metadata.get(key)) + } + + @Test + fun `test typed key get returns null for non-existent key`() { + val key = GrpcMetadataKey("my-key", TestUserAsciiCodec) + val metadata = GrpcMetadata() + + assertEquals(null, metadata.get(key)) + } + + @Test + fun `test typed key get works with multiple keys`() { + val key1 = GrpcMetadataKey("user-key", TestUserAsciiCodec) + val key2 = GrpcMetadataKey("other-key", TestUserAsciiCodec) + val metadata = GrpcMetadata().apply { + append(key1, TestUser("Alice", 30)) + append(key2, TestUser("Bob", 25)) + } + + assertEquals(TestUser("Alice", 30), metadata.get(key1)) + assertEquals(TestUser("Bob", 25), metadata.get(key2)) + } + + @Test + fun `test typed key getAll returns all values in order`() { + val key = GrpcMetadataKey("my-key", TestUserAsciiCodec) + val metadata = GrpcMetadata().apply { + append(key, TestUser("Alice", 30)) + append(key, TestUser("Bob", 25)) + append(key, TestUser("Charlie", 35)) + } + + assertEquals( + listOf(TestUser("Alice", 30), TestUser("Bob", 25), TestUser("Charlie", 35)), + metadata.getAll(key) + ) + } + + @Test + fun `test typed key getAll returns empty list for non-existent key`() { + val key = GrpcMetadataKey("my-key", TestUserAsciiCodec) + val metadata = GrpcMetadata() + + assertEquals(emptyList(), metadata.getAll(key)) + } + + @Test + fun `test typed key append adds value`() { + val key = GrpcMetadataKey("my-key", TestUserAsciiCodec) + val metadata = GrpcMetadata() + + metadata.append(key, TestUser("Alice", 30)) + + assertEquals(TestUser("Alice", 30), metadata.get(key)) + assertEquals(listOf(TestUser("Alice", 30)), metadata.getAll(key)) + } + + @Test + fun `test typed key append adds multiple values in order`() { + val key = GrpcMetadataKey("my-key", TestUserAsciiCodec) + val metadata = GrpcMetadata() + + metadata.append(key, TestUser("Alice", 30)) + metadata.append(key, TestUser("Bob", 25)) + metadata.append(key, TestUser("Charlie", 35)) + + assertEquals( + listOf(TestUser("Alice", 30), TestUser("Bob", 25), TestUser("Charlie", 35)), + metadata.getAll(key) + ) + } + + @Test + fun `test typed key append allows duplicate values`() { + val key = GrpcMetadataKey("my-key", TestUserAsciiCodec) + val metadata = GrpcMetadata() + val user = TestUser("Alice", 30) + + metadata.append(key, user) + metadata.append(key, user) + metadata.append(key, user) + + assertEquals(listOf(user, user, user), metadata.getAll(key)) + } + + @Test + fun `test typed key remove removes first occurrence`() { + val key = GrpcMetadataKey("my-key", TestUserAsciiCodec) + val alice = TestUser("Alice", 30) + val bob = TestUser("Bob", 25) + val metadata = GrpcMetadata().apply { + append(key, alice) + append(key, bob) + append(key, alice) + } + + val result = metadata.remove(key, alice) + + assertTrue(result) + assertEquals(listOf(bob, alice), metadata.getAll(key)) + } + + @Test + fun `test typed key remove returns false for non-existent value`() { + val key = GrpcMetadataKey("my-key", TestUserAsciiCodec) + val metadata = GrpcMetadata().apply { + append(key, TestUser("Alice", 30)) + } + + val result = metadata.remove(key, TestUser("Bob", 25)) + + assertFalse(result) + assertEquals(listOf(TestUser("Alice", 30)), metadata.getAll(key)) + } + + @Test + fun `test typed key remove returns false for non-existent key`() { + val key = GrpcMetadataKey("my-key", TestUserAsciiCodec) + val metadata = GrpcMetadata() + + val result = metadata.remove(key, TestUser("Alice", 30)) + + assertFalse(result) + } + + @Test + fun `test typed key removeAll removes all values and returns them`() { + val key = GrpcMetadataKey("my-key", TestUserAsciiCodec) + val users = listOf(TestUser("Alice", 30), TestUser("Bob", 25), TestUser("Charlie", 35)) + val metadata = GrpcMetadata().apply { + users.forEach { append(key, it) } + } + + val removed = metadata.removeAll(key) + + assertEquals(users, removed) + assertEquals(emptyList(), metadata.getAll(key)) + assertEquals(null, metadata.get(key)) + } + + @Test + fun `test typed key removeAll returns empty list for non-existent key`() { + val key = GrpcMetadataKey("my-key", TestUserAsciiCodec) + val metadata = GrpcMetadata() + + val removed = metadata.removeAll(key) + + assertEquals(emptyList(), removed) + } + + // Tests for typed binary key methods: getBinary, getAllBinary, appendBinary, removeBinary, removeAllBinary + + @Test + fun `test typed binary key getBinary returns last value`() { + val key = GrpcMetadataKey("my-key-bin", AllPrimitivesInternal.CODEC) + val metadata = GrpcMetadata().apply { + appendBinary(key, AllPrimitives { int32 = 1; string = "first" }) + appendBinary(key, AllPrimitives { int32 = 2; string = "second" }) + appendBinary(key, AllPrimitives { int32 = 3; string = "third" }) + } + + val result = metadata.getBinary(key) + assertEquals(3, result?.int32) + assertEquals("third", result?.string) + } + + @Test + fun `test typed binary key getBinary returns null for non-existent key`() { + val key = GrpcMetadataKey("my-key-bin", AllPrimitivesInternal.CODEC) + val metadata = GrpcMetadata() + + assertEquals(null, metadata.getBinary(key)) + } + + @Test + fun `test typed binary key getBinary works with different data`() { + val key = GrpcMetadataKey("data-bin", AllPrimitivesInternal.CODEC) + val metadata = GrpcMetadata().apply { + appendBinary(key, AllPrimitives { + int32 = 42 + int64 = 123456789L + double = 3.14 + string = "test" + bool = true + }) + } + + val result = metadata.getBinary(key)!! + assertEquals(42, result.int32) + assertEquals(123456789L, result.int64) + assertEquals(3.14, result.double) + assertEquals("test", result.string) + assertEquals(true, result.bool) + } + + @Test + fun `test typed binary key getAllBinary returns all values in order`() { + val key = GrpcMetadataKey("my-key-bin", AllPrimitivesInternal.CODEC) + val metadata = GrpcMetadata().apply { + appendBinary(key, AllPrimitives { int32 = 1 }) + appendBinary(key, AllPrimitives { int32 = 2 }) + appendBinary(key, AllPrimitives { int32 = 3 }) + } + + val results = metadata.getAllBinary(key) + assertEquals(3, results.size) + assertEquals(1, results[0].int32) + assertEquals(2, results[1].int32) + assertEquals(3, results[2].int32) + } + + @Test + fun `test typed binary key getAllBinary returns empty list for non-existent key`() { + val key = GrpcMetadataKey("my-key-bin", AllPrimitivesInternal.CODEC) + val metadata = GrpcMetadata() + + assertEquals(emptyList(), metadata.getAllBinary(key)) + } + + @Test + fun `test typed binary key appendBinary adds value`() { + val key = GrpcMetadataKey("my-key-bin", AllPrimitivesInternal.CODEC) + val metadata = GrpcMetadata() + val value = AllPrimitives { int32 = 42; string = "test" } + + metadata.appendBinary(key, value) + + val result = metadata.getBinary(key)!! + assertEquals(42, result.int32) + assertEquals("test", result.string) + } + + @Test + fun `test typed binary key appendBinary adds multiple values in order`() { + val key = GrpcMetadataKey("my-key-bin", AllPrimitivesInternal.CODEC) + val metadata = GrpcMetadata() + + metadata.appendBinary(key, AllPrimitives { int32 = 1 }) + metadata.appendBinary(key, AllPrimitives { int32 = 2 }) + metadata.appendBinary(key, AllPrimitives { int32 = 3 }) + + val results = metadata.getAllBinary(key) + assertEquals(listOf(1, 2, 3), results.map { it.int32 }) + } + + @Test + fun `test typed binary key appendBinary with complex data`() { + val key = GrpcMetadataKey("complex-bin", AllPrimitivesInternal.CODEC) + val metadata = GrpcMetadata() + + val complexValue = AllPrimitives { + double = 1.23 + float = 4.56f + int32 = 789 + int64 = 123456789012345L + uint32 = 111u + uint64 = 222uL + bool = true + string = "complex data" + bytes = byteArrayOf(1, 2, 3, 4, 5) + } + + metadata.appendBinary(key, complexValue) + + val result = metadata.getBinary(key)!! + assertEquals(1.23, result.double) + assertEquals(4.56f, result.float) + assertEquals(789, result.int32) + assertEquals(123456789012345L, result.int64) + assertEquals(111u, result.uint32) + assertEquals(222uL, result.uint64) + assertEquals(true, result.bool) + assertEquals("complex data", result.string) + assertContentEquals(byteArrayOf(1, 2, 3, 4, 5), result.bytes) + } + + @Test + fun `test typed binary key removeBinary removes first occurrence`() { + val key = GrpcMetadataKey("my-key-bin", AllPrimitivesInternal.CODEC) + val value1 = AllPrimitives { int32 = 1 } + val value2 = AllPrimitives { int32 = 2 } + val metadata = GrpcMetadata().apply { + appendBinary(key, value1) + appendBinary(key, value2) + appendBinary(key, value1) + } + + val result = metadata.removeBinary(key, value1) + + assertTrue(result) + val remaining = metadata.getAllBinary(key) + assertEquals(2, remaining.size) + assertEquals(2, remaining[0].int32) + assertEquals(1, remaining[1].int32) + } + + @Test + fun `test typed binary key removeBinary returns false for non-existent value`() { + val key = GrpcMetadataKey("my-key-bin", AllPrimitivesInternal.CODEC) + val metadata = GrpcMetadata().apply { + appendBinary(key, AllPrimitives { int32 = 1 }) + } + + val result = metadata.removeBinary(key, AllPrimitives { int32 = 2 }) + + assertFalse(result) + } + + @Test + fun `test typed binary key removeBinary returns false for non-existent key`() { + val key = GrpcMetadataKey("my-key-bin", AllPrimitivesInternal.CODEC) + val metadata = GrpcMetadata() + + val result = metadata.removeBinary(key, AllPrimitives { int32 = 1 }) + + assertFalse(result) + } + + @Test + fun `test typed binary key removeAllBinary removes all values and returns them`() { + val key = GrpcMetadataKey("my-key-bin", AllPrimitivesInternal.CODEC) + val metadata = GrpcMetadata().apply { + appendBinary(key, AllPrimitives { int32 = 1 }) + appendBinary(key, AllPrimitives { int32 = 2 }) + appendBinary(key, AllPrimitives { int32 = 3 }) + } + + val removed = metadata.removeAllBinary(key) + + assertEquals(3, removed.size) + assertEquals(listOf(1, 2, 3), removed.map { it.int32 }) + assertEquals(emptyList(), metadata.getAllBinary(key)) + assertEquals(null, metadata.getBinary(key)) + } + + @Test + fun `test typed binary key removeAllBinary returns empty list for non-existent key`() { + val key = GrpcMetadataKey("my-key-bin", AllPrimitivesInternal.CODEC) + val metadata = GrpcMetadata() + + val removed = metadata.removeAllBinary(key) + + assertEquals(emptyList(), removed) + } + + // Integration tests + + @Test + fun `test typed key case insensitivity`() { + val key1 = GrpcMetadataKey("My-Key", TestUserAsciiCodec) + val key2 = GrpcMetadataKey("my-key", TestUserAsciiCodec) + val key3 = GrpcMetadataKey("MY-KEY", TestUserAsciiCodec) + val metadata = GrpcMetadata() + val user = TestUser("Alice", 30) + + metadata.append(key1, user) + + // All case variations should access the same key + assertEquals(user, metadata.get(key2)) + assertEquals(user, metadata.get(key3)) + assertEquals(listOf(user), metadata.getAll(key1)) + assertEquals(listOf(user), metadata.getAll(key2)) + assertEquals(listOf(user), metadata.getAll(key3)) + } + + @Test + fun `test typed binary key case insensitivity`() { + val key1 = GrpcMetadataKey("My-Key-bin", AllPrimitivesInternal.CODEC) + val key2 = GrpcMetadataKey("my-key-bin", AllPrimitivesInternal.CODEC) + val key3 = GrpcMetadataKey("MY-KEY-bin", AllPrimitivesInternal.CODEC) + val metadata = GrpcMetadata() + val value = AllPrimitives { int32 = 42 } + + metadata.appendBinary(key1, value) + + // All case variations should access the same key + assertEquals(42, metadata.getBinary(key2)?.int32) + assertEquals(42, metadata.getBinary(key3)?.int32) + } + + @Test + fun `test typed key with copy operation`() { + val key = GrpcMetadataKey("my-key", TestUserAsciiCodec) + val original = GrpcMetadata().apply { + append(key, TestUser("Alice", 30)) + append(key, TestUser("Bob", 25)) + } + + val copied = original.copy() + + assertEquals( + listOf(TestUser("Alice", 30), TestUser("Bob", 25)), + copied.getAll(key) + ) + + // Verify independence + copied.append(key, TestUser("Charlie", 35)) + assertEquals(2, original.getAll(key).size) + assertEquals(3, copied.getAll(key).size) + } + + @Test + fun `test typed binary key with copy operation`() { + val key = GrpcMetadataKey("my-key-bin", AllPrimitivesInternal.CODEC) + val original = GrpcMetadata().apply { + appendBinary(key, AllPrimitives { int32 = 1 }) + appendBinary(key, AllPrimitives { int32 = 2 }) + } + + val copied = original.copy() + + assertEquals(listOf(1, 2), copied.getAllBinary(key).map { it.int32 }) + + // Verify independence + copied.appendBinary(key, AllPrimitives { int32 = 3 }) + assertEquals(2, original.getAllBinary(key).size) + assertEquals(3, copied.getAllBinary(key).size) + } + + @Test + fun `test typed key with merge operation`() { + val key = GrpcMetadataKey("my-key", TestUserAsciiCodec) + val md1 = GrpcMetadata().apply { + append(key, TestUser("Alice", 30)) + } + val md2 = GrpcMetadata().apply { + append(key, TestUser("Bob", 25)) + } + + val merged = md1 + md2 + + assertEquals( + listOf(TestUser("Alice", 30), TestUser("Bob", 25)), + merged.getAll(key) + ) + assertEquals(1, md1.getAll(key).size) + assertEquals(1, md2.getAll(key).size) + } + + @Test + fun `test typed binary key with merge operation`() { + val key = GrpcMetadataKey("my-key-bin", AllPrimitivesInternal.CODEC) + val md1 = GrpcMetadata().apply { + appendBinary(key, AllPrimitives { int32 = 1 }) + } + val md2 = GrpcMetadata().apply { + appendBinary(key, AllPrimitives { int32 = 2 }) + } + + val merged = md1 + md2 + + assertEquals(listOf(1, 2), merged.getAllBinary(key).map { it.int32 }) + assertEquals(1, md1.getAllBinary(key).size) + assertEquals(1, md2.getAllBinary(key).size) + } + + @Test + fun `test typed key preserves value order across operations`() { + val key = GrpcMetadataKey("my-key", TestUserAsciiCodec) + val metadata = GrpcMetadata() + + // Add values in specific order + for (i in 1..5) { + metadata.append(key, TestUser("User$i", 20 + i)) + } + + // Verify order is preserved + val values = metadata.getAll(key) + assertEquals(5, values.size) + for (i in 1..5) { + assertEquals("User$i", values[i - 1].name) + assertEquals(20 + i, values[i - 1].age) + } + + // Remove first occurrence of User3 + metadata.remove(key, TestUser("User3", 23)) + + val afterRemove = metadata.getAll(key) + assertEquals(4, afterRemove.size) + assertEquals("User1", afterRemove[0].name) + assertEquals("User2", afterRemove[1].name) + assertEquals("User4", afterRemove[2].name) + assertEquals("User5", afterRemove[3].name) + } + + @Test + fun `test typed binary key preserves value order across operations`() { + val key = GrpcMetadataKey("my-key-bin", AllPrimitivesInternal.CODEC) + val metadata = GrpcMetadata() + + // Add values in specific order + for (i in 1..5) { + metadata.appendBinary(key, AllPrimitives { int32 = i; int64 = i.toLong() * 100 }) + } + + // Verify order is preserved + val values = metadata.getAllBinary(key) + assertEquals(5, values.size) + for (i in 1..5) { + assertEquals(i, values[i - 1].int32) + assertEquals(i.toLong() * 100, values[i - 1].int64) + } + + // Remove value with int32 = 3 + metadata.removeBinary(key, AllPrimitives { int32 = 3; int64 = 300L }) + + val afterRemove = metadata.getAllBinary(key) + assertEquals(4, afterRemove.size) + } + + @Test + fun `test send and receive typed key headers and trailers`() = runTest { + // Define typed keys for ASCII metadata + val userKey = GrpcMetadataKey("user-metadata", TestUserAsciiCodec) + val multiUserKey = GrpcMetadataKey("multi-user-metadata", TestUserAsciiCodec) + + // Define typed keys for binary metadata + val dataBinKey = GrpcMetadataKey("data-metadata-bin", AllPrimitivesInternal.CODEC) + val multiDataBinKey = GrpcMetadataKey("multi-data-metadata-bin", AllPrimitivesInternal.CODEC) + + var clientHeaders: GrpcMetadata? = null + val responseHeadersDef = CompletableDeferred() + val responseTrailersDef = CompletableDeferred() + + // Helper function to append typed metadata + fun GrpcMetadata.appendTypedMetadata() { + // Add single ASCII value + append(userKey, TestUser("Alice", 30)) + + // Add multiple ASCII values + append(multiUserKey, TestUser("Bob", 25)) + append(multiUserKey, TestUser("Charlie", 35)) + append(multiUserKey, TestUser("David", 28)) + + // Add single binary value with complex data + appendBinary(dataBinKey, AllPrimitives { + int32 = 42 + int64 = 9876543210L + double = 3.14159 + float = 2.718f + bool = true + string = "Complex metadata" + bytes = byteArrayOf(10, 20, 30, 40, 50) + uint32 = 100u + uint64 = 200uL + sint32 = -50 + sint64 = -1000L + fixed32 = 777u + fixed64 = 888uL + sfixed32 = -99 + sfixed64 = -999L + }) + + // Add multiple binary values + appendBinary(multiDataBinKey, AllPrimitives { + int32 = 1 + string = "First binary" + double = 1.1 + }) + appendBinary(multiDataBinKey, AllPrimitives { + int32 = 2 + string = "Second binary" + double = 2.2 + }) + appendBinary(multiDataBinKey, AllPrimitives { + int32 = 3 + string = "Third binary" + double = 3.3 + }) + } + + runGrpcTest( + clientInterceptors = clientInterceptor { + requestHeaders.appendTypedMetadata() + onHeaders { headers -> responseHeadersDef.complete(headers) } + onClose { _, trailers -> responseTrailersDef.complete(trailers) } + proceed(it) + }, + serverInterceptors = serverInterceptor { + responseHeaders.appendTypedMetadata() + responseTrailers.appendTypedMetadata() + clientHeaders = this.requestHeaders + proceed(it) + } + ) { unaryEcho(it) } + + // Helper function to assert typed metadata + fun GrpcMetadata.assertTypedMetadata() { + // Verify single ASCII value + val user = get(userKey) + assertEquals("Alice", user?.name) + assertEquals(30, user?.age) + + // Verify multiple ASCII values + val multiUsers = getAll(multiUserKey) + assertEquals(3, multiUsers.size) + assertEquals(TestUser("Bob", 25), multiUsers[0]) + assertEquals(TestUser("Charlie", 35), multiUsers[1]) + assertEquals(TestUser("David", 28), multiUsers[2]) + + // Verify last multi-user is returned by get() + val lastMultiUser = get(multiUserKey) + assertEquals(TestUser("David", 28), lastMultiUser) + + // Verify single binary value with all fields + val data = getBinary(dataBinKey)!! + assertEquals(42, data.int32) + assertEquals(9876543210L, data.int64) + assertEquals(3.14159, data.double) + assertEquals(2.718f, data.float) + assertEquals(true, data.bool) + assertEquals("Complex metadata", data.string) + assertContentEquals(byteArrayOf(10, 20, 30, 40, 50), data.bytes) + assertEquals(100u, data.uint32) + assertEquals(200uL, data.uint64) + assertEquals(-50, data.sint32) + assertEquals(-1000L, data.sint64) + assertEquals(777u, data.fixed32) + assertEquals(888uL, data.fixed64) + assertEquals(-99, data.sfixed32) + assertEquals(-999L, data.sfixed64) + + // Verify multiple binary values + val multiData = getAllBinary(multiDataBinKey) + assertEquals(3, multiData.size) + assertEquals(1, multiData[0].int32) + assertEquals("First binary", multiData[0].string) + assertEquals(1.1, multiData[0].double) + assertEquals(2, multiData[1].int32) + assertEquals("Second binary", multiData[1].string) + assertEquals(2.2, multiData[1].double) + assertEquals(3, multiData[2].int32) + assertEquals("Third binary", multiData[2].string) + assertEquals(3.3, multiData[2].double) + + // Verify last multi-data is returned by getBinary() + val lastMultiData = getBinary(multiDataBinKey)!! + assertEquals(3, lastMultiData.int32) + assertEquals("Third binary", lastMultiData.string) + assertEquals(3.3, lastMultiData.double) + } + + // Assert client request headers received by server + clientHeaders!!.assertTypedMetadata() + + // Assert response headers received by client + responseHeadersDef.await().assertTypedMetadata() + + // Assert response trailers received by client + responseTrailersDef.await().assertTypedMetadata() + } + } \ No newline at end of file From 590aa4c54684b747b9973c4702ad5a64cdad0aeb Mon Sep 17 00:00:00 2001 From: Johannes Zottele Date: Tue, 28 Oct 2025 09:56:07 +0100 Subject: [PATCH 6/6] grpc: Extend Metadata API and docs --- .../kotlin/kotlinx/rpc/grpc/GrpcMetadata.jvm.kt | 4 ++-- .../kotlin/kotlinx/rpc/grpc/GrpcMetadata.native.kt | 10 +++------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.jvm.kt b/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.jvm.kt index 00180e1e6..323d9eae0 100644 --- a/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.jvm.kt +++ b/grpc/grpc-core/src/jvmMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.jvm.kt @@ -13,7 +13,7 @@ import kotlinx.rpc.internal.utils.InternalRpcApi @InternalRpcApi public actual typealias GrpcMetadata = io.grpc.Metadata -public actual class GrpcMetadataKey internal actual constructor( +public actual class GrpcMetadataKey public actual constructor( private val name: String, private val codec: MessageCodec, ) { @@ -54,7 +54,7 @@ public actual operator fun GrpcMetadata.get(key: String): String? { return get(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER)) } -public actual fun GrpcMetadata.get(key: GrpcMetadataKey): T? { +public actual operator fun GrpcMetadata.get(key: GrpcMetadataKey): T? { return get(key.toAsciiKey()) } diff --git a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.native.kt b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.native.kt index 2c9590382..f25c89249 100644 --- a/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.native.kt +++ b/grpc/grpc-core/src/nativeMain/kotlin/kotlinx/rpc/grpc/GrpcMetadata.native.kt @@ -33,12 +33,8 @@ import libkgrpc.grpc_slice_unref import libkgrpc.kgrpc_metadata_array_append import kotlin.experimental.ExperimentalNativeApi -public actual class GrpcMetadataKey actual constructor(public var name: String, public val codec: MessageCodec) { - - init { - name = name.lowercase() - } - +public actual class GrpcMetadataKey actual constructor(name: String, public val codec: MessageCodec) { + public val name: String = name.lowercase() internal val isBinary get() = name.endsWith("-bin") internal fun encode(value: T): ByteArray = codec.encode(value).buffer.readByteArray() @@ -115,7 +111,7 @@ public actual operator fun GrpcMetadata.get(key: String): String? { return get(key.toAsciiKey()) } -public actual fun GrpcMetadata.get(key: GrpcMetadataKey): T? { +public actual operator fun GrpcMetadata.get(key: GrpcMetadataKey): T? { key.validateForString() return map[key.name]?.lastOrNull()?.let { key.decode(it)