diff --git a/.gitignore b/.gitignore index 7f78b22..6e5aa8d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ priv *.o *.beam *.plt +_build/ +rebar3 +rebar.lock diff --git a/README.md b/README.md index d9be3ac..82fe8e2 100644 --- a/README.md +++ b/README.md @@ -1,160 +1,104 @@ JSON-RPC 2.0 for Erlang ======================= -Transport agnostic library for JSON-RPC 2.0 servers and clients. +Library for JSON-RPC 2.0 using JSON library (EEP 68) from stdlib, logger (OTP 21.0) and Maps (EEP 43). + + +Dependencies +------------ + +* Erlang/OTP 27.0+ -This page contains the manual for the server part, the `jsonrpc2` module. The client part has a -separate module `jsonrpc2_client`. Client docs are yet to be written. For documentation on the -client library, see the source code: [jsonrpc2_client.erl](src/jsonrpc2_client.erl). Features -------- -* can use any JSON encoder and decoder that supports the eep0018 style terms - format, -* transport neutral -* dispatches parsed requests to a simple callback function +* transport neutral; +* dispatches parsed requests to a simple callback function; * supports an optional callback "map" function for batch requests, e.g. to - support concurrent processing of the requests in a batch, -* handles rpc calls and notifications, -* supports named and unnamed parameters, + support concurrent processing of the requests in a batch or collecting statistics; +* handles rpc calls and notifications; +* supports named and unnamed parameters; * includes unit tests for all examples in the JSON-RPC 2.0 specification. -Example +Server examples ------- -``` erlang -1> Json = <<"{\"jsonrpc\": \"2.0\", \"method\": \"foo\", \"params\": [1,2,3], \"id\": 1}">>. -<<"{\"jsonroc\": \"2.0\", \"method\": \"foo\", \"params\": [1,2,3], \"id\": 1}">> -2> -2> MyHandler = fun (<<"foo">>, Params) -> lists:reverse(Params); -2> (_, _) -> throw(method_not_found) -2> end. -#Fun -3> -3> jsonrpc2:handle(Json, MyHandler, fun jiffy:decode/1, fun jiffy:encode/1). -{reply,<<"{\"jsonrpc\":\"2.0\",\"result\":[3,2,1],\"id\":1}">>} -4> -4> jsonrpc2:handle(<<"dummy">>, MyHandler, fun jiffy:decode/1, fun jiffy:encode/1). -{reply,<<"{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32700,\"message\":\"Parse error.\"},\"id\":null}">>} -5> -5> jsonrpc2:handle(<<"{\"x\":42}">>, MyHandler, fun jiffy:decode/1, fun jiffy:encode/1). -{reply,<<"{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32600,\"message\":\"Invalid Request.\"},\"id\":null}">>} -``` - -Types ------ - -```Erlang -json() :: true | false | null | binary() | [json()] | {[{binary(), json()}]}. - -handlerfun() :: fun((method(), params()) -> json()). -method() :: binary(). -params() :: [json()] | {[{binary(), json()}]}. - -mapfun() :: fun((fun((A) -> B), [A]) -> [B]). %% the same as lists:map/2 -``` - -Functions ---------- - -Any of the `jsonrpc2:handle/2,3,4,5` functions can be used to handle JSON-RPC -request by delegating the actual procedure call to a handler callback function. -They all return `{reply, Data}` where Data is a result or an error response or -`noreply` when no response should be sent to the client. The handler callback -function must return a term that can be encoded to JSON using the -representation explained on the page https://github.com/davisp/jiffy#data-format, -as required by jiffy and other compatible JSON parses. - -```Erlang -handle(json(), handlerfun()) -> {reply, json()} | noreply -``` - -Handles decoded JSON and returns a reply as decoded JSON or noreply. Use -this if you want to handle JSON encoding separately. - -```Erlang -handle(json(), handlerfun(), mapfun()) -> {reply, json()} | noreply -``` - -Like `handle/2`, handles decoded JSON, but takes an extra -"map" function callback to be used instead of `lists:map/2` -for batch processing. The map function should be a function that behaves -similarly to `lists:map/2`, such as the `plists:map/2` -from the plists library for concurrent batch handling. - -```Erlang -handle(Req::term(), handlerfun(), JsonDecode::fun(), JsonEncode::fun()) -> - {reply, term()} | noreply -``` - -Handles JSON as binary or string. Uses the supplied functions -JsonDecode to parse the JSON request and JsonEncode to encode the reply as JSON. +```erlang +1> Body = ~'{"jsonrpc": "2.0", "method": "foo", "params": [1,2,3], "id": 1}'. +<<"{\"jsonrpc\": \"2.0\", \"method\": \"foo\", \"params\": [1,2,3], \"id\": 1}">> + +2> Handler = fun(~"foo", Params) -> {ok, lists:reverse(Params)}; (_, _) -> {error, method_not_found} end. +#Fun + +3> jsonrpc2:handle(Body, Handler). +{reply,["{", + [[34,<<"id">>,34],58|<<"1">>], + [[44, + [34,<<"result">>,34], + 58,91,<<"3">>,44,<<"2">>,44,<<"1">>,93], + [44,[34,<<"jsonrpc">>,34],58,34,<<"2.0">>,34]], + "}"]} + +4> jsonrpc2:handle(~"dummy", Handler). +=WARNING REPORT==== 10-Aug-2025::13:58:12.336132 === +Failed to decode request in JSON format: error {invalid_byte,100} +{reply,["{", + [[34,<<"error">>,34], + 58,"{", + [[34,<<"code">>,34],58|<<"-32700">>], + [[44,[34,<<"message">>,34],58,34,<<"Parse error.">>,34]], + "}"], + [[44,[34,<<"id">>,34],58|<<"null">>], + [44,[34,<<"jsonrpc">>,34],58,34,<<"2.0">>,34]], + "}"]} + +5> {reply, R} = jsonrpc2:handle(~'{"x": 42}', Handler). +{reply,["{", + [[34,<<"error">>,34], + 58,"{", + [[34,<<"code">>,34],58|<<"-32600">>], + [[44,[34,<<"message">>,34],58,34,<<"Invalid Request.">>,34]], + "}"], + [[44,[34,<<"id">>,34],58|<<"null">>], + [44,[34,<<"jsonrpc">>,34],58,34,<<"2.0">>,34]], + "}"]} + +6> iolist_to_binary(R). +<<"{\"error\":{\"code\":-32600,\"message\":\"Invalid Request.\"},\"id\":null,\"jsonrpc\":\"2.0\"}">> + + +7> Term = #{~"jsonrpc" => ~"2.0", ~"method" => ~"foo", ~"params" => [1,2,3], ~"id" => 1}. +#{<<"id">> => 1,<<"jsonrpc">> => <<"2.0">>, + <<"method">> => <<"foo">>, + <<"params">> => [1,2,3]} + +8> jsonrpc2:handle_term(Term, Handler). +{reply,#{id => 1,result => [3,2,1],jsonrpc => <<"2.0">>}} -```Erlang -handle(Req::term(), handlerfun(), mapfun(), JsonDecode::fun(), - JsonEncode::fun()) -> {reply, term()} | noreply ``` -Like `handle/4`, but also takes a map function for batch -processing. See `handle/3` above. -Error Handling +Client example -------------- -A requests that is not valid JSON results in a "Parse error" JSON-RPC response. - -An invalid JSON-RPC request (though valid JSON) results in an "Invalid Request" -response. In these two cases the handler callback function is never called. - -To produce an error response from the handler function, you may throw one of -the exceptions below. They will be caught and turned into a corresponding -JSON-RPC error response. - - * `throw(method_not_found)` is reported as "Method not found" (-32601) - * `throw(invalid_params)` is reported as "Invalid params" (-32602) - * `throw(internal_error)` is reported as "Internal error" (-32603) - * `throw(server_error)` is reported as "Server error" (-32000) -If you also want to include `data` in the JSON-RPC error response, throw a pair -with the error type and the data, such as `{internal_error, Data}`. - -For your own *application-defined errors*, it is possible to set a custom error -code by throwing a tuple with the atom `jsonrpc2`, an integer error code, a -binary message and optional data. - - * `throw({jsonrpc2, Code, Message)` - * `throw({jsonrpc2, Code, Message, Data})` - -If any other exception is thrown or an error occurs in the handler, this is -caught, an error message is logged (using the standard error logger -`error_logger:error_msg/2`) and an "Internal error" response is returned. +```erlang +1> RequestsSpec = [ + #{method => add, params => [7000, -77]}, + #{method => add, params => [-1, 900], id => 0} + ]. +[#{params => [7000,-77],method => add}, + #{id => 0,params => [-1,900],method => add}] -If you're working with already parsed JSON, i.e. you're using `handle/2` or -`handle/3`, you may want to produce an error message that you can use when the -client sends invalid JSON that can't be parsed. Use `jsonrpc2:parseerror()` to -create the appropriate error response for this purpose. +2> TransportFn = fun(Body) -> {ok, {_,_,Resp}} = httpc:request(post, {"http://localhost:8080/rpc", [{"Content-Length", integer_to_binary(iolist_size(Body))}], "application/json", Body}, [], [{body_format, binary}]), Resp end. +#Fun -Examples: +jsonrpc2_client:multi_call(RequestsSpec, TransportFn, 1). +[{ok,899,undefined}] -```erlang -my_handler(<<"Foo">>, [X, Y]) when is_integer(X), is_integer(Y) -> - {[{<<"Foo says">>}, X + Y + 42}]}; -my_handler(<<"Foo">>, _SomeOtherParams) -> - throw(invalid_params); -my_handler(<<"Logout">>, [Username]) -> - throw({jsonrpc2, 123, <<"Not logged in">>}); -my_handler(_SomeOtherMethod, _) -> - throw(method_not_found). ``` -Compatible JSON parsers ------------------------ - -* Jiffy, https://github.com/davisp/jiffy -* erlang-json, https://github.com/hio/erlang-json -* Mochijson2 using ```mochijson2:decode(Bin, [{format, eep18}])``` -* Probably more... Links ----- @@ -163,11 +107,13 @@ Links * rjsonrpc2, a "restricted" implementation of JSON-RPC 2.0, https://github.com/imprest/rjsonrpc2 * ejrpc2, another JSON-RPC 2 library, https://github.com/jvliwanag/ejrpc2 + License ------- ``` Copyright 2013-2014 Viktor Söderqvist +Copyright 2025 Andy (https://github.com/m-2k) Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -181,15 +127,3 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` - -**Author's note:** -The Apache 2.0 is a very permissive license just like MIT and BSD, but as -FSF notes, it includes "certain patent termination and indemnification -provisions", which is a good thing. We (the authours) cannot come to you -(the users) to claim any patents we might have on something in the code. - -If you have any compatibility issues with this license, keep in mind that if -you're using this as an external dependency (e.g. with Rebar or Erlang.mk) -you're not actually distributing this dependency anyway. Even if you do -distribute dependencies, they are not actually linked together until they -are loaded and run in the BEAM unless you compile the release with HiPE. diff --git a/src/jsonrpc2.app.src b/src/jsonrpc2.app.src index c537214..c18b6b4 100644 --- a/src/jsonrpc2.app.src +++ b/src/jsonrpc2.app.src @@ -1,4 +1,5 @@ %% Copyright 2013-2014 Viktor Söderqvist +%% Copyright 2025 Andy (https://github.com/m-2k) %% %% Copying and distribution of this file, with or without modification, %% are permitted in any medium without royalty provided the copyright @@ -7,7 +8,7 @@ {application, jsonrpc2, [ {description, "JSON-RPC 2.0 request handler"}, - {vsn, "0.9.2"}, + {vsn, "2.0.0"}, {modules, [jsonrpc2, jsonrpc2_client]}, {registered, []}, {applications, [ diff --git a/src/jsonrpc2.erl b/src/jsonrpc2.erl index f03a763..9174233 100644 --- a/src/jsonrpc2.erl +++ b/src/jsonrpc2.erl @@ -1,4 +1,5 @@ %% Copyright 2013-2014 Viktor Söderqvist +%% Copyright 2025 Andy (https://github.com/m-2k) %% %% Licensed under the Apache License, Version 2.0 (the "License"); %% you may not use this file except in compliance with the License. @@ -12,440 +13,731 @@ %% See the License for the specific language governing permissions and %% limitations under the License. -%% @doc This module handles JSON-RPC 2.0 requests. -%% -%% JSON encoding and decoding is not handled by this module. Thus, it must be -%% handled outside, by the caller. -%% -%% The format of the parsed JSON is the so called eep0018 style terms, where -%% strings are represented as binaries and objects are represented as proplists -%% wrapped in a single element tuple. This format is supported by several JSON -%% parsers. -module(jsonrpc2). --export([handle/2, handle/3, handle/4, handle/5, parseerror/0]). - --type json() :: true | false | null | binary() | [json()] | {[{binary(), json()}]}. --type method() :: binary(). --type params() :: [json()] | {[{binary(), json()}]}. --type id() :: number() | null. --type errortype() :: parse_error | method_not_found | invalid_params | - internal_error | server_error. --type error() :: errortype() | {errortype(), json()} | {jsonrpc2, integer(), binary()} | - {jsonrpc2, integer(), binary(), json()}. --type request() :: {method(), params(), id() | undefined} | invalid_request. --type response() :: {reply, json()} | noreply. - --type handlerfun() :: fun((method(), params()) -> json()). --type mapfun() :: fun((fun((A) -> B), [A]) -> [B]). % should be the same as lists:map/2 - --export_type([json/0, method/0, params/0, id/0, handlerfun/0, mapfun/0, - response/0, errortype/0, error/0]). - -%% @doc Handles a raw JSON-RPC request, using the supplied JSON decode and -%% encode functions. --spec handle(Req::term(), handlerfun(), JsonDecode::fun(), JsonEncode::fun()) -> - noreply | {reply, term()}. -handle(Req, HandlerFun, JsonDecode, JsonEncode) - when is_function(HandlerFun, 2), - is_function(JsonDecode, 1), - is_function(JsonEncode, 1) -> - handle(Req, HandlerFun, fun lists:map/2, JsonDecode, JsonEncode). - -%% @doc Handles a raw JSON-RPC request, using the supplied JSON decode and -%% encode functions and a custom map function. --spec handle(Req::term(), handlerfun(), mapfun(), JsonDecode::fun(), - JsonEncode::fun()) -> noreply | {reply, term()}. -handle(Req, HandlerFun, MapFun, JsonDecode, JsonEncode) - when is_function(HandlerFun, 2), - is_function(MapFun, 2), - is_function(JsonDecode, 1), - is_function(JsonEncode, 1) -> - Response = try JsonDecode(Req) of - DecodedJson -> handle(DecodedJson, HandlerFun, MapFun) - catch - _:_ -> {reply, parseerror()} - end, - case Response of - noreply -> noreply; - {reply, Reply} -> - try JsonEncode(Reply) of - EncodedReply -> {reply, EncodedReply} - catch _:_ -> - error_logger:error_msg("Failed encoding reply as JSON: ~p", - [Reply]), - {reply, Error} = make_standard_error_response(internal_error, null), - {reply, JsonEncode(Error)} - end - end. - -%% @doc Handles the requests using the handler function. Batch requests are -%% handled sequentially. Since this module doesn't handle the JSON encoding and -%% decoding, the request must be JSON decoded before passing it to this -%% function. Likewise, if a reply is returned, it should be JSON encoded before -%% sending it to the client. --spec handle(json(), handlerfun()) -> response(). -handle(Req, HandlerFun) -> - handle(Req, HandlerFun, fun lists:map/2). - -%% @doc Handles the requests using the handler function and a custom map -%% function for batch requests. The map function should be compatible with -%% lists:map/2. This is useful for concurrent processing of batch requests. --spec handle(json(), handlerfun(), mapfun()) -> response(). -handle(Req, HandlerFun, MapFun) -> - case parse(Req) of - BatchRpc when is_list(BatchRpc), length(BatchRpc) > 0 -> - Responses = MapFun(fun(Rpc) -> dispatch(Rpc, HandlerFun) end, BatchRpc), - merge_responses(Responses); - Rpc -> - dispatch(Rpc, HandlerFun) - end. - -%% @doc Returns a jsonrpc2 parse error response. -%% -%% This function can be used to manually create a JSON-SPC error response, for -%% the case when the client sends invalid JSON. This function is exported for -%% completeness, since the JSON encoding and decoding is not handled by this -%% module. --spec parseerror() -> json(). -parseerror() -> - make_error(-32700, <<"Parse error.">>, null). - -%% helpers - --spec make_result_response(json(), id() | undefined) -> response(). -make_result_response(_Result, undefined) -> - noreply; -make_result_response(Result, Id) -> - {reply, {[{<<"jsonrpc">>, <<"2.0">>}, - {<<"result">>, Result}, - {<<"id">>, Id}]}}. - --spec make_standard_error_response(errortype(), id() | undefined) -> response(). -make_standard_error_response(ErrorType, Id) -> - {Code, Msg} = error_code_and_message(ErrorType), - make_error_response(Code, Msg, Id). - --spec make_standard_error_response(errortype(), json(), id() | undefined) -> response(). -make_standard_error_response(ErrorType, Data, Id) -> - {Code, Msg} = error_code_and_message(ErrorType), - make_error_response(Code, Msg, Data, Id). - -%% @doc Custom error, with data --spec make_error_response(integer(), binary(), json(), id() | undefined) -> response(). -make_error_response(_Code, _Message, _Data, undefined) -> - noreply; -make_error_response(Code, Message, Data, Id) -> - {reply, make_error(Code, Message, Data, Id)}. - -%% @doc Custom error, without data --spec make_error_response(integer(), binary(), id() | undefined) -> response(). -make_error_response(_Code, _Message, undefined) -> - noreply; -make_error_response(Code, Message, Id) -> - {reply, make_error(Code, Message, Id)}. - -%% @doc Make json-rpc error response, with data --spec make_error(integer(), binary(), json(), id()) -> json(). -make_error(Code, Msg, Data, Id) -> - {[{<<"jsonrpc">>, <<"2.0">>}, - {<<"error">>, {[{<<"code">>, Code}, - {<<"message">>, Msg}, - {<<"data">>, Data}]}}, - {<<"id">>, Id}]}. - -%% @doc Make json-rpc error response, without data --spec make_error(integer(), binary(), id()) -> json(). -make_error(Code, Msg, Id) -> - {[{<<"jsonrpc">>, <<"2.0">>}, - {<<"error">>, {[{<<"code">>, Code}, - {<<"message">>, Msg}]}}, - {<<"id">>, Id}]}. - -%% @doc Parses the RPC part of an already JSON decoded request. Returns a tuple -%% {Method, Params, Id} for a single request, 'invalid_request' for an invalid -%% request and a list of these for a batch request. An Id value of 'undefined' -%% is used when the id is not present in the request. --spec parse(json()) -> request() | [request()]. -parse(Reqs) when is_list(Reqs) -> - [parse(Req) || Req <- Reqs]; -parse({Req}) -> - Version = proplists:get_value(<<"jsonrpc">>, Req), - Method = proplists:get_value(<<"method">>, Req), - Params = proplists:get_value(<<"params">>, Req, []), - Id = proplists:get_value(<<"id">>, Req, undefined), - case Version =:= <<"2.0">> - andalso is_binary(Method) - andalso (is_list(Params) orelse is_tuple(Params)) - andalso (Id =:= undefined orelse Id =:= null - orelse is_binary(Id) - orelse is_number(Id)) of - true -> - {Method, Params, Id}; - false -> - invalid_request - end; +-include_lib("kernel/include/logger.hrl"). + +-export([ + handle/2, + handle/3, + handle/4, + handle_term/2, + handle_term/3, + handle_term/4 +]). + +-define(MF_SEP, [<<":">>, <<".">>]). +-define(OPAQUE_CTX(Opaque), {opaque, Opaque}). +-define(WITHOUT_OPAQUE, undefined). +-define(ENCODE(Term), json:encode(Term)). +-define(DECODE(Data), case Data of <> -> json:decode(Data); Data -> json:decode(iolist_to_binary(Data)) end). + + +-export_type([ + pre_defined_error/0, + handle_body/0, + handle_term/0, + handler/0, + map_handler/0, + internal_request/0, + internal_request_finished/0 +]). + +-type pre_defined_error() :: + parse_error + | invalid_request + | method_not_found + | invalid_params + | internal_error + | server_error. + +-type internal_request() :: #{ + module => atom(), + function_name := atom(), + params := #{ binary() => json:decode_value() } | [ json:decode_value() ] % this will be an empty list if the field "params" were omitted in the original request +}. + +-type internal_request_finished() :: #{ + module => atom(), + function_name := atom(), + result | error := any() +}. + +-type handle_body() :: iodata(). +-type handle_term() :: json:decode_value(). +-type handler_fn() :: fun((Method :: binary(), Params :: json:decode_value()) -> + ok + | {ok, Result :: any()} + | {error, pre_defined_error() | Reason :: any()} +). + +-type handler_with_opaque_fn() :: fun((Method :: binary(), Params :: json:decode_value(), OpaqueIn :: opaque()) -> + ok + | {ok, Result :: any()} + | {ok, Result :: any(), OpaqueOut :: opaque()} + | {error, pre_defined_error() | Reason :: any()} + | {error, pre_defined_error() | Reason :: any(), OpaqueOut :: opaque()} +). + +-type modules_white_list_and_overrides() :: #{ ModuleFromMethod :: binary() => ModuleOverride :: module() }. +-type handler() :: handler_fn() | module() | modules_white_list_and_overrides(). +-type handler_with_opaque() :: handler_with_opaque_fn() | module() | modules_white_list_and_overrides(). +-type opaque() :: any(). +-type map_handler_fn() ::fun((internal_request(), opaque()) -> {internal_request_finished(), opaque()}). +-type map_handler() :: fun((map_handler_fn(), opaque(), [internal_request()]) -> {[internal_request_finished()], opaque()}). + + +-spec handle(Body :: handle_body(), Handler :: handler()) -> {reply, Reply :: iodata()} | noreply. +handle(Body, Handler) -> + {ReplyBin, OpaqueCtx} = do_handle(Body, Handler, ?WITHOUT_OPAQUE, fun lists:mapfoldl/3), + opaque_on_demand(ReplyBin, OpaqueCtx). + +-spec handle(Body :: handle_body(), Handler :: handler_with_opaque(), Opaque :: opaque()) -> {reply, Reply :: iodata(), opaque()} | {noreply, opaque()}. +handle(Body, Handler, Opaque) -> + {ReplyBin, OpaqueCtx} = do_handle(Body, Handler, ?OPAQUE_CTX(Opaque), fun lists:mapfoldl/3), + opaque_on_demand(ReplyBin, OpaqueCtx). + +-spec handle(Body :: handle_body(), Handler :: handler_with_opaque(), Opaque :: opaque(), MapFoldlFun :: map_handler()) -> {reply, Reply :: iodata(), opaque()} | {noreply, opaque()}. +handle(Body, Handler, Opaque, MapFoldlFun) when is_function(MapFoldlFun, 3) -> + {ReplyBin, OpaqueCtx} = do_handle(Body, Handler, ?OPAQUE_CTX(Opaque), MapFoldlFun), + opaque_on_demand(ReplyBin, OpaqueCtx). + + +-spec handle_term(Term :: handle_term(), Handler :: handler()) -> {reply, Reply :: json:encode_value()} | noreply. +handle_term(Term, Handler) -> + {ReplyTerm, OpaqueCtx} = do_handle_term(Term, Handler, ?WITHOUT_OPAQUE, fun lists:mapfoldl/3), + opaque_on_demand(ReplyTerm, OpaqueCtx). + +-spec handle_term(Term :: handle_term(), Handler :: handler_with_opaque(), Opaque :: opaque()) -> {reply, Reply :: json:encode_value(), opaque()} | {noreply, opaque()}. +handle_term(Term, Handler, Opaque) -> + {ReplyTerm, OpaqueCtx} = do_handle_term(Term, Handler, ?OPAQUE_CTX(Opaque), fun lists:mapfoldl/3), + opaque_on_demand(ReplyTerm, OpaqueCtx). + +-spec handle_term(Term :: handle_term(), Handler :: handler_with_opaque(), Opaque :: opaque(), MapFoldlFun :: map_handler()) -> {reply, Reply :: json:encode_value(), opaque()} | {noreply, opaque()}. +handle_term(Term, Handler, Opaque, MapFoldlFun) when is_function(MapFoldlFun, 3) -> + {ReplyTerm, OpaqueCtx} = do_handle_term(Term, Handler, ?OPAQUE_CTX(Opaque), MapFoldlFun), + opaque_on_demand(ReplyTerm, OpaqueCtx). + + +%%% Private + +do_handle(Body, Handler, OpaqueCtxIn, MapFoldFun) -> + {ReplyTerm, OpaqueCtxOut} = try ?DECODE(Body) of + Term -> + do_handle_term(Term, Handler, OpaqueCtxIn, MapFoldFun) + catch Class1:Reason1 -> + ?LOG_WARNING("Failed to decode request in JSON format: ~tp ~tp", [Class1, Reason1]), + {response(#{error => parse_error}), OpaqueCtxIn} + end, + + Reply = case ReplyTerm of + noreply -> + noreply; + + _ -> + try ?ENCODE(ReplyTerm) + catch Class2:Reason2 -> + ?LOG_ERROR("Failed to encode response in JSON format: ~tp ~tp", [Class2, Reason2]), + ?ENCODE(response(#{error => internal_error })) + end + end, + {Reply, OpaqueCtxOut}. + + +do_handle_term(Term, Handler, OpaqueCtxIn, MapFoldFun) -> + case parse(Term) of + [_|_] = Objectives -> + {Executed, OpaqueCtxOut} = execute(Objectives, Handler, OpaqueCtxIn, MapFoldFun), + Responses = [R || R <- [ response(E) || E <- Executed], R =/= noreply ], + case Responses of + [] -> {noreply, OpaqueCtxOut}; + _ -> {Responses, OpaqueCtxOut} + end; + Objective -> + {[Executed], OpaqueCtxOut} = execute([Objective], Handler, OpaqueCtxIn, MapFoldFun), + {response(Executed), OpaqueCtxOut} + end. + + +opaque_on_demand(noreply, ?OPAQUE_CTX(Opaque)) -> {noreply, Opaque}; +opaque_on_demand(noreply, ?WITHOUT_OPAQUE) -> noreply; +opaque_on_demand(Reply, ?OPAQUE_CTX(Opaque)) -> {reply, Reply, Opaque}; +opaque_on_demand(Reply, ?WITHOUT_OPAQUE) -> {reply, Reply}. + + +response(#{ type := notification}) -> + noreply; +response(#{ error := Error} = E) -> + with_header(#{id => maps:get(id, E, null), error => response_error(Error)}); +response(#{ result := Result, id := Id}) -> + with_header(#{id => Id, result => Result}). + + +response_error({Code, Reason, Data}) when is_integer(Code) andalso (Code < -32768 orelse Code > -32000) -> #{ + code => Code, + message => response_error_message(Reason), + data => Data +}; +response_error({Code, Reason}) when is_integer(Code) andalso (Code < -32768 orelse Code > -32000) -> #{ + code => Code, + message => response_error_message(Reason) +}; +response_error({Reason, Data}) when is_atom(Reason) -> + (response_error_tag(Reason))#{ + data => Data + }; +response_error(Reason) when is_atom(Reason) -> + response_error_tag(Reason). + + +response_error_tag(parse_error) -> #{code => -32700, message => ~"Parse error."}; +response_error_tag(invalid_request) -> #{code => -32600, message => ~"Invalid Request."}; +response_error_tag(method_not_found) -> #{code => -32601, message => ~"Method not found."}; +response_error_tag(invalid_params) -> #{code => -32602, message => ~"Invalid params."}; +response_error_tag(internal_error) -> #{code => -32603, message => ~"Internal error."}; +response_error_tag(server_error) -> #{code => -32000, message => ~"Server error."}; +response_error_tag(A) when is_atom(A) -> #{ + code => erlang:crc32(atom_to_binary(A)), + message => response_error_message(A) +}. + +response_error_message(A) when is_atom(A) -> + <<_:8, _/binary>> = B = atom_to_binary(A), + <<(string:titlecase(binary:replace(B, ~"_", ~" ")))/binary, $.>>; +response_error_message(B) when is_binary(B) -> B. + + +with_header(#{} = E) -> + E#{jsonrpc => ~"2.0"}. + + +parse(Req) when is_map(Req) -> + Funs = [ + fun parse_version/2, + fun parse_id/2, + fun parse_method/2, + fun parse_params/2, + fun(_, Obj) -> inspect_type(Obj) end + ], + ApplyFun = fun + (_, Objective = #{error := _}) -> Objective; + (F, RespAcc) -> F(Req, RespAcc) + end, + _Resp = lists:foldl(ApplyFun, #{}, Funs); +parse(Reqs = [_|_]) -> + [ parse(Req) || Req <- Reqs ]; parse(_) -> - invalid_request. - -%% @doc Calls the handler function, catches errors and composes a json-rpc response. --spec dispatch(request(), handlerfun()) -> response(). -dispatch({Method, Params, Id}, HandlerFun) -> - try HandlerFun(Method, Params) of - Response -> make_result_response(Response, Id) - catch - throw:E when E == method_not_found; E == invalid_params; - E == internal_error; E == server_error -> - make_standard_error_response(E, Id); - throw:{E, Data} when E == method_not_found; E == invalid_params; - E == internal_error; E == server_error -> - make_standard_error_response(E, Data, Id); - throw:{jsonrpc2, Code, Message} when is_integer(Code), is_binary(Message) -> - %% Custom error, without data - %% -32000 to -32099 Server error Reserved for implementation-defined server-errors. - %% The remainder of the space is available for application defined errors. - make_error_response(Code, Message, Id); - throw:{jsonrpc2, Code, Message, Data} when is_integer(Code), is_binary(Message) -> - %% Custom error, with data - make_error_response(Code, Message, Data, Id); - Class:Error -> - error_logger:error_msg( - "Error in JSON-RPC handler for method ~s with params ~p (id: ~p): ~p:~p from ~p", - [Method, Params, Id, Class, Error, erlang:get_stacktrace()]), - make_standard_error_response(internal_error, Id) - end; -dispatch(_, _HandlerFun) -> - make_standard_error_response(invalid_request, null). - -%% @doc Returns JSON-RPC error code and error message -error_code_and_message(invalid_request) -> {-32600, <<"Invalid Request.">>}; -error_code_and_message(method_not_found) -> {-32601, <<"Method not found.">>}; -error_code_and_message(invalid_params) -> {-32602, <<"Invalid params.">>}; -error_code_and_message(internal_error) -> {-32603, <<"Internal error.">>}; -error_code_and_message(server_error) -> {-32000, <<"Server error.">>}. - -%% @doc Transforms a list of responses into a single response. --spec merge_responses([response()]) -> response(). -merge_responses(Responses) when is_list(Responses) -> - case [Reply || {reply, Reply} <- Responses] of - [] -> noreply; - Replies -> {reply, Replies} - end. + #{error => invalid_request}. + + +parse_version(#{<<"jsonrpc">> := <<"2.0">> = V}, Objective) -> Objective#{version => V}; +parse_version(_, Objective) -> Objective#{error => invalid_request}. + + +parse_id(#{<<"id">> := Id = null}, Objective) -> Objective#{id => Id}; +parse_id(#{<<"id">> := Id = <<_:8, _/binary>>}, Objective) -> Objective#{id => Id}; +parse_id(#{<<"id">> := Id}, Objective) when is_integer(Id) -> Objective#{id => Id}; +parse_id(#{<<"id">> := _}, Objective) -> Objective#{error => invalid_request}; +parse_id(#{ }, Objective) -> Objective; +parse_id(_, Objective) -> Objective#{error => invalid_request}. + + +parse_method(#{<<"method">> := <<_:8, _/binary>> = Method}, Objective) -> + Objective#{method => Method}; +parse_method(_, Objective) -> + Objective#{error => invalid_request}. + + +parse_params(#{<<"params">> := Params}, Objective) when is_list(Params); is_map(Params) -> + Objective#{params => Params}; +parse_params(#{<<"params">> := _}, Objective) -> + Objective#{error => invalid_request}; +parse_params(#{}, Objective) -> + Objective#{params => []}. + + +inspect_type(#{id := _} = Objective) -> + Objective#{type => request}; +inspect_type(#{} = Objective) -> + Objective#{type => notification}. + + +execute(Objectives, Handler, ?WITHOUT_OPAQUE, MapFoldFun) -> + MapFoldFun(fun + (#{ error := _} = ObjA, _) -> + {ObjA, ?WITHOUT_OPAQUE}; + (ObjA, _) -> + Arity = method_arity(ObjA), + case inspect_handler(ObjA, Handler, Arity) of + #{ error := _} = ObjB -> + {ObjB, ?WITHOUT_OPAQUE}; + ObjB -> + {_, _} = dispatch(ObjB, ?WITHOUT_OPAQUE) + end + end, ?WITHOUT_OPAQUE, Objectives); + +execute(Objectives, Handler, ?OPAQUE_CTX(Opaque), MapFoldFun) -> + {Res, ResOpaque} = MapFoldFun(fun + (#{ error := _} = ObjA, OpaqueIn) -> % errors also go through MapFoldFun for possible request counting + {ObjA, OpaqueIn}; + (ObjA, OpaqueIn) -> + Arity = method_arity(ObjA) + 1, + case inspect_handler(ObjA, Handler, Arity) of % inside MapFioldFun for possible dynamic module loading + #{ error := _} = ObjB -> + {ObjB, OpaqueIn}; + ObjB -> + {ObjC, ?OPAQUE_CTX(OpaqueOut)} = dispatch(ObjB, ?OPAQUE_CTX(OpaqueIn)), + {ObjC, OpaqueOut} + end + end, Opaque, Objectives), + {Res, ?OPAQUE_CTX(ResOpaque)}. + + +dispatch(Objective, ?OPAQUE_CTX(OpaqueIn)) -> + Result = case Objective of + #{module := Mod, function_name := F, params := #{} = P} -> Mod:F(P, OpaqueIn); + #{module := Mod, function_name := F, params := P} -> erlang:apply(Mod, F, P ++ [OpaqueIn]); + #{method := Method, function := Fun, params := P} -> Fun(Method, P, OpaqueIn) + end, + #{type := Type} = ObjectiveOut = maps:without([params], Objective), + case {Type, Result} of + {request, {ok, Ret, OpaqueOut}} -> {ObjectiveOut#{result => Ret}, ?OPAQUE_CTX(OpaqueOut)}; + {request, {ok, Ret}} -> {ObjectiveOut#{result => Ret}, ?OPAQUE_CTX(OpaqueIn)}; + {notification, {ok, OpaqueOut}} -> {ObjectiveOut, ?OPAQUE_CTX(OpaqueOut)}; + {notification, _} -> {ObjectiveOut, ?OPAQUE_CTX(OpaqueIn)}; + {_, {error, Reason, OpaqueOut}} -> {ObjectiveOut#{error => Reason}, ?OPAQUE_CTX(OpaqueOut)}; + {_, {error, Reason}} -> {ObjectiveOut#{error => Reason}, ?OPAQUE_CTX(OpaqueIn)} + end; + +dispatch(Objective, ?WITHOUT_OPAQUE) -> + Result = case Objective of + #{module := Mod, function_name := F, params := #{} = P} -> Mod:F(P); + #{module := Mod, function_name := F, params := P} -> erlang:apply(Mod, F, P); + #{method := Method, function := Fun, params := P} -> Fun(Method, P) + end, + #{type := Type} = ObjectiveOut = maps:without([params], Objective), + + case {Type, Result} of + {request, {ok, Ret}} -> {ObjectiveOut#{result => Ret}, ?WITHOUT_OPAQUE}; + {notification, _} -> {ObjectiveOut, ?WITHOUT_OPAQUE}; + {_, {error, Reason}} -> {ObjectiveOut#{error => Reason}, ?WITHOUT_OPAQUE} + end. + + +inspect_handler(Objective, HandlerFun, _Arity) when is_function(HandlerFun, 3) orelse is_function(HandlerFun, 2) -> + Objective#{ function => HandlerFun }; + +inspect_handler(Objective = #{ method := M}, HandlerModule, Arity) when is_atom(HandlerModule) -> + maybe + {ok, FunctionName} ?= catch {ok, binary_to_existing_atom(M)}, + true ?= erlang:function_exported(HandlerModule, FunctionName, Arity), + Objective#{ module => HandlerModule, function_name => FunctionName } + else + _ -> Objective#{ error => method_not_found } + end; + +inspect_handler(Objective = #{ method := M}, HandlerAllowedModules, Arity) when is_map(HandlerAllowedModules) -> + maybe + [ModuleBin, MethodBin] ?= binary:split(M, ?MF_SEP), + #{ModuleBin := HandlerModule} ?= HandlerAllowedModules, + {ok, FunctionName} ?= catch {ok, binary_to_existing_atom(MethodBin)}, + true ?= erlang:function_exported(HandlerModule, FunctionName, Arity), + Objective#{ module => HandlerModule, function_name => FunctionName } + else + _ -> Objective#{ error => method_not_found } + end. + + +method_arity(#{ params := #{} }) -> 1; +method_arity(#{ params := [] }) -> 0; +method_arity(#{ params := [_|_] = P }) -> length(P). + + +%%------------ +%% Unit tests +%%------------ -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). +-compile(export_all). + +test_make_object(Id) -> #{<<"jsonrpc">> => <<"2.0">>, <<"id">> => Id}. +test_make_object(Id, Method) -> (test_make_object(Id))#{<<"method">> => Method}. +test_make_object(Id, Method, Params) -> (test_make_object(Id, Method))#{<<"params">> => Params}. + +'parse/1_positive_test'() -> + ?assertEqual(#{type => request, id => 567,version => <<"2.0">>, params => [], method => <<"X">>}, + jsonrpc2:parse(test_make_object(567, <<88>>, []))), + ?assertEqual(#{type => request, id => -10567,version => <<"2.0">>, params=>#{a=>null, b=>true}, method => <<"mod:fun">>}, + jsonrpc2:parse(test_make_object(-10567, <<"mod:fun">>, #{a=>null, b=>true}))), + ?assertEqual(#{type => request, id => null, version => <<"2.0">>, params => [], method => <<"Y">>}, + jsonrpc2:parse(test_make_object(null, <<89>>, []))). + +'parse/1_negative_test'() -> + ?assertEqual(#{error => invalid_request}, + jsonrpc2:parse(#{})), + ?assertEqual(#{error => invalid_request}, + jsonrpc2:parse([])), + ?assertEqual(#{error => invalid_request}, + jsonrpc2:parse(<<>>)), + ?assertEqual(#{error => invalid_request}, + jsonrpc2:parse(#{<<"jsonrpc">> => <<"2.00">>})), + ?assertEqual(#{error => invalid_request,version => <<"2.0">>}, + jsonrpc2:parse(#{<<"jsonrpc">> => <<"2.0">>})), + ?assertEqual(#{error => invalid_request,id => <<"x-m">>,version => <<"2.0">>}, + jsonrpc2:parse(test_make_object(<<"x-m">>))), + ?assertEqual(#{error => invalid_request, id => -10568, version => <<"2.0">>}, + jsonrpc2:parse(test_make_object(-10568, undefined, #{a=>null, b=>true}))), + ?assertEqual(#{error => invalid_request,version => <<"2.0">>}, + jsonrpc2:parse(test_make_object(undefined, <<87>>, []))), + ?assertEqual(#{error => invalid_request, version => <<"2.0">>}, + jsonrpc2:parse(test_make_object(#{}))). + + + +test_handler(M, A, O) -> + case test_handler(M, A) of + ok -> {ok, O + 1}; + T -> list_to_tuple(tuple_to_list(T) ++ [O + 1]) + end. + +test_handler(<<"subtract">>, [A, B]) -> {ok, A - B}; +test_handler(<<"subtract">>, #{<<"subtrahend">> := 23, <<"minuend">> := 42}) -> {ok, 19}; +test_handler(<<"update">>, [1, 2, 3, 4, 5]) -> ok; +test_handler(<<"sum">>, [1, 2, 4]) -> {ok, 7}; +test_handler(<<"get_data">>, []) -> {ok, [<<"hello">>, 5]}; +test_handler(_, _) -> {error, method_not_found}. + + +test_handler_module_subtract(A, B) -> {ok, A - B}. +test_handler_module_subtract(A, B, C) -> {ok, A - B - C}. + +test_handler_module_subtract_opaque(A, B, O) -> {ok, A - B, O + 1}. +test_handler_module_subtract_opaque(A, B, C, O) -> {ok, A - B - C, O + 1}. + +test_handler_module_notify() -> any_term. +test_handler_module_notify_opaque(O) -> {ok, O + 1}. + + +-define(OBJ(M), maps:merge(#{jsonrpc => ~"2.0"}, M)). +-define(BIN(M), iolist_to_binary(json:encode(M))). +-define(TERM(M), json:decode(?BIN(M))). +-define(DECODE_REPLY(R), json:decode(iolist_to_binary(R))). + +-define(REQUEST_TERMS, [ ?TERM(?OBJ(#{id => -N, method => subtract, params => [N+11, 11]})) || N <- lists:seq(10, 20) ]). +-define(EXPECTED_TERMS, [ ?OBJ(#{id => -N, result => N}) || N <- lists:seq(10, 20) ]). + +'handle_term/2_test'() -> + Result = handle_term(hd(?REQUEST_TERMS), fun test_handler/2), + ?assertMatch({reply, _}, Result), + ?assertEqual(hd(?EXPECTED_TERMS), element(2, Result)). + +'handle_term/2_batch_test'() -> + [ begin + Result = handle_term(lists:sublist(?REQUEST_TERMS, C), fun test_handler/2), + ?assertMatch({reply, _}, Result), + ?assertEqual(lists:sublist(?EXPECTED_TERMS, C), element(2, Result)) + end || C <- lists:seq(1, length(?REQUEST_TERMS)) ]. + +'handle_raw_/3_test'() -> + Opaque = 1001, + Result = handle_term(hd(?REQUEST_TERMS), fun test_handler/3, Opaque), + ?assertMatch({reply, _, _}, Result), + ?assertEqual(hd(?EXPECTED_TERMS), element(2, Result)), + ?assertEqual(Opaque + 1, element(3, Result)). + +'handle_term/3_batch_test'() -> + Opaque = 1001, + [ begin + Result = handle_term(lists:sublist(?REQUEST_TERMS, C), fun test_handler/3, Opaque), + ?assertMatch({reply, _, _}, Result), + ?assertEqual(lists:sublist(?EXPECTED_TERMS, C), element(2, Result)), + ?assertEqual(Opaque + C, element(3, Result)) + end || C <- lists:seq(1, length(?REQUEST_TERMS)) ]. + +'handle/2_test'() -> + Result = handle(?BIN(hd(?REQUEST_TERMS)), fun test_handler/2), + ?assertMatch({reply, _}, Result), + ?assertEqual(?TERM(hd(?EXPECTED_TERMS)), ?DECODE_REPLY(element(2, Result))). + +'handle/2_batch_test'() -> + [ begin + Result = handle(?BIN(lists:sublist(?REQUEST_TERMS, C)), fun test_handler/2), + ?assertMatch({reply, _}, Result), + ?assertEqual(?TERM(lists:sublist(?EXPECTED_TERMS, C)), ?DECODE_REPLY(element(2, Result))) + end || C <- lists:seq(1, length(?REQUEST_TERMS)) ]. + + +'handle/3_test'() -> + Opaque = 1001, + Result = handle(?BIN(hd(?REQUEST_TERMS)), fun test_handler/3, Opaque), + ?assertMatch({reply, _, _}, Result), + ?assertEqual(?TERM(hd(?EXPECTED_TERMS)), ?DECODE_REPLY(element(2, Result))), + ?assertEqual(Opaque + 1, element(3, Result)). + +'handle/3_batch_test'() -> + Opaque = 1001, + [ begin + Result = handle(?BIN(lists:sublist(?REQUEST_TERMS, C)), fun test_handler/3, Opaque), + ?assertMatch({reply, _, _}, Result), + ?assertEqual(?TERM(lists:sublist(?EXPECTED_TERMS, C)), ?DECODE_REPLY(element(2, Result))), + ?assertEqual(Opaque + C, element(3, Result)) + end || C <- lists:seq(1, length(?REQUEST_TERMS)) ]. + + +'handle/2_notify_test'() -> + Notify = ?OBJ(#{method => update, params => [1, 2, 3, 4, 5]}), + ?assertEqual(noreply, handle(?BIN(Notify), fun test_handler/2)), + ?assertEqual(noreply, handle(?BIN([Notify]), fun test_handler/2)), + ?assertEqual(noreply, handle(?BIN([Notify, Notify]), fun test_handler/2)), + + Request = ?OBJ(#{method => sum, params => [1, 2, 4], id => 99}), + Result = handle(?BIN([Notify, Request,Notify]), fun test_handler/2), + ?assertMatch({reply, _}, Result), + ?assertEqual(?TERM([?OBJ(#{id => 99, result => 7})]), ?DECODE_REPLY(element(2, Result))). + + +'handle/3_notify_test'() -> + Opaque = 1001, + Notify = ?OBJ(#{method => update, params => [1, 2, 3, 4, 5]}), + ?assertEqual({noreply, 1002}, handle(?BIN(Notify), fun test_handler/3, Opaque)), + ?assertEqual({noreply, 1002}, handle(?BIN([Notify]), fun test_handler/3, Opaque)), + ?assertEqual({noreply, 1003}, handle(?BIN([Notify, Notify]), fun test_handler/3, Opaque)), + + Request = ?OBJ(#{method => sum, params => [1, 2, 4], id => -99}), + Result = handle(?BIN([Notify, Request,Notify]), fun test_handler/3, Opaque), + ?assertMatch({reply, _, _}, Result), + ?assertEqual(?TERM([?OBJ(#{id => -99, result => 7})]), ?DECODE_REPLY(element(2, Result))), + ?assertEqual(Opaque + 3, element(3, Result)). + +'handle/2_module_test'() -> + Request = ?OBJ(#{id => 76, method => test_handler_module_subtract, params => [-11, 20]}), + Expected = ?OBJ(#{id => 76, result => -31}), + Result = handle(?BIN(Request), ?MODULE), + ?assertMatch({reply, _}, Result), + ?assertEqual(?TERM(Expected), ?DECODE_REPLY(element(2, Result))). + +'handle/3_module_test'() -> + Opaque = 1001, + Request = ?OBJ(#{id => 77, method => test_handler_module_subtract_opaque, params => [-11, 20]}), + Expected = ?OBJ(#{id => 77, result => -31}), + Result = handle(?BIN(Request), ?MODULE, Opaque), + ?assertMatch({reply, _,_}, Result), + ?assertEqual(?TERM(Expected), ?DECODE_REPLY(element(2, Result))), + ?assertEqual(Opaque + 1, element(3, Result)). + +'handle/2_allowed_modules_test'() -> + Method1 = ~"FUN_SCOPE_1:test_handler_module_subtract", + Method2 = ~"FUN_SCOPE_2:test_handler_module_subtract", + MethodNotFound1 = ~"FUN_SCOPE_NOT_FOUND:test_handler_module_subtract", + MethodNotFound2 = ~"FUN_SCOPE_1:test_handler_module_subtract_NOT_FOUND", + AllowedOverrides = #{~"FUN_SCOPE_1" => ?MODULE, ~"FUN_SCOPE_2" => ?MODULE}, + + Request = [ + ?OBJ(#{id => 78, method => Method1, params => [-11, 21]}), + ?OBJ(#{id => 79, method => Method2, params => [-11, 22]}), + ?OBJ(#{id => 80, method => MethodNotFound1, params => [0, 0]}), + ?OBJ(#{id => 81, method => MethodNotFound2, params => [0, 0]}) + ], + + Expected = [ + ?OBJ(#{id => 78, result => -32}), + ?OBJ(#{id => 79, result => -33}), + ?OBJ(#{id => 80, error => #{code => -32601, message => ~"Method not found."}}), + ?OBJ(#{id => 81, error => #{code => -32601, message => ~"Method not found."}}) + ], + Result = handle(?BIN(Request), AllowedOverrides), + ?assertMatch({reply, _}, Result), + ?assertEqual(?TERM(Expected), ?DECODE_REPLY(element(2, Result))). + +'handle/3_allowed_modules_test'() -> + Method1 = ~"FUN_SCOPE_1:test_handler_module_subtract_opaque", + Method2 = ~"FUN_SCOPE_2:test_handler_module_subtract_opaque", + MethodNotFound1 = ~"FUN_SCOPE_NOT_FOUND:test_handler_module_subtract_opaque", + MethodNotFound2 = ~"FUN_SCOPE_1:test_handler_module_subtract_opaque_NOT_FOUND", + AllowedOverrides = #{~"FUN_SCOPE_1" => ?MODULE, ~"FUN_SCOPE_2" => ?MODULE}, + + Opaque = 1001, + Request = [ + ?OBJ(#{id => 82, method => Method1, params => [-11, 21]}), + ?OBJ(#{id => 83, method => Method2, params => [-11, 22]}), + ?OBJ(#{id => 84, method => MethodNotFound1, params => [0, 0]}), + ?OBJ(#{id => 85, method => MethodNotFound2, params => [0, 0]}) + ], + + Expected = [ + ?OBJ(#{id => 82, result => -32}), + ?OBJ(#{id => 83, result => -33}), + ?OBJ(#{id => 84, error => #{code => -32601, message => ~"Method not found."}}), + ?OBJ(#{id => 85, error => #{code => -32601, message => ~"Method not found."}}) + ], + Result = handle(?BIN(Request), AllowedOverrides, Opaque), + ?assertMatch({reply, _, _}, Result), + ?assertEqual(?TERM(Expected), ?DECODE_REPLY(element(2, Result))), + ?assertEqual(Opaque + 2, element(3, Result)). + +% TODO: handle/4 tests -%% Testing the examples from http://www.jsonrpc.org/specification -test_handler(<<"subtract">>, [42,23]) -> 19; -test_handler(<<"subtract">>, [23,42]) -> -19; -test_handler(<<"subtract">>, {[{<<"subtrahend">>,23},{<<"minuend">>,42}]}) -> 19; -test_handler(<<"subtract">>, {[{<<"minuend">>,42},{<<"subtrahend">>,23}]}) -> 19; -test_handler(<<"update">>, [1,2,3,4,5]) -> ok; -test_handler(<<"sum">>, [1,2,4]) -> 7; -test_handler(<<"get_data">>, []) -> [<<"hello">>,5]; -test_handler(_, _) -> throw(method_not_found). +%% Testing the examples from http://www.jsonrpc.org/specification %% rpc call with positional parameters - call_test() -> - Req = {[{<<"jsonrpc">>,<<"2.0">>}, - {<<"method">>,<<"subtract">>}, - {<<"params">>,[42,23]}, - {<<"id">>,1}]}, - Reply = {[{<<"jsonrpc">>,<<"2.0">>},{<<"result">>,19},{<<"id">>,1}]}, - {reply, Reply} = handle(Req, fun test_handler/2). +call_test() -> + RequestBin = ~'{"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}', + Expected = ?OBJ(#{id => 1, result => 19}), + Result = handle(RequestBin, fun test_handler/2), + ?assertMatch({reply, _}, Result), + ?assertEqual(?TERM(Expected), ?DECODE_REPLY(element(2, Result))). %% rpc call with positional parameters, reverse order call2_test() -> - Req = {[{<<"jsonrpc">>,<<"2.0">>}, - {<<"method">>,<<"subtract">>}, - {<<"params">>,[23,42]}, - {<<"id">>,2}]}, - Reply = {[{<<"jsonrpc">>,<<"2.0">>},{<<"result">>,-19},{<<"id">>,2}]}, - {reply, Reply} = handle(Req, fun test_handler/2). + RequestBin = ~'{"jsonrpc": "2.0", "method": "subtract", "params": [23, 42], "id": 2}', + Expected = ?OBJ(#{id => 2, result => -19}), + Result = handle(RequestBin, fun test_handler/2), + ?assertMatch({reply, _}, Result), + ?assertEqual(?TERM(Expected), ?DECODE_REPLY(element(2, Result))). %% rpc call with named parameters named_test() -> - Req = {[{<<"jsonrpc">>,<<"2.0">>}, - {<<"method">>,<<"subtract">>}, - {<<"params">>,{[{<<"subtrahend">>,23},{<<"minuend">>,42}]}}, - {<<"id">>,3}]}, - Reply = {[{<<"jsonrpc">>,<<"2.0">>},{<<"result">>,19},{<<"id">>,3}]}, - {reply, Reply} = handle(Req, fun test_handler/2). + RequestBin = ~'{"jsonrpc": "2.0", "method": "subtract", "params": {"subtrahend": 23, "minuend": 42}, "id": 3}', + Expected = ?OBJ(#{id => 3, result => 19}), + Result = handle(RequestBin, fun test_handler/2), + ?assertMatch({reply, _}, Result), + ?assertEqual(?TERM(Expected), ?DECODE_REPLY(element(2, Result))). %% rpc call with named parameters, reverse order named2_test() -> - Req = {[{<<"jsonrpc">>,<<"2.0">>}, - {<<"method">>,<<"subtract">>}, - {<<"params">>,{[{<<"minuend">>,42},{<<"subtrahend">>,23}]}}, - {<<"id">>,4}]}, - Reply = {[{<<"jsonrpc">>,<<"2.0">>},{<<"result">>,19},{<<"id">>,4}]}, - {reply, Reply} = handle(Req, fun test_handler/2). + RequestBin = ~'{"jsonrpc": "2.0", "method": "subtract", "params": {"minuend": 42, "subtrahend": 23}, "id": 4}', + Expected = ?OBJ(#{id => 4, result => 19}), + Result = handle(RequestBin, fun test_handler/2), + ?assertMatch({reply, _}, Result), + ?assertEqual(?TERM(Expected), ?DECODE_REPLY(element(2, Result))). %% a Notification notif_test() -> - Req = {[{<<"jsonrpc">>,<<"2.0">>}, - {<<"method">>,<<"update">>}, - {<<"params">>,[1,2,3,4,5]}]}, - noreply = handle(Req, fun test_handler/2). + RequestBin = ~'{"jsonrpc": "2.0", "method": "update", "params": [1,2,3,4,5]}', + ?assertEqual(noreply, handle(RequestBin, fun test_handler/2)). %% a Notification + non-existent method notif2_test() -> - Req = {[{<<"jsonrpc">>,<<"2.0">>},{<<"method">>,<<"foobar">>}]}, - noreply = handle(Req, fun test_handler/2). + RequestBin = ~'{"jsonrpc": "2.0", "method": "foobar"}', + ?assertEqual(noreply, handle(RequestBin, fun test_handler/2)). %% rpc call of non-existent method bad_method_test() -> - Req = {[{<<"jsonrpc">>,<<"2.0">>},{<<"method">>,<<"foobar">>},{<<"id">>,<<"1">>}]}, - Reply = {[{<<"jsonrpc">>,<<"2.0">>}, - {<<"error">>, - {[{<<"code">>,-32601},{<<"message">>,<<"Method not found.">>}]}}, - {<<"id">>,<<"1">>}]}, - {reply, Reply} = handle(Req, fun test_handler/2). + RequestBin = ~'{"jsonrpc": "2.0", "method": "foobar", "id": "1"}', + Expected = ?OBJ(#{id => ~"1", error => #{code => -32601, message => ~"Method not found."}}), + Result = handle(RequestBin, fun test_handler/2), + ?assertMatch({reply, _}, Result), + ?assertEqual(?TERM(Expected), ?DECODE_REPLY(element(2, Result))). %% rpc call with invalid JSON -%% Not applicable, since JSON parsing is not done in this module. We test the error -%% response though. bad_json_test() -> - Expected = {[{<<"jsonrpc">>,<<"2.0">>}, - {<<"error">>,{[{<<"code">>,-32700},{<<"message">>,<<"Parse error.">>}]}}, - {<<"id">>,null}]}, - Reply = parseerror(), - Reply = Expected. + RequestBin = ~'{"jsonrpc": "2.0", "method": "foobar, "params": "bar", "baz]', + Expected = ?OBJ(#{error => #{code => -32700, message => ~"Parse error."}, id => null}), + Result = handle(RequestBin, fun test_handler/2), + ?assertMatch({reply, _}, Result), + ?assertEqual(?TERM(Expected), ?DECODE_REPLY(element(2, Result))). %% rpc call with invalid Request object bad_rpc_test() -> - Req = {[{<<"jsonrpc">>,<<"2.0">>},{<<"method">>,1},{<<"params">>,<<"bar">>}]}, - Reply = {[{<<"jsonrpc">>,<<"2.0">>}, - {<<"error">>,{[{<<"code">>,-32600},{<<"message">>,<<"Invalid Request.">>}]}}, - {<<"id">>,null}]}, - {reply, Reply} = handle(Req, fun test_handler/2). + RequestBin = ~'{"jsonrpc": "2.0", "method": 1, "params": "bar"}', + Expected = ?OBJ(#{error => #{code => -32600, message => ~"Invalid Request."}, id => null}), + Result = handle(RequestBin, fun test_handler/2), + ?assertMatch({reply, _}, Result), + ?assertEqual(?TERM(Expected), ?DECODE_REPLY(element(2, Result))). %% rpc call Batch, invalid JSON: -%% Not applicable, see bad_json_test/0 above. bad_json_batch_test() -> - ok. + RequestBin = ~'[{"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"},{"jsonrpc": "2.0", "method"]', + Expected = ?OBJ(#{error => #{code => -32700, message => ~"Parse error."}, id => null}), + Result = handle(RequestBin, fun test_handler/2), + ?assertMatch({reply, _}, Result), + ?assertEqual(?TERM(Expected), ?DECODE_REPLY(element(2, Result))). %% rpc call with an empty Array empty_batch_test() -> - Req = [], - Reply = {[{<<"jsonrpc">>,<<"2.0">>}, - {<<"error">>,{[{<<"code">>,-32600},{<<"message">>,<<"Invalid Request.">>}]}}, - {<<"id">>,null}]}, - {reply, Reply} = handle(Req, fun test_handler/2). + RequestBin = ~"[]", + Expected = ?OBJ(#{error => #{code => -32600, message => ~"Invalid Request."}, id => null}), + Result = handle(RequestBin, fun test_handler/2), + ?assertMatch({reply, _}, Result), + ?assertEqual(?TERM(Expected), ?DECODE_REPLY(element(2, Result))). %% rpc call with an invalid Batch (but not empty) invalid_batch_test() -> - Req = [1], - Reply = [{[{<<"jsonrpc">>,<<"2.0">>}, - {<<"error">>, - {[{<<"code">>,-32600},{<<"message">>,<<"Invalid Request.">>}]}}, - {<<"id">>,null}]}], - {reply, Reply} = handle(Req, fun test_handler/2). + RequestBin = ~"[1]", + Expected = [?OBJ(#{error => #{code => -32600, message => ~"Invalid Request."}, id => null})], + Result = handle(RequestBin, fun test_handler/2), + ?assertMatch({reply, _}, Result), + ?assertEqual(?TERM(Expected), ?DECODE_REPLY(element(2, Result))). %% rpc call with invalid Batch invalid_batch2_test() -> - Req = [1,2,3], - Reply = [{[{<<"jsonrpc">>,<<"2.0">>}, - {<<"error">>, - {[{<<"code">>,-32600},{<<"message">>,<<"Invalid Request.">>}]}}, - {<<"id">>,null}]}, - {[{<<"jsonrpc">>,<<"2.0">>}, - {<<"error">>, - {[{<<"code">>,-32600},{<<"message">>,<<"Invalid Request.">>}]}}, - {<<"id">>,null}]}, - {[{<<"jsonrpc">>,<<"2.0">>}, - {<<"error">>, - {[{<<"code">>,-32600},{<<"message">>,<<"Invalid Request.">>}]}}, - {<<"id">>,null}]}], - {reply, Reply} = handle(Req, fun test_handler/2). + RequestBin = ~"[1,2,3]", + Resp = ?OBJ(#{error => #{code => -32600, message => ~"Invalid Request."}, id => null}), + Expected = [Resp, Resp, Resp], + Result = handle(RequestBin, fun test_handler/2), + ?assertMatch({reply, _}, Result), + ?assertEqual(?TERM(Expected), ?DECODE_REPLY(element(2, Result))). %% rpc call Batch batch_test() -> - Req = [{[{<<"jsonrpc">>,<<"2.0">>}, - {<<"method">>,<<"sum">>}, - {<<"params">>,[1,2,4]}, - {<<"id">>,<<"1">>}]}, - {[{<<"jsonrpc">>,<<"2.0">>}, - {<<"method">>,<<"notify_hello">>}, - {<<"params">>,[7]}]}, - {[{<<"jsonrpc">>,<<"2.0">>}, - {<<"method">>,<<"subtract">>}, - {<<"params">>,[42,23]}, - {<<"id">>,<<"2">>}]}, - {[{<<"foo">>,<<"boo">>}]}, - {[{<<"jsonrpc">>,<<"2.0">>}, - {<<"method">>,<<"foo.get">>}, - {<<"params">>,{[{<<"name">>,<<"myself">>}]}}, - {<<"id">>,<<"5">>}]}, - {[{<<"jsonrpc">>,<<"2.0">>}, - {<<"method">>,<<"get_data">>}, - {<<"id">>,<<"9">>}]}], - Reply = [{[{<<"jsonrpc">>,<<"2.0">>},{<<"result">>,7},{<<"id">>,<<"1">>}]}, - {[{<<"jsonrpc">>,<<"2.0">>},{<<"result">>,19},{<<"id">>,<<"2">>}]}, - {[{<<"jsonrpc">>,<<"2.0">>}, - {<<"error">>, - {[{<<"code">>,-32600},{<<"message">>,<<"Invalid Request.">>}]}}, - {<<"id">>,null}]}, - {[{<<"jsonrpc">>,<<"2.0">>}, - {<<"error">>, - {[{<<"code">>,-32601},{<<"message">>,<<"Method not found.">>}]}}, - {<<"id">>,<<"5">>}]}, - {[{<<"jsonrpc">>,<<"2.0">>}, - {<<"result">>,[<<"hello">>,5]}, - {<<"id">>,<<"9">>}]}], - {reply, Reply} = handle(Req, fun test_handler/2). + RequestBin = ~'[ + {"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"}, + {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]}, + {"jsonrpc": "2.0", "method": "subtract", "params": [42,23], "id": "2"}, + {"foo": "boo"}, + {"jsonrpc": "2.0", "method": "foo.get", "params": {"name": "myself"}, "id": "5"}, + {"jsonrpc": "2.0", "method": "get_data", "id": "9"} + ]', + Expected = [ + ?OBJ(#{id => ~"1", result => 7}), + ?OBJ(#{id => ~"2", result => 19}), + ?OBJ(#{error => #{code => -32600, message => ~"Invalid Request."}, id => null}), + ?OBJ(#{error => #{code => -32601, message => ~"Method not found."}, id => ~"5"}), + ?OBJ(#{id => ~"9", result => [hello, 5]}) + ], + Result = handle(RequestBin, fun test_handler/2), + ?assertMatch({reply, _}, Result), + ?assertEqual(?TERM(Expected), ?DECODE_REPLY(element(2, Result))). %% rpc call Batch (all notifications) batch_notif_test() -> - Req = [{[{<<"jsonrpc">>,<<"2.0">>}, - {<<"method">>,<<"notify_sum">>}, - {<<"params">>,[1,2,4]}]}, - {[{<<"jsonrpc">>,<<"2.0">>}, - {<<"method">>,<<"notify_hello">>}, - {<<"params">>,[7]}]}], - noreply = handle(Req, fun test_handler/2). - --define(ENCODED_REQUEST, <<"{\"jsonrpc\":\"2.0\"," - "\"method\":\"subtract\"," - "\"params\":[42,23]," - "\"id\":1}">>). --define(DECODED_REQUEST, {[{<<"jsonrpc">>,<<"2.0">>}, - {<<"method">>,<<"subtract">>}, - {<<"params">>,[42,23]}, - {<<"id">>,1}]}). --define(ENCODED_RESPONSE, <<"{\"jsonrpc\":\"2.0\"," - "\"result\":19," - "\"id\":1}">>). --define(DECODED_RESPONSE, {[{<<"jsonrpc">>,<<"2.0">>}, - {<<"result">>,19}, - {<<"id">>,1}]}). --define(ENCODED_PARSE_ERROR, <<"{\"jsonrpc\":\"2.0\"," - "\"error\":{\"code\":-32700," - "\"message\":\"Parse error.\"}," - "\"id\":null}">>). --define(DECODED_PARSE_ERROR, {[{<<"jsonrpc">>,<<"2.0">>}, - {<<"error">>, - {[{<<"code">>,-32700}, - {<<"message">>,<<"Parse error.">>}]}}, - {<<"id">>,null}]}). - -%% define json encode and decode only for the cases we need in the tests -json_decode(?ENCODED_REQUEST) -> ?DECODED_REQUEST. -json_encode(?DECODED_RESPONSE) -> ?ENCODED_RESPONSE; -json_encode(?DECODED_PARSE_ERROR) -> ?ENCODED_PARSE_ERROR. - -%% test handle/4 with encode and decode callbacks -json_callbacks_test() -> - Req = ?ENCODED_REQUEST, - Reply = ?ENCODED_RESPONSE, - {reply, Reply} = handle(Req, fun test_handler/2, fun json_decode/1, - fun json_encode/1). - -parse_error_test() -> - Error = ?ENCODED_PARSE_ERROR, - {reply, Error} = handle(<<"dummy">>, fun test_handler/2, fun json_decode/1, - fun json_encode/1). + RequestBin = ~'[ + {"jsonrpc": "2.0", "method": "notify_sum", "params": [1,2,4]}, + {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]} + ]', + ?assertEqual(noreply, handle(RequestBin, fun test_handler/2)). + +response_error_test() -> + ?assertEqual(#{code => -32769, message => <<>>, data => #{a => b}}, response_error({-32769, <<>>, #{a => b}})), + ?assertEqual(#{code => -31999, message => ~"a", data => #{a => b}}, response_error({-31999, ~"a", #{a => b}})), + ?assertError(function_clause, response_error({-32768, <<>>, #{}})), + ?assertError(function_clause, response_error({-32000, <<>>, #{}})), + + ?assertEqual(#{code => -32769, message => <<>>}, response_error({-32769, <<>>})), + ?assertEqual(#{code => -31999, message => ~"a"}, response_error({-31999, ~"a"})), + ?assertError(function_clause, response_error({-32768, <<>>})), + ?assertError(function_clause, response_error({-32000, <<>>})), + + Data = #{ + parse_error => #{code => -32700, message => ~"Parse error."}, + invalid_request => #{code => -32600, message => ~"Invalid Request."}, + method_not_found => #{code => -32601, message => ~"Method not found."}, + invalid_params => #{code => -32602, message => ~"Invalid params."}, + internal_error => #{code => -32603, message => ~"Internal error."}, + server_error => #{code => -32000, message => ~"Server error."} + }, + + [ begin + X = #{rand:uniform(1000) => rand:uniform(1000)}, + ?assertEqual(Expected#{data => X}, response_error({Reason, X})), + ?assertEqual(Expected, response_error(Reason)) + end || Reason := Expected <- Data ], + + ?assertEqual(#{code => 2732708230, message => ~"User defined."}, response_error(user_defined)), + ?assertEqual(#{code => 629900081, message => ~"User defined for Great Good."}, response_error('user defined for Great Good')). + -endif. diff --git a/src/jsonrpc2_client.erl b/src/jsonrpc2_client.erl index ace115b..48c362d 100644 --- a/src/jsonrpc2_client.erl +++ b/src/jsonrpc2_client.erl @@ -1,4 +1,5 @@ %% Copyright 2013-2014 Viktor Söderqvist +%% Copyright 2025 Andy (https://github.com/m-2k) %% %% Licensed under the Apache License, Version 2.0 (the "License"); %% you may not use this file except in compliance with the License. @@ -15,173 +16,236 @@ %% @doc JSON-RPC 2.0 client -module(jsonrpc2_client). --export_type([request/0, response/0]). --export([create_request/1, parse_response/1, batch_call/5]). - --type call_req() :: {jsonrpc2:method(), jsonrpc2:params(), jsonrpc2:id()}. --type notification_req() :: {jsonrpc2:method(), jsonrpc2:params()}. --type batch_req() :: [call_req() | notification_req()]. --type request() :: call_req() | notification_req() | batch_req(). - --type response() :: {ok, jsonrpc2:json()} | {error, jsonrpc2:error()}. - --type transportfun() :: fun ((binary()) -> binary()). --type json_encode() :: fun ((jsonrpc2:json()) -> binary()). --type json_decode() :: fun ((binary()) -> jsonrpc2:json()). - -%% @doc Creates a call, notification or batch request, depending on the parameter. --spec create_request(request()) -> jsonrpc2:json(). -create_request({Method, Params}) -> - {[{<<"jsonrpc">>, <<"2.0">>}, - {<<"method">>, Method}, - {<<"params">>, Params}]}; -create_request({Method, Params, Id}) -> - {[{<<"jsonrpc">>, <<"2.0">>}, - {<<"method">>, Method}, - {<<"params">>, Params}, - {<<"id">>, Id}]}; -create_request(Reqs) when is_list(Reqs) -> - lists:map(fun create_request/1, Reqs). - -%% @doc Parses a structured response (already json-decoded) and returns a list of pairs, with id -%% and a tuple {ok, Reply} or {error, Error}. -%% TODO: Define the structure of Error. --spec parse_response(jsonrpc2:json()) -> [{jsonrpc2:id(), response()}]. -parse_response({_} = Response) -> - [parse_single_response(Response)]; -parse_response(BatchResponse) when is_list(BatchResponse) -> - lists:map(fun parse_single_response/1, BatchResponse). - -%% @doc Calls multiple methods as a batch call and returns the results in the same order. -%% TODO: Sort out what this function returns in the different error cases. --spec batch_call([{jsonrpc2:method(), jsonrpc2:params()}], transportfun(), - json_decode(), json_encode(), FirstId :: integer()) -> - [response()]. -batch_call(MethodsAndParams, TransportFun, JsonDecode, JsonEncode, FirstId) -> - MethodParamsIds = enumerate_call_tuples(MethodsAndParams, FirstId), - JsonReq = create_request(MethodParamsIds), - BinReq = JsonEncode(JsonReq), - try - %% The transport fun can fail gracefully by throwing {transport_error, binary()} - BinResp = try TransportFun(BinReq) - catch throw:{transport_error, TransportError} when is_binary(TransportError) -> - throw({jsonrpc2_client, TransportError}) - end, - - %% JsonDecode can fail (any kind of error) - JsonResp = try JsonDecode(BinResp) - catch _:_ -> throw({jsonrpc2_client, invalid_json}) - end, - - %% parse_response can fail by throwing invalid_jsonrpc_response - RepliesById = try parse_response(JsonResp) - catch throw:invalid_jsonrpc_response -> - throw({jsonrpc2_client, invalid_jsonrpc_response}) - end, - - %% Decompose the replies into a list in the same order as MethodsAndParams. - LastId = FirstId + length(MethodsAndParams) - 1, - denumerate_replies(RepliesById, FirstId, LastId) - - catch throw:{jsonrpc2_client, ErrorData} -> - %% Failure in transport function. Repeat the error data for each request to - %% simulate a batch response. - lists:duplicate(length(MethodsAndParams), {error, {server_error, ErrorData}}) +-include_lib("kernel/include/logger.hrl"). + +-export_type([ + request_spec/0, + transport_fn/0, + response/0 +]). + +-export([ + call/2, + multi_call/3 +]). + + +-type request_id() :: atom() | binary() | integer() | null | auto. % auto – internal option, uses only with multi_call/3 +-type request_method() :: atom() | binary(). +-type request_params() :: [ json:encode_value() ] | #{ binary() | atom() | integer() => json:encode_value() }. +-type request_context() :: any(). + +-type call_request() :: #{ + id := request_id(), + method := request_method(), + params => request_params(), + context => request_context() +}. + +-type notification_request() :: #{ + method := request_method(), + params => request_params(), + context => request_context() +}. + +-type request_spec() :: call_request() | notification_request(). +-type transport_fn() :: fun ((iodata()) -> binary()). + +-type error_response() :: + #{ + code => integer(), + message => binary(), + data => json:decode_value()} + | #{ + code => integer(), + message => binary()}. + +-type response() :: + {ok, json:decode_value()} + | {error, {invalid_response, json:decode_value()}} + | {error, {error_response, error_response()}}. + + +-spec call(RequestSpec :: request_spec(), TransportFn :: transport_fn()) -> response(). +call(RequestSpec, TransportFn) -> + Req = request(RequestSpec), + maybe + {ok, ReqBin} ?= safe_eval(json_encoder, json, encode, [Req]), + {ok, RespBin} ?= safe_eval(transport, TransportFn, [ReqBin]), + {ok, ReqId} ?= case Req of #{id := Id} -> {ok, Id}; #{} -> ok end, % notification + {ok, Resp} ?= safe_eval(json_decoder, json, decode, [RespBin]), + {ok, _Result} ?= parse_response(Resp, [ReqId]) + else + Result -> Result end. -%%---------- -%% Internal -%%---------- - -%% @doc Helper for parse_response/1. Returns a single pair {Id, Response}. --spec parse_single_response(jsonrpc2:json()) -> {jsonrpc2:id(), response()}. -parse_single_response({Response}) -> - <<"2.0">> == proplists:get_value(<<"jsonrpc">>, Response) - orelse throw(invalid_jsonrpc_response), - Id = proplists:get_value(<<"id">>, Response), - is_number(Id) orelse Id == null - orelse throw(invalid_jsonrpc_response), - Result = proplists:get_value(<<"result">>, Response, undefined), - Error = proplists:get_value(<<"error">>, Response, undefined), - Reply = case {Result, Error} of - {undefined, undefined} -> - {error, {server_error, <<"Invalid JSON-RPC 2.0 response">>}}; - {_, undefined} -> - {ok, Result}; - {undefined, {ErrorProplist}} -> - Code = proplists:get_value(<<"code">>, ErrorProplist, -32000), - Message = proplists:get_value(<<"message">>, ErrorProplist, <<"Unknown error">>), - ErrorTuple = case proplists:get_value(<<"data">>, ErrorProplist) of - undefined -> - {jsonrpc2, Code, Message}; - Data -> - {jsonrpc2, Code, Message, Data} - end, - {error, ErrorTuple}; - _ -> - %% both error and result - {error, {server_error, <<"Invalid JSON-RPC 2.0 response">>}} + +-spec multi_call(RequestSpecs :: [ request_spec() ], TransportFn :: transport_fn(), AutoIdStart :: integer()) -> response(). +multi_call(RequestSpecs, TransportFn, AutoIdStart) -> + {SpecsWithId, _} = lists:mapfoldl(fun + (RS = #{id := auto}, Id) -> {RS#{id => Id}, Id + 1}; + (RS, Id) -> {RS, Id} + end, AutoIdStart, RequestSpecs), + Ids = [ Id || #{id := Id} <- SpecsWithId ], + Req = [ request(RS) || RS <- SpecsWithId], + + maybe + {ok, ReqBin} ?= safe_eval(json_encoder, json, encode, [Req]), + {ok, RespBin} ?= safe_eval(transport, TransportFn, [ReqBin]), + {ok, _} ?= case RespBin of <<>> -> ok; _ -> {ok, RespBin} end, % empty reply + {ok, Resp} ?= safe_eval(json_decoder, json, decode, [RespBin]), + case Resp of + [_|_] -> + [ begin + Ctx = find_context(R, SpecsWithId), + list_to_tuple(tuple_to_list(parse_response(R, Ids)) ++ [Ctx]) + end || R <- Resp ] + end + else + Result -> Result + end. + + +find_context(#{~"id" := Id} = _Resp, SpecsWithId) -> + Search = fun + (#{id := SpecId}) -> SpecId =:= Id; + (#{ }) -> false + end, + case lists:search(Search, SpecsWithId) of + {value, #{ context := Context}} -> Context; + _ -> undefined + end; +find_context(#{} = _Resp, _SpecsWithId) -> + undefined. + + +request(#{method := Mtd} = RequestSpec) -> + Method = case RequestSpec of + #{module := Mod} -> <<(atom_to_binary(Mod))/binary, $:, (atom_to_binary(Mtd))/binary>>; + _ -> Mtd end, - {Id, Reply}. - -%% @doc Gives each method-params pair a number. Returns a list of triples: method-params-id. -enumerate_call_tuples(MethodParamsPairs, FirstId) -> - enumerate_call_tuples(MethodParamsPairs, FirstId, []). - -%% @doc Helper for enumerate_call_tuples/2. -enumerate_call_tuples([{Method, Params} | MPs], NextId, Acc) -> - Triple = {Method, Params, NextId}, - enumerate_call_tuples(MPs, NextId + 1, [Triple | Acc]); -enumerate_call_tuples([], _, Acc) -> - lists:reverse(Acc). - -%% @doc Finds each pair {Id, Reply} for each Id in the range FirstId..LastId in the proplist -%% Replies. Removes the id and returns the only the replies in the correct order. -denumerate_replies(Replies, FirstId, LastId) -> - denumerate_replies(dict:from_list(Replies), FirstId, LastId, []). - -%% @doc Helper for denumerate_replies/3. -denumerate_replies(ReplyDict, FirstId, LastId, Acc) when FirstId =< LastId -> - Reply = dict:fetch(FirstId, ReplyDict), - Acc1 = [Reply | Acc], - denumerate_replies(ReplyDict, FirstId + 1, LastId, Acc1); -denumerate_replies(_, _, _, Acc) -> - lists:reverse(Acc). + _Req = maps:merge(#{jsonrpc => ~"2.0", method => Method}, maps:with([id, params], RequestSpec)). + + +safe_eval(Scope, F, A) -> + safe_eval(Scope, erlang, apply, [F, A]). + +safe_eval(Scope, M, F, A) -> + try {ok, erlang:apply(M, F, A)} + catch Class:Reason:Stacktrace -> + ?LOG_ERROR("Error: ~tp ~tp ~tp", [Class, Reason, Stacktrace]), + {error, {Scope, Reason}} + end. + + +parse_response(R = #{ + ~"jsonrpc" := ~"2.0", + ~"id" := Id, + ~"error" := #{~"code" := Code, ~"message" := <>} = Error +}, Ids) when is_integer(Code) -> + case lists:member(Id, Ids) orelse Id =:= null of + true -> + Err = #{code => Code, message => Message}, + ErrB = case Error of + #{data := Data} -> Err#{data => Data}; + _ -> Err + end, + {error, {error_response, ErrB}}; + false -> + {error, {invalid_response, R}} + end; + +parse_response(R = #{ + ~"jsonrpc" := ~"2.0", + ~"id" := Id, + ~"result" := Result +}, Ids) -> + case lists:member(Id, Ids) orelse Id =:= null of + true -> + {ok, Result}; + false -> + {error, {invalid_response, R}} + end; + +parse_response(R, _Ids) -> + {error, {invalid_response, R}}. + %%------------ %% Unit tests %%------------ -ifdef(TEST). + + -include_lib("eunit/include/eunit.hrl"). -enumerate_call_tuples_test() -> - Input = [{x, foo}, {y, bar}, {z, baz}], - FirstId = 3, - Expect = [{x, foo, 3}, {y, bar, 4}, {z, baz, 5}], - Expect = enumerate_call_tuples(Input, FirstId). - -denumerate_replies_test() -> - Input = [{3, foo}, {5, baz}, {4, bar}], - FirstId = 3, - LastId = 5 = FirstId + length(Input) - 1, - Expect = [foo, bar, baz], - Expect = denumerate_replies(Input, FirstId, LastId). - -transport_error_test() -> - TransportFun = fun (_) -> throw({transport_error, <<"404 or whatever">>}) end, - JsonEncode = fun (_) -> <<"foo">> end, - JsonDecode = fun (_) -> [] end, - MethodsAndParams = [{<<"foo">>, []}], - Expect = [{error, {server_error, <<"404 or whatever">>}}], - ?assertEqual(Expect, batch_call(MethodsAndParams, TransportFun, JsonDecode, JsonEncode, 1)). - -transport_return_invalid_json_test() -> - TransportFun = fun (_) -> <<"some non-JSON junk">> end, - JsonEncode = fun (_) -> <<"{\"foo\":\"bar\"}">> end, - JsonDecode = fun (_) -> throw(invalid_json) end, - MethodsAndParams = [{<<"foo">>, []}], - Expect = [{error, {server_error, invalid_json}}], - ?assertEqual(Expect, batch_call(MethodsAndParams, TransportFun, JsonDecode, JsonEncode, 1)). + +test_handler(<<"add">>, [A, B]) -> {ok, A+B}; +test_handler(<<"notify">>, []) -> ok. + + +test_transport(Req) -> + case jsonrpc2:handle(Req, fun test_handler/2) of + {reply, Reply} -> iolist_to_binary(Reply); + noreply -> <<>> + end. + + +call__1_test() -> + ?assertEqual({ok, 7}, call(#{id => 1, method => add, params => [-1,8]}, fun test_transport/1)), + ?assertEqual(ok, call(#{ method => add, params => [-1,8]}, fun test_transport/1)). + + +call_2_test() -> + ?assertEqual({ok, 7}, call(#{id => 1, method => add, params => [-1,8]}, fun test_transport/1)), + ?assertEqual(ok, call(#{ method => notify}, fun test_transport/1)). + + +multi_call_1_test() -> + Req = [ #{id => 1, method => add, params => [-1,8]} ], + ?assertEqual([{ok, 7, undefined}], multi_call(Req, fun test_transport/1, 1)). + + +multi_call_2_test() -> + Ctx = #{ctx => 3000}, + Req = [ #{id => 1, method => add, params => [-1,8], context => Ctx} ], + ?assertEqual([{ok, 7, Ctx}], multi_call(Req, fun test_transport/1, 1)). + + +multi_call_3_test() -> + Req = [ + #{id => auto, method => add, params => [-1,8], context => #{ctx => 1000}}, + #{id => auto, method => add, params => [-1,9], context => #{ctx => 2000}}, + #{id => auto, method => add, params => [-1,10], context => #{ctx => 3000}} + ], + ?assertEqual([ + {ok, 7, #{ctx => 1000}}, + {ok, 8, #{ctx => 2000}}, + {ok, 9, #{ctx => 3000}} + ], multi_call(Req, fun test_transport/1, 10)). + + +multi_call_4_test() -> + Req = [ + #{id => auto, method => add, params => [-1,8]}, + #{ method => notify } + ], + ?assertEqual([ {ok, 7, undefined} ], multi_call(Req, fun test_transport/1, 10)). + + +multi_call_5_test() -> + Req = [ + #{id => 98, method => add, params => [98,0], context => a}, + #{ method => notify, context => c}, + #{id => -98, method => add, params => [0,-98], context => b} + ], + ?assertEqual([ {ok, 98, a}, {ok, -98, b} ], multi_call(Req, fun test_transport/1, 10)). + + +multi_call_6_test() -> + Req = [ #{method => add, params => [-1,8]} ], + ?assertEqual(ok, multi_call(Req, fun test_transport/1, 1)). + -endif.