A Nim implementation of Protocol Buffers 3 (proto3) with support for parsing .proto files, generating Nim code, serializing/deserializing data in both binary (protobuf wire format) and JSON formats, and gRPC server/client.
✅ Full Proto3 Syntax Support - Messages, enums, nested types, maps, repeated fields, oneofs, services
✅ Compile-time Code Generation - Use the importProto3/proto3 macro to generate Nim types at compile time
✅ Runtime Code Generation - Parse and generate code from proto files or strings at runtime
✅ Binary Serialization - toBinary/fromBinary for protobuf wire format
✅ JSON Serialization - toJson/fromJson for JSON representation
✅ Import Resolution - Automatically resolves and processes imported .proto files
✅ CLI Tool - protonim command-line tool for standalone code generation
nimble install nimproto3 ## Quick Start
The easiest way to use proto3 in your Nim projects is with the importProto3 macro, which generates Nim types and gRPC client stubs at compile time.
Step 1: Create a `.proto` file
syntax = "proto3";
service UserService {
rpc GetUser(UserRequest) returns (User) {};
rpc ListUsers(stream UserRequest) returns (stream User) {};
}
message UserRequest {
int32 id = 1;
}
message User {
string name = 1;
int32 id = 2;
repeated string emails = 3;
map<string, int32> scores = 4;
}
Step 2: Import and use in your Nim code (server/client)
import nimproto3
# Import the proto file - generates types and gRPC stubs at compile time
importProto3 currentSourcePath.parentDir & "/user_service.proto" # full path to the proto file
# importProto3/proto3 macro generates the following types and procs:
# Types:
# - User = object
# - UserRequest = object
# Serialization procs:
# - proc toBinary*(self: User): seq[byte]
# - proc fromBinary*(T: typedesc[User], data: openArray[byte]): User
# - proc toJson*(self: User): JsonNode
# - proc fromJson*(T: typedesc[User], node: JsonNode): User
# gRPC client stubs:
# - proc getUser*(c: GrpcChannel, req: UserRequest, metadata: seq[HpackHeader] = @[]): Future[User]
# - proc listUsers*(c: GrpcChannel, reqs: seq[UserRequest]): Future[seq[User]]
proc handleGetUser(stream: GrpcStream) {.async.} =
# 1. Read Request (Unary = Read 1 message)
let msgOpt = await stream.recvMsg()
if msgOpt.isNone:
# Client closed without sending data?
return
let input = msgOpt.get()
let req = UserRequest.fromBinary(input)
# Demonstrate reading Metadata (Headers)
let auth = stream.headers.getOrDefault("authorization", "none")
echo "[Service] Received: ", req, " | Auth: ", auth
# 2. Logic
let reply = User(
name: "Alice",
id: 42,
emails: @["[email protected]", "[email protected]"],
scores: {"math": 95.int32, "science": 88.int32}.toTable
)
# 3. Send Response (Unary = Send 1 message)
await stream.sendMsg(reply.toBinary())
# When this async proc finishes, the server automatically closes the stream
# and sends the Trailing Headers (Status: OK).
# Example of a Server Streaming handler (returning multiple items)
proc handleListUsers(stream: GrpcStream) {.async.} =
# Read incoming requests loop (Bidirectional or Client Stream)
while true:
let msgOpt = await stream.recvMsg()
if msgOpt.isNone: break # End of Stream
let req = fromBinary(UserRequest, msgOpt.get())
echo "[Service] Stream item: ", req
# Send a reply immediately (Echo)
let reply = User(
name: "Alice",
id: 42,
emails: @["[email protected]", "[email protected]"],
scores: {"math": 95.int32, "science": 88.int32}.toTable
)
await stream.sendMsg(reply.toBinary())
# =============================================================================
# MAIN SERVER
# =============================================================================
when isMainModule:
# Enable server-side compression preference (e.g., Gzip)
let server = newGrpcServer(50051, CompressionGzip)
# Register routes
server.registerHandler("/UserService/GetUser", handleGetUser) # "/package_name.UserService/GetUser" if package_name is defined in the .proto file
server.registerHandler("/UserService/ListUsers", handleListUsers) # "/package_name.UserService/ListUsers" if package_name is defined in the .proto file
echo "Starting gRPC Server (Stream Architecture)..."
waitFor server.serve()
import nimproto3
importProto3 currentSourcePath.parentDir & "/user_service.proto" # full path to the proto file
when isMainModule:
proc runTests() {.async.} =
echo "================================================================================"
echo "Nim gRPC Client (Stream Architecture)"
echo "================================================================================"
# Example 1: Identity + Custom Metadata
let client = newGrpcClient("localhost", 50051, CompressionIdentity)
await client.connect()
await sleepAsync(200) # Wait for settings exchange
echo "\n[TEST 1] Unary Call with Metadata"
try:
# Pass custom authorization header
let meta = @[("authorization", "Bearer my-secret-token")]
let reply = await client.getUser(
UserRequest(id: 1),
metadata = meta
)
echo "Reply: ", reply
except:
echo "Error: ", getCurrentExceptionMsg()
client.close()
waitFor runTests()
nim r -d:showGeneratedProto3Code ./tests/grpc_example/server.nim # -d:showGeneratedProto3Code will show generated code during compile time; # -d:traceGrpc will print out the gRPC network traffic
nim r -d:showGeneratedProto3Code ./tests/grpc_example/client.nim
import nimproto3
# Define schema inline
proto3 """
syntax = "proto3";
package oneofs;
message RpcCall {
string function_name = 1;
repeated Argument args = 2;
int32 call_id = 3;
}
message Argument {
oneof value {
int32 int_val = 1;
bool bool_val = 2;
string string_val = 3;
bytes data_val = 4;
}
}"""
when isMainModule:
import print
# Argument 1: Integer
var arg_1 = Argument(int_val: 1024) # ERROR Here!!
# Argument 2: String
# var arg_2 = Argument(string_val: "Hello Proto!")
# Argument 3: Boolean
# var arg_3 = Argument(bool_val: true)
let caller_msg = RpcCall(
function_name: "add",
# args: @[arg_1, arg_2, arg_3],
args: @[arg_1],
call_id: 101
)
echo caller_msg.toBinary()
let callee_msg = RpcCall.fromBinary(caller_msg.toBinary())
print callee_msg
pb_test1.nim(4, 1) template/generic instantiation of `proto3` from here
(132, 28) Error: type mismatch
Expression: encodeString(s)
[1] s: seq[byte]
Expected one of (first mismatch at [position]):
[1] proc encodeString(value: string): seq[byte]
how to make oneof work? the github doc and test are not vary helpful...
I just added a fix for "bytes".
Oneofs are translated as object variants:
#tmp.nim
import nimproto3
# Define schema inline
proto3 """
syntax = "proto3";
package oneofs;
message RpcCall {
string function_name = 1;
repeated Argument args = 2;
int32 call_id = 3;
}
message Argument {
oneof value {
int32 int_val = 1;
bool bool_val = 2;
string string_val = 3;
bytes data_val = 4;
}
}
"""
when isMainModule:
var arg_1 = Argument(valueKind: rkIntVal, int_val: 1024) # added some more description in README.md
let caller_msg = RpcCall(
function_name: "add",
args: @[arg_1],
call_id: 101
)
echo caller_msg.toBinary()
let callee_msg = RpcCall.fromBinary(caller_msg.toBinary())
echo callee_msg.repr
then run
nim r -d:showGeneratedProto3Code -d:nimOldCaseObjects ./tmp.nim
# here nimOldCaseObjects is needed as current code looks like to change kind in runtime.
results:
@[10, 3, 97, 100, 100, 18, 3, 8, 128, 8, 24, 101]
RpcCall(function_name: "add", args: @[Argument(valueKind: rkInt_val, int_val: 1024)], call_id: 101)Cyclic types are not supported directly.
The following proto code
proto3 """
syntax = "proto3";
package cyclic;
message Node {
Node child = 1;
}
"""
will be translated (protonim -i ./cyclic.proto -o ./cyclic.nim) into some problematic code, which you need do some manual adjustments:
# original nim code
# Generated from protobuf
type
Node* = object # nim compiler will complain recursion error
child*: Node
... and you should replace the type defintion to (after this, the rest still works fine)
type
Node* = ref NodeObj
NodeObj* = object
child*: Node
As for stranger case like:
message NodeA {
NodeB child = 1;
}
message NodeB {
NodeA child = 1;
} --->
type
NodeA* = object
child*: NodeB
NodeB* = object
child*: NodeA you can adjust the type definition to:
type
NodeA* = ref NodeAObj
NodeB* = ref NodeBObj
NodeAObj* = object
child*: NodeB
NodeBObj* = object
child*: NodeA
import nimproto3_src/src/nimproto3
import nimproto3_src/src/nimproto3/wire_format
import print
# Define schema inline
proto3 """
syntax = "proto3";
package oneofs;
message RpcCall {
string function_name = 1;
repeated Argument args = 2;
int32 call_id = 3;
}
message Location {
float latitude = 1;
float longitude = 2;
}
message Argument {
oneof value {
int32 val_int = 1;
bool val_bool = 2;
string val_str = 3;
bytes val_data = 4;
Location val_location = 5;
}
}"""
when isMainModule:
echo "========================================="
echo " Variant Object Construction Demo"
echo "========================================="
echo ""
var arg_1: Argument = 1024
var arg_2: Argument = "Hello Proto!"
var arg_3: Argument = true
var arg_4: Argument = @[1.byte, 2.byte, 3.byte, 0xDE.byte, 0xAD.byte, 0xBE.byte, 0xEF.byte]
var arg_5: Argument = Location(latitude: 37.7749, longitude: -122.4194)
echo "========================================="
echo " Full Integration Test"
echo "========================================="
echo ""
let caller_msg = RpcCall(
function_name: "add",
args: @[arg_1, arg_2, arg_3, arg_4, arg_5],
call_id: 101
)
echo "Encoded binary:"
echo caller_msg.toBinary()
echo "\nDecoding..."
let callee_msg = RpcCall.fromBinary(caller_msg.toBinary())
print callee_msg
echo "\nVerifying bytes argument:"
echo "Expected: @[1, 2, 3, 222, 173, 190, 239]"
echo "Got: ", callee_msg.args[3].val_data
if callee_msg.args[3].val_data == @[1.byte, 2.byte, 3.byte, 0xDE.byte, 0xAD.byte, 0xBE.byte, 0xEF.byte]:
echo "\n✓ SUCCESS: bytes in oneof now works correctly!"
else:
echo "\n✗ FAILED: bytes data doesn't match"
=========================================
Variant Object Construction Demo
=========================================
=========================================
Full Integration Test
=========================================
Encoded binary:
@[10, 3, 97, 100, 100, 18, 3, 8, 128, 8, 18, 14, 26, 12, 72, 101, 108, 108, 111, 32, 80, 114, 111, 116, 111, 33, 18, 2, 16, 1, 18, 9, 34, 7, 1, 2, 3, 222, 173, 190, 239, 18, 12, 42, 10, 13, 127, 25, 23, 66, 21, 188, 214, 244, 194, 24, 101]
Decoding...
callee_msg=RpcCall(
function_name: "add",
args: @[
Argument(valueKind: "rkVal_int", val_int: 1024),
Argument(valueKind: "rkVal_str", val_str: "Hello Proto!"),
Argument(valueKind: "rkVal_bool", val_bool: true),
Argument(valueKind: "rkVal_data", val_data: @[1, 2, 3, 222, 173, 190, 239]),
Argument(valueKind: "rkVal_location", val_location: Location(latitude: 37.7749, longitude: -122.4194))
],
call_id: 101
)
Verifying bytes argument:
Expected: @[1, 2, 3, 222, 173, 190, 239]
Got: @[1, 2, 3, 222, 173, 190, 239]
✓ SUCCESS: bytes in oneof now works correctly!