From 9f1acc30103d66e54b4b1e987c78a3edbeefe89c Mon Sep 17 00:00:00 2001 From: Jacek Sieka Date: Mon, 24 Nov 2025 13:38:18 +0100 Subject: [PATCH 1/6] Only call message callback in bidirectional streams (et al) The message processing hook is intended for handling notifications / requests from the server which can happen with for example websockets and other bidirectional transports. When using the HTTP endpoint, the server does not really have the opportunity to send anything except the response since the connection is unidirectional. * don't call processing hook for http endpoint * make raised exceptions more accurate with respect to spec * `InvalidRequest` -> `ApplicationError`, deprecate former name * one less copy of parameters in rpc macro code * more strict handling of missing / null fields in jrpc_sys * differentiate between `null` and "not present" id in requests * allow `null` request id ("not present"/notifications TODO!) * don't allow `null` parameter list - allow a non-present one * stop parsing earlier when `jsonrpc` tag is missing/wrong * restore some backwards compat with web3, broken in #247 * discard empty batches earlier --- json_rpc/client.nim | 27 +++--- json_rpc/clients/httpclient.nim | 17 +--- json_rpc/clients/socketclient.nim | 6 +- json_rpc/clients/websocketclient.nim | 9 +- json_rpc/errors.nim | 7 +- json_rpc/private/client_handler_wrapper.nim | 13 +-- json_rpc/private/jrpc_sys.nim | 79 ++++++++++++++--- json_rpc/router.nim | 97 +++++++++------------ tests/private/helpers.nim | 16 ++++ tests/test_client_hook.nim | 40 +++++---- tests/test_jrpc_sys.nim | 72 +++++++-------- tests/testhttp.nim | 3 +- tests/testrpcmacro.nim | 20 ----- 13 files changed, 215 insertions(+), 191 deletions(-) diff --git a/json_rpc/client.nim b/json_rpc/client.nim index 179c535..6965814 100644 --- a/json_rpc/client.nim +++ b/json_rpc/client.nim @@ -11,9 +11,9 @@ import std/[deques, json, tables, macros], + chronos, chronicles, stew/byteutils, - chronos, results, ./private/[client_handler_wrapper, jrpc_sys, shared_wrapper], ./[errors, jsonmarshal, router] @@ -21,7 +21,7 @@ import from strutils import replace export - chronos, chronicles, deques, tables, jsonmarshal, RequestParamsTx, RequestBatchTx, + chronos, deques, tables, jsonmarshal, RequestParamsTx, RequestBatchTx, ResponseBatchRx, RequestIdKind, RequestId, RequestTx, RequestParamKind, results logScope: @@ -60,16 +60,16 @@ func parseResponse*(payload: openArray[byte], T: type): T {.raises: [JsonRpcErro try: JrpcSys.decode(payload, T) except SerializationError as exc: - raise (ref RequestDecodeError)( + raise (ref InvalidResponse)( msg: exc.formatMsg("msg"), payload: @payload, parent: exc ) proc processsSingleResponse( - response: sink ResponseRx, id: int + response: sink ResponseRx2, id: int ): JsonString {.raises: [JsonRpcError].} = if response.id.kind != RequestIdKind.riNumber or response.id.num != id: raise - (ref RequestDecodeError)(msg: "Expected `id` " & $id & ", got " & $response.id) + (ref InvalidResponse)(msg: "Expected `id` " & $id & ", got " & $response.id) case response.kind of ResponseKind.rkError: @@ -80,7 +80,7 @@ proc processsSingleResponse( proc processsSingleResponse*( body: openArray[byte], id: int ): JsonString {.raises: [JsonRpcError].} = - processsSingleResponse(parseResponse(body, ResponseRx), id) + processsSingleResponse(parseResponse(body, ResponseRx2), id) template withPendingFut*(client, fut, body: untyped): untyped = let fut = ResponseFut.init("jsonrpc.client.pending") @@ -145,10 +145,11 @@ proc call*( # helps debugging, if nothing else id = client.getNextId() requestData = JrpcSys.withWriter(writer): - writer.requestTxEncode(name, params, id) + writer.writeRequest(name, params, id) debug "Sending JSON-RPC request", name, len = requestData.len, id, remote = client.remote + trace "Parameters", params # Release params memory earlier by using a raw proc for the initial # processing @@ -175,14 +176,14 @@ proc call*( proc callBatch*( client: RpcClient, calls: seq[RequestTx] -): Future[seq[ResponseRx]] {. +): Future[seq[ResponseRx2]] {. async: (raises: [CancelledError, JsonRpcError], raw: true) .} = if calls.len == 0: - let res = Future[seq[ResponseRx]].Raising([CancelledError, JsonRpcError]).init( + let res = Future[seq[ResponseRx2]].Raising([CancelledError, JsonRpcError]).init( "empty batch" ) - res.complete(default(seq[ResponseRx])) + res.complete(default(seq[ResponseRx2])) return res let requestData = JrpcSys.withWriter(writer): @@ -194,12 +195,12 @@ proc callBatch*( proc complete( client: RpcClient, request: auto - ): Future[seq[ResponseRx]] {.async: (raises: [CancelledError, JsonRpcError]).} = + ): Future[seq[ResponseRx2]] {.async: (raises: [CancelledError, JsonRpcError]).} = try: let resData = await request debug "Processing JSON-RPC batch response", len = resData.len, remote = client.remote - parseResponse(resData, seq[ResponseRx]) + parseResponse(resData, seq[ResponseRx2]) except JsonRpcError as exc: debug "JSON-RPC batch request failed", err = exc.msg, remote = client.remote raise exc @@ -248,7 +249,7 @@ proc send*( debug "Processing JSON-RPC batch response", len = resData.len, lastId, remote = client.remote - parseResponse(resData, seq[ResponseRx]) + parseResponse(resData, seq[ResponseRx2]) except JsonRpcError as exc: debug "JSON-RPC batch request failed", err = exc.msg, remote = client.remote diff --git a/json_rpc/clients/httpclient.nim b/json_rpc/clients/httpclient.nim index 78a8914..cb8e9a5 100644 --- a/json_rpc/clients/httpclient.nim +++ b/json_rpc/clients/httpclient.nim @@ -75,24 +75,11 @@ method request( if res.status < 200 or res.status >= 300: # res.status is not 2xx (success) raise (ref ErrorResponse)(status: res.status, msg: res.reason) - let - resData = await res.getBodyBytes(client.maxMessageSize) - # TODO remove this processMessage hook when subscriptions / pubsub is - # properly supported - fallback = client.callOnProcessMessage(resData).valueOr: - raise (ref RequestDecodeError)(msg: error, payload: resData) - - if not fallback: - # TODO http channels are unidirectional, so it doesn't really make sense - # to call onProcessMessage from http - this should be deprecated - # as soon as bidirectionality is supported - raise (ref InvalidResponse)(msg: "onProcessMessage handled response") - - resData + await res.getBodyBytes(client.maxMessageSize) except HttpError as exc: raise (ref RpcTransportError)(msg: exc.msg, parent: exc) finally: - await req.closeWait() + await res.closeWait() proc newRpcHttpClient*( maxBodySize = defaultMaxMessageSize, diff --git a/json_rpc/clients/socketclient.nim b/json_rpc/clients/socketclient.nim index fcb7dc3..9118c3f 100644 --- a/json_rpc/clients/socketclient.nim +++ b/json_rpc/clients/socketclient.nim @@ -33,16 +33,16 @@ method request( client: RpcSocketClient, reqData: seq[byte] ): Future[seq[byte]] {.async: (raises: [CancelledError, JsonRpcError]).} = ## Remotely calls the specified RPC method. - if client.transport.isNil: + let transport = client.transport + if transport.isNil: raise newException( RpcTransportError, "Transport is not initialised (missing a call to connect?)" ) - let transport = client.transport client.withPendingFut(fut): try: discard await transport.write(reqData & "\r\n".toBytes()) - except TransportError as exc: + except CatchableError as exc: # If there's an error sending, the "next messages" facility will be # broken since we don't know if the server observed the message or not transport.close() diff --git a/json_rpc/clients/websocketclient.nim b/json_rpc/clients/websocketclient.nim index b87f1c6..712eb51 100644 --- a/json_rpc/clients/websocketclient.nim +++ b/json_rpc/clients/websocketclient.nim @@ -43,20 +43,19 @@ method request*( client: RpcWebSocketClient, reqData: seq[byte] ): Future[seq[byte]] {.async: (raises: [CancelledError, JsonRpcError]).} = ## Remotely calls the specified RPC method. - if client.transport.isNil: + let transport = client.transport + if transport.isNil: raise newException( RpcTransportError, "Transport is not initialised (missing a call to connect?)" ) - let transport = client.transport client.withPendingFut(fut): try: await transport.send(reqData, Opcode.Binary) - except CancelledError as exc: - raise exc except CatchableError as exc: # If there's an error sending, the "next messages" facility will be - # broken since we don't know if the server observed the message or not + # broken since we don't know if the server observed the message or not - + # the same goes for cancellation during write try: await noCancel transport.close() except CatchableError: diff --git a/json_rpc/errors.nim b/json_rpc/errors.nim index f8b4128..ecdbf3b 100644 --- a/json_rpc/errors.nim +++ b/json_rpc/errors.nim @@ -33,14 +33,11 @@ type InvalidResponse* = object of JsonRpcError ## raised when the server response violates the JSON-RPC protocol + payload*: seq[byte] RpcBindError* = object of JsonRpcError RpcAddressUnresolvableError* = object of JsonRpcError - InvalidRequest* = object of JsonRpcError - ## raised when the server recieves an invalid JSON request object - code*: int - RequestDecodeError* = object of JsonRpcError ## raised when fail to decode RequestRx payload*: seq[byte] @@ -52,3 +49,5 @@ type ## be provided. code*: int data*: results.Opt[JsonString] + + InvalidRequest* {.deprecated: "ApplicationError".} = ApplicationError diff --git a/json_rpc/private/client_handler_wrapper.nim b/json_rpc/private/client_handler_wrapper.nim index c558c51..14bf9bf 100644 --- a/json_rpc/private/client_handler_wrapper.nim +++ b/json_rpc/private/client_handler_wrapper.nim @@ -22,8 +22,6 @@ func createRpcProc(procName, parameters, callBody: NimNode): NimNode = # build proc result = newProc(procName, paramList, callBody) - # make proc async - result.addPragma ident"async" # export this proc result[0] = nnkPostfix.newTree(ident"*", newIdentNode($procName)) @@ -53,11 +51,14 @@ func setupConversion(reqParams, params: NimNode): NimNode = template maybeUnwrapClientResult*(client, meth, reqParams, returnType): auto = ## Don't decode e.g. JsonString, return as is - when noWrap(typeof returnType): - await client.call(meth, reqParams) + when noWrap(returnType): + client.call(meth, reqParams) else: - let res = await client.call(meth, reqParams) - decode(JrpcConv, res.string, typeof returnType) + proc complete(f: auto): Future[returnType] {.async.} = + let res = await f + decode(JrpcConv, res.string, returnType) + let fut = client.call(meth, reqParams) + complete(fut) func createRpcFromSig*(clientType, rpcDecl: NimNode, alias = NimNode(nil)): NimNode = ## This procedure will generate something like this: diff --git a/json_rpc/private/jrpc_sys.nim b/json_rpc/private/jrpc_sys.nim index 2e10aea..952d1d4 100644 --- a/json_rpc/private/jrpc_sys.nim +++ b/json_rpc/private/jrpc_sys.nim @@ -71,12 +71,19 @@ type id* : results.Opt[RequestId] # Request received by server - RequestRx* = object + # TODO used in nim-web3 - remove eventually + RequestRx* {.deprecated: "ResultsRx2".} = object jsonrpc* : results.Opt[JsonRPC2] `method`*: results.Opt[string] params* : RequestParamsRx id* : RequestId + RequestRx2* = object + jsonrpc* : JsonRPC2 + `method`*: string + params* : RequestParamsRx + id* : results.Opt[RequestId] + # Request sent by client RequestTx* = object jsonrpc* : JsonRPC2 @@ -104,7 +111,14 @@ type id* : RequestId # Response received by client - ResponseRx* = object + # TODO used in nim-web3 tests + ResponseRx* {.deprecated.} = object + jsonrpc*: results.Opt[JsonRPC2] + id* : results.Opt[RequestId] + result* : JsonString + error* : results.Opt[ResponseError] + + ResponseRx2* = object jsonrpc*: JsonRPC2 case kind*: ResponseKind of rkResult: @@ -120,9 +134,9 @@ type RequestBatchRx* = object case kind*: ReBatchKind of rbkMany: - many* : seq[RequestRx] + many* : seq[RequestRx2] of rbkSingle: - single*: RequestRx + single*: RequestRx2 RequestBatchTx* = object case kind*: ReBatchKind @@ -134,9 +148,9 @@ type ResponseBatchRx* = object case kind*: ReBatchKind of rbkMany: - many* : seq[ResponseRx] + many* : seq[ResponseRx2] of rbkSingle: - single*: ResponseRx + single*: ResponseRx2 ResponseBatchTx* = object case kind*: ReBatchKind @@ -149,14 +163,15 @@ type # actual response/params encoding createJsonFlavor JrpcSys, automaticObjectSerialization = false, - requireAllFields = false, + requireAllFields = true, omitOptionalFields = true, # Skip optional fields==none in Writer allowUnknownFields = true, - skipNullFields = true # Skip optional fields==null in Reader + skipNullFields = false # Skip optional fields==null in Reader ResponseError.useDefaultSerializationIn JrpcSys RequestTx.useDefaultWriterIn JrpcSys RequestRx.useDefaultReaderIn JrpcSys +RequestRx2.useDefaultReaderIn JrpcSys ReqRespHeader.useDefaultReaderIn JrpcSys const @@ -186,9 +201,22 @@ func `==`*(a, b: RequestId): bool = of riString: a.str == b.str of riNull: true -func meth*(rx: RequestRx): Opt[string] = +func meth*(rx: RequestRx | RequestRx2): string = rx.`method` +template shouldWriteObjectField*(field: RequestParamsTx): bool = + case field.kind + of rpPositional: + field.positional.len > 0 + of rpNamed: + field.named.len > 0 + +func isFieldExpected*(_: type RequestParamsRx): bool {.compileTime.} = + # A Structured value that holds the parameter values to be used during the + # invocation of the method. This member MAY be omitted. + + false + proc readValue*(r: var JsonReader[JrpcSys], val: var JsonRPC2) {.gcsafe, raises: [IOError, JsonReaderError].} = let version = r.parseAsString() @@ -196,6 +224,15 @@ proc readValue*(r: var JsonReader[JrpcSys], val: var JsonRPC2) r.raiseUnexpectedValue("Invalid JSON-RPC version, want=" & JsonRPC2Literal.string & " got=" & version.string) +proc readValue*( + r: var JsonReader, value: var results.Opt[RequestId] +) {.raises: [IOError, SerializationError].} = + # Unlike the default reader in `results`, pass `null` to RequestId reader that + # will handle it + mixin readValue + + value.ok r.readValue(RequestId) + proc writeValue*(w: var JsonWriter[JrpcSys], val: JsonRPC2) {.gcsafe, raises: [IOError].} = w.writeValue JsonRPC2Literal @@ -266,6 +303,18 @@ proc writeValue*(w: var JsonWriter[JrpcSys], val: ResponseTx) proc readValue*(r: var JsonReader[JrpcSys], val: var ResponseRx) {.gcsafe, raises: [IOError, SerializationError].} = + # We need to overload ResponseRx reader because + # we don't want to skip null fields + r.parseObjectWithoutSkip(key): + case key + of "jsonrpc": r.readValue(val.jsonrpc) + of "id" : r.readValue(val.id) + of "result" : val.result = r.parseAsString() + of "error" : r.readValue(val.error) + else: discard + +proc readValue*(r: var JsonReader[JrpcSys], val: var ResponseRx2) + {.gcsafe, raises: [IOError, SerializationError].} = # https://www.jsonrpc.org/specification#response_object var @@ -292,11 +341,11 @@ proc readValue*(r: var JsonReader[JrpcSys], val: var ResponseRx) if errorOpt.isSome(): if resultOpt.isSome(): - r.raiseIncompleteObject("Both `result` and `error` fields present") + r.raiseUnexpectedValue("Both `result` and `error` fields present") - val = ResponseRx(id: id, kind: ResponseKind.rkError, error: move(errorOpt[])) + val = ResponseRx2(id: id, kind: ResponseKind.rkError, error: move(errorOpt[])) else: - val = ResponseRx(id: id, kind: ResponseKind.rkResult, result: move(resultOpt[])) + val = ResponseRx2(id: id, kind: ResponseKind.rkResult, result: move(resultOpt[])) proc writeValue*(w: var JsonWriter[JrpcSys], val: RequestBatchTx) {.gcsafe, raises: [IOError].} = @@ -312,6 +361,8 @@ proc readValue*(r: var JsonReader[JrpcSys], val: var RequestBatchRx) of JsonValueKind.Array: val = RequestBatchRx(kind: rbkMany) r.readValue(val.many) + if val.many.len == 0: + r.raiseUnexpectedValue("Batch must contain at least one message") of JsonValueKind.Object: val = RequestBatchRx(kind: rbkSingle) r.readValue(val.single) @@ -332,6 +383,8 @@ proc readValue*(r: var JsonReader[JrpcSys], val: var ResponseBatchRx) of JsonValueKind.Array: val = ResponseBatchRx(kind: rbkMany) r.readValue(val.many) + if val.many.len == 0: + r.raiseUnexpectedValue("Batch must contain at least one message") of JsonValueKind.Object: val = ResponseBatchRx(kind: rbkSingle) r.readValue(val.single) @@ -348,7 +401,7 @@ func toTx*(params: RequestParamsRx): RequestParamsTx = result = RequestParamsTx(kind: rpNamed) result.named = params.named -template requestTxEncode*(writer: var JrpcSys.Writer, name: string, params: RequestParamsTx, id: int) = +template writeRequest*(writer: var JrpcSys.Writer, name: string, params: RequestParamsTx, id: int) = writer.writeObject: writer.writeMember("jsonrpc", JsonRPC2()) writer.writeMember("id", id) diff --git a/json_rpc/router.nim b/json_rpc/router.nim index f3c763a..2e1bc50 100644 --- a/json_rpc/router.nim +++ b/json_rpc/router.nim @@ -27,9 +27,9 @@ logScope: topics = "json-rpc-router" type - # Procedure signature accepted as an RPC call by server - RpcProc* = proc(params: RequestParamsRx): Future[JsonString] - {.async.} + RpcProc* = proc(params: RequestParamsRx): Future[JsonString] {.async.} + ## Procedure signature accepted as an RPC call by server - if the function + ## has no return value, return `JsonString("null")` RpcRouter* = object procs*: Table[string, RpcProc] @@ -57,25 +57,31 @@ func methodNotFound(msg: string): ResponseError = func serverError(msg: string, data: JsonString): ResponseError = ResponseError(code: SERVER_ERROR, message: msg, data: Opt.some(data)) -func somethingError(code: int, msg: string): ResponseError = - ResponseError(code: code, message: msg) - func applicationError(code: int, msg: string, data: Opt[JsonString]): ResponseError = ResponseError(code: code, message: msg, data: data) -proc validateRequest(router: RpcRouter, req: RequestRx): - Result[RpcProc, ResponseError] = - if req.jsonrpc.isNone: - return invalidRequest("'jsonrpc' missing or invalid").err +proc respResult(req: RequestRx2, res: sink JsonString): ResponseTx = + ResponseTx( + kind: rkResult, + result: res, + id: req.id.expect("should have been checked in validateRequest"), + ) - if req.id.kind == riNull: - return invalidRequest("'id' missing or invalid").err +proc respError*(req: RequestRx2, error: sink ResponseError): ResponseTx = + ResponseTx( + kind: rkError, + error: error, + id: req.id.valueOr(default(RequestId)), # `null` id on responses we can't parse + ) - if req.meth.isNone: - return invalidRequest("'method' missing or invalid").err +proc validateRequest(router: RpcRouter, req: RequestRx2): + Result[RpcProc, ResponseError] = + if req.id.isNone: + # TODO implement notifications + return invalidRequest("'id' missing or invalid").err let - methodName = req.meth.get + methodName = req.meth rpcProc = router.procs.getOrDefault(methodName) if rpcProc.isNil: @@ -84,30 +90,9 @@ proc validateRequest(router: RpcRouter, req: RequestRx): ok(rpcProc) -proc wrapError(err: ResponseError, id: RequestId): ResponseTx = - ResponseTx( - id: id, - kind: rkError, - error: err, - ) - -proc wrapError(code: int, msg: string, id: RequestId): ResponseTx = - ResponseTx( - id: id, - kind: rkError, - error: somethingError(code, msg), - ) - -proc wrapReply(res: JsonString, id: RequestId): ResponseTx = - ResponseTx( - id: id, - kind: rkResult, - result: res, - ) - proc wrapError(code: int, msg: string): string = - """{"jsonrpc":"2.0","id":null,"error":{"code":""" & $code & - ""","message":""" & escapeJson(msg) & "}}" + """{"jsonrpc":"2.0","error":{"code":""" & $code & + ""","message":""" & escapeJson(msg) & ""","id":null}}""" # ------------------------------------------------------------------------------ # Public functions @@ -116,11 +101,7 @@ proc wrapError(code: int, msg: string): string = proc init*(T: type RpcRouter): T = discard proc register*(router: var RpcRouter, path: string, call: RpcProc) = - # this proc should not raise exception - try: - router.procs[path] = call - except CatchableError as exc: - doAssert(false, exc.msg) + router.procs[path] = call proc clear*(router: var RpcRouter) = router.procs.clear @@ -128,32 +109,30 @@ proc clear*(router: var RpcRouter) = proc hasMethod*(router: RpcRouter, methodName: string): bool = router.procs.hasKey(methodName) -proc route*(router: RpcRouter, req: RequestRx): +proc route*(router: RpcRouter, req: RequestRx2): Future[ResponseTx] {.async: (raises: []).} = let rpcProc = router.validateRequest(req).valueOr: - return wrapError(error, req.id) + return req.respError(error) try: - debug "Processing JSON-RPC request", id = req.id, name = req.`method`.get() + debug "Processing JSON-RPC request", id = req.id, name = req.meth let res = await rpcProc(req.params) debug "Returning JSON-RPC response", - id = req.id, name = req.`method`.get(), len = string(res).len - return wrapReply(res, req.id) + id = req.id, name = req.meth, len = string(res).len + req.respResult(res) except ApplicationError as err: - return wrapError(applicationError(err.code, err.msg, err.data), req.id) - except InvalidRequest as err: - # TODO: deprecate / remove this usage and use InvalidRequest only for - # internal errors. - return wrapError(err.code, err.msg, req.id) + req.respError(applicationError(err.code, err.msg, err.data)) except CatchableError as err: # Note: Errors that are not specifically raised as `ApplicationError`s will # be returned as custom server errors. - let methodName = req.meth.get # this Opt already validated + let methodName = req.meth debug "Error occurred within RPC", methodName = methodName, err = err.msg - return serverError("`" & methodName & "` raised an exception", - escapeJson(err.msg).JsonString). - wrapError(req.id) + req.respError( + serverError( + "`" & methodName & "` raised an exception", escapeJson(err.msg).JsonString + ) + ) proc route*(router: RpcRouter, data: string|seq[byte]): Future[string] {.async: (raises: []).} = @@ -163,7 +142,9 @@ proc route*(router: RpcRouter, data: string|seq[byte]): let request = try: JrpcSys.decode(data, RequestBatchRx) - except CatchableError as err: + except IncompleteObjectError as err: + return wrapError(INVALID_REQUEST, err.msg) + except SerializationError as err: return wrapError(JSON_PARSE_ERROR, err.msg) try: diff --git a/tests/private/helpers.nim b/tests/private/helpers.nim index 09eb815..ca3932c 100644 --- a/tests/private/helpers.nim +++ b/tests/private/helpers.nim @@ -7,6 +7,8 @@ # This file may not be copied, modified, or distributed except according to # those terms. +{.used.} + import ../../json_rpc/router @@ -17,3 +19,17 @@ template `==`*(a: JsonString, b: JsonNode): bool = template `==`*(a: JsonNode, b: JsonString): bool = a == parseJson(string b) + +when declared(json_serialization.automaticSerialization): + # Nim 1.6 cannot use this new feature + JrpcConv.automaticSerialization(int, true) + JrpcConv.automaticSerialization(string, true) + JrpcConv.automaticSerialization(array, true) + JrpcConv.automaticSerialization(byte, true) + JrpcConv.automaticSerialization(seq, true) + JrpcConv.automaticSerialization(float, true) + JrpcConv.automaticSerialization(JsonString, true) + JrpcConv.automaticSerialization(bool, true) + JrpcConv.automaticSerialization(int64, true) + JrpcConv.automaticSerialization(ref, true) + JrpcConv.automaticSerialization(enum, true) diff --git a/tests/test_client_hook.nim b/tests/test_client_hook.nim index 613619b..11f7f80 100644 --- a/tests/test_client_hook.nim +++ b/tests/test_client_hook.nim @@ -13,7 +13,8 @@ import chronicles, websock/websock, ../json_rpc/rpcclient, - ../json_rpc/rpcserver + ../json_rpc/rpcserver, + ./private/helpers createRpcSigsFromNim(RpcClient): proc get_Banana(id: int): int @@ -52,32 +53,35 @@ proc setupClientHook(client: RpcClient): Shadow = shadow suite "test client features": - var server = newRpcHttpServer(["127.0.0.1:0"]) - server.installHandlers() - var client = newRpcHttpClient() - let shadow = client.setupClientHook() - - server.start() - waitFor client.connect("http://" & $server.localAddress()[0]) - - test "client onProcessMessage hook": + setup: + var server = newRpcWebSocketServer("127.0.0.1", Port(0)) + server.installHandlers() + var client = newRpcWebSocketClient() + let shadow = client.setupClientHook() + + server.start() + waitFor client.connect("ws://" & $server.localAddress()) + teardown: + server.stop() + waitFor server.closeWait() + waitFor client.close() + + test "hook success": let res = waitFor client.get_Banana(99) check res == 123 check shadow.something == 0 + test "hook error": expect JsonRpcError: let res2 = waitFor client.get_Banana(123) check res2 == 0 check shadow.something == 77 - expect InvalidResponse: - let res2 = waitFor client.get_Banana(100) - check res2 == 0 - check shadow.something == 123 - - waitFor server.stop() - waitFor server.closeWait() - + # test "hook invalid": + # expect InvalidResponse: + # let res2 = waitFor client.get_Banana(100) + # check res2 == 0 + # check shadow.something == 123 type TestSocketServer = ref object of RpcSocketServer diff --git a/tests/test_jrpc_sys.nim b/tests/test_jrpc_sys.nim index 76928ed..84b3efd 100644 --- a/tests/test_jrpc_sys.nim +++ b/tests/test_jrpc_sys.nim @@ -107,79 +107,75 @@ suite "jrpc_sys conversion": let np1 = namedPar(("banana", JsonString("true")), ("apple", JsonString("123"))) let pp1 = posPar(JsonString("123"), JsonString("true"), JsonString("\"hello\"")) - test "RequestTx -> RequestRx: id(int), positional": + test "RequestTx -> RequestRx2: id(int), positional": let tx = req(123, "int_positional", pp1) let txBytes = JrpcSys.encode(tx) - let rx = JrpcSys.decode(txBytes, RequestRx) + let rx = JrpcSys.decode(txBytes, RequestRx2) check: - rx.jsonrpc.isSome - rx.id.kind == riNumber - rx.id.num == 123 - rx.meth.get == "int_positional" + rx.id[].kind == riNumber + rx.id[].num == 123 + rx.meth == "int_positional" rx.params.kind == rpPositional rx.params.positional.len == 3 rx.params.positional[0].kind == JsonValueKind.Number rx.params.positional[1].kind == JsonValueKind.Bool rx.params.positional[2].kind == JsonValueKind.String - test "RequestTx -> RequestRx: id(string), named": + test "RequestTx -> RequestRx2: id(string), named": let tx = req("word", "string_named", np1) let txBytes = JrpcSys.encode(tx) - let rx = JrpcSys.decode(txBytes, RequestRx) + let rx = JrpcSys.decode(txBytes, RequestRx2) check: - rx.jsonrpc.isSome - rx.id.kind == riString - rx.id.str == "word" - rx.meth.get == "string_named" + rx.id[].kind == riString + rx.id[].str == "word" + rx.meth == "string_named" rx.params.kind == rpNamed rx.params.named[0].name == "banana" rx.params.named[0].value.string == "true" rx.params.named[1].name == "apple" rx.params.named[1].value.string == "123" - test "RequestTx -> RequestRx: id(null), named": + test "RequestTx -> RequestRx2: id(null), named": let tx = reqNull("null_named", np1) let txBytes = JrpcSys.encode(tx) - let rx = JrpcSys.decode(txBytes, RequestRx) + let rx = JrpcSys.decode(txBytes, RequestRx2) check: - rx.jsonrpc.isSome - rx.id.kind == riNull - rx.meth.get == "null_named" + rx.id[].kind == riNull + rx.meth == "null_named" rx.params.kind == rpNamed rx.params.named[0].name == "banana" rx.params.named[0].value.string == "true" rx.params.named[1].name == "apple" rx.params.named[1].value.string == "123" - test "RequestTx -> RequestRx: none, none": + test "RequestTx -> RequestRx2: none, none": let tx = reqNoId("none_positional", posPar()) let txBytes = JrpcSys.encode(tx) - let rx = JrpcSys.decode(txBytes, RequestRx) + let rx = JrpcSys.decode(txBytes, RequestRx2) check: - rx.jsonrpc.isSome - rx.id.kind == riNull - rx.meth.get == "none_positional" + rx.id.isNone() + rx.meth == "none_positional" rx.params.kind == rpPositional rx.params.positional.len == 0 - test "ResponseTx -> ResponseRx: id(int), res": + test "ResponseTx -> ResponseRx2: id(int), res": let tx = res(777, JsonString("true")) let txBytes = JrpcSys.encode(tx) - let rx = JrpcSys.decode(txBytes, ResponseRx) + let rx = JrpcSys.decode(txBytes, ResponseRx2) check: rx.id.num == 777 rx.kind == ResponseKind.rkResult rx.result.string.len > 0 rx.result == JsonString("true") - test "ResponseTx -> ResponseRx: id(string), err: nodata": + test "ResponseTx -> ResponseRx2: id(string), err: nodata": let tx = res("gum", resErr(999, "fatal")) let txBytes = JrpcSys.encode(tx) - let rx = JrpcSys.decode(txBytes, ResponseRx) + let rx = JrpcSys.decode(txBytes, ResponseRx2) check: rx.id.str == "gum" rx.kind == ResponseKind.rkError @@ -187,10 +183,10 @@ suite "jrpc_sys conversion": rx.error.message == "fatal" rx.error.data.isNone - test "ResponseTx -> ResponseRx: id(string), err: some data": + test "ResponseTx -> ResponseRx2: id(string), err: some data": let tx = res("gum", resErr(999, "fatal", JsonString("888.999"))) let txBytes = JrpcSys.encode(tx) - let rx = JrpcSys.decode(txBytes, ResponseRx) + let rx = JrpcSys.decode(txBytes, ResponseRx2) check: rx.id.str == "gum" rx.kind == ResponseKind.rkError @@ -237,11 +233,17 @@ suite "jrpc_sys conversion": rx.kind == rbkMany rx.many.len == 3 - test "skip null value": - let jsonBytes = """{"jsonrpc":null, "id":null, "method":null, "params":null}""" - let x = JrpcSys.decode(jsonBytes, RequestRx) + test "Request decoding errors": + expect UnexpectedValueError: + discard JrpcSys.decode("""{"jsonrpc":"1.0","method":"test"}""", RequestRx2) + expect IncompleteObjectError: + discard JrpcSys.decode("""{"jsonrpc":"2.0"}""", RequestRx2) + expect IncompleteObjectError: + discard JrpcSys.decode("""{"method":"test"}""", RequestRx2) + + # missing params/id ok + discard JrpcSys.decode("""{"jsonrpc":"2.0","method":"test"}""", RequestRx2) + + # legacy (used in web3 0.8.0) check: - x.jsonrpc.isNone - x.id.kind == riNull - x.`method`.isNone - x.params.kind == rpPositional + JrpcSys.decode("""{"jsonrpc":"2.0", "id":"null"}""", RequestRx).`method`.isNone diff --git a/tests/testhttp.nim b/tests/testhttp.nim index eac1bab..2a34892 100644 --- a/tests/testhttp.nim +++ b/tests/testhttp.nim @@ -9,7 +9,8 @@ import unittest2, chronos/unittest2/asynctests, - ../json_rpc/[rpcserver, rpcclient, jsonmarshal] + ../json_rpc/[rpcserver, rpcclient, jsonmarshal], + ./private/helpers const TestsCount = 100 const bigChunkSize = 4 * 8192 diff --git a/tests/testrpcmacro.nim b/tests/testrpcmacro.nim index 291a322..94510d5 100644 --- a/tests/testrpcmacro.nim +++ b/tests/testrpcmacro.nim @@ -50,20 +50,6 @@ MyOptional.useDefaultSerializationIn JrpcConv MyOptionalNotBuiltin.useDefaultSerializationIn JrpcConv MuscleCar.useDefaultSerializationIn JrpcConv -when declared(json_serialization.automaticSerialization): - # Nim 1.6 cannot use this new feature - JrpcConv.automaticSerialization(int, true) - JrpcConv.automaticSerialization(string, true) - JrpcConv.automaticSerialization(array, true) - JrpcConv.automaticSerialization(byte, true) - JrpcConv.automaticSerialization(seq, true) - JrpcConv.automaticSerialization(float, true) - JrpcConv.automaticSerialization(JsonString, true) - JrpcConv.automaticSerialization(bool, true) - JrpcConv.automaticSerialization(int64, true) - JrpcConv.automaticSerialization(ref, true) - JrpcConv.automaticSerialization(enum, true) - proc readValue*(r: var JsonReader[JrpcConv], val: var MyEnum) {.gcsafe, raises: [IOError, SerializationError].} = let intVal = r.parseInt(int) @@ -378,11 +364,5 @@ suite "Server types": let x = waitFor s.executeMethod("echo", """{"car":{"color":null,"wheel":77}}""".JsonString) check x == """{"color":"","wheel":77}""" - let y = waitFor s.executeMethod("echo", """{"car":null}""".JsonString) - check y == """{"color":"","wheel":0}""" - - let z = waitFor s.executeMethod("echo", "[null]".JsonString) - check z == """{"color":"","wheel":0}""" - s.stop() waitFor s.closeWait() From 1bc34dba5825284f7f7e3760eee9ccb94f11ed24 Mon Sep 17 00:00:00 2001 From: Jacek Sieka Date: Mon, 24 Nov 2025 19:02:45 +0100 Subject: [PATCH 2/6] fix cancellation on close with released webscok --- json_rpc.nimble | 2 +- json_rpc/clients/websocketclient.nim | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/json_rpc.nimble b/json_rpc.nimble index 05e14bf..5477abb 100644 --- a/json_rpc.nimble +++ b/json_rpc.nimble @@ -24,7 +24,7 @@ requires "nim >= 1.6.0", "chronos >= 4.0.3 & < 5.0.0", "httputils >= 0.3.0", "chronicles", - "websock >= 0.2.0 & < 0.3.0", + "websock >= 0.2.1 & < 0.3.0", "serialization >= 0.4.4", "json_serialization >= 0.4.2", "unittest2" diff --git a/json_rpc/clients/websocketclient.nim b/json_rpc/clients/websocketclient.nim index 712eb51..25feb04 100644 --- a/json_rpc/clients/websocketclient.nim +++ b/json_rpc/clients/websocketclient.nim @@ -58,9 +58,9 @@ method request*( # the same goes for cancellation during write try: await noCancel transport.close() - except CatchableError: + except CatchableError as exc: # TODO https://github.com/status-im/nim-websock/pull/178 - raiseAssert "Doesn't actually raise" + raiseAssert exc.msg raise (ref RpcPostError)(msg: exc.msg, parent: exc) await fut @@ -85,10 +85,11 @@ proc processData(client: RpcWebSocketClient) {.async: (raises: []).} = client.clearPending(lastError) try: - await client.transport.close() + await noCancel client.transport.close() client.transport = nil - except CatchableError: - raiseAssert "Doesn't actually raise" + except CatchableError as exc: + # TODO https://github.com/status-im/nim-websock/pull/178 + raiseAssert exc.msg if not client.onDisconnect.isNil: client.onDisconnect() From f4db90d79533bc4eba3550f7c1e44d9d0e5a9328 Mon Sep 17 00:00:00 2001 From: Jacek Sieka Date: Mon, 24 Nov 2025 19:12:53 +0100 Subject: [PATCH 3/6] tests --- tests/test_jrpc_sys.nim | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_jrpc_sys.nim b/tests/test_jrpc_sys.nim index 84b3efd..39a2e58 100644 --- a/tests/test_jrpc_sys.nim +++ b/tests/test_jrpc_sys.nim @@ -242,8 +242,10 @@ suite "jrpc_sys conversion": discard JrpcSys.decode("""{"method":"test"}""", RequestRx2) # missing params/id ok - discard JrpcSys.decode("""{"jsonrpc":"2.0","method":"test"}""", RequestRx2) + block: + discard JrpcSys.decode("""{"jsonrpc":"2.0","method":"test"}""", RequestRx2) - # legacy (used in web3 0.8.0) - check: - JrpcSys.decode("""{"jsonrpc":"2.0", "id":"null"}""", RequestRx).`method`.isNone + block: # legacy (used in web3 0.8.0) + let rx = JrpcSys.decode("""{"jsonrpc":"2.0", "id":"null"}""", RequestRx) + check: + rx.`method`.isNone From 4b60ad017df243224a186ec28364f4fb0e15eb75 Mon Sep 17 00:00:00 2001 From: Jacek Sieka Date: Tue, 25 Nov 2025 10:07:25 +0100 Subject: [PATCH 4/6] more jrcp_sys cleanup * remove unused `RequestBatchTx`/`ResponseBatchTx` * streamline jrpc_sys tests --- json_rpc/client.nim | 4 +- json_rpc/private/jrpc_sys.nim | 51 +--- json_rpc/router.nim | 31 +-- tests/test_jrpc_sys.nim | 473 +++++++++++++++++----------------- tests/test_router_rpc.nim | 36 +-- 5 files changed, 274 insertions(+), 321 deletions(-) diff --git a/json_rpc/client.nim b/json_rpc/client.nim index 6965814..84e7f33 100644 --- a/json_rpc/client.nim +++ b/json_rpc/client.nim @@ -21,8 +21,8 @@ import from strutils import replace export - chronos, deques, tables, jsonmarshal, RequestParamsTx, RequestBatchTx, - ResponseBatchRx, RequestIdKind, RequestId, RequestTx, RequestParamKind, results + chronos, deques, tables, jsonmarshal, RequestParamsTx, ResponseBatchRx, RequestIdKind, + RequestId, RequestTx, RequestParamKind, results logScope: topics = "JSONRPC-CLIENT" diff --git a/json_rpc/private/jrpc_sys.nim b/json_rpc/private/jrpc_sys.nim index 952d1d4..6888d7e 100644 --- a/json_rpc/private/jrpc_sys.nim +++ b/json_rpc/private/jrpc_sys.nim @@ -103,7 +103,7 @@ type # Response sent by server ResponseTx* = object jsonrpc* : JsonRPC2 - case kind*: ResponseKind + case kind*{.dontSerialize.}: ResponseKind of rkResult: result* : JsonString of rkError: @@ -138,13 +138,6 @@ type of rbkSingle: single*: RequestRx2 - RequestBatchTx* = object - case kind*: ReBatchKind - of rbkMany: - many* : seq[RequestTx] - of rbkSingle: - single*: RequestTx - ResponseBatchRx* = object case kind*: ReBatchKind of rbkMany: @@ -152,13 +145,6 @@ type of rbkSingle: single*: ResponseRx2 - ResponseBatchTx* = object - case kind*: ReBatchKind - of rbkMany: - many* : seq[ResponseTx] - of rbkSingle: - single*: ResponseTx - # don't mix the json-rpc system encoding with the # actual response/params encoding createJsonFlavor JrpcSys, @@ -168,11 +154,15 @@ createJsonFlavor JrpcSys, allowUnknownFields = true, skipNullFields = false # Skip optional fields==null in Reader -ResponseError.useDefaultSerializationIn JrpcSys -RequestTx.useDefaultWriterIn JrpcSys +ReqRespHeader.useDefaultReaderIn JrpcSys RequestRx.useDefaultReaderIn JrpcSys RequestRx2.useDefaultReaderIn JrpcSys -ReqRespHeader.useDefaultReaderIn JrpcSys + +ParamDescNamed.useDefaultWriterIn JrpcSys +RequestTx.useDefaultWriterIn JrpcSys +ResponseTx.useDefaultWriterIn JrpcSys + +ResponseError.useDefaultSerializationIn JrpcSys const JsonRPC2Literal = JsonString("\"2.0\"") @@ -290,17 +280,6 @@ proc writeValue*(w: var JsonWriter[JrpcSys], val: RequestParamsTx) w.writeField(x.name, x.value) w.endRecord() -proc writeValue*(w: var JsonWriter[JrpcSys], val: ResponseTx) - {.gcsafe, raises: [IOError].} = - w.beginRecord ResponseTx - w.writeField("jsonrpc", val.jsonrpc) - w.writeField("id", val.id) - if val.kind == rkResult: - w.writeField("result", val.result) - else: - w.writeField("error", val.error) - w.endRecord() - proc readValue*(r: var JsonReader[JrpcSys], val: var ResponseRx) {.gcsafe, raises: [IOError, SerializationError].} = # We need to overload ResponseRx reader because @@ -347,13 +326,6 @@ proc readValue*(r: var JsonReader[JrpcSys], val: var ResponseRx2) else: val = ResponseRx2(id: id, kind: ResponseKind.rkResult, result: move(resultOpt[])) -proc writeValue*(w: var JsonWriter[JrpcSys], val: RequestBatchTx) - {.gcsafe, raises: [IOError].} = - if val.kind == rbkMany: - w.writeArray(val.many) - else: - w.writeValue(val.single) - proc readValue*(r: var JsonReader[JrpcSys], val: var RequestBatchRx) {.gcsafe, raises: [IOError, SerializationError].} = let tok = r.tokKind @@ -369,13 +341,6 @@ proc readValue*(r: var JsonReader[JrpcSys], val: var RequestBatchRx) else: r.raiseUnexpectedValue("RequestBatch must be either array or object, got=" & $tok) -proc writeValue*(w: var JsonWriter[JrpcSys], val: ResponseBatchTx) - {.gcsafe, raises: [IOError].} = - if val.kind == rbkMany: - w.writeArray(val.many) - else: - w.writeValue(val.single) - proc readValue*(r: var JsonReader[JrpcSys], val: var ResponseBatchRx) {.gcsafe, raises: [IOError, SerializationError].} = let tok = r.tokKind diff --git a/json_rpc/router.nim b/json_rpc/router.nim index 2e1bc50..aeb316e 100644 --- a/json_rpc/router.nim +++ b/json_rpc/router.nim @@ -10,7 +10,7 @@ {.push raises: [], gcsafe.} import - std/[macros, tables, json], + std/[macros, sequtils, tables, json], chronicles, chronos, ./private/server_handler_wrapper, @@ -147,23 +147,18 @@ proc route*(router: RpcRouter, data: string|seq[byte]): except SerializationError as err: return wrapError(JSON_PARSE_ERROR, err.msg) - try: - if request.kind == rbkSingle: - let response = await router.route(request.single) - JrpcSys.encode(response) - elif request.many.len == 0: - wrapError(INVALID_REQUEST, "no request object in request array") - else: - var resFut: seq[Future[ResponseTx]] - for req in request.many: - resFut.add router.route(req) - await noCancel(allFutures(resFut)) - var response = ResponseBatchTx(kind: rbkMany) - for fut in resFut: - response.many.add fut.read() - JrpcSys.encode(response) - except CatchableError as err: - wrapError(JSON_ENCODE_ERROR, err.msg) + case request.kind + of rbkSingle: + let response = await router.route(request.single) + JrpcSys.encode(response) + of rbkMany: + # check raising type to ensure `value` below is safe to use + let resFut: seq[Future[ResponseTx].Raising([])] = + request.many.mapIt(router.route(it)) + + await noCancel(allFutures(resFut)) + + JrpcSys.encode(resFut.mapIt(it.value)) macro rpc*(server: RpcRouter, path: static[string], body: untyped): untyped = ## Define a remote procedure call. diff --git a/tests/test_jrpc_sys.nim b/tests/test_jrpc_sys.nim index 39a2e58..40ffdb1 100644 --- a/tests/test_jrpc_sys.nim +++ b/tests/test_jrpc_sys.nim @@ -1,251 +1,244 @@ # json-rpc -# Copyright (c) 2023 Status Research & Development GmbH +# Copyright (c) 2023-2025 Status Research & Development GmbH # Licensed under either of # * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE)) # * MIT license ([LICENSE-MIT](LICENSE-MIT)) # at your option. -# This file may not be copied, modified, or distributed except according to -# those terms. -import - unittest2, - ../json_rpc/private/jrpc_sys - -func id(): RequestId = - RequestId(kind: riNull) - -func id(x: string): RequestId = - RequestId(kind: riString, str: x) - -func id(x: int): RequestId = - RequestId(kind: riNumber, num: x) - -func req(id: int or string, meth: string, params: RequestParamsTx): RequestTx = - RequestTx( - id: Opt.some(id(id)), - `method`: meth, - params: params - ) - -func reqNull(meth: string, params: RequestParamsTx): RequestTx = - RequestTx( - id: Opt.some(id()), - `method`: meth, - params: params - ) - -func reqNoId(meth: string, params: RequestParamsTx): RequestTx = - RequestTx( - `method`: meth, - params: params - ) - -func toParams(params: varargs[(string, JsonString)]): seq[ParamDescNamed] = - for x in params: - result.add ParamDescNamed(name:x[0], value:x[1]) - -func namedPar(params: varargs[(string, JsonString)]): RequestParamsTx = - RequestParamsTx( - kind: rpNamed, - named: toParams(params) - ) - -func posPar(params: varargs[JsonString]): RequestParamsTx = - RequestParamsTx( - kind: rpPositional, - positional: @params - ) - -func res(id: int or string, r: JsonString): ResponseTx = - ResponseTx( - id: id(id), - kind: rkResult, - result: r, - ) - -func res(id: int or string, err: ResponseError): ResponseTx = - ResponseTx( - id: id(id), - kind: rkError, - error: err, - ) - -func resErr(code: int, msg: string): ResponseError = - ResponseError( - code: code, - message: msg, - ) - -func resErr(code: int, msg: string, data: JsonString): ResponseError = - ResponseError( - code: code, - message: msg, - data: Opt.some(data) - ) - -func reqBatch(args: varargs[RequestTx]): RequestBatchTx = - if args.len == 1: - RequestBatchTx( - kind: rbkSingle, single: args[0] - ) - else: - RequestBatchTx( - kind: rbkMany, many: @args - ) - -func resBatch(args: varargs[ResponseTx]): ResponseBatchTx = - if args.len == 1: - ResponseBatchTx( - kind: rbkSingle, single: args[0] - ) - else: - ResponseBatchTx( - kind: rbkMany, many: @args - ) - -suite "jrpc_sys conversion": - let np1 = namedPar(("banana", JsonString("true")), ("apple", JsonString("123"))) - let pp1 = posPar(JsonString("123"), JsonString("true"), JsonString("\"hello\"")) - - test "RequestTx -> RequestRx2: id(int), positional": - let tx = req(123, "int_positional", pp1) - let txBytes = JrpcSys.encode(tx) - let rx = JrpcSys.decode(txBytes, RequestRx2) - - check: - rx.id[].kind == riNumber - rx.id[].num == 123 - rx.meth == "int_positional" - rx.params.kind == rpPositional - rx.params.positional.len == 3 - rx.params.positional[0].kind == JsonValueKind.Number - rx.params.positional[1].kind == JsonValueKind.Bool - rx.params.positional[2].kind == JsonValueKind.String - - test "RequestTx -> RequestRx2: id(string), named": - let tx = req("word", "string_named", np1) - let txBytes = JrpcSys.encode(tx) - let rx = JrpcSys.decode(txBytes, RequestRx2) - - check: - rx.id[].kind == riString - rx.id[].str == "word" - rx.meth == "string_named" - rx.params.kind == rpNamed - rx.params.named[0].name == "banana" - rx.params.named[0].value.string == "true" - rx.params.named[1].name == "apple" - rx.params.named[1].value.string == "123" - - test "RequestTx -> RequestRx2: id(null), named": - let tx = reqNull("null_named", np1) - let txBytes = JrpcSys.encode(tx) - let rx = JrpcSys.decode(txBytes, RequestRx2) - - check: - rx.id[].kind == riNull - rx.meth == "null_named" - rx.params.kind == rpNamed - rx.params.named[0].name == "banana" - rx.params.named[0].value.string == "true" - rx.params.named[1].name == "apple" - rx.params.named[1].value.string == "123" - - test "RequestTx -> RequestRx2: none, none": - let tx = reqNoId("none_positional", posPar()) - let txBytes = JrpcSys.encode(tx) - let rx = JrpcSys.decode(txBytes, RequestRx2) - - check: - rx.id.isNone() - rx.meth == "none_positional" - rx.params.kind == rpPositional - rx.params.positional.len == 0 - - test "ResponseTx -> ResponseRx2: id(int), res": - let tx = res(777, JsonString("true")) - let txBytes = JrpcSys.encode(tx) - let rx = JrpcSys.decode(txBytes, ResponseRx2) - check: - rx.id.num == 777 - rx.kind == ResponseKind.rkResult - rx.result.string.len > 0 - rx.result == JsonString("true") - - test "ResponseTx -> ResponseRx2: id(string), err: nodata": - let tx = res("gum", resErr(999, "fatal")) - let txBytes = JrpcSys.encode(tx) - let rx = JrpcSys.decode(txBytes, ResponseRx2) - check: - rx.id.str == "gum" - rx.kind == ResponseKind.rkError - rx.error.code == 999 - rx.error.message == "fatal" - rx.error.data.isNone - - test "ResponseTx -> ResponseRx2: id(string), err: some data": - let tx = res("gum", resErr(999, "fatal", JsonString("888.999"))) - let txBytes = JrpcSys.encode(tx) - let rx = JrpcSys.decode(txBytes, ResponseRx2) - check: - rx.id.str == "gum" - rx.kind == ResponseKind.rkError - rx.error.code == 999 - rx.error.message == "fatal" - rx.error.data.get == JsonString("888.999") - - test "RequestBatchTx -> RequestBatchRx: single": - let tx1 = req(123, "int_positional", pp1) - let tx = reqBatch(tx1) - let txBytes = JrpcSys.encode(tx) - let rx = JrpcSys.decode(txBytes, RequestBatchRx) - check: - rx.kind == rbkSingle - - test "RequestBatchTx -> RequestBatchRx: many": - let tx1 = req(123, "int_positional", pp1) - let tx2 = req("word", "string_named", np1) - let tx3 = reqNull("null_named", np1) - let tx4 = reqNoId("none_positional", posPar()) - let tx = reqBatch(tx1, tx2, tx3, tx4) - let txBytes = JrpcSys.encode(tx) - let rx = JrpcSys.decode(txBytes, RequestBatchRx) - check: - rx.kind == rbkMany - rx.many.len == 4 - - test "ResponseBatchTx -> ResponseBatchRx: single": - let tx1 = res(777, JsonString("true")) - let tx = resBatch(tx1) - let txBytes = JrpcSys.encode(tx) - let rx = JrpcSys.decode(txBytes, ResponseBatchRx) - check: - rx.kind == rbkSingle - - test "ResponseBatchTx -> ResponseBatchRx: many": - let tx1 = res(777, JsonString("true")) - let tx2 = res("gum", resErr(999, "fatal")) - let tx3 = res("gum", resErr(999, "fatal", JsonString("888.999"))) - let tx = resBatch(tx1, tx2, tx3) - let txBytes = JrpcSys.encode(tx) - let rx = JrpcSys.decode(txBytes, ResponseBatchRx) - check: - rx.kind == rbkMany - rx.many.len == 3 +import unittest2, ../json_rpc/private/jrpc_sys + +suite "jrpc_sys serialization": + test "request: id": + const cases = [ + ( + """{"jsonrpc":"2.0","method":"none"}""", + RequestTx(`method`: "none", id: Opt.none(RequestId)), + ), + ( + """{"jsonrpc":"2.0","method":"null","id":null}""", + RequestTx(`method`: "null", id: Opt.some(RequestId(kind: riNull))), + ), + ( + """{"jsonrpc":"2.0","method":"num","id":42}""", + RequestTx(`method`: "num", id: Opt.some(RequestId(kind: riNumber, num: 42))), + ), + ( + """{"jsonrpc":"2.0","method":"str","id":"str"}""", + RequestTx(`method`: "str", id: Opt.some(RequestId(kind: riString, str: "str"))), + ), + ] + + for (expected, tx) in cases: + let + encoded = JrpcSys.encode(tx) + rx = JrpcSys.decode(expected, RequestRx2) + checkpoint(expected) + checkpoint(encoded) + checkpoint($rx) + check: + encoded == expected + tx.id == rx.id + + test "request: parameters": + const cases = [ + ( + """{"jsonrpc":"2.0","method":"empty_positional"}""", + RequestTx( + `method`: "empty_positional", + params: RequestParamsTx(kind: rpPositional, positional: @[]), + ), + ), + ( + """{"jsonrpc":"2.0","method":"int_positional","params":[123,true,"hello"],"id":123}""", + RequestTx( + `method`: "int_positional", + id: Opt.some(RequestId(kind: riNumber, num: 123)), + params: RequestParamsTx( + kind: rpPositional, + positional: + @[JsonString("123"), JsonString("true"), JsonString("\"hello\"")], + ), + ), + ), + ( + """{"jsonrpc":"2.0","method":"string_named","params":{"banana":true,"apple":123},"id":"word"}""", + RequestTx( + `method`: "string_named", + id: Opt.some(RequestId(kind: riString, str: "word")), + params: RequestParamsTx( + kind: rpNamed, + named: + @[ + ParamDescNamed(name: "banana", value: JsonString("true")), + ParamDescNamed(name: "apple", value: JsonString("123")), + ], + ), + ), + ), + ] + for (expected, tx) in cases: + let + encoded = JrpcSys.encode(tx) + rx = JrpcSys.decode(encoded, RequestRx2) + checkpoint(expected) + checkpoint(encoded) + checkpoint($rx) + check: + encoded == expected + tx.params.kind == rx.params.kind + if tx.params.kind == rpPositional: + let + tpos = tx.params.positional + rpos = rx.params.positional + check: + tpos.len == rpos.len + for i in 0 ..< tpos.len: + check tpos[i] == rpos[i].param + elif tx.params.kind == rpNamed: + let + tnamed = tx.params.named + rnamed = rx.params.named + check: + tnamed.len == rnamed.len + for i in 0 ..< tnamed.len: + check: + tnamed[i].name == rnamed[i].name + tnamed[i].value == rnamed[i].value + + test "response: result and error encodings": + const cases = [ + ( + """{"jsonrpc":"2.0","result":true,"id":null}""", + ResponseTx(kind: rkResult, result: JsonString("true")), + ), + ( + """{"jsonrpc":"2.0","error":{"code":999,"message":"fatal"},"id":null}""", + ResponseTx(kind: rkError, error: ResponseError(code: 999, message: "fatal")), + ), + ] + for (expected, tx) in cases: + let + encoded = JrpcSys.encode(tx) + rx = JrpcSys.decode(encoded, ResponseRx2) + checkpoint(expected) + checkpoint(encoded) + checkpoint($rx) + check: + encoded == expected + if tx.kind == rkResult: + check: + rx.kind == ResponseKind.rkResult + rx.id == tx.id + else: + check: + rx.kind == ResponseKind.rkError + rx.id == tx.id + rx.error.code == tx.error.code + rx.error.message == tx.error.message + + test "batch requests: single and many encodings": + const cases = [ + ( + """{"jsonrpc":"2.0","method":"a"}""", + RequestBatchRx(kind: rbkSingle, single: RequestRx2(`method`: "a")), + ), + ( + """[{"jsonrpc":"2.0","method":"a"},{"jsonrpc":"2.0","method":"b"}]""", + RequestBatchRx( + kind: rbkMany, many: @[RequestRx2(`method`: "a"), RequestRx2(`method`: "b")] + ), + ), + ] + for (expected, tx) in cases: + let rx = JrpcSys.decode(expected, RequestBatchRx) + checkpoint(expected) + checkpoint($rx) + if tx.kind == rbkSingle: + check: + rx.kind == rbkSingle + rx.single.`method` == tx.single.`method` + else: + check: + rx.kind == rbkMany + rx.many.len == tx.many.len + + test "batch responses: single and many encodings": + const cases = [ + ( + """{"jsonrpc":"2.0","result":null,"id":null}""", + ResponseBatchRx( + kind: rbkSingle, + single: ResponseRx2(kind: rkResult, result: JsonString("null")), + ), + ), + ( + """[{"jsonrpc":"2.0","result":null,"id":null},{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":null}]""", + ResponseBatchRx( + kind: rbkMany, + many: + @[ + ResponseRx2(kind: rkResult, result: JsonString("null")), + ResponseRx2( + kind: rkError, + error: ResponseError(code: -32601, message: "Method not found"), + ), + ], + ), + ), + ] + for (expected, tx) in cases: + let rx = JrpcSys.decode(expected, ResponseBatchRx) + checkpoint(expected) + checkpoint($rx) + if tx.kind == rbkSingle: + check: + rx.kind == rbkSingle + else: + check: + rx.kind == rbkMany + rx.many.len == tx.many.len + + test "malformed JSON and top-level incorrect types are rejected": + expect UnexpectedValueError: + discard JrpcSys.decode("{ this is not valid json }", RequestRx2) + expect UnexpectedValueError: + discard JrpcSys.decode("123", RequestRx2) + expect UnexpectedValueError: + discard JrpcSys.decode("\"just a string\"", RequestRx2) - test "Request decoding errors": + test "invalid constructs: empty batch and mixed-type batch entries rejected": + expect UnexpectedValueError: + discard JrpcSys.decode("[]", RequestBatchRx) expect UnexpectedValueError: - discard JrpcSys.decode("""{"jsonrpc":"1.0","method":"test"}""", RequestRx2) - expect IncompleteObjectError: - discard JrpcSys.decode("""{"jsonrpc":"2.0"}""", RequestRx2) - expect IncompleteObjectError: - discard JrpcSys.decode("""{"method":"test"}""", RequestRx2) + discard JrpcSys.decode("[]", ResponseBatchRx) - # missing params/id ok - block: - discard JrpcSys.decode("""{"jsonrpc":"2.0","method":"test"}""", RequestRx2) + let mixed = + """[{"jsonrpc":"2.0","method":"foo","params":[]},42,{"jsonrpc":"2.0","method":"notify_no_id","params":["a"]}]""" + expect UnexpectedValueError: + discard JrpcSys.decode(mixed, RequestBatchRx) - block: # legacy (used in web3 0.8.0) - let rx = JrpcSys.decode("""{"jsonrpc":"2.0", "id":"null"}""", RequestRx) + test "invalid id types rejected": + expect UnexpectedValueError: + discard JrpcSys.decode("""{"jsonrpc":"2.0","id":{},"method":"m"}""", RequestRx2) + expect UnexpectedValueError: + discard + JrpcSys.decode("""{"jsonrpc":"2.0","id":[1,2],"method":"m"}""", RequestRx2) + + test "error response preserves standard fields and encoder correctness": + const cases = [ + ( + """{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":null}""", + ResponseTx( + kind: rkError, error: ResponseError(code: -32601, message: "Method not found") + ), + ) + ] + for (expected, tx) in cases: + let + encoded = JrpcSys.encode(tx) + rx = JrpcSys.decode(encoded, ResponseRx2) check: - rx.`method`.isNone + encoded == expected + rx.kind == ResponseKind.rkError + rx.error.code == tx.error.code + rx.error.message == tx.error.message diff --git a/tests/test_router_rpc.nim b/tests/test_router_rpc.nim index fdbbc8f..ba0a0d5 100644 --- a/tests/test_router_rpc.nim +++ b/tests/test_router_rpc.nim @@ -63,67 +63,67 @@ server.rpc("returnJsonString") do(a, b, c: int) -> JsonString: return JsonString($(a+b+c)) func req(meth: string, params: string): string = - """{"jsonrpc":"2.0", "id":0, "method": """ & - "\"" & meth & "\", \"params\": " & params & "}" + """{"jsonrpc":"2.0", "method": """ & + "\"" & meth & "\", \"params\": " & params & """, "id":0}""" template test_optional(meth: static[string]) = test meth & " B E, positional": let n = req(meth, "[44, null, \"apple\", 33]") let res = waitFor server.route(n) - check res == """{"jsonrpc":"2.0","id":0,"result":"A: 44, B: 99, C: apple, D: 33, E: none"}""" + check res == """{"jsonrpc":"2.0","result":"A: 44, B: 99, C: apple, D: 33, E: none","id":0}""" test meth & " B D E, positional": let n = req(meth, "[44, null, \"apple\"]") let res = waitFor server.route(n) - check res == """{"jsonrpc":"2.0","id":0,"result":"A: 44, B: 99, C: apple, D: 77, E: none"}""" + check res == """{"jsonrpc":"2.0","result":"A: 44, B: 99, C: apple, D: 77, E: none","id":0}""" test meth & " D E, positional": let n = req(meth, "[44, 567, \"apple\"]") let res = waitFor server.route(n) - check res == """{"jsonrpc":"2.0","id":0,"result":"A: 44, B: 567, C: apple, D: 77, E: none"}""" + check res == """{"jsonrpc":"2.0","result":"A: 44, B: 567, C: apple, D: 77, E: none","id":0}""" test meth & " D wrong E, positional": let n = req(meth, "[44, 567, \"apple\", \"banana\"]") let res = waitFor server.route(n) when meth == "std_option": - check res == """{"jsonrpc":"2.0","id":0,"error":{"code":-32000,"message":"`std_option` raised an exception","data":"Parameter [D] of type 'Option[system.int]' could not be decoded: number expected"}}""" + check res == """{"jsonrpc":"2.0","error":{"code":-32000,"message":"`std_option` raised an exception","data":"Parameter [D] of type 'Option[system.int]' could not be decoded: number expected"},"id":0}""" elif meth == "results_opt": - check res == """{"jsonrpc":"2.0","id":0,"error":{"code":-32000,"message":"`results_opt` raised an exception","data":"Parameter [D] of type 'Opt[system.int]' could not be decoded: number expected"}}""" + check res == """{"jsonrpc":"2.0","error":{"code":-32000,"message":"`results_opt` raised an exception","data":"Parameter [D] of type 'Opt[system.int]' could not be decoded: number expected"},"id":0}""" elif meth == "mixed_opt": - check res == """{"jsonrpc":"2.0","id":0,"error":{"code":-32000,"message":"`mixed_opt` raised an exception","data":"Parameter [D] of type 'Option[system.int]' could not be decoded: number expected"}}""" + check res == """{"jsonrpc":"2.0","error":{"code":-32000,"message":"`mixed_opt` raised an exception","data":"Parameter [D] of type 'Option[system.int]' could not be decoded: number expected"},"id":0}""" else: - check res == """{"jsonrpc":"2.0","id":0,"error":{"code":-32000,"message":"`alias_opt` raised an exception","data":"Parameter [D] of type 'Option[system.int]' could not be decoded: number expected"}}""" + check res == """{"jsonrpc":"2.0","error":{"code":-32000,"message":"`alias_opt` raised an exception","data":"Parameter [D] of type 'Option[system.int]' could not be decoded: number expected"},"id":0}""" test meth & " D extra, positional": let n = req(meth, "[44, 567, \"apple\", 999, \"banana\", true]") let res = waitFor server.route(n) - check res == """{"jsonrpc":"2.0","id":0,"result":"A: 44, B: 567, C: apple, D: 999, E: banana"}""" + check res == """{"jsonrpc":"2.0","result":"A: 44, B: 567, C: apple, D: 999, E: banana","id":0}""" test meth & " B D E, named": let n = req(meth, """{"A": 33, "C":"banana" }""") let res = waitFor server.route(n) - check res == """{"jsonrpc":"2.0","id":0,"result":"A: 33, B: 99, C: banana, D: 77, E: none"}""" + check res == """{"jsonrpc":"2.0","result":"A: 33, B: 99, C: banana, D: 77, E: none","id":0}""" test meth & " B E, D front, named": let n = req(meth, """{"D": 8887, "A": 33, "C":"banana" }""") let res = waitFor server.route(n) - check res == """{"jsonrpc":"2.0","id":0,"result":"A: 33, B: 99, C: banana, D: 8887, E: none"}""" + check res == """{"jsonrpc":"2.0","result":"A: 33, B: 99, C: banana, D: 8887, E: none","id":0}""" test meth & " B E, D front, extra X, named": let n = req(meth, """{"D": 8887, "X": false , "A": 33, "C":"banana"}""") let res = waitFor server.route(n) - check res == """{"jsonrpc":"2.0","id":0,"result":"A: 33, B: 99, C: banana, D: 8887, E: none"}""" + check res == """{"jsonrpc":"2.0","result":"A: 33, B: 99, C: banana, D: 8887, E: none","id":0}""" suite "rpc router": test "no params": let n = req("noParams", "[]") let res = waitFor server.route(n) - check res == """{"jsonrpc":"2.0","id":0,"result":123}""" + check res == """{"jsonrpc":"2.0","result":123,"id":0}""" test "no params with params": let n = req("noParams", "[123]") let res = waitFor server.route(n) - check res == """{"jsonrpc":"2.0","id":0,"error":{"code":-32000,"message":"`noParams` raised an exception","data":"Expected 0 JSON parameter(s) but got 1"}}""" + check res == """{"jsonrpc":"2.0","error":{"code":-32000,"message":"`noParams` raised an exception","data":"Expected 0 JSON parameter(s) but got 1"},"id":0}""" test_optional("std_option") test_optional("results_opt") @@ -133,14 +133,14 @@ suite "rpc router": test "empty params": let n = req("emptyParams", "[]") let res = waitFor server.route(n) - check res == """{"jsonrpc":"2.0","id":0,"result":777}""" + check res == """{"jsonrpc":"2.0","result":777,"id":0}""" test "combo params": let n = req("comboParams", "[6,7,8]") let res = waitFor server.route(n) - check res == """{"jsonrpc":"2.0","id":0,"result":21}""" + check res == """{"jsonrpc":"2.0","result":21,"id":0}""" test "return json string": let n = req("returnJsonString", "[6,7,8]") let res = waitFor server.route(n) - check res == """{"jsonrpc":"2.0","id":0,"result":21}""" + check res == """{"jsonrpc":"2.0","result":21,"id":0}""" From 4dc0bc7781fcbeb50308e1a0aa4408d351c72572 Mon Sep 17 00:00:00 2001 From: Jacek Sieka Date: Thu, 27 Nov 2025 07:00:45 +0100 Subject: [PATCH 5/6] add workaround https://github.com/nim-lang/Nim/issues/24844 --- json_rpc/client.nim | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/json_rpc/client.nim b/json_rpc/client.nim index 84e7f33..6c1ae73 100644 --- a/json_rpc/client.nim +++ b/json_rpc/client.nim @@ -9,6 +9,14 @@ {.push raises: [], gcsafe.} +import chronos/futures + +# json_rpc seems to frequently trigger this bug so add a workaround here +when (NimMajor, NimMinor, NimPatch) < (2, 2, 6): + proc json_rpc_workaround_24844_future_string*() {.exportc.} = + # TODO https://github.com/nim-lang/Nim/issues/24844 + discard Future[string]().value() + import std/[deques, json, tables, macros], chronos, From 8bfc0c0fe87822b5688e0b15fa68484bb51487ed Mon Sep 17 00:00:00 2001 From: Jacek Sieka Date: Fri, 28 Nov 2025 08:37:11 +0100 Subject: [PATCH 6/6] oops --- json_rpc/router.nim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/json_rpc/router.nim b/json_rpc/router.nim index aeb316e..82c9b62 100644 --- a/json_rpc/router.nim +++ b/json_rpc/router.nim @@ -92,7 +92,7 @@ proc validateRequest(router: RpcRouter, req: RequestRx2): proc wrapError(code: int, msg: string): string = """{"jsonrpc":"2.0","error":{"code":""" & $code & - ""","message":""" & escapeJson(msg) & ""","id":null}}""" + ""","message":""" & escapeJson(msg) & """},"id":null}""" # ------------------------------------------------------------------------------ # Public functions