diff --git a/json_rpc/private/server_handler_wrapper.nim b/json_rpc/private/server_handler_wrapper.nim index fe20c59..e26a5e6 100644 --- a/json_rpc/private/server_handler_wrapper.nim +++ b/json_rpc/private/server_handler_wrapper.nim @@ -12,6 +12,7 @@ import stew/[byteutils, objects], json_serialization, json_serialization/std/[options], + json_serialization/stew/results, ../errors, ./jrpc_sys, ./shared_wrapper, @@ -20,8 +21,26 @@ import export jsonmarshal +type + RpcSetup = object + numFields: int + numOptionals: int + minLength: int + {.push gcsafe, raises: [].} +# ------------------------------------------------------------------------------ +# Optional resolvers +# ------------------------------------------------------------------------------ + +template rpc_isOptional(_: auto): bool = false +template rpc_isOptional[T](_: results.Opt[T]): bool = true +template rpc_isOptional[T](_: options.Option[T]): bool = true + +# ------------------------------------------------------------------------------ +# Run time helpers +# ------------------------------------------------------------------------------ + proc unpackArg(args: JsonString, argName: string, argType: type): argType {.gcsafe, raises: [JsonRpcError].} = ## This where input parameters are decoded from JSON into @@ -33,78 +52,95 @@ proc unpackArg(args: JsonString, argName: string, argType: type): argType "Parameter [" & argName & "] of type '" & $argType & "' could not be decoded: " & err.msg) -proc expectArrayLen(node, paramsIdent: NimNode, length: int) = - ## Make sure positional params meets the handler expectation - let - expected = "Expected " & $length & " Json parameter(s) but got " - node.add quote do: - if `paramsIdent`.positional.len != `length`: - raise newException(RequestDecodeError, `expected` & - $`paramsIdent`.positional.len) +# ------------------------------------------------------------------------------ +# Compile time helpers +# ------------------------------------------------------------------------------ +func hasOptionals(setup: RpcSetup): bool {.compileTime.} = + setup.numOptionals > 0 -iterator paramsRevIter(params: NimNode): tuple[name, ntype: NimNode] = - ## Bacward iterator of handler parameters - for i in countdown(params.len-1,1): - let arg = params[i] - let argType = arg[^2] - for j in 0 ..< arg.len-2: - yield (arg[j], argType) +func rpcSetupImpl[T](val: T): RpcSetup {.compileTime.} = + ## Counting number of fields, optional fields, and + ## minimum fields needed by a rpc method + mixin rpc_isOptional + var index = 1 + for field in fields(val): + inc result.numFields + if rpc_isOptional(field): + inc result.numOptionals + else: + result.minLength = index + inc index -proc isOptionalArg(typeNode: NimNode): bool = - # typed version - (typeNode.kind == nnkCall and - typeNode.len > 1 and - typeNode[1].kind in {nnkIdent, nnkSym} and - typeNode[1].strVal == "Option") or - - # untyped version - (typeNode.kind == nnkBracketExpr and - typeNode[0].kind == nnkIdent and - typeNode[0].strVal == "Option") - -proc expectOptionalArrayLen(node: NimNode, - parameters: NimNode, - paramsIdent: NimNode, - maxLength: int): int = - ## Validate if parameters sent by client meets - ## minimum expectation of server - var minLength = maxLength - - for arg, typ in paramsRevIter(parameters): - if not typ.isOptionalArg: break - dec minLength +func rpcSetupFromType(T: type): RpcSetup {.compileTime.} = + var dummy: T + rpcSetupImpl(dummy) +template expectOptionalParamsLen(params: RequestParamsRx, + minLength, maxLength: static[int]) = + ## Make sure positional params with optional fields + ## meets the handler expectation let expected = "Expected at least " & $minLength & " and maximum " & $maxLength & " Json parameter(s) but got " - node.add quote do: - if `paramsIdent`.positional.len < `minLength`: - raise newException(RequestDecodeError, `expected` & - $`paramsIdent`.positional.len) + if params.positional.len < minLength: + raise newException(RequestDecodeError, + expected & $params.positional.len) - minLength +template expectParamsLen(params: RequestParamsRx, length: static[int]) = + ## Make sure positional params meets the handler expectation + let + expected = "Expected " & $length & " Json parameter(s) but got " -proc containsOptionalArg(params: NimNode): bool = - ## Is one of handler parameters an optional? - for n, t in paramsIter(params): - if t.isOptionalArg: - return true + if params.positional.len != length: + raise newException(RequestDecodeError, + expected & $params.positional.len) -proc jsonToNim(paramVar: NimNode, - paramType: NimNode, - paramVal: NimNode, - paramName: string): NimNode = +template setupPositional(setup: static[RpcSetup], params: RequestParamsRx) = + ## Generate code to check positional params length + when setup.hasOptionals: + expectOptionalParamsLen(params, setup.minLength, setup.numFields) + else: + expectParamsLen(params, setup.numFields) + +template len(params: RequestParamsRx): int = + params.positional.len + +template notNull(params: RequestParamsRx, pos: int): bool = + params.positional[pos].kind != JsonValueKind.Null + +template val(params: RequestParamsRx, pos: int): auto = + params.positional[pos].param + +template unpackPositional(params: RequestParamsRx, + paramVar: auto, + paramName: static[string], + pos: static[int], + setup: static[RpcSetup], + paramType: type) = ## Convert a positional parameter from Json into Nim - result = quote do: - `paramVar` = `unpackArg`(`paramVal`, `paramName`, `paramType`) -proc calcActualParamCount(params: NimNode): int = - ## this proc is needed to calculate the actual parameter count - ## not matter what is the declaration form - ## e.g. (a: U, b: V) vs. (a, b: T) - for n, t in paramsIter(params): - inc result + template innerNode() = + paramVar = unpackArg(params.val(pos), paramName, paramType) + + # e.g. (A: int, B: Option[int], C: string, D: Option[int], E: Option[string]) + when rpc_isOptional(paramVar): + when pos >= setup.minLength: + # allow both empty and null after mandatory args + # D & E fall into this category + if params.len > pos and params.notNull(pos): + innerNode() + else: + # allow null param for optional args between/before mandatory args + # B fall into this category + if params.notNull(pos): + innerNode() + else: + # mandatory args + # A and C fall into this category + # unpack Nim type and assign from json + if params.notNull(pos): + innerNode() proc makeType(typeName, params: NimNode): NimNode = ## Generate type section contains an object definition @@ -120,60 +156,6 @@ proc makeType(typeName, params: NimNode): NimNode = obj[2] = recList typeSec -proc setupPositional(params, paramsIdent: NimNode): (NimNode, int) = - ## Generate code to check positional params length - var - minLength = 0 - code = newStmtList() - - if params.containsOptionalArg(): - # more elaborate parameters array check - minLength = code.expectOptionalArrayLen(params, paramsIdent, - calcActualParamCount(params)) - else: - # simple parameters array length check - code.expectArrayLen(paramsIdent, calcActualParamCount(params)) - - (code, minLength) - -proc setupPositional(code: NimNode; - paramsObj, paramsIdent, paramIdent, paramType: NimNode; - pos, minLength: int) = - ## processing multiple params of one type - ## e.g. (a, b: T), including common (a: U, b: V) form - let - paramName = $paramIdent - paramVal = quote do: - `paramsIdent`.positional[`pos`].param - paramKind = quote do: - `paramsIdent`.positional[`pos`].kind - paramVar = quote do: - `paramsObj`.`paramIdent` - innerNode = jsonToNim(paramVar, paramType, paramVal, paramName) - - # e.g. (A: int, B: Option[int], C: string, D: Option[int], E: Option[string]) - if paramType.isOptionalArg: - if pos >= minLength: - # allow both empty and null after mandatory args - # D & E fall into this category - code.add quote do: - if `paramsIdent`.positional.len > `pos` and - `paramKind` != JsonValueKind.Null: - `innerNode` - else: - # allow null param for optional args between/before mandatory args - # B fall into this category - code.add quote do: - if `paramKind` != JsonValueKind.Null: - `innerNode` - else: - # mandatory args - # A and C fall into this category - # unpack Nim type and assign from json - code.add quote do: - if `paramKind` != JsonValueKind.Null: - `innerNode` - proc makeParams(retType: NimNode, params: NimNode): seq[NimNode] = ## Convert rpc params into handler params result.add retType @@ -262,13 +244,14 @@ proc wrapServerHandler*(methName: string, params, procBody, procWrapper: NimNode paramsIdent = genSym(nskParam, "rpcParams") returnType = params[0] hasParams = params.len > 1 # not including return type - (posSetup, minLength) = setupPositional(params, paramsIdent) + rpcSetup = ident"rpcSetup" handler = makeHandler(handlerName, params, procBody, returnType) named = setupNamed(paramsObj, paramsIdent, params) if hasParams: setup.add makeType(typeName, params) setup.add quote do: + const `rpcSetup` = rpcSetupFromType(`typeName`) var `paramsObj`: `typeName` # unpack each parameter and provide assignments @@ -278,8 +261,15 @@ proc wrapServerHandler*(methName: string, params, procBody, procWrapper: NimNode executeParams: seq[NimNode] for paramIdent, paramType in paramsIter(params): - positional.setupPositional(paramsObj, paramsIdent, - paramIdent, paramType, pos, minLength) + let paramName = $paramIdent + positional.add quote do: + unpackPositional(`paramsIdent`, + `paramsObj`.`paramIdent`, + `paramName`, + `pos`, + `rpcSetup`, + `paramType`) + executeParams.add quote do: `paramsObj`.`paramIdent` inc pos @@ -287,7 +277,7 @@ proc wrapServerHandler*(methName: string, params, procBody, procWrapper: NimNode if hasParams: setup.add quote do: if `paramsIdent`.kind == rpPositional: - `posSetup` + setupPositional(`rpcSetup`, `paramsIdent`) `positional` else: `named` @@ -297,7 +287,7 @@ proc wrapServerHandler*(methName: string, params, procBody, procWrapper: NimNode # still be checked (RPC spec) setup.add quote do: if `paramsIdent`.kind == rpPositional: - `posSetup` + expectParamsLen(`paramsIdent`, 0) let awaitedResult = ident "awaitedResult" diff --git a/json_rpc/router.nim b/json_rpc/router.nim index 53ae8c7..741bb64 100644 --- a/json_rpc/router.nim +++ b/json_rpc/router.nim @@ -137,7 +137,7 @@ proc route*(router: RpcRouter, req: RequestRx): let methodName = req.meth.get # this Opt already validated debug "Error occurred within RPC", methodName = methodName, err = err.msg - return serverError(methodName & " raised an exception", + return serverError("`" & methodName & "` raised an exception", escapeJson(err.msg).JsonString). wrapError(req.id) diff --git a/tests/test_batch_call.nim b/tests/test_batch_call.nim index 0b14889..4e6c6ed 100644 --- a/tests/test_batch_call.nim +++ b/tests/test_batch_call.nim @@ -56,7 +56,7 @@ suite "Socket batch call": check r[1].result.string == "\"apple: green\"" check r[2].error.isSome - check r[2].error.get == """{"code":-32000,"message":"get_except raised an exception","data":"get_except error"}""" + check r[2].error.get == """{"code":-32000,"message":"`get_except` raised an exception","data":"get_except error"}""" check r[2].result.string.len == 0 test "rpc call after batch call": @@ -95,7 +95,7 @@ suite "HTTP batch call": check r[1].result.string == "\"apple: green\"" check r[2].error.isSome - check r[2].error.get == """{"code":-32000,"message":"get_except raised an exception","data":"get_except error"}""" + check r[2].error.get == """{"code":-32000,"message":"`get_except` raised an exception","data":"get_except error"}""" check r[2].result.string.len == 0 test "rpc call after batch call": @@ -134,7 +134,7 @@ suite "Websocket batch call": check r[1].result.string == "\"apple: green\"" check r[2].error.isSome - check r[2].error.get == """{"code":-32000,"message":"get_except raised an exception","data":"get_except error"}""" + check r[2].error.get == """{"code":-32000,"message":"`get_except` raised an exception","data":"get_except error"}""" check r[2].result.string.len == 0 test "rpc call after batch call": diff --git a/tests/test_router_rpc.nim b/tests/test_router_rpc.nim index a65b6a3..da71640 100644 --- a/tests/test_router_rpc.nim +++ b/tests/test_router_rpc.nim @@ -10,11 +10,15 @@ import unittest2, ../json_rpc/router, - json_serialization/std/options + json_serialization/std/options, + json_serialization/stew/results var server = RpcRouter() -server.rpc("optional") do(A: int, B: Option[int], C: string, D: Option[int], E: Option[string]) -> string: +type + OptAlias[T] = results.Opt[T] + +server.rpc("std_option") do(A: int, B: Option[int], C: string, D: Option[int], E: Option[string]) -> string: var res = "A: " & $A res.add ", B: " & $B.get(99) res.add ", C: " & C @@ -22,6 +26,30 @@ server.rpc("optional") do(A: int, B: Option[int], C: string, D: Option[int], E: res.add ", E: " & E.get("none") return res +server.rpc("results_opt") do(A: int, B: Opt[int], C: string, D: Opt[int], E: Opt[string]) -> string: + var res = "A: " & $A + res.add ", B: " & $B.get(99) + res.add ", C: " & C + res.add ", D: " & $D.get(77) + res.add ", E: " & E.get("none") + return res + +server.rpc("mixed_opt") do(A: int, B: Opt[int], C: string, D: Option[int], E: Opt[string]) -> string: + var res = "A: " & $A + res.add ", B: " & $B.get(99) + res.add ", C: " & C + res.add ", D: " & $D.get(77) + res.add ", E: " & E.get("none") + return res + +server.rpc("alias_opt") do(A: int, B: OptAlias[int], C: string, D: Option[int], E: OptAlias[string]) -> string: + var res = "A: " & $A + res.add ", B: " & $B.get(99) + res.add ", C: " & C + res.add ", D: " & $D.get(77) + res.add ", E: " & E.get("none") + return res + server.rpc("noParams") do() -> int: return 123 @@ -38,6 +66,54 @@ func req(meth: string, params: string): string = """{"jsonrpc":"2.0", "id":0, "method": """ & "\"" & meth & "\", \"params\": " & params & "}" +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"}""" + + 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"}""" + + 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"}""" + + 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"}}""" + 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"}}""" + 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"}}""" + 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"}}""" + + 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"}""" + + 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"}""" + + 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"}""" + + 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"}""" + suite "rpc router": test "no params": let n = req("noParams", "[]") @@ -47,47 +123,12 @@ suite "rpc router": 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","id":0,"error":{"code":-32000,"message":"`noParams` raised an exception","data":"Expected 0 Json parameter(s) but got 1"}}""" - test "optional B E, positional": - let n = req("optional", "[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"}""" - - test "optional B D E, positional": - let n = req("optional", "[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"}""" - - test "optional D E, positional": - let n = req("optional", "[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"}""" - - test "optional D wrong E, positional": - let n = req("optional", "[44, 567, \"apple\", \"banana\"]") - let res = waitFor server.route(n) - check res == """{"jsonrpc":"2.0","id":0,"error":{"code":-32000,"message":"optional raised an exception","data":"Parameter [D] of type 'Option[system.int]' could not be decoded: number expected"}}""" - - test "optional D extra, positional": - let n = req("optional", "[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"}""" - - test "optional B D E, named": - let n = req("optional", """{"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"}""" - - test "optional B E, D front, named": - let n = req("optional", """{"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"}""" - - test "optional B E, D front, extra X, named": - let n = req("optional", """{"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"}""" + test_optional("std_option") + test_optional("results_opt") + test_optional("mixed_opt") + test_optional("alias_opt") test "empty params": let n = req("emptyParams", "[]")